This commit is contained in:
zlwu committed 2026-08-06 09:58:20 +08:00
1 parent 2716c2b6f8
commit f3693be913
140 files changed
+799199 -11262

No files matched your search

@@ -31,6 +31,7 @@ const props = defineProps<{
modelValue: string
lng?: number | null
lat?: number | null
contact?: string
placeholder?: string
disabled?: boolean
}>()
@@ -39,6 +40,7 @@ const emit = defineEmits<{
'update:modelValue': [value: string]
'update:lng': [value: number | null]
'update:lat': [value: number | null]
'update:contact': [value: string]
}>()
const libraryVisible = ref(false)
@@ -58,10 +60,21 @@ const innerLat = computed({
set: (value: number | null) => emit('update:lat', value)
})
function formatAddressContact(row: TransportAddress): string {
const name = (row.contactName || '').trim()
const phone = (row.contactPhone || '').trim()
if (name && phone) return `${name} ${phone}`
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
const contactText = formatAddressContact(row)
if (contactText) {
emit('update:contact', contactText)
}
}
</script>
@@ -0,0 +1,278 @@
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="960px"
align-center
append-to-body
destroy-on-close
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
style="backdrop-filter: none"
:close-on-click-modal="false"
@close="handleDialogClose"
>
<el-form
ref="formRef"
v-loading="dialogLoading"
:model="formData"
:rules="formRules"
label-position="right"
label-width="100px"
class="pl-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="承运商名称" prop="carrierName">
<el-input v-model="formData.carrierName" placeholder="请输入承运商名称" maxlength="200" />
</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="contactName">
<el-input v-model="formData.contactName" placeholder="请输入联系人" maxlength="100" />
</el-form-item>
<el-form-item label="联系电话" prop="contactPhone">
<el-input v-model="formData.contactPhone" placeholder="请输入联系电话" maxlength="32" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
maxlength="512"
/>
</el-form-item>
</div>
<el-divider content-position="left">资质附件</el-divider>
<div class="form-grid-2">
<el-form-item label="营业执照" prop="businessLicenseUrls" class="form-col-span-2">
<AttachmentUploadField
ref="businessLicenseUploadRef"
v-model="formData.businessLicenseUrls"
:limit="5"
accept="image/*,.pdf"
folder="pl/carrier/license"
hint="支持 JPG、PNG、PDF,最多 5 个;可拖拽或在此区域粘贴上传"
/>
</el-form-item>
<el-form-item label="道路经营许可证" prop="roadTransportLicenseUrls" class="form-col-span-2">
<AttachmentUploadField
ref="roadTransportLicenseUploadRef"
v-model="formData.roadTransportLicenseUrls"
:limit="5"
accept="image/*,.pdf"
folder="pl/carrier/permit"
hint="支持 JPG、PNG、PDF,最多 5 个;可拖拽或在此区域粘贴上传"
/>
</el-form-item>
<el-form-item label="运输协议" prop="transportAgreementUrls" class="form-col-span-2">
<AttachmentUploadField
ref="transportAgreementUploadRef"
v-model="formData.transportAgreementUrls"
:limit="5"
accept="image/*,.pdf"
folder="pl/carrier/agreement"
hint="支持 JPG、PNG、PDF,最多 5 个;可拖拽或在此区域粘贴上传"
/>
</el-form-item>
<el-form-item label="协议有效期" prop="transportAgreementExpireDate">
<el-date-picker
v-model="formData.transportAgreementExpireDate"
type="date"
placeholder="请选择协议有效期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = 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>
</template>
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { carrierApi, type Carrier } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { pickBxData } from '@/utils/http/bx'
defineOptions({ name: 'PlCarrierFormDialog' })
const props = defineProps<{
modelValue: boolean
/** 有值则为编辑,无值则为新增 */
carrierId?: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: [payload: { mode: 'create' | 'edit'; carrier: Carrier }]
}>()
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const isEdit = computed(() => !!props.carrierId)
const dialogTitle = computed(() => (isEdit.value ? '编辑承运商' : '新增承运商'))
const formRef = ref<FormInstance>()
const dialogLoading = ref(false)
const submitLoading = ref(false)
const businessLicenseUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const roadTransportLicenseUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const transportAgreementUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const formData = reactive({
id: '',
carrierName: '',
contactName: '',
contactPhone: '',
status: '1',
remark: '',
businessLicenseUrls: '',
roadTransportLicenseUrls: '',
transportAgreementUrls: '',
transportAgreementExpireDate: ''
})
const formRules: FormRules = {
carrierName: [{ required: true, message: '请输入承运商名称', trigger: 'blur' }],
contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
contactPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }]
}
function hasAttachmentUploading() {
return [businessLicenseUploadRef, roadTransportLicenseUploadRef, transportAgreementUploadRef].some(
item => item.value?.hasUploading?.()
)
}
function resetForm() {
Object.assign(formData, {
id: '',
carrierName: '',
contactName: '',
contactPhone: '',
status: '1',
remark: '',
businessLicenseUrls: '',
roadTransportLicenseUrls: '',
transportAgreementUrls: '',
transportAgreementExpireDate: ''
})
formRef.value?.clearValidate()
}
async function openDialog() {
if (!props.carrierId) {
resetForm()
return
}
dialogLoading.value = true
try {
const res = await carrierApi.get(props.carrierId)
const detail = pickBxData<Carrier>(res) || ({} as Carrier)
Object.assign(formData, {
id: detail.id || '',
carrierName: detail.carrierName || '',
contactName: detail.contactName || '',
contactPhone: detail.contactPhone || '',
status: detail.status || '1',
remark: detail.remark || '',
businessLicenseUrls: detail.businessLicenseUrls || '',
roadTransportLicenseUrls: detail.roadTransportLicenseUrls || '',
transportAgreementUrls: detail.transportAgreementUrls || '',
transportAgreementExpireDate: detail.transportAgreementExpireDate || ''
})
} catch {
ElMessage.error('加载承运商详情失败')
visible.value = false
} finally {
dialogLoading.value = false
}
}
function handleDialogClose() {
formRef.value?.resetFields()
resetForm()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
if (hasAttachmentUploading()) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitLoading.value = true
try {
const payload = {
carrierName: formData.carrierName,
contactName: formData.contactName,
contactPhone: formData.contactPhone,
status: formData.status,
remark: formData.remark,
businessLicenseUrls: formData.businessLicenseUrls,
roadTransportLicenseUrls: formData.roadTransportLicenseUrls,
transportAgreementUrls: formData.transportAgreementUrls,
...(formData.transportAgreementExpireDate
? { transportAgreementExpireDate: formData.transportAgreementExpireDate }
: {})
}
if (isEdit.value) {
await carrierApi.update(formData.id, payload)
const res = await carrierApi.get(formData.id)
const detail = pickBxData<Carrier>(res)
if (!detail?.id) {
ElMessage.error('编辑成功但未返回承运商信息')
return
}
ElMessage.success('编辑成功')
emit('saved', { mode: 'edit', carrier: detail })
visible.value = false
return
}
const res = await carrierApi.create(payload)
const created = pickBxData<Carrier>(res)
if (!created?.id) {
ElMessage.error('新增成功但未返回承运商信息')
return
}
ElMessage.success('新增成功')
emit('saved', { mode: 'create', carrier: created })
visible.value = false
} finally {
submitLoading.value = false
}
}
watch(
() => props.modelValue,
value => {
if (value) void openDialog()
}
)
</script>
@@ -0,0 +1,240 @@
<template>
<el-dialog
v-model="innerVisible"
title="从承运商库选择"
width="1200px"
align-center
destroy-on-close
append-to-body
class="dialog-form-layout pl-carrier-library-select-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
style="backdrop-filter: none"
@open="handleOpen"
>
<div class="panel dialog-list-panel">
<div class="list-toolbar">
<div class="list-toolbar-row pl-carrier-library-select-toolbar">
<el-input
v-model="keyword"
class="pl-carrier-library-select-search"
placeholder="搜索承运商名称、联系人或电话"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button class="btn-main" type="primary" @click="handleOpenCreate">
<el-icon><Plus /></el-icon>新增承运商
</el-button>
</div>
</div>
<div class="list-body list-wrap dialog-list-body-only">
<ListTable
table-key="business-pl-carrier-library-select-dialog"
class="list-table pl-carrier-library-select-table"
:data="rows"
v-loading="loading"
stripe
row-key="id"
highlight-current-row
:max-height="560"
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="carrierName" label="承运商名称" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="handleConfirm(row)">{{ row.carrierName }}</el-link>
</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">
<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
label="操作"
min-width="160"
align="center"
header-align="center"
fixed="right"
class-name="col-actions"
>
<template #default="{ row }">
<el-button link type="primary" @click="handleOpenEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="primary" @click="handleConfirm(row)">
<el-icon><Select /></el-icon>选择
</el-button>
</template>
</el-table-column>
</ListTable>
<ListPagination
v-show="pagination.total > 0"
:total="pagination.total"
v-model:page="pagination.page"
v-model:limit="pagination.pageSize"
@pagination="fetchList"
/>
</div>
</div>
<PlCarrierFormDialog
v-model="formVisible"
:carrier-id="editingId"
@saved="handleFormSaved"
/>
</el-dialog>
</template>
<script setup lang="ts">
import { Edit, Plus, Search, Select } from '@element-plus/icons-vue'
import { carrierApi, type Carrier } from '@/api/pl/index'
import PlCarrierFormDialog from '@/components/business/pl-carrier-form-dialog/index.vue'
import ListPagination from '@/components/ListPagination/index.vue'
import { pickBxPageResult } from '@/utils/http/bx'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
defineOptions({ name: 'PlCarrierLibrarySelectDialog' })
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
select: [row: Carrier]
}>()
const innerVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const loading = ref(false)
const keyword = ref('')
const rows = ref<Carrier[]>([])
const pagination = reactive({
page: 1,
pageSize: DEFAULT_LIST_PAGE_SIZE,
total: 0
})
const formVisible = ref(false)
const editingId = ref<string | null>(null)
async function fetchList() {
loading.value = true
try {
const res = await carrierApi.list({
q: keyword.value.trim() || undefined,
page: pagination.page,
pageSize: pagination.pageSize
})
const { list, total } = pickBxPageResult<Carrier>(res)
rows.value = list
pagination.total = total
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchList()
}
function handleOpen() {
keyword.value = ''
pagination.page = 1
pagination.pageSize = DEFAULT_LIST_PAGE_SIZE
pagination.total = 0
rows.value = []
formVisible.value = false
editingId.value = null
fetchList()
}
function handleOpenCreate() {
editingId.value = null
formVisible.value = true
}
function handleOpenEdit(row: Carrier) {
editingId.value = row.id
formVisible.value = true
}
function handleFormSaved(payload: { mode: 'create' | 'edit'; carrier: Carrier }) {
formVisible.value = false
editingId.value = null
if (payload.mode === 'create') {
handleConfirm(payload.carrier)
return
}
fetchList()
}
function handleConfirm(row: Carrier) {
emit('select', row)
innerVisible.value = false
}
</script>
<style scoped lang="scss">
.pl-carrier-library-select-dialog {
:deep(.el-dialog__body) {
min-height: 560px;
}
.dialog-list-panel {
.list-toolbar {
margin-bottom: 0;
padding: 10px 0 12px;
}
}
}
.pl-carrier-library-select-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pl-carrier-library-select-search {
width: 320px;
max-width: 100%;
}
</style>
@@ -39,7 +39,11 @@
v-model="carrierId"
placeholder="请选择承运商"
filterable
remote
clearable
:remote-method="searchCarriers"
:loading="carrierSelectLoading"
@focus="onCarrierSelectFocus"
style="width: 100%"
>
<el-option
@@ -130,7 +134,6 @@
<script setup lang="ts">
import { Check, Close, MagicStick } from '@element-plus/icons-vue'
import {
carrierApi,
pasteRecognizeApi,
personnelApi,
vehicleApi,
@@ -138,6 +141,7 @@
type PasteConflictAction,
type PasteConflictItem
} from '@/api/pl'
import { usePlRemoteCarrierOptions } from '@/composables/usePlRemoteOptions'
import { pickBxPageResult } from '@/utils/http/bx'
import {
PL_PASTE_SAMPLE_TEXT,
@@ -175,7 +179,14 @@
const carrierId = ref('')
const createVehicle = ref(true)
const submitLoading = ref(false)
const localCarriers = ref<Carrier[]>([])
const {
list: remoteCarriers,
loading: carrierSelectLoading,
search: searchCarriers,
onFocus: onCarrierSelectFocus
} = usePlRemoteCarrierOptions({
getSelectedId: () => carrierId.value
})
const vehicleForm = reactive({
licensePlate: '',
@@ -202,9 +213,10 @@
}
const conflictRows = ref<ConflictRow[]>([])
const carrierOptions = computed(() =>
props.carrierOptions.length ? props.carrierOptions : localCarriers.value
)
const carrierOptions = computed(() => {
if (remoteCarriers.value.length) return remoteCarriers.value
return props.carrierOptions
})
const hasPlate = computed(() => Boolean(vehicleForm.licensePlate.trim()))
const showVehicleFields = computed(() => {
@@ -213,11 +225,7 @@
})
async function ensureCarriers() {
if (props.carrierOptions.length) return
if (localCarriers.value.length) return
const res = await carrierApi.list({ page: 1, pageSize: 100 })
const page = pickBxPageResult<Carrier>(res)
localCarriers.value = page.list || []
await searchCarriers('')
}
async function handleParse() {
@@ -45,7 +45,11 @@
class="pl-arrange-vehicle-select"
placeholder="请选择车辆"
filterable
:loading="vehicleLoading"
remote
clearable
:remote-method="searchVehicles"
:loading="vehicleSelectLoading"
@focus="onVehicleSelectFocus"
@change="handleVehicleChange"
>
<el-option
@@ -69,8 +73,9 @@
<el-form-item label="选择司机" prop="driverId">
<el-select
v-model="formData.driverId"
placeholder="请先选择车辆"
placeholder="请先选择车辆(可选)"
filterable
clearable
:disabled="!formData.vehicleId || vehicleDriverList.length === 0"
>
<el-option
@@ -86,7 +91,11 @@
v-model="formData.carrierId"
placeholder="请选择运输公司"
filterable
remote
clearable
:remote-method="searchCarriers"
:loading="carrierSelectLoading"
@focus="onCarrierSelectFocus"
>
<el-option v-for="c in carrierList" :key="c.id" :label="c.carrierName" :value="c.id" />
</el-select>
@@ -104,6 +113,7 @@
v-model="formData.pickupAddress"
v-model:lng="formData.pickupLng"
v-model:lat="formData.pickupLat"
v-model:contact="formData.pickupContact"
placeholder="取货地址"
/>
</el-form-item>
@@ -117,10 +127,13 @@
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-input v-model="formData.pickupNotes" placeholder="取货注意事项" />
<el-form-item label="取货联系方式">
<el-input v-model="formData.pickupContact" placeholder="取货联系方式" />
</el-form-item>
</div>
<el-form-item label="注意事项">
<el-input v-model="formData.pickupNotes" placeholder="取货注意事项" />
</el-form-item>
</div>
</div>
@@ -152,23 +165,27 @@
v-model="formData.destinationAddress"
v-model:lng="formData.destinationLng"
v-model:lat="formData.destinationLat"
v-model:contact="formData.deliveryContact"
placeholder="送货地址"
/>
</el-form-item>
<div class="form-grid-2 pl-logistics-meta-row">
<el-form-item label="送达时间">
<el-form-item label="送货时间">
<el-date-picker
v-model="formData.deliveryTime"
type="date"
placeholder="选择送达时间"
placeholder="选择送货时间"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-input v-model="formData.destinationNotes" placeholder="送货注意事项" />
<el-form-item label="送货联系方式">
<el-input v-model="formData.deliveryContact" placeholder="送货联系方式" />
</el-form-item>
</div>
<el-form-item label="注意事项">
<el-input v-model="formData.destinationNotes" placeholder="送货注意事项" />
</el-form-item>
</div>
</div>
</div>
@@ -251,7 +268,9 @@
destinationAddress: formData.destinationAddress,
destinationLng: formData.destinationLng,
destinationLat: formData.destinationLat,
pickupContact: formData.pickupContact,
pickupNotes: formData.pickupNotes,
deliveryContact: formData.deliveryContact,
destinationNotes: formData.destinationNotes,
licensePlate: vehicleList.find(v => v.id === formData.vehicleId)?.licensePlate,
driverUrl: arrangeResult?.driverUrl
@@ -322,16 +341,12 @@
<script setup lang="ts">
import { ArrowDown, Check, Close, CopyDocument, Printer, View } from '@element-plus/icons-vue'
import {
carrierApi,
planApi,
transportApi,
vehicleApi,
type Carrier,
type Plan,
type PlanProduct,
type Transport,
type TransportArrangeResult,
type Vehicle,
type VehiclePersonnelRow
} from '@/api/pl'
import DbPoundSlipQrcodeDialog from '@/components/business/db/DbPoundSlipQrcodeDialog.vue'
@@ -343,13 +358,17 @@ import PlVehicleViewDialog from '@/components/business/pl-vehicle-view-dialog/in
import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { usePlFreightCalc } from '@/composables/usePlFreightCalc'
import {
usePlRemoteCarrierOptions,
usePlRemoteVehicleOptions
} from '@/composables/usePlRemoteOptions'
import { loadVehicleViewBundle, type VehicleViewBundle } from '@/composables/usePlVehicleViewDetail'
import { buildMissingHazardCertReadyMessage } from '@/composables/usePlToxicProductCertCheck'
import { usePlPlanArrangeActions } from '@/composables/usePlPlanArrangeActions'
import { useDictStore } from '@/store/modules/dict'
import { isTonProductUnit } from '@/utils/crm-product-display'
import { formatDate } from '@/utils/format'
import { pickBxData, pickBxList } from '@/utils/http/bx'
import { pickBxData } from '@/utils/http/bx'
import { canPrintSlipCommand, formatPlVehiclePlateDisplay, type PlPrintSlipCommand } from '@/utils/pl-display'
const dictStore = useDictStore()
@@ -399,8 +418,28 @@ const subpackSlipPrintRef = ref<InstanceType<typeof PlSubpackSlipPrintDialog> |
const formRef = ref()
const plan = ref<Partial<Plan>>({})
const products = ref<PlanProduct[]>([])
const vehicleList = ref<Vehicle[]>([])
const carrierList = ref<Carrier[]>([])
const {
list: vehicleList,
loading: vehicleSelectLoading,
search: searchVehicles,
onFocus: onVehicleSelectFocus,
reset: resetVehicleOptions,
ensureById: ensureSelectedVehicleOption
} = usePlRemoteVehicleOptions({
getSelectedId: () => formData.vehicleId,
listParams: { vehicleStatus: 'available' }
})
const {
list: carrierList,
loading: carrierSelectLoading,
search: searchCarriers,
onFocus: onCarrierSelectFocus,
reset: resetCarrierOptions,
ensureById: ensureSelectedCarrierOption
} = usePlRemoteCarrierOptions({
getSelectedId: () => formData.carrierId,
listParams: { status: '1' }
})
const vehicleDriverList = ref<VehiclePersonnelRow[]>([])
const prefilledDriverId = ref('')
const vehicleBundle = ref<VehicleViewBundle | null>(null)
@@ -416,11 +455,13 @@ const formData = reactive({
pickupLng: null as number | null,
pickupLat: null as number | null,
pickupTime: '',
pickupContact: '',
pickupNotes: '',
destinationAddress: '',
destinationLng: null as number | null,
destinationLat: null as number | null,
deliveryTime: '',
deliveryContact: '',
destinationNotes: '',
hasFreight: 0,
distanceKm: null as number | null,
@@ -545,11 +586,13 @@ async function applyPrefillFromPlan(data: Plan) {
formData.pickupLng = data.pickupLng != null ? Number(data.pickupLng) : null
formData.pickupLat = data.pickupLat != null ? Number(data.pickupLat) : null
formData.pickupTime = formatDate(data.pickupTime) || ''
formData.pickupContact = data.pickupContact || ''
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.deliveryContact = data.deliveryContact || ''
formData.destinationNotes = data.destinationNotes || ''
formData.hasFreight = data.hasFreight ?? 0
formData.remark = data.remark || ''
@@ -610,6 +653,8 @@ function resetState() {
plan.value = {}
products.value = []
vehicleDriverList.value = []
resetVehicleOptions()
resetCarrierOptions()
prefilledDriverId.value = ''
vehicleBundle.value = null
vehicleViewVisible.value = false
@@ -623,11 +668,13 @@ function resetState() {
pickupLng: null,
pickupLat: null,
pickupTime: '',
pickupContact: '',
pickupNotes: '',
destinationAddress: '',
destinationLng: null,
destinationLat: null,
deliveryTime: '',
deliveryContact: '',
destinationNotes: '',
hasFreight: 0,
distanceKm: null,
@@ -640,12 +687,7 @@ function resetState() {
}
async function loadMasterData() {
const [vehicleRes, carrierRes] = await Promise.all([
vehicleApi.list({ vehicleStatus: 'available' }),
carrierApi.list({ status: '1', page: 1, pageSize: 100 })
])
vehicleList.value = pickBxList<Vehicle>(vehicleRes)
carrierList.value = pickBxList<Carrier>(carrierRes)
await Promise.all([searchVehicles(''), searchCarriers('')])
}
async function handleVehicleChange(vehicleId: string, preferredDriverId?: string) {
@@ -672,6 +714,9 @@ async function handleVehicleChange(vehicleId: string, preferredDriverId?: string
if (vehicleBundle.value.detail?.carrierId) {
formData.carrierId = vehicleBundle.value.detail.carrierId
}
if (formData.carrierId) {
await ensureSelectedCarrierOption(formData.carrierId)
}
applyVehiclePersonnel(vehicleBundle.value.personnelList || [], preferredDriverId)
} finally {
vehicleLoading.value = false
@@ -693,8 +738,12 @@ async function handleOpened() {
products.value = Array.isArray(data.products) ? data.products : []
await applyPrefillFromPlan(data)
if (formData.vehicleId) {
await ensureSelectedVehicleOption(formData.vehicleId)
await handleVehicleChange(formData.vehicleId, prefilledDriverId.value || data.driverId)
}
if (formData.carrierId) {
await ensureSelectedCarrierOption(formData.carrierId)
}
} catch (error) {
innerVisible.value = false
const message = error instanceof Error ? error.message : '加载运输计划失败'
@@ -754,17 +803,19 @@ async function handleSubmit() {
const payload = {
vehicleId: formData.vehicleId,
driverId: formData.driverId,
driverId: formData.driverId || undefined,
carrierId: formData.carrierId || undefined,
pickupAddress: formData.pickupAddress || undefined,
pickupLng: formData.pickupLng,
pickupLat: formData.pickupLat,
pickupTime: formData.pickupTime || undefined,
pickupContact: formData.pickupContact || undefined,
pickupNotes: formData.pickupNotes || undefined,
destinationAddress: formData.destinationAddress || undefined,
destinationLng: formData.destinationLng,
destinationLat: formData.destinationLat,
deliveryTime: formData.deliveryTime || undefined,
deliveryContact: formData.deliveryContact || undefined,
destinationNotes: formData.destinationNotes || undefined,
transportMedium: transportMediumText.value || undefined,
distanceKm: formData.distanceKm ?? undefined,
@@ -108,31 +108,61 @@
<el-form :model="formData" label-position="right" label-width="100px" class="pl-dialog-form">
<div class="form-grid-4">
<el-form-item label="车牌号">
<el-select
v-model="formData.vehicleId"
placeholder="请选择车牌号"
filterable
clearable
:loading="vehicleLoading"
@change="handleVehicleChange"
>
<el-option
v-for="item in vehicleList"
:key="item.id"
:label="item.licensePlate"
:value="item.id"
/>
</el-select>
<div class="pl-plan-vehicle-with-library">
<el-select
v-model="formData.vehicleId"
class="pl-plan-vehicle-with-library__select"
placeholder="请选择车牌号"
filterable
remote
clearable
:remote-method="searchVehicles"
:loading="vehicleSelectLoading || vehicleLoading"
@focus="onVehicleSelectFocus"
@change="handleVehicleChange"
>
<el-option
v-for="item in vehicleList"
:key="item.id"
:label="item.licensePlate"
:value="item.id"
/>
</el-select>
<el-button
class="pl-plan-vehicle-with-library__btn btn-ghost"
@click="vehicleLibraryVisible = true"
>
<el-icon><FolderOpened /></el-icon>从车辆库选择
</el-button>
</div>
</el-form-item>
<el-form-item label="运输公司">
<el-select
v-model="formData.carrierId"
placeholder="请选择运输公司"
filterable
clearable
>
<el-option v-for="c in carrierList" :key="c.id" :label="c.carrierName" :value="c.id" />
</el-select>
<div class="pl-plan-carrier-with-library">
<el-select
v-model="formData.carrierId"
class="pl-plan-carrier-with-library__select"
placeholder="请选择运输公司"
filterable
remote
clearable
:remote-method="searchCarriers"
:loading="carrierSelectLoading"
@focus="onCarrierSelectFocus"
>
<el-option
v-for="c in carrierList"
:key="c.id"
:label="c.carrierName"
:value="c.id"
/>
</el-select>
<el-button
class="pl-plan-carrier-with-library__btn btn-ghost"
@click="carrierLibraryVisible = true"
>
<el-icon><FolderOpened /></el-icon>从承运商库选择
</el-button>
</div>
</el-form-item>
</div>
@@ -153,6 +183,7 @@
v-model="formData.pickupAddress"
v-model:lng="formData.pickupLng"
v-model:lat="formData.pickupLat"
v-model:contact="formData.pickupContact"
placeholder="取货地址"
/>
</el-form-item>
@@ -165,7 +196,10 @@
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-form-item label="取货联系方式">
<el-input v-model="formData.pickupContact" placeholder="取货联系方式" />
</el-form-item>
<el-form-item label="注意事项" class="form-col-span-2">
<el-input v-model="formData.pickupNotes" placeholder="取货注意事项" />
</el-form-item>
</div>
@@ -200,19 +234,23 @@
v-model="formData.destinationAddress"
v-model:lng="formData.destinationLng"
v-model:lat="formData.destinationLat"
v-model:contact="formData.deliveryContact"
placeholder="送货地址"
/>
</el-form-item>
<el-form-item label="送达时间">
<el-form-item label="送货时间">
<el-date-picker
v-model="formData.deliveryTime"
type="date"
placeholder="选择送达时间"
placeholder="选择送货时间"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="注意事项">
<el-form-item label="送货联系方式">
<el-input v-model="formData.deliveryContact" placeholder="送货联系方式" />
</el-form-item>
<el-form-item label="注意事项" class="form-col-span-2">
<el-input v-model="formData.destinationNotes" placeholder="送货注意事项" />
</el-form-item>
</div>
@@ -263,19 +301,42 @@
:vehicle-id="formData.vehicleId"
:bundle="vehicleBundle"
/>
<PlVehicleLibrarySelectDialog
v-model="vehicleLibraryVisible"
@select="handleVehicleLibrarySelect"
/>
<PlCarrierLibrarySelectDialog
v-model="carrierLibraryVisible"
@select="handleCarrierLibrarySelect"
/>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close, Search, Van, View } from '@element-plus/icons-vue'
import { Check, Close, FolderOpened, Search, Van, View } from '@element-plus/icons-vue'
import { getContract } from '@/api/crm/contract'
import type { ContractVO } from '@/api/crm/contract/types'
import { carrierApi, planApi, transportApi, vehicleApi, type Carrier, type Plan, type PlanProduct, type PlanUpdatePayload, type Vehicle, type VehiclePersonnelRow } from '@/api/pl'
import {
planApi,
transportApi,
type Plan,
type PlanProduct,
type PlanUpdatePayload,
type Carrier,
type Vehicle,
type VehiclePersonnelRow
} from '@/api/pl'
import {
usePlRemoteCarrierOptions,
usePlRemoteVehicleOptions
} from '@/composables/usePlRemoteOptions'
import ContractSelector from '@/components/business/contract-selector/index.vue'
import ViewKvGrid from '@/components/business/system-view-dialog/ViewKvGrid.vue'
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 PlVehicleLibrarySelectDialog from '@/components/business/pl-vehicle-library-select-dialog/index.vue'
import PlCarrierLibrarySelectDialog from '@/components/business/pl-carrier-library-select-dialog/index.vue'
import PlAddressWithLibrary from '@/components/business/pl-address-with-library/index.vue'
import PlPlanFormProductTable from './PlPlanFormProductTable.vue'
import {
@@ -294,7 +355,7 @@ import { loadVehicleViewBundle, type VehicleViewBundle } from '@/composables/use
import { useDictStore } from '@/store/modules/dict'
import { useUserStore } from '@/store/modules/user'
import { formatDate } from '@/utils/format'
import { pickBxData, pickBxList } from '@/utils/http/bx'
import { pickBxData } from '@/utils/http/bx'
const ContractDetailDialog = defineAsyncComponent(
() => import('@/views/crm/contract/components/ContractDetailDialog.vue')
@@ -365,11 +426,31 @@ const contractSelectorVisible = ref(false)
const contractDetailDialogActive = ref(false)
const contractDetailRef = ref<InstanceType<typeof ContractDetailDialog>>()
const vehicleViewVisible = ref(false)
const vehicleLibraryVisible = ref(false)
const carrierLibraryVisible = ref(false)
const vehicleLoading = ref(false)
const basicFormRef = ref()
const contractSnapshot = ref<ContractVO | null>(null)
const vehicleList = ref<Vehicle[]>([])
const carrierList = ref<Carrier[]>([])
const {
list: vehicleList,
loading: vehicleSelectLoading,
search: searchVehicles,
onFocus: onVehicleSelectFocus,
ensureById: ensureSelectedVehicleOption
} = usePlRemoteVehicleOptions({
getSelectedId: () => formData.vehicleId,
listParams: { vehicleStatus: 'available' }
})
const {
list: carrierList,
loading: carrierSelectLoading,
search: searchCarriers,
onFocus: onCarrierSelectFocus,
ensureById: ensureSelectedCarrierOption
} = usePlRemoteCarrierOptions({
getSelectedId: () => formData.carrierId,
listParams: { status: '1' }
})
const vehicleBundle = ref<VehicleViewBundle | null>(null)
const selectedDriverId = ref('')
@@ -400,11 +481,13 @@ const formData = reactive({
pickupLng: null as number | null,
pickupLat: null as number | null,
pickupTime: '',
pickupContact: '',
pickupNotes: '',
destinationAddress: '',
destinationLng: null as number | null,
destinationLat: null as number | null,
deliveryTime: '',
deliveryContact: '',
destinationNotes: '',
vehicleId: '',
carrierId: '',
@@ -615,11 +698,13 @@ function buildPlanCommonPayload(options?: { allowClearTransport?: boolean }): Pl
pickupLng: formData.pickupLng,
pickupLat: formData.pickupLat,
pickupTime: formData.pickupTime || undefined,
pickupContact: formData.pickupContact || undefined,
pickupNotes: formData.pickupNotes || undefined,
destinationAddress: formData.destinationAddress || undefined,
destinationLng: formData.destinationLng,
destinationLat: formData.destinationLat,
deliveryTime: formData.deliveryTime || undefined,
deliveryContact: formData.deliveryContact || undefined,
destinationNotes: formData.destinationNotes || undefined,
followUserId: formData.followUserId || undefined,
followUserName: formData.followUserName || undefined,
@@ -702,11 +787,13 @@ function resetForm() {
pickupLng: null as number | null,
pickupLat: null as number | null,
pickupTime: '',
pickupContact: '',
pickupNotes: '',
destinationAddress: '',
destinationLng: null as number | null,
destinationLat: null as number | null,
deliveryTime: '',
deliveryContact: '',
destinationNotes: '',
vehicleId: '',
carrierId: '',
@@ -778,11 +865,13 @@ async function loadPlanForEdit(planId: string) {
pickupLng: data.pickupLng != null ? Number(data.pickupLng) : null,
pickupLat: data.pickupLat != null ? Number(data.pickupLat) : null,
pickupTime: formatDate(data.pickupTime) || '',
pickupContact: data.pickupContact || '',
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) || '',
deliveryContact: data.deliveryContact || '',
destinationNotes: data.destinationNotes || '',
vehicleId: data.vehicleId || '',
remark: data.remark || '',
@@ -806,10 +895,14 @@ async function loadPlanForEdit(planId: string) {
await refreshContractProductRows()
if (data.vehicleId) {
await ensureSelectedVehicleOption(data.vehicleId)
await handleVehicleChange(data.vehicleId)
}
freightCalc.resetFreightCalc()
await restoreFreightFromTransport(data)
if (formData.carrierId) {
await ensureSelectedCarrierOption(formData.carrierId)
}
} finally {
detailLoading.value = false
}
@@ -866,17 +959,7 @@ function handleClosed() {
}
async function loadMasterData() {
try {
const [vehicleRes, carrierRes] = await Promise.all([
vehicleApi.list({ vehicleStatus: 'available' }),
carrierApi.list({ status: '1', page: 1, pageSize: 100 })
])
vehicleList.value = pickBxList<Vehicle>(vehicleRes)
carrierList.value = pickBxList<Carrier>(carrierRes)
} catch {
vehicleList.value = []
carrierList.value = []
}
await Promise.all([searchVehicles(''), searchCarriers('')])
}
async function handleViewContract() {
@@ -957,6 +1040,22 @@ async function handleVehicleChange(vehicleId: string) {
}
}
async function handleVehicleLibrarySelect(vehicle: Vehicle) {
if (!vehicle?.id) return
await ensureSelectedVehicleOption(vehicle.id)
formData.vehicleId = vehicle.id
await handleVehicleChange(vehicle.id)
if (vehicle.carrierId) {
await ensureSelectedCarrierOption(vehicle.carrierId)
}
}
async function handleCarrierLibrarySelect(carrier: Carrier) {
if (!carrier?.id) return
await ensureSelectedCarrierOption(carrier.id)
formData.carrierId = carrier.id
}
function validateBusinessRules() {
if (!formData.contractId) {
ElMessage.warning('请选择合同')
@@ -1046,6 +1145,26 @@ async function handleSubmit(mode: 'save' | 'saveAndArrange' = 'save') {
</script>
<style scoped lang="scss">
.pl-plan-vehicle-with-library,
.pl-plan-carrier-with-library {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.pl-plan-vehicle-with-library__select,
.pl-plan-carrier-with-library__select {
flex: 1 1 auto;
min-width: 0;
}
.pl-plan-vehicle-with-library__btn,
.pl-plan-carrier-with-library__btn {
flex: 0 0 auto;
white-space: nowrap;
}
.pl-plan-form-body {
display: flex;
flex-direction: column;
@@ -8,8 +8,10 @@
<span
class="pl-plan-list-product-cell__line"
:class="{
'pl-plan-list-product-cell__line--qty': field === 'qty',
'pl-plan-list-product-cell__line--center': field === 'spec' || field === 'unit'
'pl-plan-list-product-cell__line--qty':
field === 'qty' || field === 'planQty' || field === 'actualQty',
'pl-plan-list-product-cell__line--center':
field === 'spec' || field === 'model' || field === 'packageSpec' || field === 'unit'
}"
>
<template v-if="!field">
@@ -88,11 +90,23 @@ import {
import { useDictStore } from '@/store/modules/dict'
import {
formatPlanProductListText,
getPlanProductActualQuantity,
getPlanProductModel,
getPlanProductPackageSpec,
getPlanProductPlannedQuantity,
getPlanProductQuantity,
getPlanProductSpec
} from '@/utils/pl-display'
export type PlPlanListProductCellField = 'name' | 'spec' | 'qty' | 'unit'
export type PlPlanListProductCellField =
| 'name'
| 'spec'
| 'model'
| 'packageSpec'
| 'qty'
| 'planQty'
| 'actualQty'
| 'unit'
const props = withDefaults(
defineProps<{
@@ -122,9 +136,21 @@ function formatFieldValue(product: PlanProduct) {
if (props.field === 'spec') {
return getPlanProductSpec(product)
}
if (props.field === 'model') {
return getPlanProductModel(product)
}
if (props.field === 'packageSpec') {
return getPlanProductPackageSpec(product)
}
if (props.field === 'qty') {
return getPlanProductQuantity(product)
}
if (props.field === 'planQty') {
return getPlanProductPlannedQuantity(product)
}
if (props.field === 'actualQty') {
return getPlanProductActualQuantity(product)
}
if (props.field === 'unit') {
const unitRaw = product.unit || ''
if (!unitRaw) return '—'
@@ -5,7 +5,7 @@
@tab-change="handleTabChange"
>
<el-tab-pane
v-for="item in PL_PLAN_TYPE_TABS"
v-for="item in resolvedTabs"
:key="item.name"
:label="item.label"
:name="item.name"
@@ -14,19 +14,23 @@
</template>
<script setup lang="ts">
import { PL_PLAN_TYPE_TABS, type PlPlanTypeTab } from '@/utils/pl-display'
import { PL_PLAN_TYPE_TABS } from '@/utils/pl-display'
defineProps<{
modelValue: PlPlanTypeTab
const props = defineProps<{
modelValue: string
/** 覆盖默认单据类型 Tab(如危化品证上传页追加「未上传证书」) */
tabs?: { label: string; name: string }[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: PlPlanTypeTab]
change: [value: PlPlanTypeTab]
'update:modelValue': [value: string]
change: [value: string]
}>()
const resolvedTabs = computed(() => props.tabs ?? PL_PLAN_TYPE_TABS)
function handleTabChange(name: string | number) {
const value = String(name) as PlPlanTypeTab
const value = String(name)
emit('update:modelValue', value)
emit('change', value)
}
@@ -1131,7 +1131,8 @@
const pickupViewFields = computed<ViewField[]>(() => [
{ label: '取货地址', value: plan.value?.pickupAddress, span: 'full' },
{ label: '取货时间', value: formatDate(plan.value?.pickupTime) || '—', long: false },
{ label: '注意事项', value: plan.value?.pickupNotes, long: false, fullSpan: false }
{ label: '取货联系方式', value: plan.value?.pickupContact, long: false, fullSpan: false },
{ label: '注意事项', value: plan.value?.pickupNotes, span: 'full' }
])
const deliveryViewFields = computed<ViewField[]>(() => [
@@ -1140,8 +1141,9 @@
value: plan.value?.destinationAddress || plan.value?.destinationName,
span: 'full'
},
{ label: '送达时间', value: formatDate(plan.value?.deliveryTime) || '—', long: false },
{ label: '注意事项', value: plan.value?.destinationNotes, long: false, fullSpan: false }
{ label: '送货时间', value: formatDate(plan.value?.deliveryTime) || '—', long: false },
{ label: '送货联系方式', value: plan.value?.deliveryContact, long: false, fullSpan: false },
{ label: '注意事项', value: plan.value?.destinationNotes, span: 'full' }
])
const freightViewFields = computed<ViewField[]>(() => {
@@ -2,7 +2,7 @@
<el-dialog
v-model="innerVisible"
title="从地址库选择"
width="1000px"
width="1200px"
align-center
destroy-on-close
append-to-body
@@ -12,35 +12,21 @@
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-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-row pl-transport-address-select-toolbar">
<el-input
v-model="keyword"
class="pl-transport-address-select-search"
placeholder="搜索地址名称、详细地址、联系人或电话"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button class="btn-main" type="primary" @click="handleOpenCreate">
<el-icon><Plus /></el-icon>新增地址
</el-button>
@@ -48,7 +34,8 @@
</div>
<div class="list-body list-wrap dialog-list-body-only">
<ListTable table-key="business-pl-transport-address-select-dialog"
<ListTable
table-key="business-pl-transport-address-select-dialog"
class="list-table pl-transport-address-select-table"
:data="rows"
v-loading="loading"
@@ -72,13 +59,7 @@
<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="fullAddress" label="详细地址" min-width="280" show-overflow-tooltip />
<el-table-column
prop="contactName"
label="联系人"
@@ -95,13 +76,16 @@
/>
<el-table-column
label="操作"
min-width="100"
min-width="160"
align="center"
header-align="center"
fixed="right"
class-name="col-actions"
>
<template #default="{ row }">
<el-button link type="primary" @click="handleOpenEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="primary" @click="handleConfirm(row)">
<el-icon><Select /></el-icon>选择
</el-button>
@@ -120,22 +104,22 @@
</div>
<el-dialog
v-model="createVisible"
title="新增地址"
v-model="formVisible"
:title="formDialogTitle"
width="640px"
align-center
destroy-on-close
append-to-body
class="dialog-form-layout pl-transport-address-create-dialog"
class="dialog-form-layout pl-transport-address-form-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
style="backdrop-filter: none"
@closed="handleCreateClosed"
@closed="handleFormClosed"
>
<el-form
ref="createFormRef"
:model="createForm"
:rules="createRules"
ref="formRef"
:model="addressForm"
:rules="formRules"
label-position="top"
class="pl-dialog-form"
scroll-to-error
@@ -143,33 +127,33 @@
>
<div class="form-grid-2">
<el-form-item label="地址名称" prop="addressName">
<el-input v-model="createForm.addressName" placeholder="如:安庆仓库、某某化工厂" />
<el-input v-model="addressForm.addressName" placeholder="如:安庆仓库、某某化工厂" />
</el-form-item>
<el-form-item label="详细地址" prop="fullAddress" class="form-col-span-2">
<AmapAddressPicker
v-model="createForm.fullAddress"
v-model:lng="createForm.lng"
v-model:lat="createForm.lat"
v-model="addressForm.fullAddress"
v-model:lng="addressForm.lng"
v-model:lat="addressForm.lat"
placeholder="请地图选点或搜索 POI"
/>
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="createForm.contactName" placeholder="联系人(可选)" />
<el-input v-model="addressForm.contactName" placeholder="联系人(可选)" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="createForm.contactPhone" placeholder="联系电话(可选)" />
<el-input v-model="addressForm.contactPhone" placeholder="联系电话(可选)" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="createForm.remark" type="textarea" :rows="2" placeholder="备注(可选)" />
<el-input v-model="addressForm.remark" type="textarea" :rows="2" placeholder="备注(可选)" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="createVisible = false">
<el-button @click="formVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="createLoading" @click="handleCreateSubmit">
<el-button type="primary" :loading="formLoading" @click="handleFormSubmit">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
@@ -179,7 +163,7 @@
</template>
<script setup lang="ts">
import { Check, Close, Plus, RefreshLeft, Search, Select } from '@element-plus/icons-vue'
import { Check, Close, Edit, Plus, Search, Select } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import {
transportAddressApi,
@@ -215,11 +199,12 @@ const pagination = reactive({
total: 0
})
const createVisible = ref(false)
const createLoading = ref(false)
const createFormRef = ref<FormInstance>()
const formVisible = ref(false)
const formLoading = ref(false)
const formRef = ref<FormInstance>()
const editingId = ref<string | null>(null)
const createForm = reactive({
const addressForm = reactive({
addressName: '',
fullAddress: '',
lng: null as number | null,
@@ -229,11 +214,13 @@ const createForm = reactive({
remark: ''
})
const createRules: FormRules = {
const formRules: FormRules = {
addressName: [{ required: true, message: '请输入地址名称', trigger: 'blur' }],
fullAddress: [{ required: true, message: '请填写详细地址', trigger: 'blur' }]
}
const formDialogTitle = computed(() => (editingId.value ? '编辑地址' : '新增地址'))
async function fetchList() {
loading.value = true
try {
@@ -256,24 +243,19 @@ function handleSearch() {
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 = []
createVisible.value = false
formVisible.value = false
editingId.value = null
fetchList()
}
function resetCreateForm() {
Object.assign(createForm, {
function resetAddressForm() {
Object.assign(addressForm, {
addressName: '',
fullAddress: '',
lng: null,
@@ -282,45 +264,78 @@ function resetCreateForm() {
contactPhone: '',
remark: ''
})
createFormRef.value?.resetFields()
formRef.value?.resetFields()
}
function fillAddressForm(row: TransportAddress) {
Object.assign(addressForm, {
addressName: row.addressName || '',
fullAddress: row.fullAddress || '',
lng: row.lng != null ? Number(row.lng) : null,
lat: row.lat != null ? Number(row.lat) : null,
contactName: row.contactName || '',
contactPhone: row.contactPhone || '',
remark: row.remark || ''
})
}
function handleOpenCreate() {
resetCreateForm()
createVisible.value = true
editingId.value = null
resetAddressForm()
formVisible.value = true
}
function handleCreateClosed() {
resetCreateForm()
function handleOpenEdit(row: TransportAddress) {
editingId.value = row.id
resetAddressForm()
fillAddressForm(row)
formVisible.value = true
}
async function handleCreateSubmit() {
const valid = await createFormRef.value?.validate().catch(() => false)
function handleFormClosed() {
editingId.value = null
resetAddressForm()
}
function buildPayload(): TransportAddressPayload {
return {
addressName: addressForm.addressName.trim(),
fullAddress: addressForm.fullAddress.trim(),
lng: addressForm.lng as number,
lat: addressForm.lat as number,
contactName: addressForm.contactName.trim() || undefined,
contactPhone: addressForm.contactPhone.trim() || undefined,
status: '1',
remark: addressForm.remark.trim() || undefined
}
}
async function handleFormSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
if (createForm.lng == null || createForm.lat == null) {
if (addressForm.lng == null || addressForm.lat == null) {
ElMessage.warning('请通过地图选点填写经纬度')
return
}
createLoading.value = true
formLoading.value = true
try {
const payload: TransportAddressPayload = {
addressName: createForm.addressName.trim(),
fullAddress: createForm.fullAddress.trim(),
lng: createForm.lng,
lat: createForm.lat,
contactName: createForm.contactName.trim() || undefined,
contactPhone: createForm.contactPhone.trim() || undefined,
status: '1',
remark: createForm.remark.trim() || undefined
const payload = buildPayload()
if (editingId.value) {
await transportAddressApi.update(editingId.value, payload)
ElMessage.success('编辑成功')
formVisible.value = false
await fetchList()
return
}
const res = await transportAddressApi.create(payload)
const created = (res.data ?? res) as TransportAddress
ElMessage.success('新增成功')
createVisible.value = false
formVisible.value = false
handleConfirm(created)
} finally {
createLoading.value = false
formLoading.value = false
}
}
@@ -331,43 +346,9 @@ function handleConfirm(row: TransportAddress) {
</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;
min-height: 560px;
}
.dialog-list-panel {
@@ -377,4 +358,16 @@ function handleConfirm(row: TransportAddress) {
}
}
}
.pl-transport-address-select-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pl-transport-address-select-search {
width: 360px;
max-width: 100%;
}
</style>
@@ -0,0 +1,708 @@
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="1360px"
align-center
append-to-body
destroy-on-close
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
style="backdrop-filter: none"
:close-on-click-modal="false"
@close="handleDialogClose"
>
<el-form
ref="formRef"
v-loading="dialogLoading"
:model="formData"
:rules="formRules"
label-position="right"
label-width="100px"
class="pl-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-3">
<el-form-item label="车牌号" prop="licensePlate">
<el-input v-model="formData.licensePlate" placeholder="请输入车牌号" maxlength="20" />
</el-form-item>
<el-form-item label="挂车号" prop="trailerNo">
<el-input
v-model="formData.trailerNo"
placeholder="请输入挂车号"
maxlength="20"
clearable
/>
</el-form-item>
<el-form-item label="载重(吨)" prop="loadCapacity">
<el-input-number
v-model="formData.loadCapacity"
:precision="2"
:min="0"
placeholder="请输入载重"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="承运商" prop="carrierId">
<el-select
v-model="formData.carrierId"
placeholder="请选择承运商"
filterable
remote
clearable
:remote-method="searchCarriers"
:loading="carrierSelectLoading"
@focus="onCarrierSelectFocus"
>
<el-option v-for="c in carrierList" :key="c.id" :label="c.carrierName" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="车辆状态" prop="vehicleStatus">
<el-select v-model="formData.vehicleStatus" placeholder="请选择">
<el-option label="可用" value="available" />
<el-option label="维修中" value="maintaining" />
<el-option label="报废" value="scrapped" />
</el-select>
</el-form-item>
<el-form-item
label="备注"
prop="remark"
:class="{ 'form-col-span-2': props.vehicleType === 'self' }"
>
<el-input
v-model="formData.remark"
type="textarea"
:rows="2"
placeholder="请输入备注"
maxlength="512"
/>
</el-form-item>
<el-form-item label="车辆照片" style="grid-column: 1 / -1">
<AttachmentUploadField
ref="photoUploadRef"
v-model="formData.photoUrls"
:limit="1"
accept="image/*"
folder="pl/vehicle-photo"
/>
</el-form-item>
</div>
<div class="sub-table-section">
<div class="sub-table-header">
<span class="sub-table-title">关联人员</span>
<el-button type="primary" size="small" @click="handleAddPersonnel">
<el-icon><Plus /></el-icon>添加人员
</el-button>
</div>
<ListTable
table-key="pl-vehicle-form-dialog-personnel"
:data="personnelList"
stripe
class="list-table sub-table"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column label="姓名" min-width="160">
<template #default="{ row }">
<el-select
v-model="row.personnelId"
placeholder="请选择人员"
filterable
remote
clearable
:remote-method="searchPersonnel"
:loading="personnelSelectLoading"
@focus="onPersonnelSelectFocus"
@change="(id) => handlePersonnelSelect(row, id)"
>
<el-option
v-for="p in getPersonnelOptionsForRow(row)"
:key="p.id"
:label="formatPersonnelLabel(p)"
:value="p.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="手机号" min-width="120">
<template #default="{ row }">
<span>{{ row.phoneNo || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="年龄" min-width="80" align="center">
<template #default="{ row }">
<span>{{ row.age ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="人员类型" min-width="100" align="center">
<template #default="{ row }">
<el-select v-model="row.role" placeholder="类型*" @change="handleRoleChange(row)">
<el-option label="司机" value="driver" />
<el-option label="押运员" value="escort" />
</el-select>
</template>
</el-table-column>
<el-table-column label="备注" min-width="100">
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="60" align="center">
<template #default="{ $index }">
<el-button link type="danger" @click="handleRemovePersonnel($index)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</ListTable>
</div>
<PlVehicleCertPartTabs
ref="certFormListRef"
v-model="certList"
:license-plate="formData.licensePlate"
:trailer-no="formData.trailerNo"
title="车辆证书"
upload-folder="pl/vehicle-cert"
:cert-name-options="certNameOptions"
layout="table"
:enforce-self-vehicle-required="props.vehicleType === 'self'"
:enforce-carrier-vehicle-required="false"
/>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = 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>
</template>
<script setup lang="ts">
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlVehicleCertPartTabs from '@/components/business/pl-vehicle-cert-part-tabs/index.vue'
import { Check, Close, Delete, Plus } from '@element-plus/icons-vue'
import {
vehicleApi,
type VehicleDetail,
type VehiclePersonnelRow,
type CertRow,
type CertRead,
type Personnel
} from '@/api/pl'
import {
usePlRemoteCarrierOptions,
usePlRemotePersonnelOptions
} from '@/composables/usePlRemoteOptions'
import { pickBxData } from '@/utils/http/bx'
import {
collectSelfVehicleRequiredCertErrors,
ensureSelfVehicleCertSlots,
ensureVehicleCertSlots,
formatRequiredCertErrors
} from '@/utils/pl-cert-slots'
import { PL_VEHICLE_CERT_NAME_OPTIONS } from '@/utils/pl-vehicle-cert'
import {
mapPersonnelTypeToVehicleRole,
validatePersonnelTypeRoleCompat,
validatePersonnelAffiliationVehicleCompat
} from '@/utils/pl-personnel-cert'
type CertListItem = CertRow & { _key?: string }
type CertFormListRef = InstanceType<typeof PlVehicleCertPartTabs>
const props = defineProps<{
modelValue: boolean
/** carrier=三方,self=自有;锁定类型,不可切换 */
vehicleType: 'carrier' | 'self'
/** 有值则为编辑,无值则为新增 */
vehicleId?: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
/** 新增成功:返回详情;编辑成功:返回详情(父级刷新列表用) */
saved: [payload: { mode: 'create' | 'edit'; vehicle: VehicleDetail }]
}>()
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const isEdit = computed(() => Boolean(props.vehicleId))
const dialogTitle = computed(() => {
if (isEdit.value) {
return props.vehicleType === 'self' ? '编辑自有车辆' : '编辑车辆'
}
return props.vehicleType === 'self' ? '新增自有车辆' : '新增车辆'
})
const submitLoading = ref(false)
const dialogLoading = ref(false)
const formRef = ref()
const photoUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const personnelList = ref<VehiclePersonnelRow[]>([])
const certList = ref<CertListItem[]>([])
const certFormListRef = ref<CertFormListRef>()
const certNameOptions = PL_VEHICLE_CERT_NAME_OPTIONS
const formData = reactive({
id: '',
licensePlate: '',
trailerNo: '',
vehicleType: props.vehicleType as 'carrier' | 'self',
carrierId: '',
carrierName: '',
loadCapacity: null as number | null,
vehicleStatus: 'available',
remark: '',
photoUrls: ''
})
const formRules = computed(() => {
const rules: Record<string, unknown[]> = {
licensePlate: [{ required: true, message: '请输入车牌号', trigger: 'blur' }]
}
if (props.vehicleType === 'self') {
rules.vehicleStatus = [{ required: true, message: '请选择车辆状态', trigger: 'change' }]
rules.carrierId = [{ required: true, message: '请选择承运商', trigger: 'change' }]
}
return rules
})
const {
list: carrierList,
loading: carrierSelectLoading,
search: searchCarriers,
onFocus: onCarrierSelectFocus,
ensureById: ensureSelectedCarrierOption
} = usePlRemoteCarrierOptions({
getSelectedId: () => formData.carrierId
})
const {
list: personnelOptions,
loading: personnelSelectLoading,
search: searchPersonnel,
onFocus: onPersonnelSelectFocus,
invalidate: invalidatePersonnelOptions
} = usePlRemotePersonnelOptions({
listParams: () => ({
status: '1',
personnelAffiliation: props.vehicleType === 'carrier' ? 'carrier' : 'self'
})
})
function createEmptyPersonnel(): VehiclePersonnelRow {
return {
personnelId: undefined,
personnelName: '',
phoneNo: '',
age: null,
role: 'driver',
isDefault: 0,
remark: ''
}
}
function formatPersonnelLabel(p: Personnel) {
return p.phoneNo ? `${p.personnelName}(${p.phoneNo})` : p.personnelName
}
function getPersonnelOptionsForRow(row: VehiclePersonnelRow) {
const selectedIds = new Set(
personnelList.value
.filter((item) => item !== row && item.personnelId)
.map((item) => item.personnelId as string)
)
const options = personnelOptions.value.filter(
(p) => !selectedIds.has(p.id) || p.id === row.personnelId
)
if (row.personnelId && !options.some((p) => p.id === row.personnelId) && row.personnelName) {
return [
{
id: row.personnelId,
personnelName: row.personnelName,
phoneNo: row.phoneNo,
age: row.age ?? undefined,
status: '0',
createTime: ''
} as Personnel,
...options
]
}
return options
}
function handlePersonnelSelect(row: VehiclePersonnelRow, personnelId?: string) {
if (!personnelId) {
row.personnelId = undefined
row.personnelName = ''
row.phoneNo = ''
row.age = null
row.role = ''
return
}
const person = personnelOptions.value.find((p) => p.id === personnelId)
if (!person) return
row.personnelId = person.id
row.personnelName = person.personnelName
row.phoneNo = person.phoneNo || ''
row.age = person.age ?? null
const mappedRole = mapPersonnelTypeToVehicleRole(person.personnelType)
if (mappedRole) {
row.role = mappedRole
handleRoleChange(row)
}
}
function resolvePersonnelRows(rows: VehiclePersonnelRow[]) {
return rows.map((row) => {
if (row.personnelId) {
handlePersonnelSelect(row, row.personnelId)
return row
}
const matched = personnelOptions.value.find(
(p) =>
p.personnelName === row.personnelName &&
(!row.phoneNo || !p.phoneNo || p.phoneNo === row.phoneNo)
)
if (matched) {
handlePersonnelSelect(row, matched.id)
}
return row
})
}
async function loadPersonnelOptions() {
invalidatePersonnelOptions()
await searchPersonnel('')
}
async function loadCarrierOptions() {
await searchCarriers('')
}
function handleAddPersonnel() {
const row = createEmptyPersonnel()
if (!personnelList.value.some((p) => p.role === 'driver' && p.isDefault === 1)) {
row.isDefault = 1
}
personnelList.value.push(row)
}
function handleRemovePersonnel(index: number) {
const wasDefault = personnelList.value[index]?.isDefault === 1
personnelList.value.splice(index, 1)
if (wasDefault) {
const firstDriver = personnelList.value.find((p) => p.role === 'driver')
if (firstDriver) firstDriver.isDefault = 1
}
}
function handleRoleChange(row: VehiclePersonnelRow) {
if (row.role !== 'driver') {
row.isDefault = 0
if (!personnelList.value.some((p) => p.role === 'driver' && p.isDefault === 1)) {
const firstDriver = personnelList.value.find((p) => p.role === 'driver')
if (firstDriver) firstDriver.isDefault = 1
}
} else if (!personnelList.value.some((p) => p.role === 'driver' && p.isDefault === 1)) {
row.isDefault = 1
}
}
function resetSubTables() {
personnelList.value = []
certList.value = []
certFormListRef.value?.clearUploadRefs()
}
function resetFormData() {
Object.assign(formData, {
id: '',
licensePlate: '',
trailerNo: '',
vehicleType: props.vehicleType,
carrierId: '',
carrierName: '',
loadCapacity: null,
vehicleStatus: 'available',
remark: '',
photoUrls: ''
})
}
function ensureEmptyCertSlots() {
if (props.vehicleType === 'self') {
certList.value = ensureSelfVehicleCertSlots([], formData.trailerNo)
} else {
certList.value = ensureVehicleCertSlots([], formData.trailerNo, 'carrier')
}
}
function mapCertRowsForForm(certs: CertRead[] | undefined | null) {
return (certs || []).map((c) => ({
_key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
certId: c.id || c.certId,
vehiclePart: c.vehiclePart,
certName: c.certName,
certNo: c.certNo,
expireDate: c.expireDate ? String(c.expireDate).slice(0, 10) : '',
remindDays: c.remindDays ?? 30,
attachmentUrls: c.attachmentUrls || ''
}))
}
function validateSubTables(): boolean {
for (const row of personnelList.value) {
if (!row.personnelId) {
ElMessage.warning('请选择关联人员')
return false
}
if (!row.role) {
ElMessage.warning('请选择人员类型')
return false
}
const person = personnelOptions.value.find((p) => p.id === row.personnelId)
if (
!validatePersonnelTypeRoleCompat(person?.personnelType, row.role, (message) =>
ElMessage.warning(message)
)
) {
return false
}
if (
!validatePersonnelAffiliationVehicleCompat(
person?.personnelAffiliation,
props.vehicleType,
(message) => ElMessage.warning(message)
)
) {
return false
}
}
if (props.vehicleType === 'self') {
const certErrors = collectSelfVehicleRequiredCertErrors(certList.value, formData.trailerNo)
if (certErrors.length) {
ElMessage.warning(formatRequiredCertErrors(certErrors))
return false
}
}
return true
}
async function openCreate() {
resetFormData()
resetSubTables()
ensureEmptyCertSlots()
await Promise.all([loadPersonnelOptions(), loadCarrierOptions()])
}
async function openEdit() {
const vehicleId = props.vehicleId
if (!vehicleId) return
dialogLoading.value = true
try {
await loadPersonnelOptions()
const res = await vehicleApi.get(vehicleId)
const detail = pickBxData<VehicleDetail>(res) || ({} as VehicleDetail)
if (props.vehicleType === 'self' && detail.vehicleType !== 'self') {
ElMessage.warning('仅可编辑自有车辆')
visible.value = false
return
}
Object.assign(formData, {
id: detail.id,
licensePlate: detail.licensePlate,
trailerNo: detail.trailerNo || '',
vehicleType: props.vehicleType,
carrierId: detail.carrierId || '',
carrierName: detail.carrierName || '',
loadCapacity: detail.loadCapacity ?? null,
vehicleStatus: detail.vehicleStatus,
remark: detail.remark,
photoUrls: detail.photoUrls || ''
})
if (formData.carrierId) {
await ensureSelectedCarrierOption(formData.carrierId)
} else {
await loadCarrierOptions()
}
personnelList.value = resolvePersonnelRows(
(detail.personnelList || []).map((p) => ({
...p,
age: p.age ?? null
}))
)
// 等 trailerNo 相关 watch 先消化,再回填证书,避免被 hasTrailer 逻辑误清空
await nextTick()
const sourceCerts = detail.certList || []
if (props.vehicleType === 'self') {
certList.value = ensureSelfVehicleCertSlots(
mapCertRowsForForm(sourceCerts),
formData.trailerNo
)
} else {
certList.value = ensureVehicleCertSlots(
mapCertRowsForForm(sourceCerts),
formData.trailerNo,
'carrier'
)
}
await nextTick()
certFormListRef.value?.refreshRequiredSlots?.()
} catch {
visible.value = false
ElMessage.error('加载车辆详情失败')
} finally {
dialogLoading.value = false
}
}
async function openDialog() {
if (isEdit.value) {
await openEdit()
} else {
await openCreate()
}
}
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid || !validateSubTables()) return
if (certFormListRef.value?.hasUploading?.()) {
ElMessage.warning('证书附件上传中,请稍候')
return
}
if (photoUploadRef.value?.hasUploading?.()) {
ElMessage.warning('附件上传中,请稍候')
return
}
if (formData.carrierId) {
const carrier = carrierList.value.find((c) => c.id === formData.carrierId)
formData.carrierName = carrier?.carrierName || ''
} else {
formData.carrierName = ''
}
submitLoading.value = true
try {
const payload = {
licensePlate: formData.licensePlate,
trailerNo: formData.trailerNo || undefined,
vehicleType: props.vehicleType,
carrierId: formData.carrierId || undefined,
carrierName: formData.carrierName || undefined,
loadCapacity: formData.loadCapacity,
vehicleStatus: formData.vehicleStatus,
remark: formData.remark,
photoUrls: formData.photoUrls ?? '',
personnelList: personnelList.value.map((p) => ({
personnelId: p.personnelId,
personnelName: p.personnelName.trim(),
phoneNo: p.phoneNo || undefined,
age: p.age ?? undefined,
role: p.role,
isDefault: p.role === 'driver' ? (p.isDefault ?? 0) : 0,
remark: p.remark || undefined
})),
certList: certList.value.map((c) => ({
certId: c.certId,
vehiclePart: c.vehiclePart,
certName: c.certName.trim(),
certNo: c.certNo || undefined,
expireDate: c.expireDate || undefined,
remindDays: c.remindDays ?? 30,
attachmentUrls: c.attachmentUrls || undefined
}))
}
if (isEdit.value) {
await vehicleApi.update(formData.id, payload)
const res = await vehicleApi.get(formData.id)
const detail = pickBxData<VehicleDetail>(res)
if (!detail?.id) {
ElMessage.error('编辑成功但未返回车辆信息')
return
}
ElMessage.success('编辑成功')
emit('saved', { mode: 'edit', vehicle: detail })
} else {
const res = await vehicleApi.create(payload)
const detail = pickBxData<VehicleDetail>(res)
if (!detail?.id) {
ElMessage.error('新增成功但未返回车辆信息')
return
}
ElMessage.success('新增成功')
emit('saved', { mode: 'create', vehicle: detail })
}
visible.value = false
} finally {
submitLoading.value = false
}
}
function handleDialogClose() {
formRef.value?.resetFields()
resetSubTables()
}
watch(
() => props.modelValue,
async (open) => {
if (open) {
await openDialog()
}
}
)
</script>
<style scoped lang="scss">
.sub-table-section {
margin-top: 16px;
grid-column: 1 / -1;
}
.sub-table-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.sub-table-title {
font-size: 14px;
font-weight: 500;
color: var(--text, #1e293b);
}
.sub-table {
margin-bottom: 8px;
}
</style>
@@ -0,0 +1,294 @@
<template>
<el-dialog
v-model="innerVisible"
title="从车辆库选择"
width="1360px"
align-center
destroy-on-close
append-to-body
class="dialog-form-layout pl-vehicle-library-select-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
style="backdrop-filter: none"
@open="handleOpen"
>
<div class="panel dialog-list-panel">
<el-tabs v-model="activeTab" class="pl-vehicle-library-select-tabs" @tab-change="handleTabChange">
<el-tab-pane label="三方车辆" name="carrier" />
<el-tab-pane label="自有车辆" name="self" />
</el-tabs>
<div class="list-toolbar">
<div class="list-toolbar-row pl-vehicle-library-select-toolbar">
<el-input
v-model="keyword"
class="pl-vehicle-library-select-search"
placeholder="搜索车牌号"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button class="btn-main" type="primary" @click="handleOpenCreate">
<el-icon><Plus /></el-icon>新增车辆
</el-button>
</div>
</div>
<div class="list-body list-wrap dialog-list-body-only">
<ListTable
table-key="business-pl-vehicle-library-select-dialog"
class="list-table pl-vehicle-library-select-table"
:data="rows"
v-loading="loading"
stripe
row-key="id"
highlight-current-row
:max-height="560"
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="licensePlate" label="车牌号" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="handleConfirm(row)">{{ row.licensePlate }}</el-link>
</template>
</el-table-column>
<el-table-column
prop="trailerNo"
label="挂车号"
min-width="110"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="车辆状态"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="getPlVehicleStatusTagType(row.vehicleStatus)" size="small">
{{ getPlVehicleStatusLabel(row.vehicleStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="loadCapacity"
label="载重(吨)"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
v-if="activeTab === 'carrier'"
prop="carrierName"
label="承运商"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="personnelSummary"
label="关联人员摘要"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
{{ normalizePlPersonnelSummaryDisplay(row.personnelSummary) }}
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="160"
align="center"
header-align="center"
fixed="right"
class-name="col-actions"
>
<template #default="{ row }">
<el-button link type="primary" @click="handleOpenEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="primary" @click="handleConfirm(row)">
<el-icon><Select /></el-icon>选择
</el-button>
</template>
</el-table-column>
</ListTable>
<ListPagination
v-show="pagination.total > 0"
:total="pagination.total"
v-model:page="pagination.page"
v-model:limit="pagination.pageSize"
@pagination="fetchList"
/>
</div>
</div>
<PlVehicleFormDialog
v-model="formVisible"
:vehicle-type="activeTab"
:vehicle-id="editingId"
@saved="handleFormSaved"
/>
</el-dialog>
</template>
<script setup lang="ts">
import { Edit, Plus, Search, Select } from '@element-plus/icons-vue'
import { vehicleApi, type Vehicle, type VehicleDetail } from '@/api/pl/index'
import PlVehicleFormDialog from '@/components/business/pl-vehicle-form-dialog/index.vue'
import ListPagination from '@/components/ListPagination/index.vue'
import { pickBxPageResult } from '@/utils/http/bx'
import {
getPlVehicleStatusLabel,
getPlVehicleStatusTagType,
normalizePlPersonnelSummaryDisplay
} from '@/utils/pl-display'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
type VehicleTab = 'carrier' | 'self'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
select: [row: Vehicle]
}>()
const innerVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const loading = ref(false)
const keyword = ref('')
const activeTab = ref<VehicleTab>('carrier')
const rows = ref<Vehicle[]>([])
const pagination = reactive({
page: 1,
pageSize: DEFAULT_LIST_PAGE_SIZE,
total: 0
})
const formVisible = ref(false)
const editingId = ref<string | null>(null)
async function fetchList() {
loading.value = true
try {
const res = await vehicleApi.list({
vehicleType: activeTab.value,
q: keyword.value.trim() || undefined,
page: pagination.page,
pageSize: pagination.pageSize
})
const { list, total } = pickBxPageResult<Vehicle>(res)
rows.value = list
pagination.total = total
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchList()
}
function handleTabChange() {
pagination.page = 1
formVisible.value = false
editingId.value = null
fetchList()
}
function handleOpen() {
keyword.value = ''
activeTab.value = 'carrier'
pagination.page = 1
pagination.pageSize = DEFAULT_LIST_PAGE_SIZE
pagination.total = 0
rows.value = []
formVisible.value = false
editingId.value = null
fetchList()
}
function handleOpenCreate() {
editingId.value = null
formVisible.value = true
}
function handleOpenEdit(row: Vehicle) {
editingId.value = row.id
formVisible.value = true
}
function handleFormSaved(payload: { mode: 'create' | 'edit'; vehicle: VehicleDetail }) {
formVisible.value = false
editingId.value = null
if (payload.mode === 'create') {
handleConfirm(payload.vehicle)
return
}
fetchList()
}
function handleConfirm(row: Vehicle) {
emit('select', row)
innerVisible.value = false
}
</script>
<style scoped lang="scss">
.pl-vehicle-library-select-dialog {
:deep(.el-dialog__body) {
min-height: 560px;
}
.dialog-list-panel {
.list-toolbar {
margin-bottom: 0;
padding: 10px 0 12px;
}
}
}
.pl-vehicle-library-select-tabs {
:deep(.el-tabs__header) {
margin-bottom: 0;
}
}
.pl-vehicle-library-select-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pl-vehicle-library-select-search {
width: 280px;
max-width: 100%;
}
</style>
@@ -9,6 +9,7 @@
clearable
style="width: 100%"
@focus="handleFocus"
@change="emit('change', $event)"
>
<el-option
v-for="item in displayOptions"
@@ -34,9 +35,15 @@
const model = defineModel<string>({ default: '' })
withDefaults(
const emit = defineEmits<{
change: [value: string]
}>()
const props = withDefaults(
defineProps<{
placeholder?: string
/** 透传车辆列表筛选,如 available */
vehicleStatus?: string
}>(),
{
placeholder: '请选择车牌号(三方/自有车辆)'
@@ -58,6 +65,7 @@
loading.value = true
try {
const res = await vehicleApi.list({
...(props.vehicleStatus ? { vehicleStatus: props.vehicleStatus } : {}),
...(keyword.trim() ? { q: keyword.trim() } : {}),
page: 1,
pageSize: 100