Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test

This commit is contained in:
2026-10-20 23:49:47 +08:00
40 changed files with 735 additions and 197 deletions
@@ -1,28 +0,0 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import TransferQuantitySummary from './TransferQuantitySummary.vue'
import { getTransferQuantityTotals } from '../api/transferQuantityFacts'
vi.mock('../api/transferQuantityFacts', () => ({ getTransferQuantityTotals: vi.fn() }))
describe('转运完整筛选数量', () => {
it('keeps unknown pieces explicit and renders overlapping pallet facts after paging', async () => {
vi.mocked(getTransferQuantityTotals).mockResolvedValue({ orderCount: '2', declaredPieceCount: null, knownDeclaredPieceCount: '16', unknownDeclaredOrderCount: '1', receivedPieceCount: '20', knownReceivedPieceCount: '20', unknownReceivedOrderCount: '0', declaredCartonCount: '4', receivedCartonCount: '4', receivedPalletCount: '3', availableCartonCount: '2', shippedCartonCount: '2', remainingPalletCount: '2', shippedPalletCount: '2' })
const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1, orderType: 'WAREHOUSE_TRANSFER' } } })
await flushPromises()
expect(wrapper.text()).toContain('未记录(已知 16 件,1 票未知)')
expect(wrapper.text()).toContain('20 件 / 4 箱 / 3 托')
expect(wrapper.text()).toContain('剩余 2 托')
expect(wrapper.text()).toContain('2 箱 / 2 托(托盘可重叠)')
await wrapper.setProps({ query: { page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' } })
await flushPromises()
expect(wrapper.text()).toContain('全筛选 2 票')
expect(getTransferQuantityTotals).toHaveBeenLastCalledWith({ page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' })
})
it('shows a recoverable error instead of zero totals', async () => {
vi.mocked(getTransferQuantityTotals).mockRejectedValue(new Error('offline'))
const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1 } } })
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toContain('加载失败')
expect(wrapper.text()).not.toContain('全筛选 0')
})
})
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getTransferQuantityTotals, type TransferQuantityTotals } from '../api/transferQuantityFacts'
import type { LastMileOrderQuery } from '../api/lastMileOrders'
const props = defineProps<{ query: LastMileOrderQuery }>()
const summary = ref<TransferQuantityTotals>()
const error = ref('')
let sequence = 0
watch(() => JSON.stringify(props.query), async () => {
const current = ++sequence
summary.value = undefined; error.value = ''
try { const value = await getTransferQuantityTotals(props.query); if (current === sequence) summary.value = value }
catch { if (current === sequence) error.value = '转运全筛选数量加载失败,请重新查询' }
}, { immediate: true })
</script>
<template>
<div v-if="summary" role="status" aria-label="仓储转运全筛选汇总">
<strong>仓储转运 · 全筛选 {{ summary.orderCount }} 票</strong> ·
预报 {{ summary.declaredPieceCount ?? `未记录(已知 ${summary.knownDeclaredPieceCount} 件,${summary.unknownDeclaredOrderCount} 票未知)` }}{{ summary.declaredPieceCount == null ? '' : ' 件' }} / {{ summary.declaredCartonCount }} 箱 ·
历史实收 {{ summary.receivedPieceCount ?? `未记录(已知 ${summary.knownReceivedPieceCount} 件,${summary.unknownReceivedOrderCount} 票未知)` }}{{ summary.receivedPieceCount == null ? '' : ' 件' }} / {{ summary.receivedCartonCount }} 箱 / {{ summary.receivedPalletCount }} 托 ·
当前已清点 {{ summary.availableCartonCount }} 箱 · 剩余 {{ summary.remainingPalletCount }} 托 · 已出库 {{ summary.shippedCartonCount }} 箱 / {{ summary.shippedPalletCount }} 托(托盘可重叠)
</div>
<p v-else-if="error" role="alert">{{ error }}</p>
</template>
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onBeforeRouteLeave } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { listVehicles, listDrivers, pickupStatusLabels, type VehicleDetail, type DriverDetail, type PickupTask, type PickupEvidence } from '@/modules/overseas-master-data'
import { getTransport, assignTransport, transportOccupancy, cancelTransport, releaseTransport, commandTransport, uploadTransport, downloadTransportEvidence, transportLabels, type TransportDetail } from '../api/transportTasks'
import { dispatchActionPermissions as permission } from '../dispatchPresentation'
const props = defineProps<{ dispatchNo: string }>(), emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore(), detail = ref<TransportDetail | null>(null), error = ref<ApiErrorDetails | null>(null), busy = ref(false)
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), vehicleId = ref(''), driverId = ref(''), reason = ref('')
const occupancy = ref<Record<string, boolean>>({}), candidateBusy = ref(false)
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref(1), evidence = ref<PickupEvidence[]>([]), truckEvidence = ref<PickupEvidence[]>([])
const releaseOpen = ref(false), releaseKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), endLocation = ref(''), actualEnd = ref('')
const pending = ref<{ suffix: string; body: object; kind?: 'cancel' | 'release' } | null>(null)
const trip = computed(() => detail.value?.execution.trip)
const editable = computed(() => access.can(permission.EDIT))
const locked = computed(() => busy.value || !!pending.value)
async function load() {
error.value = null
try { detail.value = await getTransport(props.dispatchNo); vehicleId.value = trip.value?.vehicleId || ''; driverId.value = trip.value?.driverId || '' }
catch (cause) { error.value = getApiErrorDetails(cause, '运输任务加载失败') }
}
watch(() => props.dispatchNo, () => { detail.value = null; pending.value = null; void load() }, { immediate: true })
/** 资源候选与占用分开校验;保存由后端锁定复验。 */
async function candidates() {
if (!trip.value || candidateBusy.value) return
candidateBusy.value = true; error.value = null
try {
const [v, d] = await Promise.all([listVehicles({ page: 1, size: 100, region: 'OVERSEAS', status: 'IN_SERVICE' }), listDrivers({ page: 1, size: 100, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: trip.value.expectedDriverEndAt })])
const [vo, dr] = await Promise.all([transportOccupancy(props.dispatchNo, 'VEHICLE', v.records.map(row => row.id)), transportOccupancy(props.dispatchNo, 'DRIVER', d.records.map(row => row.id))])
occupancy.value = Object.fromEntries([...vo.map(row => [`V:${row.resourceId}`, row.available]), ...dr.map(row => [`D:${row.resourceId}`, row.available])]); vehicles.value = v.records; drivers.value = d.records
} catch (cause) { error.value = getApiErrorDetails(cause, '资源候选加载失败') }
finally { candidateBusy.value = false }
}
async function assign() {
if (!trip.value || locked.value || !vehicleId.value || !driverId.value) return
busy.value = true; error.value = null
try { detail.value = await assignTransport(props.dispatchNo, { recordVersion: trip.value.recordVersion, vehicleId: vehicleId.value, driverId: driverId.value, reason: reason.value.trim() || '分配运输车辆及司机' }); emit('saved'); ElMessage.success('车辆司机已分配') }
catch (cause) { await load(); error.value = getApiErrorDetails(cause, '分配未完成') }
finally { busy.value = false }
}
/** 每次业务命令保留原版本与幂等键,结果未知只重试原请求。 */
async function execute() {
if (!pending.value || busy.value) return
const request = pending.value; busy.value = true; error.value = null
try {
if (request.kind === 'cancel') await cancelTransport(props.dispatchNo, request.body)
else if (request.kind === 'release') await releaseTransport(props.dispatchNo, request.body as Parameters<typeof releaseTransport>[1])
else await commandTransport(props.dispatchNo, request.suffix, request.body)
pending.value = null; pickupOpen.value = false; releaseOpen.value = false; await load(); emit('saved'); ElMessage.success('运输操作已保存')
} catch (cause) { if (cause instanceof ApiResponseError) { pending.value = null; await load() }; error.value = getApiErrorDetails(cause, '运输操作未完成') }
finally { busy.value = false }
}
async function command(action: 'depart' | 'complete' | 'arrive' | 'cancel', row?: PickupTask) {
if (!trip.value || locked.value) return
const message = { depart: '确认车辆司机已出发执行运输任务?', complete: '确认运输已实际完成?', arrive: '确认货物已实际送达仓库?确认后仓库可开始收货。', cancel: '确认取消未发车排车并释放车辆、司机和原单安排?' }[action]
try { await ElMessageBox.confirm(message, '确认运输操作', { customClass: 'admin-density-confirm' }) } catch { return }
const prefix = detail.value?.taskType === 'PICKUP' ? 'pickup' : 'truck'
const suffix = row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/${action}` : `${prefix}/${action}`
pending.value = { suffix, kind: action === 'cancel' ? 'cancel' : undefined, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || message, ...(action === 'complete' && prefix === 'truck' ? { evidenceFileIds: truckEvidence.value.map(file => file.fileId) } : {}) } }
await execute()
}
function editPickup(row: PickupTask) { selected.value = row; actual.value = Number(row.packageCount); evidence.value = []; pickupOpen.value = true }
async function download(file: PickupEvidence, row?: PickupTask) {
try { const blob = await downloadTransportEvidence(props.dispatchNo, file.fileId, row); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
catch (cause) { error.value = getApiErrorDetails(cause, '凭证下载失败') }
}
async function upload(event: Event, pickup: boolean) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; if (!file || locked.value) return
busy.value = true; error.value = null
try {
const row = selected.value, suffix = pickup && row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}` : 'truck'
const result = await uploadTransport(props.dispatchNo, suffix, file); (pickup ? evidence.value : truckEvidence.value).push(result)
} catch (cause) { error.value = getApiErrorDetails(cause, '凭证上传失败') }
finally { busy.value = false; input.value = '' }
}
async function picked() {
if (!selected.value || !trip.value || !Number.isInteger(actual.value) || actual.value <= 0 || !evidence.value.length || locked.value) return
const row = selected.value
pending.value = { suffix: `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/picked`, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), actualPackages: actual.value, evidenceFileIds: evidence.value.map(file => file.fileId), remark: reason.value.trim() } }; await execute()
}
function openRelease(kind: 'VEHICLE' | 'DRIVER') { releaseKind.value = kind; endLocation.value = ''; actualEnd.value = ''; releaseOpen.value = true }
async function release() {
if (!trip.value || !actualEnd.value || !endLocation.value.trim() || locked.value) return
pending.value = { suffix: 'release', kind: 'release', body: { recordVersion: trip.value.recordVersion, resourceKind: releaseKind.value, actualEndAt: actualEnd.value, endLocation: endLocation.value.trim(), vehicleStatus: releaseKind.value === 'VEHICLE' ? 'IN_SERVICE' : null, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || '确认资源实际任务结束' } }; await execute()
}
onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核对,离开会丢失原请求。确认离开?'))
</script>
<template>
<section class="detail-section" aria-label="运输任务执行">
<LoadErrorState v-if="error" title="运输操作未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-button v-if="pending" :loading="busy" @click="execute">按原请求重试</el-button>
<template v-if="detail && trip">
<h2>{{ transportLabels[detail.taskType] }} · {{ { PLANNED: '待发车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}</h2>
<p v-if="detail.orderNo">本土卡车原单:{{ detail.orderNo }} · 客户:{{ detail.customerId }}</p>
<p>调度仓库:{{ trip.warehouseName }} · 车辆:{{ trip.vehiclePlate || '待分配' }} · 司机:{{ trip.driverName || '待分配' }} {{ trip.driverPhone }}</p>
<p v-for="(stop, index) in trip.stops" :key="index">{{ index + 1 }}. {{ stop.name }} {{ stop.address }}</p>
<el-input v-if="editable" v-model="reason" :disabled="locked" placeholder="本次操作说明" />
<el-form v-if="trip.status === 'PLANNED' && editable" label-position="top" :disabled="locked" class="density-form">
<el-button :loading="candidateBusy" @click="candidates">加载车辆司机候选</el-button>
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位${occupancy[`V:${row.id}`] ? '' : ' · 已占用'}`" /></el-select></el-form-item>
<el-form-item label="司机"><el-select v-model="driverId" filterable><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}${occupancy[`D:${row.id}`] ? '' : ' · 已占用'}`" /></el-select></el-form-item>
<el-button :loading="busy" :disabled="candidateBusy || !vehicleId || !driverId" @click="assign">保存车辆司机</el-button>
</el-form>
<div class="density-actions">
<el-button v-if="trip.status === 'PLANNED' && access.can(permission.DEPART)" :disabled="locked || !trip.vehicleId || !trip.driverId" @click="command('depart')">确认发车</el-button>
<el-button v-if="trip.status === 'PLANNED' && access.can(permission.CANCEL)" :disabled="locked" @click="command('cancel')">取消排车</el-button>
<el-button v-if="trip.status === 'IN_TRANSIT' && editable" :disabled="locked || (detail.taskType === 'PICKUP' ? !detail.pickups.length || detail.pickups.some(row => row.status !== 'ARRIVED') : !truckEvidence.length)" @click="command('complete')">确认运输完成</el-button>
<template v-if="trip.status === 'COMPLETED' && editable"><el-button v-if="!trip.vehicleRelease" :disabled="locked" @click="openRelease('VEHICLE')">结束车辆任务</el-button><el-button v-if="!trip.driverRelease" :disabled="locked" @click="openRelease('DRIVER')">结束司机任务</el-button></template>
<el-button :disabled="locked" @click="load">刷新运输进度</el-button>
</div>
<p v-if="detail.taskType === 'PICKUP'">送仓后由仓库独立清点收货,取货数量保留为运输事实。</p>
<el-table v-if="detail.taskType === 'PICKUP'" :data="detail.pickups"><el-table-column prop="transferNo" label="转运单号" min-width="190" /><el-table-column label="进度"><template #default="{ row }">{{ pickupStatusLabels[row.status] }}</template></el-table-column><el-table-column prop="packageCount" label="预报箱数" /><el-table-column prop="actualPackages" label="实提箱数" /><el-table-column label="凭证"><template #default="{ row }"><el-button v-for="file in row.evidence" :key="file.fileId" link @click="download(file, row as PickupTask)">{{ file.fileName }}</el-button></template></el-table-column><el-table-column label="操作" width="180"><template #default="{ row }"><template v-if="trip.status === 'IN_TRANSIT' && editable"><el-button v-if="row.status === 'ASSIGNED'" link :disabled="locked" @click="editPickup(row as PickupTask)">确认取货</el-button><el-button v-if="row.status === 'PICKED'" link :disabled="locked" @click="command('arrive', row as PickupTask)">确认送仓</el-button></template></template></el-table-column></el-table>
<div v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable"><label>运输完成凭证 <input type="file" :disabled="locked" @change="upload($event, false)" /></label><p v-for="file in truckEvidence" :key="file.fileId">{{ file.fileName }}</p></div>
<div v-if="detail.completionEvidence?.length"><p>已确认的运输完成凭证</p><el-button v-for="file in detail.completionEvidence" :key="file.fileId" link @click="download(file)">{{ file.fileName }}</el-button></div>
</template>
<AppDialog class="admin-density-dialog" v-model="pickupOpen" title="确认原单取货" width="650px" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked"><p>{{ selected?.transferNo }}</p><el-form label-position="top" :disabled="locked"><el-form-item label="实提箱数" required><el-input-number v-model="actual" :min="1" :precision="0" /></el-form-item><el-form-item label="取货凭证" required><input type="file" :disabled="locked" @change="upload($event, true)" /></el-form-item><p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p></el-form><template #footer><el-button type="primary" :disabled="locked || !evidence.length" @click="picked">确认实提</el-button></template></AppDialog>
<AppDialog class="admin-density-dialog" v-model="releaseOpen" :title="releaseKind === 'VEHICLE' ? '结束车辆任务' : '结束司机任务'" width="650px" :show-close="!locked" :close-on-click-modal="!locked"><el-form label-position="top" :disabled="locked"><el-form-item label="实际任务结束时间" required><el-date-picker v-model="actualEnd" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="实际结束地点" required><el-input v-model="endLocation" /></el-form-item></el-form><template #footer><el-button type="primary" :disabled="locked || !actualEnd || !endLocation.trim()" @click="release">确认任务结束并释放占用</el-button></template></AppDialog>
</section>
</template>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import type { PickupCandidate, PickupPlan } from '@/modules/overseas-master-data'
import { pickupCandidates, truckCandidates, createTransportPickup, createTransportTruck, type TruckCandidate, type TruckPlan, type TransportType } from '../api/transportTasks'
const props = defineProps<{ modelValue: boolean; type: TransportType }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>()
const router = useRouter(), pickups = ref<PickupCandidate[]>([]), trucks = ref<TruckCandidate[]>([]), warehouses = ref<PlatformWarehouseResource[]>([])
const selected = ref<PickupCandidate[]>([]), truck = ref(''), warehouse = ref(''), departure = ref(''), end = ref('')
const loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const pending = ref<{ type: TransportType; body: PickupPlan | TruckPlan } | null>(null)
let sequence = 0
async function load() {
const current = ++sequence; loading.value = true; error.value = null
try {
if (props.type === 'PICKUP') { const value = await pickupCandidates(); if (current === sequence) pickups.value = value.records }
else { const [orders, directory] = await Promise.all([truckCandidates(), listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' })]); if (current === sequence) { trucks.value = orders; warehouses.value = directory.records } }
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '运输需求加载失败') }
finally { if (current === sequence) loading.value = false }
}
watch(() => props.modelValue, value => { if (value) { selected.value = []; truck.value = ''; warehouse.value = ''; pending.value = null; void load() } else sequence++ })
/** 结果未知时保留相同原请求,禁止重复选择来源另建单。 */
async function save() {
if (busy.value) return
if (!pending.value) {
if (!departure.value || !end.value || Date.parse(end.value) <= Date.parse(departure.value)) { ElMessage.warning('请填写有效的计划发车和预计任务结束时间'); return }
const common = { plannedDepartureAt: departure.value, expectedDriverEndAt: end.value, idempotencyKey: crypto.randomUUID() }
if (props.type === 'PICKUP') {
if (!selected.value.length || new Set(selected.value.map(row => row.warehouseId)).size !== 1) { ElMessage.warning('请选择送往同一个仓库的取货单'); return }
pending.value = { type: 'PICKUP', body: { ...common, sources: selected.value.map(({ customerId, transferNo }) => ({ customerId, transferNo })) } }
} else {
const order = trucks.value.find(row => `${row.customerId}:${row.orderNo}` === truck.value)
if (!order || !warehouse.value) { ElMessage.warning('请选择本土卡车原单和调度仓库'); return }
pending.value = { type: 'LOCAL_TRUCK', body: { ...common, customerId: order.customerId, orderNo: order.orderNo, warehouseId: warehouse.value } }
}
}
busy.value = true; error.value = null
try {
const request = pending.value
const detail = request.type === 'PICKUP' ? await createTransportPickup(request.body as PickupPlan) : await createTransportTruck(request.body as TruckPlan)
pending.value = null; emit('created'); emit('update:modelValue', false); ElMessage.success(`已生成排车单 ${detail.dispatchNo}`)
await router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: detail.dispatchNo } })
} catch (cause) { error.value = getApiErrorDetails(cause, '安排排车未完成'); if (cause instanceof ApiResponseError) pending.value = null }
finally { busy.value = false }
}
</script>
<template>
<AppDialog class="admin-density-dialog" :model-value="modelValue" :title="type === 'PICKUP' ? '安排到仓取货' : '安排本土卡车'" width="1100px" :close-on-click-modal="!busy && !pending" :close-on-press-escape="!busy && !pending" :show-close="!busy && !pending" @update:model-value="emit('update:modelValue', $event)">
<LoadErrorState v-if="error" title="排车安排未完成" :message="error.message" @retry="pending ? save() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-form label-position="top" class="density-form" :disabled="busy || !!pending" v-loading="loading">
<template v-if="type === 'PICKUP'">
<p>审核通过的仓储转运上门取货单自动进入候选;同车货物必须送往同一个海外仓。</p>
<DataTable :data="pickups" row-key="transferNo" :columns="[{ key: 'selection', type: 'selection', width: 45 }, { key: 'transferNo', prop: 'transferNo', label: '转运单号', minWidth: 190 }, { key: 'warehouseName', prop: 'warehouseName', label: '送达仓库', minWidth: 140 }, { key: 'pickupAddress', prop: 'pickupAddress', label: '取货地址', minWidth: 240 }, { key: 'packageCount', prop: 'packageCount', label: '预报箱数', width: 100 }]" :pagination="false" @selection-change="selected = $event as PickupCandidate[]" />
</template>
<template v-else>
<el-form-item label="本土卡车原单" required><el-select v-model="truck" filterable><el-option v-for="row in trucks" :key="`${row.customerId}:${row.orderNo}`" :value="`${row.customerId}:${row.orderNo}`" :label="`${row.orderNo} · ${row.origin} → ${row.destination} · ${row.palletCount}托`" /></el-select></el-form-item>
<el-form-item label="调度仓库" required><el-select v-model="warehouse" filterable><el-option v-for="row in warehouses" :key="String(row.id)" :value="String(row.id)" :label="row.warehouseName" /></el-select></el-form-item>
</template>
<el-form-item label="计划发车时间" required><el-date-picker v-model="departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="预计司机任务结束时间" required><el-date-picker v-model="end" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
</el-form>
<template #footer><el-button v-if="!pending" :disabled="busy" @click="emit('update:modelValue', false)">取消</el-button><el-button type="primary" :loading="busy" @click="save">{{ pending ? '按原请求重试' : '生成排车单' }}</el-button></template>
</AppDialog>
</template>