fix(admin): validate booking and trucking forms

This commit is contained in:
2026-08-08 15:15:04 +08:00
parent 82a25737cd
commit 1076315945
4 changed files with 102 additions and 25 deletions
@@ -0,0 +1,22 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingDetailView.vue'), 'utf8')
describe('OceanBookingDetailView form contract', () => {
it('keeps prototype parties, sailing and cargo fields', () => {
for (const field of ['SHIPPER', 'CONSIGNEE', 'NOTIFY_PARTY', 'departurePortName', 'destinationPortName', 'vesselName', 'voyageNo', 'containerRequirements', 'goodsDescription']) {
expect(source).toContain(field)
}
})
it('validates required booking fields and protects edit-only fields', () => {
expect(source).toContain("customerId: [{ required: true")
expect(source).toContain("departurePortName: [{ required: true")
expect(source).toContain("bookingAgentName: [{ required: true")
expect(source).toContain(':disabled="!isNew"')
expect(source).toContain(':disabled="!editable"')
expect(source).toContain("containerType: '40HC', quantity: 1")
})
})
@@ -1,21 +1,53 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createOceanBooking, getOceanBooking, performOceanBookingAction, updateOceanBooking, type OceanBookingAction } from '../api/platformOceanBookings'
import type { OceanBookingDetail, OceanBookingDraftData, OceanBookingSaveRequest, OceanBookingStatus } from '../types'
const route = useRoute(); const router = useRouter(); const id = computed(() => String(route.params.bookingId || '')); const detail = ref<OceanBookingDetail | null>(null); const loading = ref(false); const saving = ref(false); const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null); const actionError = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
const blankData = (): OceanBookingDraftData => ({ customerId: '', customerName: '', parties: [], containerRequirements: [{ containerType: '40HC', quantity: 1 }] })
import type { OceanBookingDetail, OceanBookingDraftData, OceanBookingParty, OceanBookingSaveRequest, OceanBookingStatus } from '../types'
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
const id = computed(() => String(route.params.bookingId || '')); const isNew = computed(() => id.value === 'new')
const detail = ref<OceanBookingDetail | null>(null); const loading = ref(false); const saving = ref(false)
const error = ref<ApiErrorDetails | null>(null); const actionError = ref<ApiErrorDetails | null>(null); const formRef = ref<FormInstance>()
const blankParty = (partyType: string): OceanBookingParty => ({ partyType, companyName: '', contactName: '', phone: '', email: '', address: '' })
const blankData = (): OceanBookingDraftData => ({ customerId: '', customerName: '', orderNo: '', parties: [blankParty('SHIPPER'), blankParty('CONSIGNEE'), blankParty('NOTIFY_PARTY')], containerRequirements: [{ containerType: '40HC', quantity: 1 }], shippingCompanyName: '', bookingAgentName: '', departurePortName: '', destinationPortName: '', vesselName: '', voyageNo: '', estimatedSailingAt: '', estimatedArrivalAt: '', cutoffAt: '', soNo: '', goodsDescription: '', packageCount: null, grossWeight: '', volume: '', remark: '' })
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: null, data: blankData() })
const rules: FormRules = { customerId: [{ required: true, message: '请输入客户 ID', trigger: 'blur' }], customerName: [{ required: true, message: '请输入委托单位', trigger: 'blur' }], departurePortName: [{ required: true, message: '请输入起运港 POL', trigger: 'blur' }], destinationPortName: [{ required: true, message: '请输入目的港 POD', trigger: 'blur' }], shippingCompanyName: [{ required: true, message: '请输入船公司', trigger: 'blur' }], bookingAgentName: [{ required: true, message: '请输入订舱代理', trigger: 'blur' }], vesselName: [{ required: true, message: '请输入船名', trigger: 'blur' }], voyageNo: [{ required: true, message: '请输入航次', trigger: 'blur' }], estimatedSailingAt: [{ type: 'string', message: '请输入有效日期', trigger: 'change' }], packageCount: [{ type: 'number', min: 0, message: '件数不能小于 0', trigger: 'change' }] }
const actionStatuses: OceanBookingStatus[] = ['SUBMITTED', 'CONFIRMED', 'RELEASED', 'FAILED', 'CHANGE_PENDING', 'CANCELLED']
const actionByStatus: Partial<Record<OceanBookingStatus, OceanBookingAction>> = { SUBMITTED: 'submit', CONFIRMED: 'confirm', RELEASED: 'release', FAILED: 'fail', CHANGE_PENDING: 'change-request', CANCELLED: 'cancel' }
async function load(){ if(!id.value || id.value==='new') return; loading.value=true; error.value=null; try { detail.value=await getOceanBooking(id.value); const source=detail.value.oceanBooking || detail.value; form.expectedVersion = Number((source as OceanBookingResourceWithVersion).expectedVersion ?? 0); form.data = { ...blankData(), ...(source as unknown as Partial<OceanBookingDraftData>), customerId: String(source.customerId || ''), customerName: source.customerName || '', parties: detail.value.parties || [], containerRequirements: detail.value.containerRequirements || [] } } catch(e){error.value=getApiErrorDetails(e)} finally{loading.value=false} }
type OceanBookingResourceWithVersion = { expectedVersion?: number | string | null }
async function save(){ saving.value=true; actionError.value=null; try { if(id.value==='new') { const created=await createOceanBooking(form); await router.replace(`/ocean-bookings/${created}`) } else await updateOceanBooking(id.value, form); await load() } catch(e){actionError.value=getApiErrorDetails(e)} finally{saving.value=false} }
async function action(status: OceanBookingStatus){ const actionName=actionByStatus[status]; if(!actionName) return; actionError.value=null; try { await performOceanBookingAction(id.value, actionName, { expectedVersion: form.expectedVersion, reason: null }); await load() } catch(e){actionError.value=getApiErrorDetails(e)} }
const terminal = computed(() => ['RELEASED', 'CANCELLED', 'FAILED'].includes(detail.value?.status || ''))
const editable = computed(() => isNew.value || (!terminal.value && access.can('platform:firstleg:ocean-booking:edit')))
const party = (type: string) => form.data.parties.find((item) => item.partyType === type) || form.data.parties[0]
const containerValid = computed(() => form.data.containerRequirements.length > 0 && form.data.containerRequirements.every((item) => item.containerType.trim() && Number(item.quantity) > 0))
function setParty(type: string, value: string) { const target = form.data.parties.find((item) => item.partyType === type); if (target) target.companyName = value }
function setDate(key: 'estimatedSailingAt' | 'estimatedArrivalAt' | 'cutoffAt', value: string | null) { form.data[key] = value || null }
async function load() { if (!id.value || isNew.value) return; loading.value = true; error.value = null; try { detail.value = await getOceanBooking(id.value); const source = detail.value.oceanBooking || detail.value; form.expectedVersion = Number((source as OceanBookingDetail & { expectedVersion?: number }).expectedVersion ?? 0); form.data = { ...blankData(), ...(source as unknown as Partial<OceanBookingDraftData>), customerId: String(source.customerId || ''), customerName: source.customerName || '', parties: detail.value.parties?.length ? detail.value.parties : blankData().parties, containerRequirements: detail.value.containerRequirements?.length ? detail.value.containerRequirements : blankData().containerRequirements } } catch (e) { error.value = getApiErrorDetails(e, '订舱详情加载失败,请稍后重试') } finally { loading.value = false } }
async function save() { if (!editable.value) return; const valid = await formRef.value?.validate().catch(() => false); if (!valid || !containerValid.value) { if (!containerValid.value) ElMessage.error('请至少填写一条有效的箱型箱量'); return } saving.value = true; actionError.value = null; try { if (isNew.value) { const created = await createOceanBooking(form); await router.replace(`/ocean-bookings/${created}`) } else { await updateOceanBooking(id.value, form); ElMessage.success('订舱已保存'); await load() } } catch (e) { actionError.value = getApiErrorDetails(e, '保存订舱失败,请稍后重试') } finally { saving.value = false } }
async function action(status: OceanBookingStatus) { const actionName = actionByStatus[status]; if (!actionName || !detail.value || terminal.value) return; actionError.value = null; try { await performOceanBookingAction(id.value, actionName, { expectedVersion: form.expectedVersion, reason: null }); await load() } catch (e) { actionError.value = getApiErrorDetails(e, '状态操作失败,请稍后重试') } }
onMounted(load)
</script>
<template><div class="business-page booking-detail"><div class="detail-heading"><el-button @click="router.push('/ocean-bookings')">返回列表</el-button><div><h1>{{ detail?.bookingNo || '新增订舱' }}</h1><p v-if="detail">{{ detail.customerName || '—' }} · {{ detail.pol || '—' }} → {{ detail.pod || '—' }}</p></div><div v-if="detail" class="heading-status"><StatusBadge :status="detail.status" /></div></div><LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" @retry="load" /><template v-else><el-alert v-if="actionError" type="error" :closable="false" show-icon>{{ actionError.message }}</el-alert><section class="surface-panel"><div class="section-title"><h2>订舱信息</h2><div class="actions"><template v-for="nextStatus in actionStatuses" :key="nextStatus"><el-button v-if="detail && detail.status !== nextStatus" size="small" @click="action(nextStatus)">{{ nextStatus }}</el-button></template><el-button type="primary" :loading="saving" @click="save">保存</el-button></div></div><el-form :model="form.data" label-position="top" class="form-grid"><el-form-item label="客户名称"><el-input v-model="form.data.customerName" /></el-form-item><el-form-item label="客户 ID"><el-input v-model="form.data.customerId" /></el-form-item><el-form-item label="关联订单号"><el-input v-model="form.data.orderNo" /></el-form-item><el-form-item label="船公司名称"><el-input v-model="form.data.shippingCompanyName" /></el-form-item><el-form-item label="船公司 ID"><el-input v-model="form.data.shippingCompanyId" /></el-form-item><el-form-item label="订舱代理"><el-input v-model="form.data.bookingAgentName" /></el-form-item><el-form-item label="起运港"><el-input v-model="form.data.departurePortName" /></el-form-item><el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" /></el-form-item><el-form-item label="船名/航次"><el-input v-model="form.data.vesselName" /><el-input v-model="form.data.voyageNo" class="mt" /></el-form-item><el-form-item label="预计开船"><el-input v-model="form.data.estimatedSailingAt" /></el-form-item><el-form-item label="预计到港"><el-input v-model="form.data.estimatedArrivalAt" /></el-form-item><el-form-item label="S/O号"><el-input v-model="form.data.soNo" /></el-form-item><el-form-item class="wide" label="货物描述"><el-input v-model="form.data.goodsDescription" type="textarea" /></el-form-item><el-form-item class="wide" label="备注"><el-input v-model="form.data.remark" type="textarea" /></el-form-item></el-form></section><section v-if="detail" class="surface-panel"><h2>状态历史</h2><el-timeline><el-timeline-item v-for="event in detail.histories" :key="event.id" :timestamp="event.occurredAt">{{ event.fromStatus || '—' }} → {{ event.toStatus }} · {{ event.operatorName || '—' }}</el-timeline-item></el-timeline></section></template></div></template>
<style scoped>.booking-detail{display:grid;gap:16px}.detail-heading{display:flex;align-items:center;gap:16px}.detail-heading h1{margin:0;font-size:20px}.detail-heading p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{margin-left:auto}.surface-panel{padding:20px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:18px}.section-title h2,.surface-panel>h2{margin:0;font-size:16px}.actions{display:flex;flex-wrap:wrap;gap:8px}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.form-grid .wide{grid-column:1/-1}.mt{margin-top:8px}@media(max-width:900px){.form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.detail-heading{align-items:flex-start;flex-wrap:wrap}.heading-status{margin-left:0}.form-grid{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.section-title{align-items:flex-start;flex-direction:column}.actions{width:100%}.actions :deep(.el-button){min-height:40px}}</style>
<template>
<div class="business-page booking-detail"><div class="detail-heading"><el-button @click="router.push('/ocean-bookings')">返回列表</el-button><div><h1>{{ detail?.bookingNo || '新增订舱' }}</h1><p v-if="detail">{{ detail.customerName || '—' }} · {{ detail.pol || '—' }} → {{ detail.pod || '—' }}</p></div><div v-if="detail" class="heading-status"><StatusBadge :status="detail.status" /></div></div>
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else><el-alert v-if="actionError" type="error" :closable="false" show-icon><template #title><span>{{ actionError.message }}</span><ApiErrorMeta :details="actionError" /></template></el-alert>
<section class="surface-panel"><div class="section-title"><h2>订舱信息</h2><div class="actions"><template v-for="nextStatus in actionStatuses" :key="nextStatus"><el-button v-if="detail && detail.status !== nextStatus && !terminal" size="small" :disabled="!editable || saving" @click="action(nextStatus)">{{ nextStatus }}</el-button></template><el-button type="primary" :loading="saving" :disabled="!editable" @click="save">保存</el-button></div></div>
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" class="form-grid">
<el-form-item label="客户名称" prop="customerName"><el-input v-model="form.data.customerName" :disabled="!isNew" placeholder="请输入委托单位" /></el-form-item><el-form-item label="客户 ID" prop="customerId"><el-input v-model="form.data.customerId" :disabled="!isNew" placeholder="请输入客户 ID" /></el-form-item><el-form-item label="关联订单号"><el-input v-model="form.data.orderNo" :disabled="!editable" placeholder="可选" /></el-form-item>
<el-form-item label="Shipper(托运人)" required><el-input :model-value="party('SHIPPER').companyName" :disabled="!editable" @update:model-value="setParty('SHIPPER', $event)" /></el-form-item><el-form-item label="Consignee(收货人)" required><el-input :model-value="party('CONSIGNEE').companyName" :disabled="!editable" @update:model-value="setParty('CONSIGNEE', $event)" /></el-form-item><el-form-item label="Notify Party(通知人)" required><el-input :model-value="party('NOTIFY_PARTY').companyName" :disabled="!editable" @update:model-value="setParty('NOTIFY_PARTY', $event)" /></el-form-item>
<el-form-item label="船公司" prop="shippingCompanyName"><el-input v-model="form.data.shippingCompanyName" :disabled="!editable" placeholder="请输入船公司" /></el-form-item><el-form-item label="订舱代理" prop="bookingAgentName"><el-input v-model="form.data.bookingAgentName" :disabled="!editable" placeholder="请输入订舱代理" /></el-form-item><el-form-item label="S/O号"><el-input v-model="form.data.soNo" :disabled="!editable" placeholder="放舱后填写" /></el-form-item>
<el-form-item label="起运港 POL" prop="departurePortName"><el-input v-model="form.data.departurePortName" :disabled="!editable" placeholder="请输入起运港" /></el-form-item><el-form-item label="目的港 POD" prop="destinationPortName"><el-input v-model="form.data.destinationPortName" :disabled="!editable" placeholder="请输入目的港" /></el-form-item><el-form-item label="船名" prop="vesselName"><el-input v-model="form.data.vesselName" :disabled="!editable" placeholder="请输入船名" /></el-form-item><el-form-item label="航次" prop="voyageNo"><el-input v-model="form.data.voyageNo" :disabled="!editable" placeholder="请输入航次" /></el-form-item>
<el-form-item label="预计开船"><el-date-picker :model-value="form.data.estimatedSailingAt || ''" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" :disabled="!editable" @update:model-value="setDate('estimatedSailingAt', $event)" /></el-form-item><el-form-item label="预计到港"><el-date-picker :model-value="form.data.estimatedArrivalAt || ''" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" :disabled="!editable" @update:model-value="setDate('estimatedArrivalAt', $event)" /></el-form-item><el-form-item label="预计截关"><el-date-picker :model-value="form.data.cutoffAt || ''" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" :disabled="!editable" @update:model-value="setDate('cutoffAt', $event)" /></el-form-item>
<el-form-item label="箱型" required><el-input v-model="form.data.containerRequirements[0].containerType" :disabled="!editable" placeholder="如 40HC" /></el-form-item><el-form-item label="箱量" required><el-input-number v-model="form.data.containerRequirements[0].quantity" :min="1" :max="999" :disabled="!editable" controls-position="right" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.data.packageCount" :min="0" :disabled="!editable" controls-position="right" /></el-form-item>
<el-form-item label="毛重"><el-input v-model="form.data.grossWeight" :disabled="!editable" placeholder="如 19290 KGS" /></el-form-item><el-form-item label="体积"><el-input v-model="form.data.volume" :disabled="!editable" placeholder="如 68 CBM" /></el-form-item><el-form-item class="wide" label="货物描述"><el-input v-model="form.data.goodsDescription" :disabled="!editable" type="textarea" :rows="3" maxlength="2000" show-word-limit /></el-form-item><el-form-item class="wide" label="备注"><el-input v-model="form.data.remark" :disabled="!editable" type="textarea" :rows="2" maxlength="500" show-word-limit /></el-form-item>
</el-form>
</section>
<section v-if="detail" class="surface-panel"><h2>状态历史</h2><el-timeline><el-timeline-item v-for="event in detail.histories" :key="event.id" :timestamp="event.occurredAt">{{ event.fromStatus || '—' }} → {{ event.toStatus }} · {{ event.operatorName || '—' }}</el-timeline-item></el-timeline></section>
</template>
</div>
</template>
<style scoped>.booking-detail{display:grid;gap:16px}.detail-heading{display:flex;align-items:center;gap:16px}.detail-heading h1{margin:0;font-size:20px}.detail-heading p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{margin-left:auto}.surface-panel{padding:20px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:18px}.section-title h2,.surface-panel>h2{margin:0;font-size:16px}.actions{display:flex;flex-wrap:wrap;gap:8px}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.form-grid .wide{grid-column:1/-1}.form-grid :deep(.el-input-number),.form-grid :deep(.el-date-editor){width:100%}@media(max-width:900px){.form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.detail-heading{align-items:flex-start;flex-wrap:wrap}.heading-status{margin-left:0}.form-grid{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.section-title{align-items:flex-start;flex-direction:column}.actions{width:100%}.actions :deep(.el-button){min-height:40px}}</style>
@@ -6,11 +6,27 @@ import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../t
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 draft = reactive<SaveTruckAppointmentRequest>({ ...props.modelValue })
watch(() => props.modelValue, (value) => Object.assign(draft, value), { deep: true })
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: '请输入运输公司 ID', trigger: 'blur' }, { pattern: /^[1-9]\d*$/, message: '运输公司 ID 必须为正整数', trigger: 'blur' }],
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(' / ') || '由服务端关联资源返回')
async function validate() { return formRef.value?.validate() }
@@ -34,27 +50,27 @@ defineExpose({ validate })
<el-col :xs="24" :sm="12"><el-form-item label="承运商 ID" prop="carrierId"><el-input v-model="draft.carrierId" placeholder="请输入后端资源 ID" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="承运商名称"><el-input v-model="draft.carrierName" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车队电话"><el-input v-model="draft.fleetPhone" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车队电话" prop="fleetPhone"><el-input v-model="draft.fleetPhone" 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" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="司机电话"><el-input v-model="draft.driverPhone" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="司机电话" prop="driverPhone"><el-input v-model="draft.driverPhone" 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" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车头号"><el-input v-model="draft.vehicleNo" /></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" 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="件数"><el-input v-model="draft.packageCount" inputmode="numeric" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="毛重(kg)"><el-input v-model="draft.grossWeightKg" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="体积(m³)"><el-input v-model="draft.volumeCbm" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="托盘数量"><el-input v-model="draft.palletCount" inputmode="numeric" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="在途分钟"><el-input v-model="draft.transitMinutes" inputmode="numeric" /></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" /></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" /></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" /></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" /></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" /></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="应付拖车费"><el-input v-model="draft.payableAmount"><template #append>RMB</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应收拖车费"><el-input v-model="draft.receivableAmount"><template #append>RMB</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="币种"><el-input v-model="draft.currency" maxlength="8" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应付拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount"><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"><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" maxlength="8" @blur="draft.currency = draft.currency?.toUpperCase()" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店名称"><el-input v-model="draft.storeName" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店联系人"><el-input v-model="draft.storeContact" /></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.storePhone" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店电话" prop="storePhone"><el-input v-model="draft.storePhone" 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" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="邮编"><el-input v-model="draft.storeZipcode" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="PO"><el-input v-model="draft.poNo" /></el-form-item></el-col>
@@ -68,4 +84,12 @@ defineExpose({ validate })
.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>
@@ -66,8 +66,7 @@ onMounted(load)
</section>
</template>
<style scoped>
.trucking-page { min-width: 0; padding: 24px; }
.trucking-page { min-width: 0; }
.surface-panel { margin-bottom:16px; }.table-panel { overflow:hidden; }.table-heading { display:flex; align-items:baseline; gap:10px; }.table-heading h2 { margin:0; font-size:16px; }.table-heading span { color:var(--el-text-color-secondary); font-size:13px; }.empty-hint { display:flex; justify-content:center; gap:8px; color:var(--el-text-color-secondary); padding:24px; }
small { display:block; color:var(--el-text-color-secondary); margin-top:3px; }
@media (max-width:600px) { .trucking-page { padding:16px 12px; } }
</style>