This commit is contained in:
zlwu committed 2026-08-21 13:45:35 +08:00
1 parent 88da74d72a
commit 9fcb9270e6
53 files changed
+612846 -3438

No files matched your search

@@ -1,14 +1,34 @@
<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"
<el-select
v-model="selectedId"
class="pl-address-with-library__picker"
popper-class="pl-address-select-dropdown"
:placeholder="placeholder"
:disabled="disabled"
/>
filterable
remote
clearable
:remote-method="searchAddresses"
:loading="loading"
:reserve-keyword="false"
@focus="handleFocus"
@clear="handleClear"
@change="handleSelectChange"
>
<el-option
v-for="item in displayOptions"
:key="item.id"
:label="optionLabel(item)"
:value="item.id"
>
<div class="pl-address-option">
<span class="pl-address-option__name">{{ item.addressName || '—' }}</span>
<span class="pl-address-option__full">{{ item.fullAddress || '—' }}</span>
</div>
</el-option>
</el-select>
<el-button
class="pl-address-with-library__library-btn btn-ghost"
:disabled="disabled"
@@ -23,18 +43,23 @@
<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'
import { transportAddressApi, type TransportAddress } from '@/api/pl/index'
import { pickBxList } from '@/utils/http/bx'
const props = defineProps<{
modelValue: string
lng?: number | null
lat?: number | null
contact?: string
placeholder?: string
disabled?: boolean
}>()
const props = withDefaults(
defineProps<{
modelValue: string
lng?: number | null
lat?: number | null
contact?: string
placeholder?: string
disabled?: boolean
}>(),
{
placeholder: '请选择地址(可按地址名称、详细地址检索)'
}
)
const emit = defineEmits<{
'update:modelValue': [value: string]
@@ -43,22 +68,31 @@ const emit = defineEmits<{
'update:contact': [value: string]
}>()
/** 下拉默认最多展示条数 */
const DROPDOWN_PAGE_SIZE = 10
const libraryVisible = ref(false)
const loading = ref(false)
const options = ref<TransportAddress[]>([])
const selectedId = ref('')
/** 编辑态/历史地址:当前值不在地址库时的回显项 */
const legacyOption = ref<TransportAddress | null>(null)
const innerAddress = computed({
get: () => props.modelValue,
set: (value: string) => emit('update:modelValue', value)
const displayOptions = computed(() => {
const list = options.value
const legacy = legacyOption.value
if (!legacy) return list
if (list.some(item => item.id === legacy.id)) return list
// 历史回显优先,仍控制总量不超过默认展示数 + 1(回显项)
return [legacy, ...list].slice(0, DROPDOWN_PAGE_SIZE + 1)
})
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 optionLabel(item: TransportAddress): string {
const name = (item.addressName || '').trim()
const full = (item.fullAddress || '').trim()
if (name && full) return `${name}(${full})`
return name || full || '—'
}
function formatAddressContact(row: TransportAddress): string {
const name = (row.contactName || '').trim()
@@ -67,15 +101,124 @@ function formatAddressContact(row: TransportAddress): string {
return name || phone
}
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
function applyAddressRow(row: TransportAddress) {
selectedId.value = row.id
legacyOption.value = null
emit('update:modelValue', row.fullAddress || '')
emit('update:lng', row.lng != null ? Number(row.lng) : null)
emit('update:lat', row.lat != null ? Number(row.lat) : null)
const contactText = formatAddressContact(row)
if (contactText) {
emit('update:contact', contactText)
}
}
/**
* 与「从地址库选择」弹窗同源(启用中的运输地址库)。
* 模糊检索走后端 q:仅匹配地址名称、详细地址(/transport/address/all)。
*/
async function searchAddresses(keyword: string) {
loading.value = true
try {
const res = await transportAddressApi.listAll({
q: keyword.trim() || undefined
})
options.value = pickBxList<TransportAddress>(res).slice(0, DROPDOWN_PAGE_SIZE)
syncSelectedFromModel()
} catch (error) {
console.error('加载运输地址列表失败', error)
options.value = []
} finally {
loading.value = false
}
}
function handleFocus() {
if (options.value.length === 0) {
searchAddresses('')
}
}
function handleClear() {
selectedId.value = ''
legacyOption.value = null
emit('update:modelValue', '')
emit('update:lng', null)
emit('update:lat', null)
}
function handleSelectChange(id: string) {
if (!id) {
handleClear()
return
}
const row = displayOptions.value.find(item => item.id === id)
if (!row) return
if (row.id.startsWith('legacy-')) {
selectedId.value = row.id
emit('update:modelValue', row.fullAddress || '')
return
}
applyAddressRow(row)
}
function handleLibrarySelect(row: TransportAddress) {
// 弹窗选中后并入当前 options,保证下拉可回显
if (!options.value.some(item => item.id === row.id)) {
options.value = [row, ...options.value].slice(0, DROPDOWN_PAGE_SIZE)
}
applyAddressRow(row)
}
function buildLegacyOption(address: string): TransportAddress {
return {
id: `legacy-${address}`,
addressName: address,
fullAddress: address,
lng: props.lng != null ? Number(props.lng) : 0,
lat: props.lat != null ? Number(props.lat) : 0,
status: '1',
createTime: '',
updateTime: ''
}
}
function syncSelectedFromModel() {
const current = (props.modelValue || '').trim()
if (!current) {
selectedId.value = ''
legacyOption.value = null
return
}
const matched =
options.value.find(item => item.fullAddress === current) ||
(selectedId.value
? options.value.find(item => item.id === selectedId.value)
: undefined)
if (matched) {
selectedId.value = matched.id
legacyOption.value = null
return
}
// 已选中的库内 id 仍在 options 中
if (selectedId.value && options.value.some(item => item.id === selectedId.value)) {
return
}
legacyOption.value = buildLegacyOption(current)
selectedId.value = legacyOption.value.id
}
watch(
() => props.modelValue,
() => {
syncSelectedFromModel()
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
@@ -93,10 +236,51 @@ function handleLibrarySelect(row: TransportAddress) {
.pl-address-with-library__picker {
flex: 1 1 auto;
min-width: 0;
width: 100%;
}
.pl-address-with-library__library-btn {
flex: 0 0 auto;
white-space: nowrap;
}
.pl-address-option {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
line-height: 1.4;
padding: 2px 0;
}
.pl-address-option__name {
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 500;
}
.pl-address-option__full {
color: var(--el-text-color-secondary, var(--mint-text-muted, #64748b));
font-size: 12px;
line-height: 1.45;
white-space: normal;
word-break: break-all;
}
</style>
<style lang="scss">
/* teleported 下拉挂到 body,需非 scoped;增高选项避免双行地址被裁切 */
.pl-address-select-dropdown {
.el-select-dropdown__wrap {
/* 约 10 条 × 较高选项高度 */
max-height: 560px;
}
.el-select-dropdown__item {
height: auto !important;
min-height: 56px;
padding: 10px 20px;
line-height: 1.4;
}
}
</style>
@@ -784,17 +784,81 @@ function handleCustomerSelectFocus() {
}
}
const MSG_SAME_CUSTOMER = '供方与需方不能为同一客户'
const MSG_SAME_ADDRESS = '取货地址与送货地址不能相同'
/** 已确认的客户 id,用于冲突时回滚 el-select */
const committedSupplierId = ref('')
const committedCustomerId = ref('')
/** 加载/重置表单时跳过地址互斥回滚 */
const suppressAddressMutex = ref(false)
function isSamePartyCustomer(supplierId?: string, customerId?: string) {
const s = String(supplierId || '').trim()
const c = String(customerId || '').trim()
return Boolean(s && c && s === c)
}
function normalizeAddressText(value?: string | null) {
return String(value || '').trim()
}
function isSameCoordPair(
lng1: number | null | undefined,
lat1: number | null | undefined,
lng2: number | null | undefined,
lat2: number | null | undefined
) {
if (lng1 == null || lat1 == null || lng2 == null || lat2 == null) return false
const aLng = Number(lng1)
const aLat = Number(lat1)
const bLng = Number(lng2)
const bLat = Number(lat2)
if ([aLng, aLat, bLng, bLat].some(n => Number.isNaN(n))) return false
return aLng === bLng && aLat === bLat
}
function isSameLogisticsAddress(
addressA?: string | null,
lngA?: number | null,
latA?: number | null,
addressB?: string | null,
lngB?: number | null,
latB?: number | null
) {
const ta = normalizeAddressText(addressA)
const tb = normalizeAddressText(addressB)
if (ta && tb && ta === tb) return true
if (isSameCoordPair(lngA, latA, lngB, latB)) return true
return false
}
function syncCommittedPartyIds() {
committedSupplierId.value = String(formData.supplierId || '')
committedCustomerId.value = String(formData.customerId || '')
}
function handleSupplierSelectChange(customerId?: string) {
if (!customerId) {
formData.supplierId = ''
formData.supplierName = ''
formData.supplierContracts = []
committedSupplierId.value = ''
return
}
const customer = customerOptions.value.find(item => String(item.id) === String(customerId))
formData.supplierId = String(customerId)
const nextId = String(customerId)
if (isSamePartyCustomer(nextId, formData.customerId)) {
ElMessage.warning(MSG_SAME_CUSTOMER)
nextTick(() => {
formData.supplierId = committedSupplierId.value
})
return
}
const customer = customerOptions.value.find(item => String(item.id) === nextId)
formData.supplierId = nextId
formData.supplierName = customer?.customerName || formData.supplierName || ''
formData.supplierContracts = []
committedSupplierId.value = nextId
}
function handleDemanderSelectChange(customerId?: string) {
@@ -806,15 +870,25 @@ function handleDemanderSelectChange(customerId?: string) {
formData.demanderContracts = []
formData.contactName = ''
formData.contactPhone = ''
committedCustomerId.value = ''
return
}
const customer = customerOptions.value.find(item => String(item.id) === String(customerId))
const nextId = String(customerId)
if (isSamePartyCustomer(formData.supplierId, nextId)) {
ElMessage.warning(MSG_SAME_CUSTOMER)
nextTick(() => {
formData.customerId = committedCustomerId.value
})
return
}
const customer = customerOptions.value.find(item => String(item.id) === nextId)
const name = customer?.customerName || formData.customerName || ''
formData.customerId = String(customerId)
formData.customerId = nextId
formData.customerName = name
formData.destinationId = String(customerId)
formData.destinationId = nextId
formData.destinationName = name
formData.demanderContracts = []
committedCustomerId.value = nextId
}
function openCustomerSelector(side: CustomerSide) {
@@ -826,13 +900,23 @@ function handleCustomerConfirm(customer: CustomerConfirmRow) {
if (!customer?.id) return
const customerId = String(customer.id)
const customerName = (customer.customerName || '').trim()
ensureCustomerOption({ id: customerId, customerName })
if (customerSelectorSide.value === 'supplier') {
if (isSamePartyCustomer(customerId, formData.customerId)) {
ElMessage.warning(MSG_SAME_CUSTOMER)
return
}
ensureCustomerOption({ id: customerId, customerName })
formData.supplierId = customerId
formData.supplierName = customerName
formData.supplierContracts = []
committedSupplierId.value = customerId
return
}
if (isSamePartyCustomer(formData.supplierId, customerId)) {
ElMessage.warning(MSG_SAME_CUSTOMER)
return
}
ensureCustomerOption({ id: customerId, customerName })
formData.customerId = customerId
formData.customerName = customerName
formData.destinationId = customerId
@@ -840,8 +924,98 @@ function handleCustomerConfirm(customer: CustomerConfirmRow) {
formData.demanderContracts = []
formData.contactName = customer.contactName || ''
formData.contactPhone = customer.mobile || ''
committedCustomerId.value = customerId
}
watch(
() =>
[formData.pickupAddress, formData.pickupLng, formData.pickupLat] as [
string,
number | null,
number | null
],
(curr, prev) => {
if (suppressAddressMutex.value || !prev) return
if (
!isSameLogisticsAddress(
curr[0],
curr[1],
curr[2],
formData.destinationAddress,
formData.destinationLng,
formData.destinationLat
)
) {
return
}
// 仅当「取货侧」变化导致冲突时回滚取货
if (
isSameLogisticsAddress(
prev[0],
prev[1],
prev[2],
formData.destinationAddress,
formData.destinationLng,
formData.destinationLat
)
) {
return
}
ElMessage.warning(MSG_SAME_ADDRESS)
suppressAddressMutex.value = true
formData.pickupAddress = prev[0] || ''
formData.pickupLng = prev[1]
formData.pickupLat = prev[2]
nextTick(() => {
suppressAddressMutex.value = false
})
}
)
watch(
() =>
[formData.destinationAddress, formData.destinationLng, formData.destinationLat] as [
string,
number | null,
number | null
],
(curr, prev) => {
if (suppressAddressMutex.value || !prev) return
if (
!isSameLogisticsAddress(
formData.pickupAddress,
formData.pickupLng,
formData.pickupLat,
curr[0],
curr[1],
curr[2]
)
) {
return
}
if (
isSameLogisticsAddress(
formData.pickupAddress,
formData.pickupLng,
formData.pickupLat,
prev[0],
prev[1],
prev[2]
)
) {
return
}
ElMessage.warning(MSG_SAME_ADDRESS)
suppressAddressMutex.value = true
formData.destinationAddress = prev[0] || ''
formData.destinationLng = prev[1]
formData.destinationLat = prev[2]
nextTick(() => {
suppressAddressMutex.value = false
})
}
)
async function handleProductConfirm(products: Array<Record<string, unknown>>) {
for (const product of products) {
const brandMeta = initLineBrandMetaFromProduct(product)
@@ -1079,6 +1253,7 @@ function resetForm() {
remark: '',
products: []
})
syncCommittedPartyIds()
freightCalc.resetFreightCalc()
basicFormRef.value?.clearValidate()
}
@@ -1113,6 +1288,7 @@ function handleOpened() {
async function loadPlanForEdit(planId: string) {
detailLoading.value = true
suppressAddressMutex.value = true
try {
resetForm()
const res = await planApi.get(planId)
@@ -1152,6 +1328,7 @@ async function loadPlanForEdit(planId: string) {
products: (data.products || []).map(mapPlanProductToFormRow)
})
loadedTransportStatus.value = data.transportStatus || 'pending'
syncCommittedPartyIds()
formData.products = await enrichPlanProductLinesAlias(
await enrichPlanProductsHazardFromCrm((data.products || []).map(mapPlanProductToFormRow))
@@ -1168,12 +1345,15 @@ async function loadPlanForEdit(planId: string) {
}
syncSelectedCustomerOptions()
} finally {
await nextTick()
suppressAddressMutex.value = false
detailLoading.value = false
}
}
async function loadPlanForCopy(planId: string) {
detailLoading.value = true
suppressAddressMutex.value = true
try {
resetForm()
const res = await planApi.get(planId)
@@ -1212,6 +1392,7 @@ async function loadPlanForCopy(planId: string) {
products: (data.products || []).map(mapPlanProductToCopyFormRow)
})
loadedTransportStatus.value = 'pending'
syncCommittedPartyIds()
formData.products = await enrichPlanProductLinesAlias(
await enrichPlanProductsHazardFromCrm((data.products || []).map(mapPlanProductToCopyFormRow))
@@ -1228,6 +1409,8 @@ async function loadPlanForCopy(planId: string) {
ElMessage.error(message.includes('加载') ? message : `加载源计划失败:${message}`)
emit('update:visible', false)
} finally {
await nextTick()
suppressAddressMutex.value = false
detailLoading.value = false
}
}
@@ -1307,6 +1490,23 @@ function validateBusinessRules() {
ElMessage.warning('请选择需方客户')
return false
}
if (isSamePartyCustomer(formData.supplierId, formData.customerId)) {
ElMessage.warning(MSG_SAME_CUSTOMER)
return false
}
if (
isSameLogisticsAddress(
formData.pickupAddress,
formData.pickupLng,
formData.pickupLat,
formData.destinationAddress,
formData.destinationLng,
formData.destinationLat
)
) {
ElMessage.warning(MSG_SAME_ADDRESS)
return false
}
if (!formData.products.length) {
ElMessage.warning('请至少保留一条产品明细')
return false
@@ -108,41 +108,45 @@
<el-tab-pane label="运输信息" name="transport">
<section v-if="showArrangeLinks" class="pl-plan-form-section pl-plan-form-section--plain pl-plan-arrange-links">
<h3 class="pl-plan-form-section-title">运输链接</h3>
<div class="pl-arrange-link-row">
<span class="pl-arrange-link-label">司机链接</span>
<el-input :model-value="arrangeDriverUrl" readonly />
<el-button @click="copyArrangeDriverUrl">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
<template v-if="hasArrangeSiteLinks">
<div v-if="arrangeSiteRecords.length === 1" class="pl-arrange-link-row">
<span class="pl-arrange-link-label">现场链接</span>
<el-input :model-value="arrangeSiteRecords[0].siteUrl || ''" readonly />
<el-button @click="copyArrangeSiteUrl(arrangeSiteRecords[0].siteUrl)">
<div class="pl-arrange-links-body">
<div class="pl-arrange-link-row">
<span class="pl-arrange-link-label">司机链接</span>
<el-input :model-value="arrangeDriverUrl" readonly />
<el-button @click="copyArrangeDriverUrl">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
<template v-else>
<div
v-for="record in arrangeSiteRecords"
:key="record.siteLinkId"
class="pl-arrange-link-row"
<div class="pl-arrange-site-col">
<template v-if="hasArrangeSiteLinks">
<div v-if="arrangeSiteRecords.length === 1" class="pl-arrange-link-row">
<span class="pl-arrange-link-label">现场链接</span>
<el-input :model-value="arrangeSiteRecords[0].siteUrl || ''" readonly />
<el-button @click="copyArrangeSiteUrl(arrangeSiteRecords[0].siteUrl)">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
<template v-else>
<div
v-for="record in arrangeSiteRecords"
:key="record.siteLinkId"
class="pl-arrange-link-row"
>
<span class="pl-arrange-link-label">{{ record.productName || '现场链接' }}</span>
<el-input :model-value="record.siteUrl || ''" readonly />
<el-button @click="copyArrangeSiteUrl(record.siteUrl)">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
</template>
</template>
<p
v-else-if="showArrangeLinks && planSupportsInventory"
class="pl-plan-form-empty-hint pl-plan-arrange-links-hint"
>
<span class="pl-arrange-link-label">{{ record.productName || '现场链接' }}</span>
<el-input :model-value="record.siteUrl || ''" readonly />
<el-button @click="copyArrangeSiteUrl(record.siteUrl)">
<el-icon><CopyDocument /></el-icon>复制链接
</el-button>
</div>
</template>
</template>
<p
v-else-if="showArrangeLinks && planSupportsInventory"
class="pl-plan-form-empty-hint pl-plan-arrange-links-hint"
>
暂无现场链接
</p>
暂无现场链接
</p>
</div>
</div>
</section>
<section class="pl-plan-form-section pl-plan-form-section--plain">
@@ -1412,24 +1416,46 @@
gap: 10px;
}
.pl-arrange-links-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px 16px;
align-items: start;
}
.pl-arrange-site-col {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.pl-plan-arrange-links-hint {
margin-top: 2px;
margin: 0;
}
.pl-arrange-link-row {
display: grid;
grid-template-columns: 72px 1fr auto;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
min-width: 0;
&--readonly {
grid-template-columns: 72px 1fr;
grid-template-columns: auto minmax(0, 1fr);
}
}
.pl-arrange-link-label {
font-size: 13px;
color: #64748b;
white-space: nowrap;
}
@media (max-width: 900px) {
.pl-arrange-links-body {
grid-template-columns: 1fr;
}
}
.pl-plan-view-remark {