339 lines
37 KiB
Vue
339 lines
37 KiB
Vue
<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>
|