优化
This commit is contained in:
@@ -4,11 +4,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import TruckAppointmentForm from './TruckAppointmentForm.vue'
|
||||
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
|
||||
import { listSuppliers } from '@/modules/suppliers'
|
||||
import { getOceanBooking } from '@/modules/ocean-bookings'
|
||||
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
|
||||
|
||||
vi.mock('@/modules/suppliers', () => ({
|
||||
listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', status: 'ACTIVE' }] }),
|
||||
listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', status: 'ACTIVE', contactName: '张调度', phone: '13800000001' }, { id: 'carrier-2', name: '第二车队', category: 'TRUCKING', status: 'ACTIVE', contactName: '李调度', phone: '13800000002' }] }),
|
||||
}))
|
||||
vi.mock('@/modules/ocean-bookings', () => ({
|
||||
getOceanBooking: vi.fn(),
|
||||
@@ -156,15 +157,61 @@ describe('TruckAppointmentForm booking-only behavior', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('offers only RMB for domestic trucking payables', async () => {
|
||||
const wrapper = mount(TruckAppointmentForm, {
|
||||
props: { modelValue: { ...model, payableAmount: '88.00', currency: 'RMB' } },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
it('starts with five main fields and optional collapsed dispatch information', async () => {
|
||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const currencySelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择币种')!
|
||||
expect(currencySelect.props('modelValue')).toBe('RMB')
|
||||
expect(currencySelect.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['RMB'])
|
||||
expect(wrapper.findAll('.appointment-grid > .el-form-item').filter(item => !item.element.closest('details'))).toHaveLength(5)
|
||||
expect((wrapper.get('details').element as HTMLDetailsElement).open).toBe(false)
|
||||
for (const label of ['门店名称', '司机身份证号', '毛重(kg)', '体积(m³)', '在途分钟', '托盘数量', '查看附件引用']) expect(wrapper.text()).not.toContain(label)
|
||||
expect(wrapper.findAllComponents({ name: 'ElSelect' })).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('元')
|
||||
expect(wrapper.text()).toContain('保存预约')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('copies supplier contacts and replaces or clears them when switching suppliers', async () => {
|
||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' })
|
||||
const supplier = wrapper.findAllComponents({ name: 'ElSelect' })[1]!
|
||||
supplier.vm.$emit('update:modelValue', 'carrier-1')
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)![0]).toMatchObject({ carrierId: 'carrier-1', fleetContact: '张调度', fleetPhone: '13800000001', currency: 'RMB' })
|
||||
expect(wrapper.get('[aria-label="车队联系方式"]').text()).toContain('张调度')
|
||||
supplier.vm.$emit('update:modelValue', 'carrier-2')
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)![0]).toMatchObject({ carrierId: 'carrier-2', fleetContact: '李调度', fleetPhone: '13800000002' })
|
||||
supplier.vm.$emit('update:modelValue', '')
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)![0]).toMatchObject({ carrierId: null, carrierName: null, fleetContact: null, fleetPhone: null })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('preserves saved contact overrides and supplementary values when editing', async () => {
|
||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1', carrierId: 'carrier-1', fleetContact: '本次调度', fleetPhone: '13900000000', storeName: '目的门店', grossWeightKg: '120.00' }, resource: resource('appointment-1', 'booking-1') }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
wrapper.findAllComponents({ name: 'ElSelect' })[1]!.vm.$emit('update:modelValue', 'carrier-1')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[aria-label="车队联系方式"]').text()).toContain('本次调度')
|
||||
expect(wrapper.text()).toContain('其他已存资料')
|
||||
expect(wrapper.findAllComponents({ name: 'ElInput' }).some(input => input.props('modelValue') === '目的门店')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps amount validation at two decimal places on the displayed field', async () => {
|
||||
const valid = { ...model, oceanBookingId: 'booking-1', carrierId: 'carrier-1', plannedLoadingAt: '2026-09-19T09:00:00', payableAmount: '100.00' }
|
||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: valid }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ modelValue: { ...valid, billOfLadingNo: 'BL-1', payableAmount: '100.001' } })
|
||||
await flushPromises()
|
||||
expect(wrapper.findAllComponents({ name: 'ElInput' }).find(input => input.props('placeholder') === '请输入应付拖车费')?.props('modelValue')).toBe('100.001')
|
||||
const rule = wrapper.findComponent({ name: 'ElForm' }).props('rules').payableAmount[0]
|
||||
const reject = vi.fn()
|
||||
rule.validator(null, '100.001', reject)
|
||||
expect(reject).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('应付拖车费') }))
|
||||
const accept = vi.fn()
|
||||
rule.validator(null, '100.00', accept)
|
||||
expect(accept).toHaveBeenCalledWith(undefined)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,15 +33,12 @@ describe('TruckAppointmentForm interaction contract', () => {
|
||||
it('freezes warehouse and bill of lading snapshots without retaining customer data', () => {
|
||||
expect(source).toContain('draft.warehouseId = item.warehouseId')
|
||||
expect(source).toContain('draft.billOfLadingNo = item.billOfLadingNo')
|
||||
expect(source).toContain('已从放舱订舱冻结资料带入')
|
||||
expect(source).toContain(':model-value="draft.billOfLadingNo || \'\'" disabled')
|
||||
expect(source).not.toContain('customerId')
|
||||
expect(source).not.toContain('customerName')
|
||||
expect(source).not.toContain('label="客户"')
|
||||
})
|
||||
|
||||
it('keeps server-side validation authoritative and preserves recoverable option errors', () => {
|
||||
expect(source).toContain('最终资格仍由服务端校验')
|
||||
expect(source).toContain('所选订舱缺少有效的订舱确认文件或提单文件')
|
||||
expect(source).toContain('所选订舱缺少有效提单号')
|
||||
expect(source).toContain("booking.status !== 'RELEASED'")
|
||||
@@ -72,12 +69,11 @@ describe('TruckAppointmentForm interaction contract', () => {
|
||||
expect(source).not.toContain('柜次')
|
||||
expect(source).not.toContain('装柜计划')
|
||||
expect(source).not.toContain('集装箱号')
|
||||
expect(source).toContain('货柜资料在货柜与预装流程维护')
|
||||
})
|
||||
|
||||
it('keeps the form usable at 390px', () => {
|
||||
expect(source).toContain('@media (max-width: 480px)')
|
||||
expect(source).toContain('min-height: 40px')
|
||||
expect(source).toContain('@media (max-width:480px)')
|
||||
expect(source).toContain('min-height:40px')
|
||||
})
|
||||
|
||||
it('suppresses duplicate submits and truncates long booking or container labels', () => {
|
||||
@@ -86,21 +82,19 @@ describe('TruckAppointmentForm interaction contract', () => {
|
||||
expect(source).toContain('text-overflow:ellipsis')
|
||||
})
|
||||
|
||||
it('uses payable-only fees with a fixed dynamic currency selector', () => {
|
||||
it('shows fixed yuan beside the amount without an unnecessary currency selector', () => {
|
||||
expect(typesSource).toContain("TRUCK_APPOINTMENT_CURRENCIES = ['RMB']")
|
||||
expect(source).toContain('<template #append>{{ draft.currency }}</template>')
|
||||
expect(source).toContain('v-for="currency in TRUCK_APPOINTMENT_CURRENCIES"')
|
||||
expect(source).toContain('placeholder="请选择币种"')
|
||||
expect(source).toContain('<template #append>元</template>')
|
||||
expect(source).not.toContain('placeholder="请选择币种"')
|
||||
expect(source).not.toContain('receivableAmount')
|
||||
expect(source).not.toContain('应收拖车费')
|
||||
expect(source).toContain('aria-label="订舱带入信息"')
|
||||
expect(source).not.toContain('label="提单号"')
|
||||
})
|
||||
|
||||
it('keeps the 1920 desktop contract compact without horizontal form overflow', () => {
|
||||
expect(source).toContain('@media (min-width: 1200px)')
|
||||
expect(source).toContain('max-width:25%; flex:0 0 25%')
|
||||
expect(source).toContain('class="relation-column"')
|
||||
expect(source).toContain('class="wide-column"')
|
||||
expect(source).toContain('max-width:50%; flex-basis:50%')
|
||||
it('keeps the compact appointment fields in two columns without horizontal overflow', () => {
|
||||
expect(source).toContain('grid-template-columns:repeat(2,minmax(0,1fr))')
|
||||
expect(source).toContain('max-width:1040px')
|
||||
expect(source).toContain('grid-column:1 / -1')
|
||||
expect(source).not.toContain('overflow-x:auto')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Eye, Save } from '@lucide/vue'
|
||||
import { TRUCK_APPOINTMENT_CURRENCIES, normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentCandidate, type TruckAppointmentResource } from '../types'
|
||||
import { normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentCandidate, type TruckAppointmentResource } from '../types'
|
||||
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import { getOceanBooking, type OceanBookingDocument } from '@/modules/ocean-bookings'
|
||||
@@ -14,18 +14,19 @@ const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?:
|
||||
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
|
||||
const formRef = ref<FormInstance>()
|
||||
const relationWarning = ref('')
|
||||
const dispatchDetailsOpen = ref(false)
|
||||
const bookingContainersLoading = ref(false)
|
||||
const bookingContainersError = ref<ApiErrorDetails | null>(null)
|
||||
const bookingDocuments = ref<OceanBookingDocument[]>([])
|
||||
const relationLocked = computed(() => Boolean(props.resource))
|
||||
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 }
|
||||
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00' }
|
||||
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: normalizeTruckAppointmentCurrency(value.currency) } }
|
||||
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) => {
|
||||
const nonNegative = (label: string, integer = false, precision = 3) => ({ 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)
|
||||
const text = String(value).trim(); const valid = integer ? /^\d+$/.test(text) : new RegExp(`^\\d+(?:\\.\\d{1,${precision}})?$`).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) => {
|
||||
@@ -39,11 +40,11 @@ const rules: FormRules = {
|
||||
if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号'))
|
||||
return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件'))
|
||||
}, trigger: 'change' }],
|
||||
carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
|
||||
plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
|
||||
carrierId: [{ required: true, message: '请选择拖车供应商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), 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('应付拖车费')],
|
||||
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费', false, 2)],
|
||||
}
|
||||
const carriers = ref<Supplier[]>([])
|
||||
const candidates = ref<TruckAppointmentCandidate[]>([])
|
||||
@@ -76,7 +77,17 @@ const selectedBookingFallback = computed(() => {
|
||||
if (!id || !relationLocked.value || String(snapshot?.oceanBookingId) !== String(id)) return null
|
||||
return { id: String(id), label: `${snapshot?.oceanBookingNo || '订舱号缺失'}(当前绑定,不可重选)` }
|
||||
})
|
||||
function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null }
|
||||
function selectCarrier(carrierId: string) {
|
||||
if (String(draft.carrierId || '') === carrierId) return
|
||||
const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId)
|
||||
draft.carrierId = carrier?.id || null
|
||||
draft.carrierName = carrier?.name || null
|
||||
draft.fleetContact = carrier?.contactName || null
|
||||
draft.fleetPhone = carrier?.phone || null
|
||||
}
|
||||
function toggleDispatchDetails(event: Event) {
|
||||
dispatchDetailsOpen.value = (event.target as HTMLDetailsElement).open
|
||||
}
|
||||
async function loadCurrentBookingContext(bookingId: string) {
|
||||
const sequence = ++bookingLoadSequence
|
||||
bookingContainersLoading.value = true
|
||||
@@ -125,7 +136,7 @@ async function loadOptions() {
|
||||
if (sequence !== optionsLoadSequence) return
|
||||
if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE')
|
||||
if (results[1].status === 'fulfilled') candidates.value = results[1].value.records
|
||||
const failed = ['承运商', '可用订舱'].filter((_, index) => results[index]?.status === 'rejected')
|
||||
const failed = ['拖车供应商', '可用订舱'].filter((_, index) => results[index]?.status === 'rejected')
|
||||
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败`
|
||||
optionsLoading.value = false
|
||||
if (relationLocked.value && draft.oceanBookingId) await loadCurrentBookingContext(String(draft.oceanBookingId))
|
||||
@@ -139,8 +150,14 @@ async function loadOptions() {
|
||||
}
|
||||
}
|
||||
}
|
||||
async function validate() { return formRef.value?.validate() }
|
||||
async function submitForm() { if (props.submitting) return; if (await formRef.value?.validate()) emit('submit') }
|
||||
async function validate() {
|
||||
try { return await formRef.value?.validate() }
|
||||
catch (fields) {
|
||||
if (fields && typeof fields === 'object' && ('fleetPhone' in fields || 'driverPhone' in fields)) dispatchDetailsOpen.value = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
async function submitForm() { if (props.submitting) return; if (await validate()) emit('submit') }
|
||||
defineExpose({ validate, loadOptions })
|
||||
onMounted(() => { void loadOptions() })
|
||||
watch(() => props.readonly, (readonly) => { if (!readonly && !carriers.value.length) void loadOptions() })
|
||||
@@ -148,38 +165,63 @@ watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) v
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form admin-density density-form">
|
||||
<div class="relation-note"><span>一张已放舱订舱对应一张有效拖车单;仓库和提单号由订舱冻结资料带入,货柜资料在货柜与预装流程维护,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
|
||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form admin-density density-form" :class="{ 'truck-form--create': !resource }">
|
||||
<div class="relation-note">
|
||||
<span>选择订舱和拖车供应商,约定装柜时间即可保存预约。</span>
|
||||
<div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div>
|
||||
</div>
|
||||
<el-alert v-if="relationWarning" type="warning" :closable="false" show-icon :title="relationWarning" class="relation-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 class="relation-column" :xs="24" :sm="12"><el-form-item label="订舱" prop="oceanBookingId"><el-select :model-value="draft.oceanBookingId" filterable :disabled="relationLocked" placeholder="请选择可创建拖车单的已放舱订舱" :loading="optionsLoading" no-data-text="暂无可创建拖车单的已放舱订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" disabled /><el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'} / 提单号:${item.billOfLadingNo || '缺失'}`" :value="item.id" /></el-select><div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadCurrentBookingContext(String(draft.oceanBookingId))">重新加载</el-button></div></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">基本信息</el-divider></el-col>
|
||||
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !draft.billOfLadingNo?.trim()" :xs="24"><el-alert title="所选订舱缺少有效提单号,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
|
||||
<el-col v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError && !bookingDocumentReady" :xs="24"><el-alert title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon /></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="承运商" prop="carrierId"><el-select :model-value="draft.carrierId" filterable clearable placeholder="请选择承运商" :loading="optionsLoading" no-data-text="暂无可用拖车供应商" 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)" disabled /><el-option v-for="item in selectableCarriers" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></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 :xs="24" :sm="12"><el-form-item label="提单号"><el-input :model-value="draft.billOfLadingNo || ''" disabled placeholder="选择订舱后自动带入" /><div v-if="selectedReleasedBooking" class="field-hint">已从放舱订舱冻结资料带入</div></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-input :model-value="selectedReleasedBooking?.warehouseName || resource?.warehouseName || ''" disabled placeholder="选择订舱后自动带入" /></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="车队联系人"><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>
|
||||
<div class="appointment-grid">
|
||||
<el-form-item label="订舱" prop="oceanBookingId">
|
||||
<el-select :model-value="draft.oceanBookingId" filterable :disabled="relationLocked" placeholder="请选择已放舱订舱" :loading="optionsLoading" no-data-text="暂无可创建拖车单的已放舱订舱" @update:model-value="selectBooking(String($event || ''))">
|
||||
<el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" disabled />
|
||||
<el-option v-for="item in bookings" :key="item.id" :label="`${item.bookingNo || '订舱号缺失'} / 提单号:${item.billOfLadingNo || '缺失'}`" :value="item.id" />
|
||||
</el-select>
|
||||
<dl v-if="draft.oceanBookingId" class="selection-summary" aria-label="订舱带入信息">
|
||||
<div><dt>仓库</dt><dd>{{ selectedReleasedBooking?.warehouseName || resource?.warehouseName || '—' }}</dd></div>
|
||||
<div><dt>提单号</dt><dd>{{ draft.billOfLadingNo || '—' }}</dd></div>
|
||||
</dl>
|
||||
<div v-if="bookingContainersError" class="field-error"><span>{{ bookingContainersError.message }}</span><ApiErrorMeta :details="bookingContainersError" /><el-button link type="primary" :loading="bookingContainersLoading" @click="draft.oceanBookingId && loadCurrentBookingContext(String(draft.oceanBookingId))">重新加载</el-button></div>
|
||||
</el-form-item>
|
||||
<el-form-item label="拖车供应商" prop="carrierId">
|
||||
<el-select :model-value="draft.carrierId" filterable clearable placeholder="请选择拖车供应商" :loading="optionsLoading" no-data-text="暂无可用拖车供应商" @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)" disabled />
|
||||
<el-option v-for="item in selectableCarriers" :key="String(item.id)" :label="item.name" :value="String(item.id)" />
|
||||
</el-select>
|
||||
<dl v-if="draft.carrierId" class="selection-summary" aria-label="车队联系方式">
|
||||
<div><dt>联系人</dt><dd>{{ draft.fleetContact || '—' }}</dd></div>
|
||||
<div><dt>电话</dt><dd>{{ draft.fleetPhone || '—' }}</dd></div>
|
||||
</dl>
|
||||
</el-form-item>
|
||||
<div v-if="draft.oceanBookingId && !bookingContainersLoading && !bookingContainersError" class="appointment-grid__wide readiness-alerts">
|
||||
<el-alert v-if="!draft.billOfLadingNo?.trim()" title="所选订舱缺少有效提单号,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="!bookingDocumentReady" title="所选订舱缺少有效的订舱确认文件或提单文件,暂不能创建或保存拖车预约" type="warning" :closable="false" show-icon />
|
||||
</div>
|
||||
<el-form-item label="预约装柜时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预约装柜时间" /></el-form-item>
|
||||
<el-form-item label="拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" inputmode="decimal" placeholder="请输入应付拖车费"><template #append>元</template></el-input><div class="field-hint">人民币;离仓完成后计入应付。</div></el-form-item>
|
||||
<el-form-item class="appointment-grid__wide" label="备注(选填)"><el-input v-model="draft.remark" type="textarea" :rows="2" maxlength="1000" placeholder="例如到仓时间要求、装柜注意事项" /></el-form-item>
|
||||
</div>
|
||||
<details class="dispatch-details" :open="dispatchDetailsOpen" @toggle="toggleDispatchDetails">
|
||||
<summary>派车信息<span>选填,司机和车辆确定后可补充</span></summary>
|
||||
<div class="appointment-grid dispatch-grid">
|
||||
<el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" maxlength="64" placeholder="选择供应商后带入,可修改" /></el-form-item>
|
||||
<el-form-item label="车队电话" prop="fleetPhone"><el-input v-model="draft.fleetPhone" maxlength="32" placeholder="选择供应商后带入,可修改" /></el-form-item>
|
||||
<el-form-item label="司机"><el-input v-model="draft.driverName" maxlength="64" placeholder="司机姓名" /></el-form-item>
|
||||
<el-form-item label="司机电话" prop="driverPhone"><el-input v-model="draft.driverPhone" maxlength="32" placeholder="司机联系电话" /></el-form-item>
|
||||
<el-form-item label="车牌号"><el-input v-model="draft.vehicleNo" maxlength="32" placeholder="车牌或车头号" /></el-form-item>
|
||||
</div>
|
||||
</details>
|
||||
<details v-if="resource" class="dispatch-details">
|
||||
<summary>其他已存资料</summary>
|
||||
<el-row :gutter="16">
|
||||
<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 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="12"><el-form-item label="应付拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" placeholder="请输入应付金额"><template #append>{{ draft.currency }}</template></el-input></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="币种" prop="currency"><el-select v-model="draft.currency" aria-label="币种" placeholder="请选择币种" style="width:100%"><el-option v-for="currency in TRUCK_APPOINTMENT_CURRENCIES" :key="currency" :label="currency" :value="currency" /></el-select></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 class="wide-column" :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>
|
||||
@@ -187,34 +229,43 @@ watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) v
|
||||
<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 class="wide-column" :xs="24" :sm="12"><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" :disabled="submitBlocked" @click="submitForm"><Save :size="16" />保存</el-button></div>
|
||||
</el-row>
|
||||
</details>
|
||||
<div class="form-actions"><el-button v-if="resource" :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" :disabled="submitBlocked" @click="submitForm"><Save :size="16" />{{ resource ? '保存' : '保存预约' }}</el-button></div>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.truck-form { padding-bottom: var(--admin-space-1); }
|
||||
.relation-note { display:flex; min-height:40px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); margin:0 0 var(--admin-space-3); padding:var(--admin-space-2) var(--admin-space-3) var(--admin-space-2) var(--admin-space-4); border-left:3px solid var(--cc-primary); background:var(--el-fill-color-extra-light); color:var(--cc-muted); font-size:var(--admin-font-caption); line-height:1.5; }
|
||||
.relation-note > span { min-width:0; }
|
||||
.relation-note__actions { display:flex; flex:0 0 auto; align-items:center; }
|
||||
.relation-note__actions :deep(.el-button) { margin-left:0; }
|
||||
.relation-alert { margin-bottom: var(--admin-space-5); }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: var(--admin-space-3); flex-wrap: wrap; }
|
||||
:deep(.el-divider) { margin: var(--admin-space-5) 0 var(--admin-space-4); }
|
||||
:deep(.el-divider__text) { color: var(--el-text-color-primary); font-size: var(--admin-font-section); font-weight: 600; }
|
||||
:deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; }
|
||||
:deep(.el-select__selected-item), :deep(.el-select-dropdown__item) { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.field-error,.field-hint { display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-3);margin-top:var(--admin-space-1);color:var(--cc-muted);font-size:var(--admin-font-caption);line-height:20px }.field-error{color:var(--el-color-danger)}
|
||||
@media (min-width: 1200px) {
|
||||
.truck-form :deep(.el-col-sm-12), .truck-form :deep(.el-col-sm-8) { max-width:25%; flex:0 0 25%; }
|
||||
.truck-form :deep(.relation-column), .truck-form :deep(.wide-column) { max-width:50%; flex-basis:50%; }
|
||||
.truck-form { min-width:0; padding-bottom:var(--admin-space-1); }
|
||||
.truck-form--create { max-width:1040px; margin-inline:auto; }
|
||||
.relation-note { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-4); margin-bottom:var(--admin-space-5); color:var(--cc-muted); font-size:var(--admin-font-caption); line-height:1.5; }
|
||||
.relation-note__actions { display:flex; flex:0 0 auto; }
|
||||
.relation-alert { margin-bottom:var(--admin-space-5); }
|
||||
.appointment-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:var(--admin-space-6); }
|
||||
.appointment-grid__wide { grid-column:1 / -1; }
|
||||
:deep(.el-form-item), :deep(.el-form-item__content) { min-width:0; }
|
||||
:deep(.el-select), :deep(.el-date-editor), :deep(.el-input), :deep(.el-textarea) { width:100%; max-width:100%; }
|
||||
:deep(.el-select__selected-item) { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.selection-summary { display:flex; flex-wrap:wrap; gap:var(--admin-space-1) var(--admin-space-5); width:100%; margin:var(--admin-space-2) 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption); line-height:1.5; }
|
||||
.selection-summary div { display:flex; min-width:0; gap:var(--admin-space-2); }
|
||||
.selection-summary dt { flex-shrink:0; }
|
||||
.selection-summary dd { margin:0; color:var(--el-text-color-regular); overflow-wrap:anywhere; }
|
||||
.field-error,.field-hint { width:100%; display:flex; flex-wrap:wrap; gap:var(--admin-space-2); margin-top:var(--admin-space-1); color:var(--cc-muted); font-size:var(--admin-font-caption); line-height:1.5; }
|
||||
.field-error { color:var(--el-color-danger); }
|
||||
.readiness-alerts:empty { display:none; }
|
||||
.readiness-alerts :deep(.el-alert) { margin-bottom:var(--admin-space-4); }
|
||||
.dispatch-details { border-top:1px solid var(--el-border-color-lighter); }
|
||||
.dispatch-details summary { padding:var(--admin-space-4) 0; cursor:pointer; font-size:var(--admin-font-body); font-weight:600; color:var(--el-text-color-primary); }
|
||||
.dispatch-details summary span { margin-left:var(--admin-space-3); color:var(--cc-muted); font-size:var(--admin-font-caption); font-weight:400; }
|
||||
.dispatch-details summary:focus-visible { outline:2px solid var(--cc-primary); outline-offset:2px; }
|
||||
.dispatch-grid { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||
.form-actions { display:flex; justify-content:flex-end; gap:var(--admin-space-3); padding-top:var(--admin-space-4); border-top:1px solid var(--el-border-color-lighter); }
|
||||
@media (max-width:720px) {
|
||||
.appointment-grid,.dispatch-grid { grid-template-columns:minmax(0,1fr); }
|
||||
.dispatch-details summary span { display:block; margin-left:var(--admin-space-4); }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.truck-form { padding-inline: 2px; }
|
||||
.relation-note { align-items:flex-start; flex-direction:column; }
|
||||
.relation-note__actions { width:100%; }
|
||||
.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; }
|
||||
@media (max-width:480px) {
|
||||
.form-actions { position:sticky; bottom:0; background:var(--el-bg-color); padding-bottom:var(--admin-space-3); z-index:2; }
|
||||
.form-actions :deep(.el-button) { flex:1; min-height:40px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user