提交
This commit is contained in:
1 parent
8e70047f4c
commit
b4a49183db
33 files changed
+11558
-5889
No files matched your search
@@ -184,6 +184,52 @@ export const personnelApi = {
|
||||
delete: (id: string) => request.delete(`/transport/personnel/${id}`)
|
||||
}
|
||||
|
||||
// 运输通用地址库
|
||||
export interface TransportAddress {
|
||||
id: string
|
||||
addressName: string
|
||||
fullAddress: string
|
||||
lng: number
|
||||
lat: number
|
||||
contactName?: string
|
||||
contactPhone?: string
|
||||
status: string
|
||||
remark?: string
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
export interface TransportAddressQuery {
|
||||
q?: string
|
||||
status?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface TransportAddressPayload {
|
||||
addressName: string
|
||||
fullAddress: string
|
||||
lng: number
|
||||
lat: number
|
||||
contactName?: string
|
||||
contactPhone?: string
|
||||
status?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export const transportAddressApi = {
|
||||
list: (params?: TransportAddressQuery) =>
|
||||
request.get<PlListResponse<TransportAddress>>('/transport/address', { params }),
|
||||
listAll: (params?: { q?: string }) =>
|
||||
request.get<PlListResponse<TransportAddress>>('/transport/address/all', { params }),
|
||||
get: (id: string) => request.get<TransportAddress>(`/transport/address/${id}`),
|
||||
create: (data: TransportAddressPayload) =>
|
||||
request.post<TransportAddress>('/transport/address', data),
|
||||
update: (id: string, data: Partial<TransportAddressPayload>) =>
|
||||
request.put<TransportAddress>(`/transport/address/${id}`, data),
|
||||
delete: (id: string) => request.delete(`/transport/address/${id}`)
|
||||
}
|
||||
|
||||
// 司机(兼容旧 API,映射至 personnel)
|
||||
export interface Driver {
|
||||
id: string
|
||||
@@ -325,9 +371,13 @@ export interface Plan {
|
||||
settlementMethod?: string
|
||||
hasFreight: number
|
||||
pickupAddress?: string
|
||||
pickupLng?: number | null
|
||||
pickupLat?: number | null
|
||||
pickupTime?: string
|
||||
pickupNotes?: string
|
||||
destinationAddress?: string
|
||||
destinationLng?: number | null
|
||||
destinationLat?: number | null
|
||||
deliveryTime?: string
|
||||
destinationNotes?: string
|
||||
vehicleId?: string
|
||||
@@ -403,9 +453,13 @@ export interface PlanCreateFullPayload {
|
||||
settlementMethod?: string
|
||||
hasFreight: number
|
||||
pickupAddress?: string
|
||||
pickupLng?: number | null
|
||||
pickupLat?: number | null
|
||||
pickupTime?: string
|
||||
pickupNotes?: string
|
||||
destinationAddress?: string
|
||||
destinationLng?: number | null
|
||||
destinationLat?: number | null
|
||||
deliveryTime?: string
|
||||
destinationNotes?: string
|
||||
followUserId?: string
|
||||
@@ -521,9 +575,13 @@ export interface TransportArrange {
|
||||
carrierId?: string
|
||||
transportMedium?: string
|
||||
pickupAddress?: string
|
||||
pickupLng?: number | null
|
||||
pickupLat?: number | null
|
||||
pickupTime?: string
|
||||
pickupNotes?: string
|
||||
destinationAddress?: string
|
||||
destinationLng?: number | null
|
||||
destinationLat?: number | null
|
||||
deliveryTime?: string
|
||||
destinationNotes?: string
|
||||
distanceKm?: number
|
||||
|
||||
@@ -13,7 +13,11 @@ export interface PublicTransportPage {
|
||||
planDate?: string
|
||||
planType: string
|
||||
pickupAddress?: string
|
||||
pickupLng?: number | null
|
||||
pickupLat?: number | null
|
||||
destinationAddress?: string
|
||||
destinationLng?: number | null
|
||||
destinationLat?: number | null
|
||||
licensePlate?: string
|
||||
driverName?: string
|
||||
driverPhone?: string
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
<template>
|
||||
<div class="amap-address-picker">
|
||||
<el-input
|
||||
:id="inputId"
|
||||
v-model="innerAddress"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
clearable
|
||||
@input="handleAddressInput"
|
||||
>
|
||||
<template #append>
|
||||
<el-button :disabled="disabled" :loading="mapOpening" @click="openMapDialog">
|
||||
<el-icon><Location /></el-icon>地图选点
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<el-dialog
|
||||
v-model="mapVisible"
|
||||
title="地图选点"
|
||||
width="760px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
class="dialog-form-layout amap-address-picker-dialog"
|
||||
modal-class="order-module-dialog-mask"
|
||||
:close-on-click-modal="false"
|
||||
style="backdrop-filter: none"
|
||||
@opened="handleMapDialogOpened"
|
||||
@closed="handleMapDialogClosed"
|
||||
>
|
||||
<div class="amap-address-picker-body">
|
||||
<div class="amap-address-picker-search">
|
||||
<el-input
|
||||
:id="searchInputId"
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索地点、POI"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #append>
|
||||
<el-button :loading="searchLoading" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>搜索
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div ref="mapContainerRef" class="amap-address-picker-map" />
|
||||
|
||||
<p v-if="pickedAddress" class="amap-address-picker-result">
|
||||
已选:<span>{{ pickedAddress }}</span>
|
||||
</p>
|
||||
<p v-else class="amap-address-picker-hint">点击地图选点,或搜索后选择结果;可拖拽标记微调位置</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="mapVisible = false">
|
||||
<el-icon><Close /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button type="primary" :disabled="!pickedAddress" @click="confirmPick">
|
||||
<el-icon><Check /></el-icon>确定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Close, Location, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
formatPoiAddress,
|
||||
isAmapConfigured,
|
||||
loadAmap,
|
||||
loadAmapPlugins,
|
||||
resolveLngLat,
|
||||
type AMapAutoComplete,
|
||||
type AMapGeocoder,
|
||||
type AMapGlobal,
|
||||
type AMapLngLat,
|
||||
type AMapMap,
|
||||
type AMapMarker,
|
||||
type AMapPlaceSearch,
|
||||
type AMapPoi
|
||||
} from '@/utils/amap/loader'
|
||||
|
||||
export interface AmapAddressPickResult {
|
||||
address: string
|
||||
lng?: number
|
||||
lat?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
lng?: number | null
|
||||
lat?: number | null
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
city?: string
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
lng: null,
|
||||
lat: null,
|
||||
placeholder: '请输入或地图选点',
|
||||
disabled: false,
|
||||
city: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
'update:lng': [value: number | null]
|
||||
'update:lat': [value: number | null]
|
||||
change: [payload: AmapAddressPickResult]
|
||||
}>()
|
||||
|
||||
const inputId = `amap-address-input-${useId()}`
|
||||
const searchInputId = `amap-address-search-${useId()}`
|
||||
|
||||
const innerAddress = ref(props.modelValue || '')
|
||||
const mapVisible = ref(false)
|
||||
const mapOpening = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const searchLoading = ref(false)
|
||||
const pickedAddress = ref('')
|
||||
const pickedLngLat = ref<[number, number] | null>(null)
|
||||
|
||||
const mapContainerRef = ref<HTMLDivElement>()
|
||||
let amapRef: AMapGlobal | null = null
|
||||
let mapInstance: AMapMap | null = null
|
||||
let markerInstance: AMapMarker | null = null
|
||||
let geocoder: AMapGeocoder | null = null
|
||||
let placeSearch: AMapPlaceSearch | null = null
|
||||
let autoComplete: AMapAutoComplete | null = null
|
||||
let mainAutoComplete: AMapAutoComplete | null = null
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
value => {
|
||||
if (value !== innerAddress.value) {
|
||||
innerAddress.value = value || ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
initMainAutoComplete()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroyMainAutoComplete()
|
||||
destroyMapResources()
|
||||
})
|
||||
|
||||
function setCoordinates(lng?: number | null, lat?: number | null) {
|
||||
const nextLng = lng ?? null
|
||||
const nextLat = lat ?? null
|
||||
emit('update:lng', nextLng)
|
||||
emit('update:lat', nextLat)
|
||||
}
|
||||
|
||||
function handleAddressInput(value: string) {
|
||||
emit('update:modelValue', value)
|
||||
setCoordinates(null, null)
|
||||
emit('change', { address: value })
|
||||
}
|
||||
|
||||
async function ensureAmapReady() {
|
||||
if (!isAmapConfigured()) {
|
||||
ElMessage.warning('未配置高德地图 Key,请在 .env.development.local 中设置 VITE_AMAP_KEY')
|
||||
return false
|
||||
}
|
||||
try {
|
||||
amapRef = await loadAmap()
|
||||
await loadAmapPlugins()
|
||||
return true
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '高德地图加载失败'
|
||||
ElMessage.error(message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function bindAutoComplete(
|
||||
target: AMapAutoComplete | null,
|
||||
onSelect: (poi?: AMapPoi) => void
|
||||
) {
|
||||
if (!target) return
|
||||
const handler = (event: { poi?: AMapPoi }) => onSelect(event.poi)
|
||||
target.on('select', handler)
|
||||
}
|
||||
|
||||
async function initMainAutoComplete() {
|
||||
const ready = await ensureAmapReady()
|
||||
if (!ready || !amapRef) return
|
||||
|
||||
amapRef.plugin(['AMap.AutoComplete'], () => {
|
||||
if (mainAutoComplete) return
|
||||
mainAutoComplete = new amapRef!.AutoComplete({
|
||||
input: inputId,
|
||||
city: props.city || '全国',
|
||||
citylimit: !!props.city
|
||||
})
|
||||
bindAutoComplete(mainAutoComplete, poi => {
|
||||
const address = formatPoiAddress(poi)
|
||||
if (!address) return
|
||||
innerAddress.value = address
|
||||
emit('update:modelValue', address)
|
||||
if (poi?.location) {
|
||||
const [lng, lat] = resolveLngLat(poi.location)
|
||||
setCoordinates(lng, lat)
|
||||
emit('change', { address, lng, lat })
|
||||
} else {
|
||||
setCoordinates(null, null)
|
||||
emit('change', { address })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function destroyMainAutoComplete() {
|
||||
mainAutoComplete = null
|
||||
}
|
||||
|
||||
async function openMapDialog() {
|
||||
if (props.disabled) return
|
||||
mapOpening.value = true
|
||||
try {
|
||||
const ready = await ensureAmapReady()
|
||||
if (!ready) return
|
||||
pickedAddress.value = innerAddress.value || ''
|
||||
pickedLngLat.value =
|
||||
props.lng != null && props.lat != null ? [Number(props.lng), Number(props.lat)] : null
|
||||
searchKeyword.value = innerAddress.value || ''
|
||||
mapVisible.value = true
|
||||
} finally {
|
||||
mapOpening.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMapDialogOpened() {
|
||||
await nextTick()
|
||||
await initMapPicker()
|
||||
if (pickedLngLat.value) {
|
||||
applyLngLat({ lng: pickedLngLat.value[0], lat: pickedLngLat.value[1] }, pickedAddress.value)
|
||||
} else if (searchKeyword.value.trim()) {
|
||||
handleSearch()
|
||||
}
|
||||
}
|
||||
|
||||
function handleMapDialogClosed() {
|
||||
destroyMapResources()
|
||||
}
|
||||
|
||||
async function initMapPicker() {
|
||||
if (!amapRef || !mapContainerRef.value) return
|
||||
|
||||
destroyMapResources()
|
||||
|
||||
mapInstance = new amapRef.Map(mapContainerRef.value, {
|
||||
zoom: 12,
|
||||
viewMode: '2D'
|
||||
})
|
||||
|
||||
geocoder = new amapRef.Geocoder()
|
||||
placeSearch = new amapRef.PlaceSearch({
|
||||
map: mapInstance,
|
||||
pageSize: 8,
|
||||
city: props.city || '全国',
|
||||
citylimit: !!props.city
|
||||
})
|
||||
|
||||
autoComplete = new amapRef.AutoComplete({
|
||||
input: searchInputId,
|
||||
city: props.city || '全国',
|
||||
citylimit: !!props.city
|
||||
})
|
||||
bindAutoComplete(autoComplete, poi => {
|
||||
if (!poi?.location) return
|
||||
applyPoiSelection(poi)
|
||||
})
|
||||
|
||||
mapInstance.on('click', event => {
|
||||
applyLngLat(event.lnglat)
|
||||
})
|
||||
|
||||
window.setTimeout(() => {
|
||||
mapInstance?.resize()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
function applyPoiSelection(poi: AMapPoi) {
|
||||
if (!poi.location) return
|
||||
applyLngLat(poi.location, formatPoiAddress(poi))
|
||||
}
|
||||
|
||||
function applyLngLat(lnglat: AMapLngLat, presetAddress?: string) {
|
||||
if (!amapRef || !mapInstance) return
|
||||
const position = resolveLngLat(lnglat)
|
||||
pickedLngLat.value = position
|
||||
|
||||
if (!markerInstance) {
|
||||
markerInstance = new amapRef.Marker({
|
||||
position,
|
||||
draggable: true
|
||||
})
|
||||
markerInstance.on('dragend', () => {
|
||||
const pos = markerInstance?.getPosition()
|
||||
if (pos) reverseGeocode(pos)
|
||||
})
|
||||
mapInstance.add(markerInstance)
|
||||
} else {
|
||||
markerInstance.setPosition(position)
|
||||
}
|
||||
|
||||
mapInstance.setCenter(position)
|
||||
mapInstance.setZoom(16)
|
||||
|
||||
if (presetAddress) {
|
||||
pickedAddress.value = presetAddress
|
||||
return
|
||||
}
|
||||
reverseGeocode(lnglat)
|
||||
}
|
||||
|
||||
function reverseGeocode(lnglat: AMapLngLat) {
|
||||
if (!geocoder) return
|
||||
geocoder.getAddress(lnglat, (status, result) => {
|
||||
if (status !== 'complete') return
|
||||
const address = result.regeocode?.formattedAddress?.trim()
|
||||
if (address) pickedAddress.value = address
|
||||
})
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
const keyword = searchKeyword.value.trim()
|
||||
if (!keyword) {
|
||||
ElMessage.warning('请输入搜索关键词')
|
||||
return
|
||||
}
|
||||
if (!placeSearch) return
|
||||
|
||||
searchLoading.value = true
|
||||
placeSearch.search(keyword, (status, result) => {
|
||||
searchLoading.value = false
|
||||
if (status !== 'complete') {
|
||||
ElMessage.warning('未找到相关地点')
|
||||
return
|
||||
}
|
||||
const poi = result.poiList?.pois?.[0]
|
||||
if (!poi) {
|
||||
ElMessage.warning('未找到相关地点')
|
||||
return
|
||||
}
|
||||
applyPoiSelection(poi)
|
||||
})
|
||||
}
|
||||
|
||||
function confirmPick() {
|
||||
if (!pickedAddress.value) return
|
||||
innerAddress.value = pickedAddress.value
|
||||
emit('update:modelValue', pickedAddress.value)
|
||||
const payload: AmapAddressPickResult = { address: pickedAddress.value }
|
||||
if (pickedLngLat.value) {
|
||||
payload.lng = pickedLngLat.value[0]
|
||||
payload.lat = pickedLngLat.value[1]
|
||||
setCoordinates(payload.lng, payload.lat)
|
||||
} else {
|
||||
setCoordinates(null, null)
|
||||
}
|
||||
emit('change', payload)
|
||||
mapVisible.value = false
|
||||
}
|
||||
|
||||
function destroyMapResources() {
|
||||
autoComplete = null
|
||||
placeSearch = null
|
||||
geocoder = null
|
||||
markerInstance = null
|
||||
if (mapInstance) {
|
||||
mapInstance.destroy()
|
||||
mapInstance = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.amap-address-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.amap-address-picker-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.amap-address-picker-map {
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
border: 1px solid var(--border, #d9e8e2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.amap-address-picker-result,
|
||||
.amap-address-picker-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted, #64748b);
|
||||
line-height: 1.5;
|
||||
|
||||
span {
|
||||
color: var(--text, #1e293b);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.el-dialog.amap-address-picker-dialog.dialog-form-layout {
|
||||
width: min(760px, calc(100vw - 80px)) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="pl-address-with-library">
|
||||
<div class="pl-address-with-library__row">
|
||||
<AmapAddressPicker
|
||||
v-model="innerAddress"
|
||||
v-model:lng="innerLng"
|
||||
v-model:lat="innerLat"
|
||||
class="pl-address-with-library__picker"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<el-button
|
||||
class="pl-address-with-library__library-btn btn-ghost"
|
||||
:disabled="disabled"
|
||||
@click="libraryVisible = true"
|
||||
>
|
||||
<el-icon><FolderOpened /></el-icon>从地址库选择
|
||||
</el-button>
|
||||
</div>
|
||||
<PlTransportAddressSelectDialog v-model="libraryVisible" @select="handleLibrarySelect" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FolderOpened } from '@element-plus/icons-vue'
|
||||
import AmapAddressPicker from '@/components/business/amap-address-picker/index.vue'
|
||||
import PlTransportAddressSelectDialog from '@/components/business/pl-transport-address-select-dialog/index.vue'
|
||||
import type { TransportAddress } from '@/api/pl/index'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
lng?: number | null
|
||||
lat?: number | null
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
'update:lng': [value: number | null]
|
||||
'update:lat': [value: number | null]
|
||||
}>()
|
||||
|
||||
const libraryVisible = ref(false)
|
||||
|
||||
const innerAddress = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: string) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const innerLng = computed({
|
||||
get: () => props.lng ?? null,
|
||||
set: (value: number | null) => emit('update:lng', value)
|
||||
})
|
||||
|
||||
const innerLat = computed({
|
||||
get: () => props.lat ?? null,
|
||||
set: (value: number | null) => emit('update:lat', value)
|
||||
})
|
||||
|
||||
function handleLibrarySelect(row: TransportAddress) {
|
||||
innerAddress.value = row.fullAddress
|
||||
innerLng.value = row.lng != null ? Number(row.lng) : null
|
||||
innerLat.value = row.lat != null ? Number(row.lat) : null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pl-address-with-library {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pl-address-with-library__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pl-address-with-library__picker {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pl-address-with-library__library-btn {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -25,61 +25,64 @@
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="pl-plan-form-section">
|
||||
<section class="pl-plan-form-section pl-plan-freight-section">
|
||||
<h4 class="pl-plan-subtitle">运费信息</h4>
|
||||
<div class="form-grid-4">
|
||||
<el-form-item label="是否有运输费用">
|
||||
<el-switch v-model="formData.hasFreight" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<template v-if="formData.hasFreight === 1">
|
||||
<el-form-item label="运输公里数">
|
||||
<el-input-number
|
||||
v-model="formData.distanceKm"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-form
|
||||
:model="formData"
|
||||
label-width="96px"
|
||||
label-position="right"
|
||||
class="pl-plan-freight-form"
|
||||
>
|
||||
<div class="form-grid-4">
|
||||
<el-form-item label="是否有运输费用">
|
||||
<el-switch v-model="formData.hasFreight" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="运费计算方式">
|
||||
<el-select v-model="formData.freightType">
|
||||
<el-option label="按重量" value="by_weight" />
|
||||
<el-option label="按趟" value="by_trip" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="formData.freightType === 'by_weight'"
|
||||
:label="`运输量(${freightUnitLabel})`"
|
||||
>
|
||||
<el-input-number
|
||||
v-model="freightCalc.transportQty.value"
|
||||
:min="0"
|
||||
:precision="3"
|
||||
style="width: 100%"
|
||||
@change="freightCalc.markTransportQtyTouched()"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="formData.freightType === 'by_weight'"
|
||||
:label="`运输单价(元/${freightUnitLabel})`"
|
||||
>
|
||||
<el-input-number
|
||||
v-model="freightCalc.freightPrice.value"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="运费金额(元)">
|
||||
<el-input-number
|
||||
v-model="freightCalc.freightAmount.value"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
@change="freightCalc.markFreightAmountTouched()"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="formData.hasFreight === 1">
|
||||
<el-form-item label="运输公里数">
|
||||
<el-input-number
|
||||
v-model="formData.distanceKm"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="运费计算方式">
|
||||
<el-select v-model="formData.freightType">
|
||||
<el-option label="按重量" value="by_weight" />
|
||||
<el-option label="按趟" value="by_trip" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="formData.freightType === 'by_weight'"
|
||||
:label="`运输量(${freightUnitLabel})`"
|
||||
>
|
||||
<el-input-number
|
||||
v-model="freightCalc.transportQty.value"
|
||||
:min="0"
|
||||
:precision="3"
|
||||
@change="freightCalc.markTransportQtyTouched()"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="formData.freightType === 'by_weight'"
|
||||
:label="`运输单价(元/${freightUnitLabel})`"
|
||||
>
|
||||
<el-input-number
|
||||
v-model="freightCalc.freightPrice.value"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="运费金额(元)">
|
||||
<el-input-number
|
||||
v-model="freightCalc.freightAmount.value"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
@change="freightCalc.markFreightAmountTouched()"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</el-form>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
@@ -384,10 +387,32 @@ async function handleSubmit() {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.pl-arrange-freight-muted {
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
.pl-plan-freight-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
height: auto !important;
|
||||
line-height: 1.4 !important;
|
||||
padding: 0 8px 0 0 !important;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-grid-4 {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.el-select),
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-arrange-footer {
|
||||
@@ -396,6 +421,7 @@ async function handleSubmit() {
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -132,7 +132,12 @@
|
||||
<h4 class="pl-plan-subtitle">取货信息</h4>
|
||||
<div class="form-grid-3">
|
||||
<el-form-item label="取货地址">
|
||||
<el-input v-model="formData.pickupAddress" placeholder="取货地址" />
|
||||
<PlAddressWithLibrary
|
||||
v-model="formData.pickupAddress"
|
||||
v-model:lng="formData.pickupLng"
|
||||
v-model:lat="formData.pickupLat"
|
||||
placeholder="取货地址"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="取货时间">
|
||||
<el-date-picker
|
||||
@@ -174,7 +179,12 @@
|
||||
<h4 class="pl-plan-subtitle">送货信息</h4>
|
||||
<div class="form-grid-3">
|
||||
<el-form-item label="送货地址">
|
||||
<el-input v-model="formData.destinationAddress" placeholder="送货地址" />
|
||||
<PlAddressWithLibrary
|
||||
v-model="formData.destinationAddress"
|
||||
v-model:lng="formData.destinationLng"
|
||||
v-model:lat="formData.destinationLat"
|
||||
placeholder="送货地址"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="送达时间">
|
||||
<el-date-picker
|
||||
@@ -315,7 +325,11 @@
|
||||
@click="
|
||||
copyPlanSummary({
|
||||
pickupAddress: formData.pickupAddress,
|
||||
pickupLng: formData.pickupLng,
|
||||
pickupLat: formData.pickupLat,
|
||||
destinationAddress: formData.destinationAddress,
|
||||
destinationLng: formData.destinationLng,
|
||||
destinationLat: formData.destinationLat,
|
||||
pickupNotes: formData.pickupNotes,
|
||||
destinationNotes: formData.destinationNotes,
|
||||
licensePlate: vehicleList.find(v => v.id === formData.vehicleId)?.licensePlate,
|
||||
@@ -374,6 +388,7 @@ import {
|
||||
type VehiclePersonnelRow
|
||||
} from '@/api/pl'
|
||||
import DbPoundSlipQrcodeDialog from '@/components/business/db/DbPoundSlipQrcodeDialog.vue'
|
||||
import PlAddressWithLibrary from '@/components/business/pl-address-with-library/index.vue'
|
||||
import PlVehicleSummaryStrip from '@/components/business/pl-vehicle-summary-strip/index.vue'
|
||||
import PlVehicleViewDialog from '@/components/business/pl-vehicle-view-dialog/index.vue'
|
||||
import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
|
||||
@@ -449,9 +464,13 @@ const formData = reactive({
|
||||
driverId: '',
|
||||
carrierId: '',
|
||||
pickupAddress: '',
|
||||
pickupLng: null as number | null,
|
||||
pickupLat: null as number | null,
|
||||
pickupTime: '',
|
||||
pickupNotes: '',
|
||||
destinationAddress: '',
|
||||
destinationLng: null as number | null,
|
||||
destinationLat: null as number | null,
|
||||
deliveryTime: '',
|
||||
destinationNotes: '',
|
||||
hasFreight: 0,
|
||||
@@ -581,9 +600,13 @@ function applyVehiclePersonnel(personnelList: VehiclePersonnelRow[], preferredDr
|
||||
|
||||
async function applyPrefillFromPlan(data: Plan) {
|
||||
formData.pickupAddress = data.pickupAddress || ''
|
||||
formData.pickupLng = data.pickupLng != null ? Number(data.pickupLng) : null
|
||||
formData.pickupLat = data.pickupLat != null ? Number(data.pickupLat) : null
|
||||
formData.pickupTime = formatDate(data.pickupTime) || ''
|
||||
formData.pickupNotes = data.pickupNotes || ''
|
||||
formData.destinationAddress = data.destinationAddress || ''
|
||||
formData.destinationLng = data.destinationLng != null ? Number(data.destinationLng) : null
|
||||
formData.destinationLat = data.destinationLat != null ? Number(data.destinationLat) : null
|
||||
formData.deliveryTime = formatDate(data.deliveryTime) || ''
|
||||
formData.destinationNotes = data.destinationNotes || ''
|
||||
formData.hasFreight = data.hasFreight ?? 0
|
||||
@@ -657,9 +680,13 @@ function resetState() {
|
||||
driverId: '',
|
||||
carrierId: '',
|
||||
pickupAddress: '',
|
||||
pickupLng: null,
|
||||
pickupLat: null,
|
||||
pickupTime: '',
|
||||
pickupNotes: '',
|
||||
destinationAddress: '',
|
||||
destinationLng: null,
|
||||
destinationLat: null,
|
||||
deliveryTime: '',
|
||||
destinationNotes: '',
|
||||
hasFreight: 0,
|
||||
@@ -829,9 +856,13 @@ async function handleSubmit() {
|
||||
driverId: formData.driverId,
|
||||
carrierId: formData.carrierId || undefined,
|
||||
pickupAddress: formData.pickupAddress || undefined,
|
||||
pickupLng: formData.pickupLng,
|
||||
pickupLat: formData.pickupLat,
|
||||
pickupTime: formData.pickupTime || undefined,
|
||||
pickupNotes: formData.pickupNotes || undefined,
|
||||
destinationAddress: formData.destinationAddress || undefined,
|
||||
destinationLng: formData.destinationLng,
|
||||
destinationLat: formData.destinationLat,
|
||||
deliveryTime: formData.deliveryTime || undefined,
|
||||
destinationNotes: formData.destinationNotes || undefined,
|
||||
transportMedium: transportMediumText.value || undefined,
|
||||
|
||||
@@ -122,9 +122,14 @@
|
||||
<div class="pl-plan-logistics-row">
|
||||
<div class="pl-plan-logistics-block">
|
||||
<h4 class="pl-plan-subtitle">取货信息</h4>
|
||||
<div class="form-grid-3">
|
||||
<el-form-item label="取货地址">
|
||||
<el-input v-model="formData.pickupAddress" placeholder="取货地址" />
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="取货地址" class="form-col-span-2">
|
||||
<PlAddressWithLibrary
|
||||
v-model="formData.pickupAddress"
|
||||
v-model:lng="formData.pickupLng"
|
||||
v-model:lat="formData.pickupLat"
|
||||
placeholder="取货地址"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="取货时间">
|
||||
<el-date-picker
|
||||
@@ -164,9 +169,14 @@
|
||||
|
||||
<div class="pl-plan-logistics-block">
|
||||
<h4 class="pl-plan-subtitle">送货信息</h4>
|
||||
<div class="form-grid-3">
|
||||
<el-form-item label="送货地址">
|
||||
<el-input v-model="formData.destinationAddress" placeholder="送货地址" />
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="送货地址" class="form-col-span-2">
|
||||
<PlAddressWithLibrary
|
||||
v-model="formData.destinationAddress"
|
||||
v-model:lng="formData.destinationLng"
|
||||
v-model:lat="formData.destinationLat"
|
||||
placeholder="送货地址"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="送达时间">
|
||||
<el-date-picker
|
||||
@@ -273,6 +283,7 @@ import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vu
|
||||
import type { ViewField } from '@/components/business/system-view-dialog/types'
|
||||
import PlVehicleSummaryStrip from '@/components/business/pl-vehicle-summary-strip/index.vue'
|
||||
import PlVehicleViewDialog from '@/components/business/pl-vehicle-view-dialog/index.vue'
|
||||
import PlAddressWithLibrary from '@/components/business/pl-address-with-library/index.vue'
|
||||
import PlPlanFormProductTable from './PlPlanFormProductTable.vue'
|
||||
import {
|
||||
applyContractToPlanForm,
|
||||
@@ -373,9 +384,13 @@ const formData = reactive({
|
||||
settlementMethod: '',
|
||||
hasFreight: 0,
|
||||
pickupAddress: '',
|
||||
pickupLng: null as number | null,
|
||||
pickupLat: null as number | null,
|
||||
pickupTime: '',
|
||||
pickupNotes: '',
|
||||
destinationAddress: '',
|
||||
destinationLng: null as number | null,
|
||||
destinationLat: null as number | null,
|
||||
deliveryTime: '',
|
||||
destinationNotes: '',
|
||||
vehicleId: '',
|
||||
@@ -571,9 +586,13 @@ function buildPlanCommonPayload(options?: { allowClearTransport?: boolean }): Pl
|
||||
settlementMethod: formData.settlementMethod || undefined,
|
||||
hasFreight: formData.hasFreight,
|
||||
pickupAddress: formData.pickupAddress || undefined,
|
||||
pickupLng: formData.pickupLng,
|
||||
pickupLat: formData.pickupLat,
|
||||
pickupTime: formData.pickupTime || undefined,
|
||||
pickupNotes: formData.pickupNotes || undefined,
|
||||
destinationAddress: formData.destinationAddress || undefined,
|
||||
destinationLng: formData.destinationLng,
|
||||
destinationLat: formData.destinationLat,
|
||||
deliveryTime: formData.deliveryTime || undefined,
|
||||
destinationNotes: formData.destinationNotes || undefined,
|
||||
followUserId: formData.followUserId || undefined,
|
||||
@@ -650,9 +669,13 @@ function resetForm() {
|
||||
settlementMethod: '',
|
||||
hasFreight: 0,
|
||||
pickupAddress: '',
|
||||
pickupLng: null as number | null,
|
||||
pickupLat: null as number | null,
|
||||
pickupTime: '',
|
||||
pickupNotes: '',
|
||||
destinationAddress: '',
|
||||
destinationLng: null as number | null,
|
||||
destinationLat: null as number | null,
|
||||
deliveryTime: '',
|
||||
destinationNotes: '',
|
||||
vehicleId: '',
|
||||
@@ -705,9 +728,13 @@ async function loadPlanForEdit(planId: string) {
|
||||
settlementMethod: data.settlementMethod || '',
|
||||
hasFreight: data.hasFreight ?? 0,
|
||||
pickupAddress: data.pickupAddress || '',
|
||||
pickupLng: data.pickupLng != null ? Number(data.pickupLng) : null,
|
||||
pickupLat: data.pickupLat != null ? Number(data.pickupLat) : null,
|
||||
pickupTime: formatDate(data.pickupTime) || '',
|
||||
pickupNotes: data.pickupNotes || '',
|
||||
destinationAddress: data.destinationAddress || '',
|
||||
destinationLng: data.destinationLng != null ? Number(data.destinationLng) : null,
|
||||
destinationLat: data.destinationLat != null ? Number(data.destinationLat) : null,
|
||||
deliveryTime: formatDate(data.deliveryTime) || '',
|
||||
destinationNotes: data.destinationNotes || '',
|
||||
vehicleId: data.vehicleId || '',
|
||||
@@ -1084,7 +1111,7 @@ async function handleSubmit() {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-grid-3 {
|
||||
.form-grid-2 {
|
||||
gap: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<ViewKvTable :fields="pickupViewFields" mode="quad" />
|
||||
</div>
|
||||
|
||||
<div class="party-flow-bridge" aria-hidden="true">
|
||||
<div class="party-flow-bridge">
|
||||
<span class="party-flow-bridge__label">运输</span>
|
||||
<span class="party-flow-bridge__arrow">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="innerVisible"
|
||||
title="从地址库选择"
|
||||
width="1000px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
class="dialog-form-layout pl-transport-address-select-dialog"
|
||||
modal-class="order-module-dialog-mask"
|
||||
:close-on-click-modal="false"
|
||||
style="backdrop-filter: none"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="filter-block pl-transport-address-select-filter">
|
||||
<el-form class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="名称/地址">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索地址名称、详细地址、联系人或电话"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<div class="filter-actions-start" />
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
|
||||
<el-icon><RefreshLeft /></el-icon>重置
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>搜索
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="panel dialog-list-panel">
|
||||
<div class="list-body list-wrap dialog-list-body-only">
|
||||
<el-table
|
||||
class="list-table pl-transport-address-select-table"
|
||||
:data="rows"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
highlight-current-row
|
||||
:max-height="480"
|
||||
empty-text="暂无可用地址"
|
||||
@row-dblclick="handleConfirm"
|
||||
>
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
min-width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="index => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="addressName" label="地址名称" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="handleConfirm(row)">{{ row.addressName }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="fullAddress" label="详细地址" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column label="坐标" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.lng != null && row.lat != null">{{ row.lng }}, {{ row.lat }}</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="contactName"
|
||||
label="联系人"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="contactPhone"
|
||||
label="联系电话"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
fixed="right"
|
||||
class-name="col-actions"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleConfirm(row)">
|
||||
<el-icon><Select /></el-icon>选择
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<ListPagination
|
||||
v-show="pagination.total > 0"
|
||||
:total="pagination.total"
|
||||
v-model:page="pagination.page"
|
||||
v-model:limit="pagination.pageSize"
|
||||
@pagination="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="innerVisible = false">
|
||||
<el-icon><Close /></el-icon>关闭
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Close, RefreshLeft, Search, Select } from '@element-plus/icons-vue'
|
||||
import { transportAddressApi, type TransportAddress } from '@/api/pl/index'
|
||||
import ListPagination from '@/components/ListPagination/index.vue'
|
||||
import { pickBxPageResult } from '@/utils/http/bx'
|
||||
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
select: [row: TransportAddress]
|
||||
}>()
|
||||
|
||||
const innerVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const rows = ref<TransportAddress[]>([])
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: DEFAULT_LIST_PAGE_SIZE,
|
||||
total: 0
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await transportAddressApi.list({
|
||||
q: keyword.value.trim() || undefined,
|
||||
status: '1',
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
const { list, total } = pickBxPageResult<TransportAddress>(res)
|
||||
rows.value = list
|
||||
pagination.total = total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
keyword.value = ''
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
keyword.value = ''
|
||||
pagination.page = 1
|
||||
pagination.pageSize = DEFAULT_LIST_PAGE_SIZE
|
||||
pagination.total = 0
|
||||
rows.value = []
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleConfirm(row: TransportAddress) {
|
||||
emit('select', row)
|
||||
innerVisible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pl-transport-address-select-filter {
|
||||
padding: 12px 16px 10px;
|
||||
background: var(--mint-surface, #fff);
|
||||
border: 1px solid var(--mint-border, #d9e8e2);
|
||||
border-radius: var(--mint-radius, 10px);
|
||||
margin-bottom: 12px;
|
||||
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px 14px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-actions-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.filter-actions-end .el-button + .el-button {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.pl-transport-address-select-dialog {
|
||||
:deep(.el-dialog__body) {
|
||||
min-height: 620px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,7 @@ import { planApi } from '@/api/pl'
|
||||
import DbPoundSlipQrcodeDialog from '@/components/business/db/DbPoundSlipQrcodeDialog.vue'
|
||||
import { useDictStore } from '@/store/modules/dict'
|
||||
import { formatDate } from '@/utils/format'
|
||||
import { appendAmapNavLine, buildAmapNavigationUrl } from '@/utils/amap/navigation'
|
||||
import { pickBxData } from '@/utils/http/bx'
|
||||
import { formatPlanProductListText } from '@/utils/pl-display'
|
||||
import { buildSiteSummaryFromInventoryLinks } from '@/utils/db-site-summary'
|
||||
@@ -109,7 +110,11 @@ export function usePlPlanArrangeActions() {
|
||||
startPoint?: string
|
||||
endPoint?: string
|
||||
pickupAddress?: string
|
||||
pickupLng?: number | null
|
||||
pickupLat?: number | null
|
||||
destinationAddress?: string
|
||||
destinationLng?: number | null
|
||||
destinationLat?: number | null
|
||||
pickupNotes?: string
|
||||
destinationNotes?: string
|
||||
licensePlate?: string
|
||||
@@ -124,15 +129,37 @@ export function usePlPlanArrangeActions() {
|
||||
.map(p => formatPlanProductListText(p, getUnitLabel, { usePlannedQuantity: true }))
|
||||
.join(';')
|
||||
lines.push(`产品:${productText || '—'}`)
|
||||
lines.push(
|
||||
`路线:${options?.pickupAddress || plan.value?.pickupAddress || options?.startPoint || '—'} → ${
|
||||
options?.destinationAddress ||
|
||||
plan.value?.destinationAddress ||
|
||||
plan.value?.destinationName ||
|
||||
options?.endPoint ||
|
||||
'—'
|
||||
}`
|
||||
|
||||
const pickupAddress =
|
||||
options?.pickupAddress || plan.value?.pickupAddress || options?.startPoint || '—'
|
||||
const destinationAddress =
|
||||
options?.destinationAddress ||
|
||||
plan.value?.destinationAddress ||
|
||||
plan.value?.destinationName ||
|
||||
options?.endPoint ||
|
||||
'—'
|
||||
lines.push(`取货地址:${pickupAddress}`)
|
||||
lines.push(`送货地址:${destinationAddress}`)
|
||||
|
||||
const pickupLng = options?.pickupLng ?? plan.value?.pickupLng
|
||||
const pickupLat = options?.pickupLat ?? plan.value?.pickupLat
|
||||
const destinationLng = options?.destinationLng ?? plan.value?.destinationLng
|
||||
const destinationLat = options?.destinationLat ?? plan.value?.destinationLat
|
||||
appendAmapNavLine(
|
||||
lines,
|
||||
'取货导航',
|
||||
buildAmapNavigationUrl(pickupLng, pickupLat, pickupAddress !== '—' ? pickupAddress : '取货点')
|
||||
)
|
||||
appendAmapNavLine(
|
||||
lines,
|
||||
'送货导航',
|
||||
buildAmapNavigationUrl(
|
||||
destinationLng,
|
||||
destinationLat,
|
||||
destinationAddress !== '—' ? destinationAddress : '送货点'
|
||||
)
|
||||
)
|
||||
|
||||
lines.push(`取货注意事项:${options?.pickupNotes || plan.value?.pickupNotes || '—'}`)
|
||||
lines.push(`送货注意事项:${options?.destinationNotes || plan.value?.destinationNotes || '—'}`)
|
||||
lines.push(`司机链接:${options?.driverUrl || driverUrl.value || '—'}`)
|
||||
@@ -208,7 +235,11 @@ export function usePlPlanArrangeActions() {
|
||||
startPoint?: string
|
||||
endPoint?: string
|
||||
pickupAddress?: string
|
||||
pickupLng?: number | null
|
||||
pickupLat?: number | null
|
||||
destinationAddress?: string
|
||||
destinationLng?: number | null
|
||||
destinationLat?: number | null
|
||||
pickupNotes?: string
|
||||
destinationNotes?: string
|
||||
licensePlate?: string
|
||||
|
||||
Vendored
+9
@@ -1,5 +1,14 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_AMAP_KEY?: string
|
||||
readonly VITE_AMAP_SECURITY_CODE?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare module 'nprogress'
|
||||
|
||||
declare module 'crypto-js'
|
||||
|
||||
@@ -32,6 +32,13 @@ export const plRoutes: AppRouteRecord[] = [
|
||||
component: '/pl/vehicle/index',
|
||||
meta: { title: '车辆管理', keepAlive: true, menuCode: 'pl:vehicle:list' }
|
||||
},
|
||||
{
|
||||
id: 'PL00000000000000000000000000004A',
|
||||
name: 'PlTransportAddress',
|
||||
path: 'transport-address',
|
||||
component: '/pl/transport-address/index',
|
||||
meta: { title: '地址库', keepAlive: true, menuCode: 'pl:transport-address:list' }
|
||||
},
|
||||
{
|
||||
id: 'PL00000000000000000000000000004',
|
||||
name: 'PlPersonnel',
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
AMap?: AMapGlobal
|
||||
_AMapSecurityConfig?: { securityJsCode: string }
|
||||
}
|
||||
}
|
||||
|
||||
export interface AMapLngLat {
|
||||
lng: number
|
||||
lat: number
|
||||
getLng?: () => number
|
||||
getLat?: () => number
|
||||
}
|
||||
|
||||
export interface AMapPoi {
|
||||
name?: string
|
||||
address?: string
|
||||
location?: AMapLngLat
|
||||
district?: string
|
||||
}
|
||||
|
||||
export interface AMapGlobal {
|
||||
plugin: (names: string | string[], callback: () => void) => void
|
||||
Map: new (container: HTMLElement | string, opts: Record<string, unknown>) => AMapMap
|
||||
Marker: new (opts: Record<string, unknown>) => AMapMarker
|
||||
AutoComplete: new (opts: Record<string, unknown>) => AMapAutoComplete
|
||||
PlaceSearch: new (opts: Record<string, unknown>) => AMapPlaceSearch
|
||||
Geocoder: new (opts?: Record<string, unknown>) => AMapGeocoder
|
||||
}
|
||||
|
||||
export interface AMapMap {
|
||||
on: (event: string, handler: (event: { lnglat: AMapLngLat }) => void) => void
|
||||
destroy: () => void
|
||||
resize: () => void
|
||||
setCenter: (center: [number, number]) => void
|
||||
setZoom: (zoom: number) => void
|
||||
add: (overlay: unknown) => void
|
||||
remove: (overlay: unknown) => void
|
||||
}
|
||||
|
||||
export interface AMapMarker {
|
||||
setPosition: (lnglat: [number, number] | AMapLngLat) => void
|
||||
getPosition: () => AMapLngLat
|
||||
on: (event: string, handler: () => void) => void
|
||||
}
|
||||
|
||||
export interface AMapAutoComplete {
|
||||
on: (event: string, handler: (event: { poi?: AMapPoi }) => void) => void
|
||||
off: (event: string, handler: (event: { poi?: AMapPoi }) => void) => void
|
||||
}
|
||||
|
||||
export interface AMapPlaceSearch {
|
||||
search: (
|
||||
keyword: string,
|
||||
callback: (status: string, result: { poiList?: { pois?: AMapPoi[] } }) => void
|
||||
) => void
|
||||
}
|
||||
|
||||
export interface AMapGeocoder {
|
||||
getAddress: (
|
||||
lnglat: [number, number] | AMapLngLat,
|
||||
callback: (status: string, result: { regeocode?: { formattedAddress?: string } }) => void
|
||||
) => void
|
||||
getLocation: (
|
||||
address: string,
|
||||
callback: (
|
||||
status: string,
|
||||
result: { geocodes?: Array<{ location?: { lng: number; lat: number } }> }
|
||||
) => void
|
||||
) => void
|
||||
}
|
||||
|
||||
const AMAP_SCRIPT_ID = 'amap-js-sdk-v2'
|
||||
const DEFAULT_PLUGINS = ['AMap.AutoComplete', 'AMap.PlaceSearch', 'AMap.Geocoder']
|
||||
|
||||
let loadPromise: Promise<AMapGlobal> | null = null
|
||||
let pluginsReadyPromise: Promise<void> | null = null
|
||||
|
||||
export function getAmapKey() {
|
||||
return String(import.meta.env.VITE_AMAP_KEY || '').trim()
|
||||
}
|
||||
|
||||
export function getAmapSecurityCode() {
|
||||
return String(import.meta.env.VITE_AMAP_SECURITY_CODE || '').trim()
|
||||
}
|
||||
|
||||
export function isAmapConfigured() {
|
||||
return !!getAmapKey()
|
||||
}
|
||||
|
||||
function injectSecurityConfig() {
|
||||
const securityJsCode = getAmapSecurityCode()
|
||||
if (!securityJsCode) return
|
||||
window._AMapSecurityConfig = { securityJsCode }
|
||||
}
|
||||
|
||||
export function loadAmap(): Promise<AMapGlobal> {
|
||||
const key = getAmapKey()
|
||||
if (!key) {
|
||||
return Promise.reject(new Error('未配置 VITE_AMAP_KEY'))
|
||||
}
|
||||
|
||||
if (window.AMap) {
|
||||
return Promise.resolve(window.AMap)
|
||||
}
|
||||
|
||||
if (loadPromise) return loadPromise
|
||||
|
||||
injectSecurityConfig()
|
||||
|
||||
loadPromise = new Promise((resolve, reject) => {
|
||||
const existing = document.getElementById(AMAP_SCRIPT_ID) as HTMLScriptElement | null
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => {
|
||||
if (window.AMap) resolve(window.AMap)
|
||||
else reject(new Error('高德地图 SDK 加载失败'))
|
||||
})
|
||||
existing.addEventListener('error', () => reject(new Error('高德地图 SDK 加载失败')))
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.id = AMAP_SCRIPT_ID
|
||||
script.type = 'text/javascript'
|
||||
script.async = true
|
||||
script.src = `https://webapi.amap.com/maps?v=2.0&key=${encodeURIComponent(key)}`
|
||||
script.onload = () => {
|
||||
if (window.AMap) resolve(window.AMap)
|
||||
else reject(new Error('高德地图 SDK 加载失败'))
|
||||
}
|
||||
script.onerror = () => reject(new Error('高德地图 SDK 加载失败'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
export function loadAmapPlugins(plugins: string[] = DEFAULT_PLUGINS): Promise<void> {
|
||||
if (pluginsReadyPromise) return pluginsReadyPromise
|
||||
|
||||
pluginsReadyPromise = loadAmap().then(
|
||||
AMap =>
|
||||
new Promise((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
pluginsReadyPromise = null
|
||||
reject(new Error('高德地图插件加载超时'))
|
||||
}, 15000)
|
||||
AMap.plugin(plugins, () => {
|
||||
window.clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return pluginsReadyPromise
|
||||
}
|
||||
|
||||
export function resolveLngLat(lnglat: AMapLngLat): [number, number] {
|
||||
const lng = typeof lnglat.getLng === 'function' ? lnglat.getLng() : lnglat.lng
|
||||
const lat = typeof lnglat.getLat === 'function' ? lnglat.getLat() : lnglat.lat
|
||||
return [lng, lat]
|
||||
}
|
||||
|
||||
export function formatPoiAddress(poi?: AMapPoi) {
|
||||
if (!poi) return ''
|
||||
const parts = [poi.district, poi.address, poi.name].filter(Boolean)
|
||||
return parts.join('').trim() || poi.name || poi.address || ''
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** 构建高德 URI 导航链接(手机点击可唤起高德 App) */
|
||||
export function buildAmapNavigationUrl(
|
||||
lng?: number | null,
|
||||
lat?: number | null,
|
||||
name?: string
|
||||
): string | null {
|
||||
if (lng == null || lat == null || Number.isNaN(Number(lng)) || Number.isNaN(Number(lat))) {
|
||||
return null
|
||||
}
|
||||
const lngText = Number(lng).toFixed(6)
|
||||
const latText = Number(lat).toFixed(6)
|
||||
const toName = encodeURIComponent((name || '目的地').trim() || '目的地')
|
||||
return `https://uri.amap.com/navigation?to=${lngText},${latText},${toName}&mode=car&coordinate=gaode&callnative=1`
|
||||
}
|
||||
|
||||
export function appendAmapNavLine(lines: string[], label: string, url: string | null) {
|
||||
if (url) {
|
||||
lines.push(`${label}:${url}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
<!-- 运输地址库 -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">地址库</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="名称/地址">
|
||||
<el-input
|
||||
v-model="searchForm.q"
|
||||
placeholder="搜索名称或详细地址"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.status" placeholder="全部" clearable>
|
||||
<el-option label="启用" value="1" />
|
||||
<el-option label="停用" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
|
||||
<el-icon><RefreshLeft /></el-icon>重置
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>搜索
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>新增地址
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
:disabled="selectedRows.length !== 1"
|
||||
@click="handleToolbarEdit"
|
||||
>
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length !== 1 || !canDeleteByCreator(selectedRows[0])"
|
||||
:title="selectedRows.length === 1 && !canDeleteByCreator(selectedRows[0]) ? CREATOR_DELETE_TIP : ''"
|
||||
@click="handleToolbarDelete"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="addressRows"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
min-width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="index => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="addressName" label="地址名称" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="handleView(row)">{{ row.addressName }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="fullAddress" label="详细地址" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column label="坐标" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.lng != null && row.lat != null">{{ row.lng }}, {{ row.lat }}</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contactName" label="联系人" min-width="100" align="center" />
|
||||
<el-table-column prop="contactPhone" label="联系电话" min-width="120" align="center" />
|
||||
<el-table-column prop="status" label="状态" min-width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === '1' ? 'success' : 'info'" size="small">
|
||||
{{ row.status === '1' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
|
||||
</el-table>
|
||||
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="640px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
class="dialog-form-layout pl-transport-address-form-dialog"
|
||||
modal-class="order-module-dialog-mask"
|
||||
:close-on-click-modal="false"
|
||||
style="backdrop-filter: none"
|
||||
@close="handleDialogClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="地址名称" prop="addressName">
|
||||
<el-input v-model="formData.addressName" placeholder="如:安庆仓库、某某化工厂" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="formData.status" placeholder="请选择状态">
|
||||
<el-option label="启用" value="1" />
|
||||
<el-option label="停用" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="详细地址" prop="fullAddress" class="form-col-span-2">
|
||||
<AmapAddressPicker
|
||||
v-model="formData.fullAddress"
|
||||
v-model:lng="formData.lng"
|
||||
v-model:lat="formData.lat"
|
||||
placeholder="请地图选点或搜索 POI"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="联系人">
|
||||
<el-input v-model="formData.contactName" placeholder="联系人(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话">
|
||||
<el-input v-model="formData.contactPhone" placeholder="联系电话(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="备注(可选)" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
<el-icon><Check /></el-icon>确定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model="viewVisible"
|
||||
title="地址详情"
|
||||
:loading="viewLoading"
|
||||
:hero-title="viewDetail?.addressName || '—'"
|
||||
:hero-meta="viewHeroMeta"
|
||||
:sections="viewSections"
|
||||
>
|
||||
<template #hero-actions>
|
||||
<el-button @click="handleEditFromView">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
:disabled="!viewDetail || !canDeleteByCreator(viewDetail)"
|
||||
@click="handleDeleteFromView"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</template>
|
||||
</SystemViewDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Close, Delete, Edit, Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
transportAddressApi,
|
||||
type TransportAddress,
|
||||
type TransportAddressPayload
|
||||
} from '@/api/pl/index'
|
||||
import AmapAddressPicker from '@/components/business/amap-address-picker/index.vue'
|
||||
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
|
||||
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
|
||||
import { pickBxPageResult } from '@/utils/http/bx'
|
||||
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const addressRows = ref<TransportAddress[]>([])
|
||||
const selectedRows = ref<TransportAddress[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const formRef = ref()
|
||||
const isEdit = ref(false)
|
||||
const viewVisible = ref(false)
|
||||
const viewLoading = ref(false)
|
||||
const viewDetail = ref<TransportAddress | null>(null)
|
||||
|
||||
const searchForm = reactive({
|
||||
q: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: DEFAULT_LIST_PAGE_SIZE,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
addressName: '',
|
||||
fullAddress: '',
|
||||
lng: null as number | null,
|
||||
lat: null as number | null,
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
status: '1',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
addressName: [{ required: true, message: '请输入地址名称', trigger: 'blur' }],
|
||||
fullAddress: [{ required: true, message: '请填写详细地址', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
lng: [{ required: true, message: '请通过地图选点获取经度', trigger: 'change' }],
|
||||
lat: [{ required: true, message: '请通过地图选点获取纬度', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const viewHeroMeta = computed(() => {
|
||||
if (!viewDetail.value) return ''
|
||||
return viewDetail.value.fullAddress || ''
|
||||
})
|
||||
|
||||
const viewSections = computed<ViewSection[]>(() => {
|
||||
const d = viewDetail.value
|
||||
if (!d) return []
|
||||
return [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '地址名称', value: d.addressName },
|
||||
{ label: '详细地址', value: d.fullAddress, span: 'full' },
|
||||
{
|
||||
label: '坐标',
|
||||
value: d.lng != null && d.lat != null ? `${d.lng}, ${d.lat}` : '—'
|
||||
},
|
||||
{ label: '联系人', value: d.contactName || '—' },
|
||||
{ label: '联系电话', value: d.contactPhone || '—' },
|
||||
{
|
||||
label: '状态',
|
||||
value: d.status === '1' ? '启用' : '停用',
|
||||
tagType: d.status === '1' ? 'success' : 'info'
|
||||
},
|
||||
{ label: '创建时间', value: formatDateTime(d.createTime) },
|
||||
{ label: '更新时间', value: formatDateTime(d.updateTime) },
|
||||
{ label: '备注', value: d.remark || '—', span: 'full' }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await transportAddressApi.list({
|
||||
q: searchForm.q || undefined,
|
||||
status: searchForm.status || undefined,
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
const { list, total } = pickBxPageResult<TransportAddress>(res)
|
||||
addressRows.value = list
|
||||
pagination.total = total
|
||||
const idSet = new Set(addressRows.value.map(item => item.id))
|
||||
selectedRows.value = selectedRows.value.filter(item => idSet.has(item.id))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.q = ''
|
||||
searchForm.status = ''
|
||||
selectedRows.value = []
|
||||
pagination.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handlePageChange() {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
pagination.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handleSelectionChange(rows: TransportAddress[]) {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
function handleToolbarEdit() {
|
||||
if (selectedRows.value.length !== 1) {
|
||||
ElMessage.warning('请选择一条地址记录')
|
||||
return
|
||||
}
|
||||
handleEdit(selectedRows.value[0])
|
||||
}
|
||||
|
||||
function handleToolbarDelete() {
|
||||
if (selectedRows.value.length !== 1) {
|
||||
ElMessage.warning('请选择一条地址记录')
|
||||
return
|
||||
}
|
||||
handleDelete(selectedRows.value[0])
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
dialogTitle.value = '新增地址'
|
||||
isEdit.value = false
|
||||
Object.assign(formData, {
|
||||
id: '',
|
||||
addressName: '',
|
||||
fullAddress: '',
|
||||
lng: null,
|
||||
lat: null,
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
status: '1',
|
||||
remark: ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleView(row: TransportAddress) {
|
||||
viewVisible.value = true
|
||||
viewLoading.value = true
|
||||
viewDetail.value = null
|
||||
try {
|
||||
const res = await transportAddressApi.get(row.id)
|
||||
viewDetail.value = (res.data ?? res) as TransportAddress
|
||||
} catch {
|
||||
viewVisible.value = false
|
||||
ElMessage.error('加载地址详情失败')
|
||||
} finally {
|
||||
viewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdit(row: TransportAddress) {
|
||||
dialogTitle.value = '编辑地址'
|
||||
isEdit.value = true
|
||||
dialogVisible.value = true
|
||||
try {
|
||||
const res = await transportAddressApi.get(row.id)
|
||||
const detail = (res.data ?? res) as TransportAddress
|
||||
Object.assign(formData, {
|
||||
id: detail.id,
|
||||
addressName: detail.addressName,
|
||||
fullAddress: detail.fullAddress,
|
||||
lng: detail.lng != null ? Number(detail.lng) : null,
|
||||
lat: detail.lat != null ? Number(detail.lat) : null,
|
||||
contactName: detail.contactName || '',
|
||||
contactPhone: detail.contactPhone || '',
|
||||
status: detail.status,
|
||||
remark: detail.remark || ''
|
||||
})
|
||||
} catch {
|
||||
ElMessage.error('加载地址详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditFromView() {
|
||||
if (!viewDetail.value) return
|
||||
viewVisible.value = false
|
||||
handleEdit(viewDetail.value)
|
||||
}
|
||||
|
||||
async function handleDeleteFromView() {
|
||||
if (!viewDetail.value) return
|
||||
await handleDelete(viewDetail.value)
|
||||
viewVisible.value = false
|
||||
viewDetail.value = null
|
||||
}
|
||||
|
||||
function validateCoordinates(): boolean {
|
||||
if (formData.lng == null || formData.lat == null) {
|
||||
ElMessage.warning('请通过地图选点填写经纬度')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid || !validateCoordinates()) return
|
||||
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload: TransportAddressPayload = {
|
||||
addressName: formData.addressName.trim(),
|
||||
fullAddress: formData.fullAddress.trim(),
|
||||
lng: formData.lng as number,
|
||||
lat: formData.lat as number,
|
||||
contactName: formData.contactName.trim() || undefined,
|
||||
contactPhone: formData.contactPhone.trim() || undefined,
|
||||
status: formData.status,
|
||||
remark: formData.remark.trim() || undefined
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await transportAddressApi.update(formData.id, payload)
|
||||
} else {
|
||||
await transportAddressApi.create(payload)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '编辑成功' : '新增成功')
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDialogClose() {
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
async function handleDelete(row: TransportAddress) {
|
||||
if (!canDeleteByCreator(row)) {
|
||||
ElMessage.warning(CREATOR_DELETE_TIP)
|
||||
return
|
||||
}
|
||||
await ElMessageBox.confirm('确定删除该地址吗?', '提示', { type: 'warning' })
|
||||
await transportAddressApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
if (viewDetail.value?.id === row.id) {
|
||||
viewVisible.value = false
|
||||
viewDetail.value = null
|
||||
}
|
||||
fetchData()
|
||||
}
|
||||
|
||||
useDbInstantSearch(
|
||||
() => ({
|
||||
q: searchForm.q,
|
||||
status: searchForm.status
|
||||
}),
|
||||
handleSearch
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
@@ -15,6 +15,35 @@
|
||||
<van-cell title="路线" :value="routeDisplay" class="route-cell" />
|
||||
</van-cell-group>
|
||||
|
||||
<van-cell-group
|
||||
v-if="pickupNavUrl || destinationNavUrl"
|
||||
inset
|
||||
class="section-card nav-card"
|
||||
title="导航"
|
||||
>
|
||||
<div class="nav-actions">
|
||||
<van-button
|
||||
v-if="pickupNavUrl"
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
@click="openNavigation(pickupNavUrl)"
|
||||
>
|
||||
导航到取货点
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="destinationNavUrl"
|
||||
type="primary"
|
||||
plain
|
||||
block
|
||||
round
|
||||
@click="openNavigation(destinationNavUrl)"
|
||||
>
|
||||
导航到送货点
|
||||
</van-button>
|
||||
</div>
|
||||
</van-cell-group>
|
||||
|
||||
<van-cell-group v-if="page.products.length" inset class="section-card" title="产品明细">
|
||||
<van-cell
|
||||
v-for="(item, idx) in page.products"
|
||||
@@ -72,6 +101,7 @@
|
||||
type PublicTransportProduct
|
||||
} from '@/api/pl/public-transport'
|
||||
import PublicMobileShell from '@/layouts/PublicMobileShell.vue'
|
||||
import { buildAmapNavigationUrl } from '@/utils/amap/navigation'
|
||||
|
||||
import 'vant/es/toast/style'
|
||||
import '@/styles/public-mobile-vant.scss'
|
||||
@@ -99,6 +129,28 @@
|
||||
return `${page.value.pickupAddress || '—'} → ${page.value.destinationAddress || '—'}`
|
||||
})
|
||||
|
||||
const pickupNavUrl = computed(() => {
|
||||
if (!page.value) return null
|
||||
return buildAmapNavigationUrl(
|
||||
page.value.pickupLng,
|
||||
page.value.pickupLat,
|
||||
page.value.pickupAddress || '取货点'
|
||||
)
|
||||
})
|
||||
|
||||
const destinationNavUrl = computed(() => {
|
||||
if (!page.value) return null
|
||||
return buildAmapNavigationUrl(
|
||||
page.value.destinationLng,
|
||||
page.value.destinationLat,
|
||||
page.value.destinationAddress || '送货点'
|
||||
)
|
||||
})
|
||||
|
||||
function openNavigation(url: string) {
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
function formatProductQuantity(value?: number) {
|
||||
if (value == null || Number.isNaN(Number(value))) return '—'
|
||||
const num = Number(value)
|
||||
@@ -207,4 +259,11 @@
|
||||
.uploader-wrap {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user