81 lines
5.1 KiB
Vue
81 lines
5.1 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import { resolveRouteLocation, RouteAddressError, type RouteLocation } from '../routeLocations'
|
||
|
||
const open = defineModel<boolean>({ default: false })
|
||
const props = defineProps<{ origin: RouteLocation; destinations: RouteLocation[] }>()
|
||
const mapRevision = ref(0)
|
||
const destinationIndex = ref(0)
|
||
const selected = computed(() => props.destinations[destinationIndex.value])
|
||
const origin = ref(''), destination = ref(''), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||
let generation = 0
|
||
let cache = new Map<string, Promise<string>>()
|
||
function resolve(location: RouteLocation) {
|
||
const key = JSON.stringify(location)
|
||
if (!cache.has(key)) cache.set(key, resolveRouteLocation(location))
|
||
return cache.get(key)!
|
||
}
|
||
async function load() {
|
||
const current = ++generation
|
||
origin.value = ''; destination.value = ''; error.value = null; loading.value = false
|
||
if (!open.value) return
|
||
const valid = (location?: RouteLocation) => location && !['', '—', '-', '仓库名称待补充'].includes(location.label.trim())
|
||
if (!valid(props.origin) || !valid(selected.value)) {
|
||
error.value = { message: '缺少起点或目的地,请先补充地点或详细地址' }; return
|
||
}
|
||
loading.value = true
|
||
try {
|
||
const [start, end] = await Promise.all([resolve(props.origin), resolve(selected.value!)])
|
||
if (current === generation && open.value) { origin.value = start; destination.value = end; mapRevision.value++ }
|
||
} catch (cause) {
|
||
if (current === generation) error.value = cause instanceof RouteAddressError ? { message: cause.message } : getApiErrorDetails(cause, '仓库地址查询失败,请确认具有仓库查看权限后重试')
|
||
} finally { if (current === generation) loading.value = false }
|
||
}
|
||
function retry() { cache = new Map(); void load() }
|
||
watch([open, () => props.origin, () => props.destinations], () => {
|
||
destinationIndex.value = 0; cache = new Map(); void load()
|
||
}, { immediate: true })
|
||
watch(destinationIndex, load)
|
||
onBeforeUnmount(() => { generation++ })
|
||
const ready = computed(() => !loading.value && !error.value && Boolean(origin.value && destination.value))
|
||
const mapUrl = computed(() => `https://maps.google.com/maps?${new URLSearchParams({ saddr: origin.value, daddr: destination.value, dirflg: 'd', output: 'embed' })}`)
|
||
const externalUrl = computed(() => `https://www.google.com/maps/dir/?${new URLSearchParams({ api: '1', origin: origin.value, destination: destination.value, travelmode: 'driving' })}`)
|
||
const tooLong = computed(() => mapUrl.value.length > 2048 || externalUrl.value.length > 2048)
|
||
</script>
|
||
|
||
<template>
|
||
<AppDialog v-model="open" class="admin-density-dialog" title="查看路线"
|
||
width="min(1100px, 96vw)" align-center :show-confirm="false" cancel-text="关闭">
|
||
<section v-if="open" class="route-map" aria-label="起点至目的地路线">
|
||
<strong>{{ props.origin.label || '起点未填写' }} → {{ selected?.label || '目的地未填写' }}</strong>
|
||
<el-select v-if="props.destinations.length > 1" v-model="destinationIndex" aria-label="选择目的仓">
|
||
<el-option v-for="(item, index) in props.destinations" :key="index" :label="item.label" :value="index" />
|
||
</el-select>
|
||
<p class="route-note">根据起点与目的地地址规划驾车路线,尚未接入 GPS,不显示车辆实时位置或实际行驶轨迹。</p>
|
||
<p v-if="loading" role="status">正在查询仓库详细地址…</p>
|
||
<LoadErrorState v-else-if="error" title="无法加载路线" :message="error.message" @retry="retry"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||
<p v-else-if="tooLong" role="status">地点信息过长,请核对起点和目的地地址后重试。</p>
|
||
<template v-else-if="ready"><div class="route-addresses"><div>起点地址:{{ origin }}</div><div>目的地地址:{{ destination }}</div></div>
|
||
<iframe :key="mapRevision" :src="mapUrl" :title="`${props.origin.label}至${selected?.label}路线地图`"
|
||
referrerpolicy="no-referrer" />
|
||
</template>
|
||
<div v-if="ready && !tooLong" class="route-help">
|
||
<span>地图需联网加载。如未显示,可重新加载或在新窗口打开。</span>
|
||
<el-button @click="mapRevision++">重新加载地图</el-button>
|
||
<el-link :href="externalUrl" target="_blank" rel="noopener noreferrer" type="primary">在新窗口打开地图</el-link>
|
||
</div>
|
||
</section>
|
||
</AppDialog>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.route-map { display: grid; gap: var(--admin-space-3); }
|
||
.route-note { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||
.route-map iframe { width: 100%; height: 58vh; min-height: 320px; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }
|
||
.route-help { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); color: var(--cc-muted); }
|
||
</style>
|