This commit is contained in:
zlwu committed 2026-07-04 08:21:06 +08:00
1 parent 8e70047f4c
commit b4a49183db
33 files changed
+11558 -5889

No files matched your search

+58
View File
@@ -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
+4
View File
@@ -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
+9
View File
@@ -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'
+7
View File
@@ -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',
+168
View File
@@ -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 || ''
}
+20
View File
@@ -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>