Files
admin-web/src/modules/trucking/components/TruckAppointmentForm.vue
T
2026-08-14 14:27:03 +08:00

141 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { Eye, Save } from '@lucide/vue'
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
const formRef = ref<FormInstance>()
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', receivableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 }
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: value.currency?.trim().toUpperCase() || 'RMB' } }
const draft = reactive<SaveTruckAppointmentRequest>(normalizeDraft(props.modelValue))
watch(() => props.modelValue, (value) => Object.assign(draft, normalizeDraft(value)), { deep: true })
watch(draft, (value) => emit('update:modelValue', { ...value }), { deep: true })
const nonNegative = (label: string, integer = false) => ({ validator: (_rule: unknown, value: unknown, callback: (error?: Error) => void) => {
if (value === null || value === undefined || value === '') return callback()
const text = String(value).trim(); const valid = integer ? /^\d+$/.test(text) : /^(?:\d+)(?:\.\d{1,3})?$/.test(text)
callback(valid ? undefined : new Error(`${label}请输入${integer ? '非负整数' : '非负数字'}`))
}, trigger: ['blur', 'change'] })
const phoneRule = (label: string) => ({ validator: (_rule: unknown, value: unknown, callback: (error?: Error) => void) => {
if (!value || /^[0-9+()\-\s]{6,32}$/.test(String(value).trim())) callback()
else callback(new Error(`${label}格式不正确`))
}, trigger: 'blur' })
const rules: FormRules = {
carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }],
plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')],
packageCount: [nonNegative('件数', true)], palletCount: [nonNegative('托盘数量', true)], transitMinutes: [nonNegative('在途分钟', true)],
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], receivableAmount: [nonNegative('应收拖车费')],
currency: [{ pattern: /^[A-Z]{3,8}$/, message: '币种请输入 3-8 位大写字母(如 RMB)', trigger: 'blur' }],
}
const relationText = computed(() => [props.resource?.customerName, props.resource?.outboundOrderId, props.resource?.containerBookingId].filter(Boolean).join(' / ') || '由服务端关联资源返回')
const customers = ref<PlatformTenantResource[]>([])
const carriers = ref<PlatformPartnerResource[]>([])
const containers = ref<ContainerBookingResource[]>([])
const bookings = ref<OceanBookingResource[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const optionsLoading = ref(false)
const optionsError = ref('')
const selectableCustomers = computed(() => customers.value.filter((item) => (
item.status === 'ACTIVE' || String(item.id) === String(draft.customerId)
)))
const selectableWarehouses = computed(() => warehouses.value.filter((item) => (
item.status === 'ACTIVE' || String(item.id) === String(draft.warehouseId)
)))
const selectableCarriers = computed(() => carriers.value.filter((item) => (
item.status === 'ACTIVE' || String(item.id) === String(draft.carrierId)
)))
function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }
function selectCustomer(customerId: string) { const customer = customers.value.find((item) => String(item.id) === customerId); draft.customerId = customerId || null; draft.customerName = customer ? customerLabel(customer) : null }
function selectCarrier(carrierId: string) { draft.carrierId = carrierId || null; draft.carrierName = carriers.value.find((item) => String(item.id) === carrierId)?.partnerName || null }
function selectContainer(containerId: string) { const item = containers.value.find((entry) => entry.id === containerId); draft.containerBookingId = containerId || null; if (item) { draft.loadingPlanId = item.planId; draft.warehouseId = item.warehouseId } }
function selectBooking(bookingId: string) { const item = bookings.value.find((entry) => entry.id === bookingId); draft.oceanBookingId = bookingId || null; if (item?.customerId) selectCustomer(item.customerId) }
async function loadOptions() {
optionsLoading.value = true; optionsError.value = ''
const results = await Promise.allSettled([
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
listPlatformPartners({ page: 1, size: 200, type: 'CARRIER', status: 'ACTIVE' }),
listContainers({ page: 1, size: 200 }),
listOceanBookings({ page: 1, size: 200 }),
listAllPlatformWarehouses(),
])
if (results[0].status === 'fulfilled') customers.value = results[0].value.records.filter((customer) => customer.status === 'ACTIVE')
if (results[1].status === 'fulfilled') carriers.value = results[1].value.records
if (results[2].status === 'fulfilled') containers.value = results[2].value.records
if (results[3].status === 'fulfilled') bookings.value = results[3].value.records
if (results[4].status === 'fulfilled') warehouses.value = results[4].value
const failed = ['客户', '承运商', '柜次', '订舱', '仓库'].filter((_, index) => results[index]?.status === 'rejected')
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败`
optionsLoading.value = false
}
async function validate() { return formRef.value?.validate() }
async function submitForm() { if (await formRef.value?.validate()) emit('submit') }
defineExpose({ validate })
onMounted(() => { if (!props.readonly) void loadOptions() })
watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.length) void loadOptions() })
</script>
<template>
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
<el-alert v-if="resource" type="info" :closable="false" class="relation-alert">关联资源:{{ relationText }};柜型由关联柜体返回(只读)</el-alert>
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
<el-row :gutter="16">
<el-col :xs="24"><el-divider content-position="left">关联资源</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="关联柜次"><el-select :model-value="draft.containerBookingId" filterable clearable placeholder="请选择柜次" :loading="optionsLoading" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-for="item in containers" :key="item.id" :label="`${item.cabinetNo || item.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="关联订舱"><el-select :model-value="draft.oceanBookingId" filterable clearable placeholder="请选择订舱单" :loading="optionsLoading" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-for="item in bookings" :key="item.id" :label="item.bookingNo || `订舱 ${item.id}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="客户"><el-select :model-value="draft.customerId" filterable clearable placeholder="请选择客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-if="draft.customerId && !selectableCustomers.some((item) => String(item.id) === String(draft.customerId))" :label="draft.customerName || '当前客户'" :value="String(draft.customerId)" /><el-option v-for="item in selectableCustomers" :key="String(item.id)" :label="customerLabel(item)" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-select v-model="draft.warehouseId" filterable clearable placeholder="请选择仓库" :loading="optionsLoading" style="width:100%"><el-option v-for="item in selectableWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="派车单号"><el-input :model-value="resource.appointmentNo || ''" disabled /></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">运输与时间计划</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="承运商" prop="carrierId"><el-select :model-value="draft.carrierId" filterable placeholder="请选择承运商" :loading="optionsLoading" style="width:100%" @update:model-value="selectCarrier(String($event || ''))"><el-option v-if="draft.carrierId && !selectableCarriers.some((item) => String(item.id) === String(draft.carrierId))" :label="draft.carrierName || '当前承运商'" :value="String(draft.carrierId)" /><el-option v-for="item in selectableCarriers" :key="String(item.id)" :label="item.partnerName" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" placeholder="请输入车队联系人" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车队电话" prop="fleetPhone"><el-input v-model="draft.fleetPhone" placeholder="请输入车队联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="司机"><el-input v-model="draft.driverName" placeholder="请输入司机姓名" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="司机电话" prop="driverPhone"><el-input v-model="draft.driverPhone" placeholder="请输入司机联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="司机身份证号"><el-input v-model="draft.driverIdCard" placeholder="请输入司机身份证号" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车头号"><el-input v-model="draft.vehicleNo" placeholder="请输入车牌或车头号" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="预计装箱时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计装箱时间" style="width:100%" /></el-form-item></el-col>
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="实际装箱时间"><el-input :model-value="resource.actualLoadingAt || ''" disabled /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="件数" prop="packageCount"><el-input v-model="draft.packageCount" inputmode="numeric" placeholder="请输入件数" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="毛重(kg)" prop="grossWeightKg"><el-input v-model="draft.grossWeightKg" inputmode="decimal" placeholder="请输入毛重" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input v-model="draft.volumeCbm" inputmode="decimal" placeholder="请输入体积" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="托盘数量" prop="palletCount"><el-input v-model="draft.palletCount" inputmode="numeric" placeholder="请输入托盘数量" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="在途分钟" prop="transitMinutes"><el-input v-model="draft.transitMinutes" inputmode="numeric" placeholder="请输入预计在途分钟" /></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">费用信息</el-divider></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应付拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" placeholder="请输入应付金额"><template #append>RMB</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应收拖车费" prop="receivableAmount"><el-input v-model="draft.receivableAmount" placeholder="请输入应收金额"><template #append>RMB</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="币种" prop="currency"><el-input v-model="draft.currency" placeholder="例如:RMB" maxlength="8" @blur="draft.currency = draft.currency?.toUpperCase()" /></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">门店信息与备注</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店名称"><el-input v-model="draft.storeName" placeholder="请输入目的门店名称" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" placeholder="请输入目的门店地址" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店联系人"><el-input v-model="draft.storeContact" placeholder="请输入门店联系人" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店电话" prop="storePhone"><el-input v-model="draft.storePhone" placeholder="请输入门店联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="城市"><el-input v-model="draft.storeCity" placeholder="请输入城市" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="邮编"><el-input v-model="draft.storeZipcode" placeholder="请输入邮政编码" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="PO"><el-input v-model="draft.poNo" placeholder="请输入采购订单号(可选)" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" placeholder="请输入拖车预约备注(可选)" /></el-form-item></el-col>
</el-row>
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" @click="submitForm"><Save :size="16" />保存</el-button></div>
</el-form>
</template>
<style scoped>
.truck-form { padding-bottom: 12px; }
.relation-alert { margin-bottom: 16px; }
.form-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
:deep(.el-divider) { margin: 18px 0 12px; }
:deep(.el-divider__text) { color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; }
:deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; }
@media (max-width: 480px) {
.truck-form { padding-inline: 2px; }
.form-actions { position: sticky; bottom: 0; z-index: 2; margin: 8px -2px -4px; padding: 10px 2px; background: var(--el-bg-color); border-top: 1px solid var(--el-border-color-lighter); }
.form-actions :deep(.el-button) { flex: 1; min-height: 40px; }
}
</style>