This commit is contained in:
2026-10-27 17:39:19 +08:00
parent b5d8ccdffe
commit c148912b73
49 changed files with 1134 additions and 464 deletions
@@ -1,10 +1,10 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, reactive, ref, watch } from 'vue'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onBeforeRouteLeave } from 'vue-router'
import { Truck, Package, RefreshCw } from '@lucide/vue'
import { Truck, Package, RefreshCw, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -12,32 +12,63 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
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 { getTransport, assignTransport, transportOccupancy, cancelTransport, releaseTransport, commandTransport, uploadTransport, downloadTransportEvidence, transportLabels, type TransportDetail } from '../api/transportTasks'
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; editResources?: boolean; readonly?: boolean }>(), emit = defineEmits<{ saved: [] }>()
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 photoBusy = reactive({ vehicle: false, driver: false, license: false })
const activeMasterLicense = computed(() => licensePhotos.value.length ? null : masterLicense.value)
const photosComplete = computed(() => detail.value?.taskType !== 'PICKUP' || !!(vehiclePhotos.value.length && driverPhotos.value.length && (licensePhotos.value.length || activeMasterLicense.value)))
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref<number>(), evidence = ref<PickupEvidence[]>([]), truckEvidence = ref<PickupEvidence[]>([])
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 && driverPhotos.value.length && (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 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 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' && !detail.value?.pickups.some(row => row.status !== 'ASSIGNED'))
const canChangeResources = computed(() => trip.value?.status === 'PLANNED' && props.pickupStage !== '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
@@ -49,8 +80,9 @@ watch(() => [driverId.value, resourceFormOpen.value, detail.value] as const, asy
let current = true
onCleanup(() => { current = false })
licenseLoading.value = false
if (!open || detail.value?.taskType !== 'PICKUP') return
const saved = detail.value.resources?.driver
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)
@@ -63,9 +95,28 @@ watch(() => [driverId.value, resourceFormOpen.value, detail.value] as const, asy
} 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 || ''; restoreVehiclePhotos() }
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 })
@@ -81,9 +132,10 @@ async function candidates() {
finally { candidateBusy.value = false }
}
async function assign() {
if (!trip.value || locked.value || !vehicleId.value || !driverId.value || !photosComplete.value) return
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() || '分配运输车辆及司机', ...(detail.value?.taskType === 'PICKUP' ? { dispatchRecordVersion: detail.value.dispatchRecordVersion, reviewPhotos: { vehicleFileIds: [...vehiclePhotos.value], driverPhotoFileIds: [...driverPhotos.value], licenseFileIds: [...licensePhotos.value] }, masterLicenseFileId: activeMasterLicense.value?.fileId || null } : {}) }); editingResources.value = false; emit('saved'); ElMessage.success('车辆司机及审核照片已保存') }
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 }
}
@@ -92,33 +144,37 @@ async function execute() {
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('运输操作已保存')
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
const message = { depart: '确认车辆司机已出发执行运输任务?', complete: '确认运输已实际完成?', arrive: '确认货物已实际送达仓库?确认后仓库可开始收货。', cancel: detail.value?.taskType === 'PICKUP' ? '确认取消未发车排车及本车全部对应转运单?车辆和司机占用将同时释放。' : '确认取消未发车排车并释放车辆、司机和原单安排?' }[action]
try { await ElMessageBox.confirm(message, '确认运输操作', { customClass: 'admin-density-confirm' }) } catch { 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}`
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) } : {}) } }
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 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
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'
const result = await uploadTransport(props.dispatchNo, suffix, file); (pickup ? evidence.value : truckEvidence.value).push(result)
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 = '' }
}
@@ -128,12 +184,8 @@ async function picked() {
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()
}
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('运输操作正在提交,确认离开?'))
onBeforeUnmount(clearTruckPreviewUrls)
</script>
<template>
<section class="transport-execution admin-density admin-workbench" aria-label="运输任务执行">
@@ -141,13 +193,10 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
<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 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="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'PLANNED' && access.can(permission.DEPART)" :disabled="locked || !trip.vehicleId || !trip.driverId" @click="command('depart')">确认发车</el-button>
<el-button v-if="canChangeResources && access.can(permission.CANCEL)" type="danger" plain :disabled="locked" @click="command('cancel')">取消排车</el-button>
<el-button v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable" :disabled="locked || !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 link :disabled="locked" aria-label="刷新运输任务" @click="load"><RefreshCw :size="14" />刷新</el-button>
</div>
</header>
@@ -164,7 +213,7 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
<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" :disabled="occupancy[`V:${row.id}`] !== true" :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" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
<section v-if="detail.taskType === 'PICKUP'" class="transport-review-photos" aria-label="调度审核照片">
<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="车辆图片" :disabled="locked" @busy="photoBusy.vehicle = $event" />
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :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" />
@@ -175,22 +224,36 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
</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>
<el-table :data="detail.pickups" size="small" class="density-table" empty-text="暂无取货明细">
<el-table-column v-if="!readonly" label="操作" width="160"><template #default="{ row }"><template v-if="editable && props.pickupApproved && ['PLANNED', 'IN_TRANSIT'].includes(trip.status) && ['ASSIGNED', 'PICKED'].includes(row.status)"><el-button v-if="row.status === 'ASSIGNED'" link type="primary" :disabled="locked" @click="editPickup(row as PickupTask)">上传装货照片</el-button><el-button v-if="row.status === 'PICKED' && trip.status === 'IN_TRANSIT'" link type="primary" :disabled="locked" @click="editPickup(row as PickupTask, true)">上传到仓照片</el-button></template><span v-else class="workbench-meta">—</span></template></el-table-column>
<el-table-column prop="transferNo" label="转运单号" width="210" />
<el-table-column label="取货地址" min-width="280"><template #default="{ row }">{{ row.pickupAddress || '—' }}</template></el-table-column>
<el-table-column label="联系人" min-width="120"><template #default="{ row }">{{ row.contactName || '—' }}</template></el-table-column>
<el-table-column label="联系电话" min-width="160"><template #default="{ row }">{{ row.contactPhone || '—' }}</template></el-table-column>
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="trip.status === 'CANCELLED' ? 'info' : row.status === 'ARRIVED' ? 'success' : 'primary'">{{ trip.status === 'CANCELLED' ? '已取消' : pickupStatusLabels[row.status] }}</el-tag></template></el-table-column>
<el-table-column label="预报/实际取货(箱)" width="180" align="right"><template #default="{ row }">{{ row.packageCount }} / {{ row.actualPackages ?? '—' }}</template></el-table-column>
<el-table-column label="取货照片" min-width="110"><template #default="{ row }"><el-button v-for="(file, index) in pickupPhotos(row as PickupTask, 'PICKUP')" :key="file.fileId" link @click="download(file, row as PickupTask)">照片{{ index + 1 }}</el-button><span v-if="!pickupPhotos(row as PickupTask, 'PICKUP').length">—</span></template></el-table-column>
<el-table-column label="到仓照片" min-width="110"><template #default="{ row }"><el-button v-for="(file, index) in pickupPhotos(row as PickupTask, 'ARRIVAL')" :key="file.fileId" link @click="download(file, row as PickupTask)">照片{{ index + 1 }}</el-button><span v-if="!pickupPhotos(row as PickupTask, 'ARRIVAL').length">—</span></template></el-table-column>
</el-table>
<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>
<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>
<el-button v-for="file in detail.completionEvidence || []" :key="file.fileId" link @click="download(file)">{{ file.fileName }}</el-button>
<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="560px" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked">
<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>
@@ -203,7 +266,6 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
</el-form>
<template #footer><el-button type="primary" :disabled="locked || !evidence.length || (!arrivalMode && !validPickupQuantity)" @click="picked">{{ arrivalMode ? '确认到仓' : '确认取货' }}</el-button></template>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="releaseOpen" :title="releaseKind === 'VEHICLE' ? '结束车辆任务' : '结束司机任务'" width="560px" :show-close="!locked" :close-on-click-modal="!locked"><el-form label-position="left" label-width="100px" :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>
@@ -223,5 +285,24 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
.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: 13px; }
.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: 12px; }
.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>