This commit is contained in:
2026-10-09 17:36:32 +08:00
parent 7a9e91b7ce
commit 3df3f874db
25 changed files with 431 additions and 12 deletions
@@ -0,0 +1,67 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TransportRouteDialog from './TransportRouteDialog.vue'
import { resolveRouteLocation, routeLocation, RouteAddressError, type RouteLocation } from '../routeLocations'
vi.mock('../routeLocations', async importOriginal => ({ ...await importOriginal<object>(), resolveRouteLocation: vi.fn() }))
const global = { stubs: {
AppDialog: { template: '<div><slot /></div>' }, ElButton: { template: '<button><slot /></button>' }, ElLink: { template: '<a><slot /></a>' },
LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' }, ApiErrorMeta: true,
ElSelect: { props: ['modelValue'], emits: ['update:modelValue'], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', Number($event.target.value))"><slot /></select>' },
ElOption: { props: ['label', 'value'], template: '<option :value="value">{{ label }}</option>' },
} }
const endpoints = { origin: { kind: 'warehouse', label: '洛杉矶仓', warehouseId: '123' } as RouteLocation, destinations: [routeLocation('ABE3'), routeLocation('ONT8')] }
const addresses: Record<string, string> = { '洛杉矶仓': '100 Example Street, Los Angeles, CA, US', ABE3: '200 Example Road, Example City, PA, 18000, US', ONT8: '300 Example Avenue, Ontario, CA, US' }
beforeEach(() => { vi.mocked(resolveRouteLocation).mockReset().mockImplementation(async location => location.kind === 'address' ? location.address : addresses[location.label]!) })
describe('transport route address resolution', () => {
it('does not query until opened and sends full addresses to both map URLs', async () => {
const wrapper = mount(TransportRouteDialog, { props: { ...endpoints, modelValue: false }, global })
expect(resolveRouteLocation).not.toHaveBeenCalled()
await wrapper.setProps({ modelValue: true }); await flushPromises()
const embedded = new URL(wrapper.get('iframe').attributes('src')!)
const external = new URL(wrapper.get('a').attributes('href')!)
expect(embedded.searchParams.get('saddr')).toBe(addresses['洛杉矶仓'])
expect(embedded.searchParams.get('daddr')).toBe(addresses.ABE3)
expect(external.searchParams.get('destination')).toBe(addresses.ABE3)
expect(wrapper.text()).toContain('目的地地址:' + addresses.ABE3)
expect(resolveRouteLocation).toHaveBeenCalledTimes(2)
const iframe = wrapper.get('iframe').element
await wrapper.get('button').trigger('click')
expect(wrapper.get('iframe').element).not.toBe(iframe)
expect(resolveRouteLocation).toHaveBeenCalledTimes(2)
await wrapper.setProps({ modelValue: false })
expect(wrapper.find('iframe').exists()).toBe(false)
wrapper.unmount()
})
it('resolves only the selected warehouse and reuses the origin for another destination', async () => {
const wrapper = mount(TransportRouteDialog, { props: { ...endpoints, modelValue: true }, global }); await flushPromises()
await wrapper.get('select').setValue('1'); await flushPromises()
expect(new URL(wrapper.get('iframe').attributes('src')!).searchParams.get('daddr')).toBe(addresses.ONT8)
expect(resolveRouteLocation).toHaveBeenCalledTimes(3)
wrapper.unmount()
})
it('never leaves the previous map visible while resolving a new destination, and ignores late results', async () => {
let finish!: (value: string) => void
vi.mocked(resolveRouteLocation).mockImplementation(location => location.label === 'ABE3' ? new Promise(resolve => { finish = resolve }) : Promise.resolve(addresses[location.label]!))
const wrapper = mount(TransportRouteDialog, { props: { ...endpoints, modelValue: true }, global })
expect(wrapper.find('iframe').exists()).toBe(false)
await wrapper.get('select').setValue('1'); await flushPromises()
finish('stale address'); await flushPromises()
expect(new URL(wrapper.get('iframe').attributes('src')!).searchParams.get('daddr')).toBe(addresses.ONT8)
await wrapper.setProps({ modelValue: false }); await flushPromises()
expect(wrapper.find('iframe').exists()).toBe(false)
wrapper.unmount()
})
it('shows missing address errors instead of falling back to a warehouse code', async () => {
vi.mocked(resolveRouteLocation).mockRejectedValue(new RouteAddressError('ABE3缺少完整仓库地址'))
const wrapper = mount(TransportRouteDialog, { props: { ...endpoints, modelValue: true }, global }); await flushPromises()
expect(wrapper.text()).toContain('ABE3缺少完整仓库地址')
expect(wrapper.find('iframe').exists()).toBe(false); expect(wrapper.find('a').exists()).toBe(false)
wrapper.unmount()
})
it('does not request missing endpoints', async () => {
const wrapper = mount(TransportRouteDialog, { props: { origin: routeLocation(''), destinations: [], modelValue: true }, global }); await flushPromises()
expect(resolveRouteLocation).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('缺少起点或目的地')
wrapper.unmount()
})
})
@@ -0,0 +1,80 @@
<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>