Files
admin-web/src/modules/overseas-warehouse/components/TransportExecutionPanel.vue
T
2026-11-03 11:43:50 +08:00

339 lines
37 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 '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onBeforeRouteLeave } from 'vue-router'
import { Truck, Package, RefreshCw, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import { listVehicles, listDrivers, getDriver, pickupStatusLabels, type VehicleDetail, type DriverDetail, type PickupTask, type PickupEvidence } from '@/modules/overseas-master-data'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import TransportPhotoGallery from './TransportPhotoGallery.vue'
import { getTransport, assignTransport, transportOccupancy, cancelTransport, commandTransport, uploadTransport, downloadTransportEvidence, transportLabels, type TransportDetail } from '../api/transportTasks'
import { dispatchActionPermissions as permission } from '../dispatchPresentation'
import { formatOverseasDateTime as time } from '../lastMilePresentation'
const props = defineProps<{ dispatchNo: string; pickupApproved?: boolean; pickupReviewState?: string; pickupStage?: string; editResources?: boolean; readonly?: boolean }>(), 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 vehiclePhotos = ref<string[]>([]), driverPhotos = ref<string[]>([]), licensePhotos = ref<string[]>([])
const masterLicense = ref<{ driverId: string; fileId: string } | null>(null), licenseLoading = ref(false)
const masterPhotoAvailable = reactive({ vehicle: false, driver: false })
const photoBusy = reactive({ vehicle: false, driver: false, license: false })
const activeMasterLicense = computed(() => detail.value?.taskType === 'PICKUP' && !licensePhotos.value.length ? masterLicense.value : null)
const photosComplete = computed(() => !['PICKUP', 'LOCAL_TRUCK'].includes(detail.value?.taskType || '') || !!((vehiclePhotos.value.length || masterPhotoAvailable.vehicle) && (driverPhotos.value.length || masterPhotoAvailable.driver) && (licensePhotos.value.length || activeMasterLicense.value)))
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref<number>(), evidence = ref<PickupEvidence[]>([])
const truckPickupEvidence = ref<PickupEvidence[]>([]), truckDeliveryEvidence = ref<PickupEvidence[]>([])
const truckPreviewUrls = ref<Record<string, string>>({})
const arrivalMode = ref(false)
const validPickupQuantity = computed(() => typeof actual.value === 'number' && Number.isInteger(actual.value) && actual.value >= 1 && actual.value <= 5000)
const editingResources = ref(props.editResources === true)
const pickupPhotos = (row: PickupTask, phase: 'PICKUP' | 'ARRIVAL') => row.evidence.filter(file => ((file as PickupEvidence & { phase?: string }).phase || 'PICKUP') === phase)
const pickupStage = computed(() => trip.value?.status === 'COMPLETED' ? '取货派送完成' : trip.value?.status === 'CANCELLED' ? '已取消' : detail.value?.pickups.some(row => row.status === 'ASSIGNED') ? props.pickupApproved ? '装货中' : ({ DRAFT: '待提交审核', DISPATCH_PENDING: '待调度审核', LOADING_PENDING: '待理货审核', REJECTED: '审核退回' }[props.pickupReviewState || 'DRAFT'] || '待审核') : '派送中')
const pending = ref<{ suffix: string; body: object; kind?: 'cancel' } | null>(null)
const trip = computed(() => detail.value?.execution.trip)
const editable = computed(() => !props.readonly && access.can(permission.EDIT))
const canChangeResources = computed(() => trip.value?.status === 'PLANNED' && props.pickupStage !== 'LOADING'
&& !(detail.value?.taskType === 'PICKUP' && (props.pickupApproved || props.pickupReviewState === 'APPROVED' || (props.pickupStage && props.pickupStage !== 'BEFORE_LOADING')))
&& !detail.value?.pickups.some(row => row.status !== 'ASSIGNED'))
const resourceFormOpen = computed(() => canChangeResources.value && editable.value && (editingResources.value || !trip.value?.vehicleId || !trip.value?.driverId))
const locked = computed(() => busy.value || !!pending.value || licenseLoading.value || Object.values(photoBusy).some(Boolean))
const completionAction = computed(() => {
if (detail.value?.taskType !== 'LOCAL_TRUCK' || !editable.value || resourceFormOpen.value || !props.pickupApproved || !trip.value) return null
if (props.pickupStage === 'LOADING' && trip.value.status === 'PLANNED') {
return { action: 'depart' as const, label: '完成', missingPhotoMessage: '请至少上传一张取货照片' }
}
if (props.pickupStage === 'DELIVERING' && trip.value.status === 'IN_TRANSIT') {
return { action: 'complete' as const, label: '完成本单', missingPhotoMessage: '请至少上传一张送达照片' }
}
return null
})
const completionVisible = computed(() => !!completionAction.value)
const completionText = computed(() => completionAction.value?.label || '完成')
const completionLoading = computed(() => busy.value && !!pending.value && ['truck/depart', 'truck/complete'].includes(pending.value.suffix))
const completionDisabled = computed(() => locked.value || (completionAction.value?.action === 'depart' && (!trip.value?.vehicleId || !trip.value?.driverId)))
async function submitCompletion() {
const action = completionAction.value
if (!action || completionDisabled.value) return
const evidence = action.action === 'depart' ? truckPickupEvidence.value : truckDeliveryEvidence.value
if (!evidence.length) {
ElMessage.warning(action.missingPhotoMessage)
return
}
if (action.action === 'depart' && !access.can(permission.DEPART)) {
ElMessage.warning('当前账号缺少确认发车权限,请联系管理员开通后再完成取货')
return
}
await command(action.action)
}
defineExpose({ completionVisible, completionText, completionLoading, completionDisabled, submitCompletion })
/** 本车已保存的照片随资源带出;换车不能继续显示原车照片。 */
function restoreVehiclePhotos() {
const saved = detail.value?.resources?.vehicle
vehiclePhotos.value = saved?.vehicleId === vehicleId.value ? [...saved.evidenceFileIds] : []
}
// 同一资源刷新沿用照片组件的可用状态;切换资源时立即清空,等待新照片确认。
watch(vehicleId, () => { masterPhotoAvailable.vehicle = false }, { flush: 'sync' })
watch(driverId, () => { masterPhotoAvailable.driver = false }, { flush: 'sync' })
watch(() => [vehicleId.value, resourceFormOpen.value, detail.value] as const, restoreVehiclePhotos, { flush: 'sync' })
/** 切换司机后重读其主档驾驶证,迟到响应不得覆盖新司机或新单据。 */
watch(() => [driverId.value, resourceFormOpen.value, detail.value] as const, async ([id, open], _, onCleanup) => {
let current = true
onCleanup(() => { current = false })
licenseLoading.value = false
const transport = detail.value
if (!open || !transport || !['PICKUP', 'LOCAL_TRUCK'].includes(transport.taskType)) return
const saved = transport.resources?.driver
const same = saved?.driverId === id ? saved.evidence : null
driverPhotos.value = [...(same?.photoFileIds || [])]
licensePhotos.value = (same?.fileIds || []).filter(file => file !== same?.masterLicenseFileId)
masterLicense.value = same?.masterLicenseFileId ? { driverId: id!, fileId: same.masterLicenseFileId } : null
if (!id || licensePhotos.value.length) return
licenseLoading.value = true
try {
const driver = await getDriver(id, trip.value?.expectedDriverEndAt)
if (current) masterLicense.value = driver.licenseFileId ? { driverId: id, fileId: driver.licenseFileId } : null
} catch (cause) { if (current) error.value = getApiErrorDetails(cause, '驾驶证带出失败,可重新读取或上传照片') }
finally { if (current) licenseLoading.value = false }
}, { immediate: true })
function clearTruckPreviewUrls() { Object.values(truckPreviewUrls.value).forEach(url => URL.revokeObjectURL(url)); truckPreviewUrls.value = {} }
async function refreshTruckPreviews(files: PickupEvidence[]) {
const results = await Promise.allSettled(files.map(async file => [file.fileId, URL.createObjectURL(await downloadTransportEvidence(props.dispatchNo, file.fileId))] as const))
const next: Record<string, string> = {}
results.forEach(result => { if (result.status === 'fulfilled') next[result.value[0]] = result.value[1] })
Object.values(truckPreviewUrls.value).forEach(url => URL.revokeObjectURL(url)); truckPreviewUrls.value = next
}
function setTruckPreview(fileId: string, file: File) {
const old = truckPreviewUrls.value[fileId]
if (old) URL.revokeObjectURL(old)
truckPreviewUrls.value = { ...truckPreviewUrls.value, [fileId]: URL.createObjectURL(file) }
}
function removeTruckEvidence(fileId: string, pickup: boolean) {
if (pickup) truckPickupEvidence.value = truckPickupEvidence.value.filter(file => file.fileId !== fileId)
else truckDeliveryEvidence.value = truckDeliveryEvidence.value.filter(file => file.fileId !== fileId)
const url = truckPreviewUrls.value[fileId]
if (url) URL.revokeObjectURL(url)
const next = { ...truckPreviewUrls.value }; delete next[fileId]; truckPreviewUrls.value = next
}
async function load() {
error.value = null
try { detail.value = await getTransport(props.dispatchNo); vehicleId.value = trip.value?.vehicleId || ''; driverId.value = trip.value?.driverId || ''; truckPickupEvidence.value = [...(detail.value.departureEvidence || [])]; truckDeliveryEvidence.value = [...(detail.value.completionEvidence || [])]; clearTruckPreviewUrls(); await refreshTruckPreviews([...truckPickupEvidence.value, ...truckDeliveryEvidence.value]); restoreVehiclePhotos() }
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.filter(row => occupancy.value[`V:${row.id}`] === true); drivers.value = d.records.filter(row => occupancy.value[`D:${row.id}`] === true && row.qualification.canDispatch)
} catch (cause) { error.value = getApiErrorDetails(cause, '资源候选加载失败') }
finally { candidateBusy.value = false }
}
async function assign() {
const transport = detail.value
if (!transport || !trip.value || locked.value || !vehicleId.value || !driverId.value || !photosComplete.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() || '分配运输车辆及司机', ...(['PICKUP', 'LOCAL_TRUCK'].includes(transport.taskType) ? { dispatchRecordVersion: transport.dispatchRecordVersion, reviewPhotos: { vehicleFileIds: [...vehiclePhotos.value], driverPhotoFileIds: [...driverPhotos.value], licenseFileIds: [...licensePhotos.value] }, masterLicenseFileId: activeMasterLicense.value?.fileId || null } : {}) }); editingResources.value = false; emit('saved'); ElMessage.success('车辆司机及审核照片已保存') }
catch (cause) { error.value = getApiErrorDetails(cause, '保存未完成,已上传照片保留,请核对后重试') }
finally { pending.value = null; 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 await commandTransport(props.dispatchNo, request.suffix, request.body)
pending.value = null; pickupOpen.value = false; await load(); emit('saved'); ElMessage.success('运输操作已保存')
} catch (cause) { error.value = getApiErrorDetails(cause, '运输操作未完成') }
finally { pending.value = null; busy.value = false }
}
async function command(action: 'depart' | 'complete' | 'arrive' | 'cancel', row?: PickupTask) {
if (!trip.value || locked.value) return
if (!row && action === 'depart' && !truckPickupEvidence.value.length) { ElMessage.warning('请至少上传一张取货照片'); return }
if (!row && action === 'complete' && !truckDeliveryEvidence.value.length) { ElMessage.warning('请至少上传一张送达照片'); return }
const message = { depart: '', complete: '确认已在 送达并完成本土卡车订单?', arrive: '确认货物已实际送达仓库?确认后仓库可开始收货。', cancel: detail.value?.taskType === 'PICKUP' ? '确认取消未发车排车及本车全部对应转运单?车辆和司机占用将同时释放。' : '确认取消未发车排车并释放车辆、司机和原单安排?' }[action]
const title = action === 'depart' ? '确认取货完成' : '确认运输操作'
try { await ElMessageBox.confirm(message, title, { 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}`
const commandReason = reason.value.trim() || (action === 'depart' && prefix === 'truck' ? '确认 取货完成' : message || '确认运输操作')
pending.value = { suffix, kind: action === 'cancel' ? 'cancel' : undefined, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: commandReason, ...(prefix === 'truck' && action === 'depart' ? { evidenceFileIds: truckPickupEvidence.value.map(file => file.fileId) } : {}), ...(action === 'complete' && prefix === 'truck' ? { evidenceFileIds: truckDeliveryEvidence.value.map(file => file.fileId) } : {}) } }
await execute()
}
/** 实际取货数量由现场填写,预报箱数不能自动作为实提事实。 */
function editPickup(row: PickupTask, arrival = false) { selected.value = row; arrivalMode.value = arrival; actual.value = undefined; evidence.value = []; pickupOpen.value = true }
async function upload(event: Event, pickup: boolean) {
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); if (!files.length || 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'
for (const file of files) {
const result = await uploadTransport(props.dispatchNo, suffix, file)
if (pickup && row) evidence.value.push(result)
else if (trip.value?.status === 'PLANNED') { truckPickupEvidence.value.push(result); setTruckPreview(result.fileId, file) }
else { truckDeliveryEvidence.value.push(result); setTruckPreview(result.fileId, file) }
}
} catch (cause) { error.value = getApiErrorDetails(cause, '凭证上传失败') }
finally { pending.value = null; busy.value = false; input.value = '' }
}
async function picked() {
if (!selected.value || !trip.value || (!arrivalMode.value && !validPickupQuantity.value) || !evidence.value.length || locked.value) return
const row = selected.value
const common = { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), evidenceFileIds: evidence.value.map(file => file.fileId) }
pending.value = { suffix: `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/${arrivalMode.value ? 'arrive' : 'picked'}`, body: arrivalMode.value ? { ...common, reason: reason.value.trim() || '上传到仓照片,确认取货派送完成' } : { ...common, actualPackages: actual.value, remark: reason.value.trim() } }; await execute()
}
onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提交,确认离开?'))
onBeforeUnmount(clearTruckPreviewUrls)
</script>
<template>
<section class="transport-execution admin-density admin-workbench" aria-label="运输任务执行">
<el-alert v-if="error" :title="error.message" type="error" :closable="false" show-icon />
<template v-if="detail && trip">
<header class="transport-toolbar">
<div class="transport-identity"><Truck :size="18" aria-hidden="true" /><strong>{{ transportLabels[detail.taskType] }}</strong><el-tag :type="trip.status === 'CANCELLED' ? 'info' : trip.status === 'COMPLETED' ? 'success' : 'primary'">{{ detail.taskType === 'PICKUP' ? pickupStage : { PLANNED: '待取货', IN_TRANSIT: '派送中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}</el-tag></div>
<div v-if="!readonly" class="density-actions transport-actions">
<slot name="actions" />
<el-button v-if="canChangeResources && access.can(permission.CANCEL)" type="danger" plain :disabled="locked" @click="command('cancel')">取消排车</el-button>
<el-button link :disabled="locked" aria-label="刷新运输任务" @click="load"><RefreshCw :size="14" />刷新</el-button>
</div>
</header>
<dl v-if="detail.cargo" class="transport-summary-cards" aria-label="货物数量统计">
<div><dt>托数</dt><dd>{{ detail.cargo.palletCount }} 托</dd></div><div><dt>箱数</dt><dd>{{ detail.cargo.packageCount }} 箱</dd></div>
<div><dt>总重量</dt><dd>{{ detail.cargo.totalWeightKg ?? '—' }} kg</dd></div><div><dt>总体积</dt><dd>{{ detail.cargo.totalVolumeCbm ?? '—' }} m³</dd></div>
</dl>
<section class="workbench-panel is-compact transport-resources" aria-label="车辆信息">
<div class="transport-section-heading"><h2 class="workbench-section-title">车辆信息</h2><el-button v-if="canChangeResources && editable && !resourceFormOpen" link type="primary" :disabled="locked" @click="editingResources = true">维护车辆信息</el-button></div>
<dl class="transport-facts">
<div><dt>{{ detail.taskType === 'PICKUP' ? '收货仓库' : '所属仓库' }}</dt><dd>{{ trip.warehouseName || '—' }}</dd></div>
<div><dt>计划发车</dt><dd>{{ time(trip.plannedDepartureAt) }}</dd></div>
<div><dt>预计结束</dt><dd>{{ time(trip.expectedDriverEndAt) }}</dd></div>
<template v-if="!resourceFormOpen"><div><dt>车辆</dt><dd>{{ trip.vehiclePlate || '待分配' }}</dd></div><div><dt>司机</dt><dd>{{ trip.driverName || '待分配' }}<span v-if="trip.driverPhone" class="transport-phone">{{ trip.driverPhone }}</span></dd></div></template>
<div><dt>实际发车</dt><dd>{{ time(trip.departedAt) }}</dd></div>
<div><dt>实际完成</dt><dd>{{ time(trip.completedAt) }}</dd></div>
<div><dt>最近更新</dt><dd>{{ time(trip.updatedAt) }}</dd></div>
<div><dt>车辆容量</dt><dd>{{ trip.maxPalletSpaces ?? '—' }} 托 / {{ trip.maxLoadKg ?? '—' }} kg / {{ trip.maxVolumeCbm ?? '—' }} m³</dd></div>
<div><dt>驾驶证号</dt><dd>{{ detail.resources?.driver?.evidence?.licenseNo || '—' }}</dd></div>
<div><dt>驾驶证有效期</dt><dd>{{ detail.resources?.driver?.evidence?.licenseExpiresOn || '—' }}</dd></div>
<div v-if="detail.orderNo"><dt>订单号</dt><dd>{{ detail.orderNo }}</dd></div>
<div v-if="detail.taskType === 'LOCAL_TRUCK'"><dt>路线</dt><dd>{{ trip.stops.map(stop => stop.name).join(' → ') }}</dd></div>
</dl>
<div v-if="readonly || ['COMPLETED', 'CANCELLED'].includes(trip.status)" class="transport-review-photos" aria-label="车辆司机资料">
<DispatchEvidenceFiles photo-card :model-value="detail.resources?.vehicle?.evidenceFileIds || []" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆照片" readonly />
<DispatchEvidenceFiles photo-card :model-value="detail.resources?.driver?.evidence?.photoFileIds || []" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" readonly />
<DispatchEvidenceFiles photo-card :model-value="detail.resources?.driver?.evidence?.fileIds || []" :dispatch-no="dispatchNo" kind="DRIVER" label="驾驶证照片" :master-license="detail.resources?.driver?.driverId && detail.resources.driver.evidence?.masterLicenseFileId ? { driverId: detail.resources.driver.driverId, fileId: detail.resources.driver.evidence.masterLicenseFileId } : null" readonly />
</div>
<el-form v-if="resourceFormOpen" label-position="left" :disabled="locked" class="density-form transport-resource-form">
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="(value: boolean) => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="(value: boolean) => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
<section v-if="['PICKUP', 'LOCAL_TRUCK'].includes(detail.taskType)" class="transport-review-photos" aria-label="调度审核照片">
<DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :master-resource="vehicleId ? { kind: 'VEHICLE', id: vehicleId } : null" @available="masterPhotoAvailable.vehicle = $event" :disabled="locked" @busy="photoBusy.vehicle = $event" />
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driverId ? { kind: 'DRIVER', id: driverId } : null" @available="masterPhotoAvailable.driver = $event" :disabled="locked" @busy="photoBusy.driver = $event" />
<DispatchEvidenceFiles photo-card v-model="licensePhotos" :dispatch-no="dispatchNo" kind="DRIVER" label="驾驶证照片" :master-license="activeMasterLicense" :reference-ids="activeMasterLicense ? [activeMasterLicense.fileId] : []" :disabled="locked" @busy="photoBusy.license = $event" />
<p class="transport-photo-hint workbench-meta">请补齐车辆图片、司机照片和驾驶证照片;保存后供调度员和理货员审核。</p>
</section>
<div class="density-actions transport-resource-save"><el-button type="primary" :loading="busy || candidateBusy" :disabled="locked || !vehicleId || !driverId || !photosComplete" @click="assign">保存车辆司机</el-button><el-button v-if="trip.vehicleId && trip.driverId" :disabled="locked" @click="editingResources = false; vehicleId = trip.vehicleId; driverId = trip.driverId; restoreVehiclePhotos()">收起</el-button></div>
</el-form>
</section>
<section v-if="detail.cargo" class="workbench-panel is-compact" aria-label="货物与取送信息">
<h2 class="workbench-section-title">货物与取送信息</h2><dl class="transport-facts">
<div><dt>取货地</dt><dd>{{ detail.cargo.origin }}</dd></div><div><dt>取货地址</dt><dd>{{ detail.cargo.originAddress || '—' }}</dd></div><div><dt>取货联系人</dt><dd>{{ detail.cargo.originContactName }} {{ detail.cargo.originContactPhone }}</dd></div>
<div><dt>送达地</dt><dd>{{ detail.cargo.destination }}</dd></div><div><dt>送达地址</dt><dd>{{ detail.cargo.destinationAddress || '—' }}</dd></div><div><dt>送达联系人</dt><dd>{{ detail.cargo.destinationContactName }} {{ detail.cargo.destinationContactPhone }}</dd></div>
</dl>
</section>
<section v-if="detail.taskType === 'PICKUP'" class="transport-pickups" aria-label="取货明细">
<div class="transport-section-heading"><h2 class="workbench-section-title"><Package :size="16" aria-hidden="true" />取货明细</h2><span class="workbench-meta">{{ detail.pickups.length }} 单 · 预报 {{ detail.pickups.reduce((total, row) => total + row.packageCount, 0) }} 箱</span></div>
<div v-if="!detail.pickups.length" class="workbench-meta">暂无取货明细</div>
<div v-for="row in detail.pickups" :key="`${row.customerId}:${row.transferNo}`" class="pickup-detail-card">
<div class="pickup-detail-heading"><strong>{{ row.transferNo }}</strong><el-tag :type="trip.status === 'CANCELLED' ? 'info' : row.status === 'ARRIVED' ? 'success' : 'primary'">{{ trip.status === 'CANCELLED' ? '已取消' : pickupStatusLabels[row.status] }}</el-tag></div>
<dl class="transport-facts pickup-detail-facts">
<div><dt>取货地址</dt><dd>{{ row.pickupAddress || '—' }}</dd></div><div><dt>联系人</dt><dd>{{ row.contactName || '—' }}</dd></div><div><dt>联系电话</dt><dd>{{ row.contactPhone || '—' }}</dd></div><div><dt>预报 / 实际箱数</dt><dd>{{ row.packageCount }} / {{ row.actualPackages ?? '—' }}</dd></div>
<div><dt>取货照片</dt><dd><TransportPhotoGallery :dispatch-no="dispatchNo" :row="row as PickupTask" :files="pickupPhotos(row as PickupTask, 'PICKUP')" label="取货照片" /></dd></div><div><dt>送达照片</dt><dd><TransportPhotoGallery :dispatch-no="dispatchNo" :row="row as PickupTask" :files="pickupPhotos(row as PickupTask, 'ARRIVAL')" label="送达照片" /></dd></div>
</dl>
<div v-if="!readonly" class="pickup-detail-actions"><template v-if="editable && props.pickupApproved && ['PLANNED', 'IN_TRANSIT'].includes(trip.status) && ['ASSIGNED', 'PICKED'].includes(row.status)"><el-button v-if="row.status === 'ASSIGNED'" type="primary" plain :disabled="locked" @click="editPickup(row as PickupTask)">上传装货照片</el-button><el-button v-if="row.status === 'PICKED' && trip.status === 'IN_TRANSIT'" type="primary" plain :disabled="locked" @click="editPickup(row as PickupTask, true)">上传到仓照片</el-button></template></div>
</div>
</section>
<section v-if="detail.taskType === 'LOCAL_TRUCK' && editable && !resourceFormOpen && props.pickupApproved && props.pickupStage === 'LOADING' && trip.status === 'PLANNED'" class="truck-evidence-panel" aria-label="取货照片上传">
<h2 class="workbench-section-title">取货照片</h2>
<label class="truck-evidence-upload">上传取货照片<input type="file" accept="image/*" multiple :disabled="locked" @change="upload($event, false)" /></label>
<div class="truck-evidence-list"><figure v-for="file in truckPickupEvidence" :key="`pickup-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`取货照片:${file.fileName}`" /><span v-else>照片加载中</span><el-button class="truck-evidence-delete" circle text type="danger" :disabled="locked" :aria-label="`删除取货照片 ${file.fileName}`" @click="removeTruckEvidence(file.fileId, true)"><Trash2 :size="16" /></el-button><figcaption>{{ file.fileName }}</figcaption></figure></div>
</section>
<div v-if="detail.taskType === 'LOCAL_TRUCK' && !props.readonly && props.pickupStage === 'DELIVERING' && trip.status === 'IN_TRANSIT' && truckPickupEvidence.length" class="truck-evidence-panel" aria-label="取货照片">
<h2 class="workbench-section-title">取货照片</h2>
<div class="truck-evidence-list"><figure v-for="file in truckPickupEvidence" :key="`pickup-readonly-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`取货照片:${file.fileName}`" /><span v-else>照片加载中</span><figcaption>{{ file.fileName }}</figcaption></figure></div>
</div>
<section v-if="detail.taskType === 'LOCAL_TRUCK' && editable && !resourceFormOpen && props.pickupApproved && props.pickupStage === 'DELIVERING' && trip.status === 'IN_TRANSIT'" class="truck-evidence-panel" aria-label="送达照片上传">
<h2 class="workbench-section-title">送达照片</h2>
<label class="truck-evidence-upload">上传送达照片<input type="file" accept="image/*" multiple :disabled="locked" @change="upload($event, false)" /></label>
<div class="truck-evidence-list"><figure v-for="file in truckDeliveryEvidence" :key="`delivery-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`送达照片:${file.fileName}`" /><span v-else>照片加载中</span><el-button class="truck-evidence-delete" circle text type="danger" :disabled="locked" :aria-label="`删除送达照片 ${file.fileName}`" @click="removeTruckEvidence(file.fileId, false)"><Trash2 :size="16" /></el-button><figcaption>{{ file.fileName }}</figcaption></figure></div>
</section>
<div v-if="detail.taskType === 'LOCAL_TRUCK' && (props.readonly || ['COMPLETED', 'CANCELLED'].includes(trip.status)) && (detail.departureEvidence?.length || detail.completionEvidence?.length)" class="truck-evidence-panel">
<template v-if="detail.departureEvidence?.length"><h2 class="workbench-section-title">取货照片</h2><div class="truck-evidence-list"><figure v-for="file in detail.departureEvidence || []" :key="`departure-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`取货照片:${file.fileName}`" /><span v-else>照片加载中</span><figcaption>{{ file.fileName }}</figcaption></figure>
</div></template><template v-if="detail.completionEvidence?.length"><h2 class="workbench-section-title">送达照片</h2><div class="truck-evidence-list"><figure v-for="file in detail.completionEvidence || []" :key="`completion-${file.fileId}`" class="truck-evidence-item"><el-image v-if="truckPreviewUrls[file.fileId]" :src="truckPreviewUrls[file.fileId]" :preview-src-list="[truckPreviewUrls[file.fileId]!]" preview-teleported fit="contain" :alt="`送达照片:${file.fileName}`" /><span v-else>照片加载中</span><figcaption>{{ file.fileName }}</figcaption></figure></div></template>
</div>
</template>
<AppDialog class="admin-density-dialog" v-model="pickupOpen" :title="arrivalMode ? '上传到仓照片' : '上传装货照片'" width="min(760px, calc(100vw - 40px))" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked">
<p>{{ selected?.transferNo }}</p>
<el-form label-position="left" label-width="150px" :disabled="locked">
<el-form-item label="预报数量(箱)">{{ selected?.packageCount ?? '—' }}</el-form-item>
<el-form-item label="实际取货数量(箱)" :required="!arrivalMode">
<span v-if="arrivalMode">{{ selected?.actualPackages ?? '—' }}</span>
<el-input-number v-else v-model="actual" :min="1" :max="5000" :precision="0" placeholder="填写实际取货箱数" aria-label="实际取货数量(箱)" />
</el-form-item>
<el-form-item label="照片" required><input type="file" accept="image/*" :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 || (!arrivalMode && !validPickupQuantity)" @click="picked">{{ arrivalMode ? '确认到仓' : '确认取货' }}</el-button></template>
</AppDialog>
</section>
</template>
<style scoped>
.transport-summary-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 12px 0; }
.transport-summary-cards > div { padding: 16px; border: 1px solid var(--cc-border, #e3eaf3); border-radius: 8px; background: #f8fbff; }
.transport-summary-cards dt { color: var(--cc-text-muted, #788699); font-size: 12px; }
.transport-summary-cards dd { margin: 8px 0 0; color: #243951; font-size: 18px; font-weight: 600; }
.transport-execution { display: grid; gap: var(--admin-space-5); min-width: 0; padding: var(--admin-space-5); }
.transport-toolbar, .transport-section-heading, .transport-identity { display: flex; align-items: center; gap: var(--admin-space-4); }
.transport-toolbar, .transport-section-heading { justify-content: space-between; flex-wrap: wrap; }
.transport-identity { color: var(--cc-text); }
.transport-identity > svg { color: var(--el-color-primary); }
.transport-actions { margin-left: auto; }
.transport-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-5) var(--admin-space-7); margin: var(--admin-space-5) 0 0; }
.transport-facts > div { display: flex; align-items: baseline; gap: var(--admin-space-4); min-width: 0; }
.transport-facts dt { color: var(--cc-muted); flex-shrink: 0; }
.transport-facts dd { margin: 0; color: var(--cc-text); overflow-wrap: anywhere; }
.transport-phone { margin-left: var(--admin-space-3); color: var(--cc-muted); }
.transport-resource-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(240px, 1fr) auto; align-items: start; gap: var(--admin-space-6); margin-top: var(--admin-space-5); }
.transport-review-photos { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-5); }
.transport-photo-hint, .transport-resource-save { grid-column: 1 / -1; }
.transport-pickups { display: grid; gap: var(--admin-space-4); min-width: 0; }
.pickup-detail-card { display: grid; gap: var(--admin-space-4); padding: var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--cc-radius-lg); background: var(--cc-surface); }
.pickup-detail-heading, .pickup-detail-actions { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
.pickup-detail-facts { margin: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.pickup-detail-facts > div { align-items: start; }
.pickup-detail-facts dd { display: flex; flex-wrap: wrap; gap: 4px 10px; }
.truck-evidence-panel { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-md); background: var(--cc-surface); }
.truck-evidence-panel > h2 { flex: 0 0 100%; margin: 0; }
.truck-evidence-panel label { display: inline-flex; align-items: center; gap: 10px; color: var(--cc-text); font-size: var(--admin-font-body); }
.truck-evidence-upload { padding: 8px 14px; border: 1px solid var(--el-color-primary); border-radius: var(--cc-radius-sm); color: var(--el-color-primary) !important; cursor: pointer; }
.truck-evidence-upload input[type='file'] { display: none; }
.truck-evidence-list { display: flex; flex-wrap: wrap; gap: 12px; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.truck-evidence-item { width: 132px; margin: 0; }
.truck-evidence-item { position: relative; }
.truck-evidence-item .el-image { display: block; width: 132px; height: 92px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-sm); background: var(--cc-surface-muted); cursor: pointer; }
.truck-evidence-item img { display: block; width: 100%; height: 100%; object-fit: cover; }
.truck-evidence-delete { position: absolute; z-index: 2; top: 4px; right: 4px; width: 26px; height: 26px; color: var(--el-color-danger) !important; background: rgb(255 255 255 / 88%) !important; box-shadow: 0 1px 4px rgb(0 0 0 / 18%); }
.truck-evidence-item figcaption { overflow: hidden; margin-top: 4px; text-overflow: ellipsis; white-space: nowrap; }
.truck-evidence-item .el-button { padding: 2px 0; }
@media (max-width: 760px) { .pickup-detail-facts { grid-template-columns: 1fr; } }
@media (max-width: 1100px) { .transport-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); } .transport-resource-form { grid-template-columns: minmax(200px, 1fr) minmax(200px, 1fr); } }
</style>