This commit is contained in:
2026-10-21 18:11:48 +08:00
parent c367fadf09
commit 872d197531
9 changed files with 168 additions and 39 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ export { listTripDispatchCandidates, prepareTrip, type TripPreparation, type Dis
export type { TripPreviousDispatch } from './tripTypes'
export { listVehicles } from './api/overseasMasterData'
export { listDrivers } from './api/drivers'
export { listDrivers, getDriver, downloadDriverLicense } from './api/drivers'
export type { DriverDetail } from './driverTypes'
export { normalizeTripDetail } from './api/trips'
export type { TripDetail, TripAssignment, TripResourceRelease } from './tripTypes'
@@ -0,0 +1,17 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { assignTransport } from './transportTasks'
vi.mock('@/shared/api/http', () => ({ http: { post: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }))
vi.mock('@/modules/overseas-master-data', () => ({ normalizeTripDetail: (value: unknown) => value }))
beforeEach(() => vi.resetAllMocks())
const body = { recordVersion: 4, vehicleId: '1', driverId: '2', reason: '保存照片', reviewPhotos: { vehicleFileIds: ['21'], driverPhotoFileIds: ['22'], licenseFileIds: [] }, masterLicenseFileId: '33' }
it('does not submit photo changes when the backend has not supplied the dispatch version', () => {
expect(() => assignTransport('PC1', body)).toThrow('照片保存版本')
expect(http.post).not.toHaveBeenCalled()
})
it('preserves zero dispatch version and opaque photo IDs in the assignment', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { dispatchNo: 'PC1', taskType: 'PICKUP', dispatchRecordVersion: '1', resources: { vehicle: { evidenceFileIds: ['21'] } }, execution: {}, pickups: [] } })
const result = await assignTransport('PC1', { ...body, dispatchRecordVersion: 0 })
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/transport-tasks/PC1/assign', { ...body, dispatchRecordVersion: 0 })
expect(result.resources?.vehicle?.evidenceFileIds).toEqual(['21'])
})
@@ -1,9 +1,11 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeTripDetail, type TripDetail, type PickupTask, type PickupCandidate, type PickupEvidence, type PickupPlan, type TripAssignment, type TripResourceRelease } from '@/modules/overseas-master-data'
import type { Resources, ReviewAttachments, Version } from '../dispatchContracts'
export type TransportType = 'PICKUP' | 'LOCAL_TRUCK'
export const transportLabels = { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' }
export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; completionEvidence: PickupEvidence[] }
export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; completionEvidence: PickupEvidence[]; dispatchRecordVersion: Version; resources: Resources | null }
export type TransportAssignment = TripAssignment & { dispatchRecordVersion?: Version; reviewPhotos?: ReviewAttachments; masterLicenseFileId?: string | null }
export interface TruckCandidate { customerId: string; orderNo: string; origin: string; originAddress: string; destination: string; destinationAddress: string; palletCount: number; packageCount: number }
export interface TruckPlan { customerId: string; orderNo: string; warehouseId: string; plannedDepartureAt: string; expectedDriverEndAt: string; idempotencyKey: string }
type WireDetail = Omit<TransportDetail, 'execution'> & { execution: Parameters<typeof normalizeTripDetail>[0] }
@@ -17,7 +19,10 @@ export const pickupCandidates = () => get<{ records: PickupCandidate[]; total: n
export const truckCandidates = () => get<TruckCandidate[]>(`${base}/truck-candidates`)
export const createTransportPickup = (body: PickupPlan) => post<WireDetail>(`${base}/pickup`, body).then(normalize)
export const createTransportTruck = (body: TruckPlan) => post<WireDetail>(`${base}/truck`, body).then(normalize)
export const assignTransport = (number: string, body: TripAssignment) => post<WireDetail>(`${path(number)}/assign`, body).then(normalize)
export const assignTransport = (number: string, body: TransportAssignment) => {
if (body.reviewPhotos && body.dispatchRecordVersion == null) throw new Error('未读取到照片保存版本,请更新本地后端并刷新后重试')
return post<WireDetail>(`${path(number)}/assign`, body).then(normalize)
}
export const transportOccupancy = (number: string, kind: 'VEHICLE' | 'DRIVER', resourceIds: string[]) => resourceIds.length ? post<Array<{ resourceId: string; occupiedTripNo: string | null; available: boolean }>>(`${path(number)}/resource-occupancy`, { kind, resourceIds }) : Promise.resolve([])
export const cancelTransport = (number: string, body: object) => post(`${path(number)}/cancel`, body)
export const releaseTransport = (number: string, body: TripResourceRelease) => post(`${path(number)}/release`, body)
@@ -6,10 +6,20 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import * as api from '../api/dispatches'
import type { DispatchEvidenceFile } from '../dispatchContracts'
import { downloadDriverLicense } from '@/modules/overseas-master-data'
vi.mock('@/modules/overseas-master-data', () => ({ downloadDriverLicense: vi.fn() }))
vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
const metadata = (fileId = '301', contentType = 'image/png'): DispatchEvidenceFile => ({ fileId, fileName: contentType === 'application/pdf' ? '本车证照.pdf' : '本车证据.png', contentType, sizeBytes: 3 })
const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHICLE' as const }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], template: '<section v-if="modelValue" role="dialog"><slot /><button @click="$emit(\'update:modelValue\', false)">关闭预览</button></section>' } } } }
const button = (wrapper: ReturnType<typeof mount>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
it('previews an unsaved master license through its driver and keeps uploaded license files on the dispatch route', async () => {
vi.mocked(downloadDriverLicense).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: ['301'], referenceIds: ['33'], masterLicense: { driverId: '2', fileId: '33' } } }); await flushPromises()
expect(downloadDriverLicense).toHaveBeenCalledWith('2', '33')
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'DRIVER', '301')
expect(api.getDispatchEvidence).not.toHaveBeenCalledWith('PC/1', 'DRIVER', '33')
expect(wrapper.findAll('figure img')).toHaveLength(2); wrapper.unmount()
})
async function selectFile(wrapper: ReturnType<typeof mount>, file: File) { const input = wrapper.get('input[type="file"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises() }
beforeEach(() => { vi.resetAllMocks(); vi.mocked(api.getDispatchEvidence).mockResolvedValue(metadata()); vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' })); URL.createObjectURL = vi.fn().mockReturnValue('blob:authenticated'); URL.revokeObjectURL = vi.fn() })
it('renders authenticated photos above their caption and releases thumbnails on unmount', async () => {
@@ -9,14 +9,16 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
import { downloadDriverLicense } from '@/modules/overseas-master-data'
import { DispatchInputError, type DispatchEvidenceFile, type DispatchEvidenceKind } from '../dispatchContracts'
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[] }>()
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[]; masterLicense?: { driverId: string; fileId: string } | null }>()
const ids = defineModel<string[]>({ required: true })
const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value])])
const emit = defineEmits<{ busy: [value: boolean] }>()
const metadata = ref<Record<string, DispatchEvidenceFile>>({}), error = ref<ApiErrorDetails | null>(null), uploading = ref(false), input = ref<HTMLInputElement | null>(null)
const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
const thumbnails = ref<Record<string, string>>({})
let masterBlob: { fileId: string; blob: Blob } | null = null
const previewId = ref(''), imageDecoding = ref(false), imageFailure = ref('')
const previewBusy = computed(() => previewLoading.value || imageDecoding.value)
const label = computed(() => props.label || (props.kind === 'VEHICLE' ? '本趟车辆证据' : props.kind === 'DRIVER_PHOTO' ? '司机照片' : '本趟司机证照'))
@@ -45,9 +47,18 @@ async function load() {
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...allIds.value]
for (const url of Object.values(thumbnails.value)) { URL.revokeObjectURL(url); urls.delete(url) }
thumbnails.value = {}
closePreview(); metadata.value = {}; error.value = null
closePreview(); metadata.value = {}; error.value = null; masterBlob = null
if (!no) return
const results = await Promise.allSettled(values.map(async id => valid(await getDispatchEvidence(no, kind, id), id)))
const master = props.kind === 'DRIVER' ? props.masterLicense : null
const results = await Promise.allSettled(values.map(async id => {
// 未保存的司机主档证照按司机身份读取;本车补传仍走排车文件归属。
if (master && master.fileId === id) {
const blob = await downloadDriverLicense(master.driverId, id)
if (current === generation) masterBlob = { fileId: id, blob }
return valid({ fileId: id, fileName: '驾驶证照片', contentType: blob.type, sizeBytes: blob.size }, id)
}
return valid(await getDispatchEvidence(no, kind, id), id)
}))
if (current !== generation) return
results.forEach(result => { if (result.status === 'fulfilled') metadata.value[result.value.fileId] = result.value; else fail(result.reason, '附件资料读取失败,请重试;原引用已保留') })
if (props.photoCard) await Promise.all(values.map(async id => {
@@ -78,7 +89,7 @@ async function upload(event: Event) {
}
function remove(id: string) { if (!props.readonly && !props.disabled && !uploading.value) ids.value = ids.value.filter(value => value !== id) }
async function readContent(no: string, kind: DispatchEvidenceKind, id: string, file: DispatchEvidenceFile) {
const blob = await getDispatchEvidenceContent(no, kind, id)
const blob = kind === 'DRIVER' && masterBlob?.fileId === id ? masterBlob.blob : await getDispatchEvidenceContent(no, kind, id)
const type = blob.type.split(';')[0]?.toLowerCase()
if (type?.includes('json')) {
let body: Partial<{ code: string; message: string; requestId: string; traceId: string }>
@@ -104,7 +115,7 @@ async function preview(id: string) {
} catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } }
finally { if (current === previewGeneration) previewLoading.value = false }
}
watch(() => JSON.stringify([props.dispatchNo, props.kind, allIds.value]), load, { immediate: true })
watch(() => JSON.stringify([props.dispatchNo, props.kind, allIds.value, props.masterLicense]), load, { immediate: true })
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
</script>
<template>
@@ -2,19 +2,74 @@ import { mount, flushPromises } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { beforeEach, expect, it, vi } from 'vitest'
import TransportExecutionPanel from './TransportExecutionPanel.vue'
import { commandTransport, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks'
import { assignTransport, commandTransport, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks'
import { getDriver } from '@/modules/overseas-master-data'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), pickupStatusLabels: { ASSIGNED: '待取货', PICKED: '已取货', ARRIVED: '已到仓' } }))
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), getDriver: vi.fn(), downloadDriverLicense: vi.fn(), pickupStatusLabels: { ASSIGNED: '待取货', PICKED: '已取货', ARRIVED: '已到仓' } }))
vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn(), assignTransport: vi.fn(), transportOccupancy: vi.fn(), cancelTransport: vi.fn(), releaseTransport: vi.fn(), commandTransport: vi.fn(), uploadTransport: vi.fn(), downloadTransportEvidence: vi.fn(), transportLabels: { PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' } }))
const facts = (arriving = false): TransportDetail => ({ dispatchNo: 'PC1', taskType: 'PICKUP', execution: { trip: { status: arriving ? 'IN_TRANSIT' : 'PLANNED', recordVersion: 4, warehouseName: '洛杉矶仓', vehicleId: '1', vehiclePlate: 'CA-1', driverId: '2', driverName: '司机甲', driverPhone: '555' } }, pickups: [{ customerId: 'C1', transferNo: 'WT1', packageCount: 5, actualPackages: arriving ? 5 : null, status: arriving ? 'PICKED' : 'ASSIGNED', evidence: [] }], completionEvidence: [] }) as unknown as TransportDetail
const options = { global: { plugins: [ElementPlus] } }
beforeEach(() => {
vi.clearAllMocks(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(getTransport).mockResolvedValue(facts())
vi.mocked(getDriver).mockResolvedValue({ id: '2', licenseFileId: '33' } as Awaited<ReturnType<typeof getDriver>>)
vi.mocked(uploadTransport).mockResolvedValue({ fileId: '88', fileName: '现场.jpg', contentType: 'image/jpeg', sizeBytes: 10 })
})
const photoOptions = { global: { plugins: [ElementPlus], stubs: { DispatchEvidenceFiles: true } } }
it('refreshes the selected driver master license when reopening resource maintenance', async () => {
const value = facts()
value.resources = { vehicle: null, driver: { driverId: '2', evidence: { photoFileIds: ['22'], fileIds: ['30'], masterLicenseFileId: '30' } } } as TransportDetail['resources']
vi.mocked(getTransport).mockResolvedValue(value)
const wrapper = mount(TransportExecutionPanel, { ...photoOptions, props: { dispatchNo: 'PC1', editResources: true } }); await flushPromises()
const license = wrapper.findAllComponents(DispatchEvidenceFiles)[2]!
expect(license.props('referenceIds')).toEqual(['33'])
expect(license.props('modelValue')).toEqual([])
wrapper.unmount()
})
it('requires three categories and sends photos with both current versions for review', async () => {
const value = facts(); value.dispatchRecordVersion = 9
vi.mocked(getTransport).mockResolvedValue(value)
vi.mocked(assignTransport).mockResolvedValue(value)
const wrapper = mount(TransportExecutionPanel, { ...photoOptions, props: { dispatchNo: 'PC1', editResources: true } }); await flushPromises()
const save = wrapper.findAll('button').find(button => button.text() === '保存车辆司机')!
expect(save.attributes('disabled')).toBeDefined()
const cards = wrapper.findAllComponents(DispatchEvidenceFiles)
expect(cards.map(card => card.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
expect(cards[2]!.props('referenceIds')).toEqual(['33'])
cards[0]!.vm.$emit('update:modelValue', ['21']); cards[1]!.vm.$emit('update:modelValue', ['22']); await flushPromises()
expect(save.attributes('disabled')).toBeUndefined()
await save.trigger('click'); await flushPromises()
expect(assignTransport).toHaveBeenCalledWith('PC1', expect.objectContaining({ recordVersion: 4, dispatchRecordVersion: 9, vehicleId: '1', driverId: '2', masterLicenseFileId: '33', reviewPhotos: { vehicleFileIds: ['21'], driverPhotoFileIds: ['22'], licenseFileIds: [] } }))
wrapper.unmount()
})
it('restores saved photos and retains uploads after a failed save', async () => {
const value = facts(); value.dispatchRecordVersion = 9
value.resources = { vehicle: { vehicleId: '1', evidenceFileIds: ['21'] }, driver: { driverId: '2', evidence: { photoFileIds: ['22'], fileIds: ['23'] } } } as TransportDetail['resources']
vi.mocked(getTransport).mockResolvedValue(value); vi.mocked(assignTransport).mockRejectedValue(new Error('保存失败'))
const wrapper = mount(TransportExecutionPanel, { ...photoOptions, props: { dispatchNo: 'PC1', editResources: true } }); await flushPromises()
const cards = wrapper.findAllComponents(DispatchEvidenceFiles)
expect(cards.map(card => card.props('modelValue'))).toEqual([['21'], ['22'], ['23']])
expect(getDriver).not.toHaveBeenCalled()
cards[0]!.vm.$emit('update:modelValue', ['24']); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '保存车辆司机')!.trigger('click'); await flushPromises()
expect(cards[0]!.props('modelValue')).toEqual(['24'])
expect(wrapper.emitted('saved')).toBeUndefined(); wrapper.unmount()
})
it('clears old photos when selecting another vehicle or driver and ignores late license responses', async () => {
let resolve!: (value: Awaited<ReturnType<typeof getDriver>>) => void
vi.mocked(getDriver).mockImplementationOnce(() => new Promise(done => { resolve = done })).mockResolvedValue({ id: '4', licenseFileId: '44' } as Awaited<ReturnType<typeof getDriver>>)
const wrapper = mount(TransportExecutionPanel, { ...photoOptions, props: { dispatchNo: 'PC1', editResources: true } }); await flushPromises()
const cards = wrapper.findAllComponents(DispatchEvidenceFiles)
cards[0]!.vm.$emit('update:modelValue', ['21']); cards[1]!.vm.$emit('update:modelValue', ['22']); cards[2]!.vm.$emit('update:modelValue', ['23'])
const selectors = wrapper.findAllComponents({ name: 'ElSelect' })
selectors[0]!.vm.$emit('update:modelValue', '3'); selectors[1]!.vm.$emit('update:modelValue', '4'); await flushPromises()
resolve({ id: '2', licenseFileId: '33' } as Awaited<ReturnType<typeof getDriver>>); await flushPromises()
expect(cards.map(card => card.props('modelValue'))).toEqual([[], [], []])
expect(cards[2]!.props('referenceIds')).toEqual(['44']); wrapper.unmount()
})
it.each([false, true])('confirms the %s photo stage with its own evidence and current transport version', async arriving => {
vi.mocked(getTransport).mockResolvedValue(facts(arriving))
const wrapper = mount(TransportExecutionPanel, { ...options, props: { dispatchNo: 'PC1', pickupApproved: true } }); await flushPromises()
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, ref, watch } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onBeforeRouteLeave } from 'vue-router'
import { Truck, Package, RefreshCw } from '@lucide/vue'
@@ -10,7 +10,8 @@ import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/sh
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { listVehicles, listDrivers, pickupStatusLabels, type VehicleDetail, type DriverDetail, type PickupTask, type PickupEvidence } from '@/modules/overseas-master-data'
import { 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 { dispatchActionPermissions as permission } from '../dispatchPresentation'
import { formatOverseasDateTime as time } from '../lastMilePresentation'
@@ -19,6 +20,11 @@ const props = defineProps<{ dispatchNo: string; pickupApproved?: boolean; pickup
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 arrivalMode = ref(false)
const validPickupQuantity = computed(() => typeof actual.value === 'number' && Number.isInteger(actual.value) && actual.value >= 1 && actual.value <= 5000)
@@ -31,10 +37,35 @@ 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 resourceFormOpen = computed(() => canChangeResources.value && editable.value && (editingResources.value || !trip.value?.vehicleId || !trip.value?.driverId))
const locked = computed(() => busy.value || !!pending.value)
const locked = computed(() => busy.value || !!pending.value || licenseLoading.value || Object.values(photoBusy).some(Boolean))
/** 本车已保存的照片随资源带出;换车不能继续显示原车照片。 */
function restoreVehiclePhotos() {
const saved = detail.value?.resources?.vehicle
vehiclePhotos.value = saved?.vehicleId === vehicleId.value ? [...saved.evidenceFileIds] : []
}
watch(vehicleId, 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
if (!open || detail.value?.taskType !== 'PICKUP') return
const saved = detail.value.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 })
async function load() {
error.value = null
try { detail.value = await getTransport(props.dispatchNo); vehicleId.value = trip.value?.vehicleId || ''; driverId.value = trip.value?.driverId || '' }
try { detail.value = await getTransport(props.dispatchNo); vehicleId.value = trip.value?.vehicleId || ''; driverId.value = trip.value?.driverId || ''; restoreVehiclePhotos() }
catch (cause) { error.value = getApiErrorDetails(cause, '运输任务加载失败') }
}
watch(() => props.dispatchNo, () => { detail.value = null; pending.value = null; void load() }, { immediate: true })
@@ -50,10 +81,10 @@ async function candidates() {
finally { candidateBusy.value = false }
}
async function assign() {
if (!trip.value || locked.value || !vehicleId.value || !driverId.value) return
if (!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() || '分配运输车辆及司机' }); editingResources.value = false; emit('saved'); ElMessage.success('车辆司机已分配') }
catch (cause) { await load(); error.value = getApiErrorDetails(cause, '分配未完成') }
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('车辆司机及审核照片已保存') }
catch (cause) { error.value = getApiErrorDetails(cause, '保存未完成,已上传照片保留,请核对后重试') }
finally { busy.value = false }
}
/** 每次业务命令保留原版本与幂等键,结果未知只重试原请求。 */
@@ -134,7 +165,13 @@ 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 => 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 => 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>
<div class="density-actions"><el-button type="primary" :loading="busy || candidateBusy" :disabled="locked || !vehicleId || !driverId" @click="assign">保存车辆司机</el-button><el-button v-if="trip.vehicleId && trip.driverId" :disabled="locked" @click="editingResources = false; vehicleId = trip.vehicleId; driverId = trip.driverId">收起</el-button></div>
<section v-if="detail.taskType === 'PICKUP'" 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" />
<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.taskType === 'PICKUP'" class="transport-pickups" aria-label="取货明细">
@@ -184,6 +221,8 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核
.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; }
@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>
@@ -11,7 +11,7 @@ export interface Gate { allowed: boolean; blockCode: string | null; blockReason:
export type DispatchActions = Partial<Record<DispatchAction, Gate>>
export interface Counts { itemCount: number; palletCount: number; packageCount: number; pickedPackageCount: number; shippedPackageCount: number }
export interface Capacity { palletSpaces: number | null; maxLoadKg: number | null; maxVolumeCbm: number | null }
export interface DriverEvidence { licenseNo: string | null; licenseExpiresOn: string | null; fileIds: string[]; expectedTaskEndAt: string | null; photoFileIds?: string[] }
export interface DriverEvidence { licenseNo: string | null; licenseExpiresOn: string | null; fileIds: string[]; expectedTaskEndAt: string | null; photoFileIds?: string[]; masterLicenseFileId?: string | null }
export interface Vehicle { mode: 'MASTER' | 'EXTERNAL'; vehicleId: string | null; plate: string | null; carrierName: string | null; capacity: Capacity | null; evidenceFileIds: string[] }
export interface Driver { mode: 'MASTER' | 'EXTERNAL'; driverId: string | null; name: string | null; phone: string | null; evidence: DriverEvidence | null }
export interface VehicleSnapshot extends Vehicle { vehicleStatus: string | null }
@@ -2,7 +2,7 @@
import '@/styles/adminWorkbench.css'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ArrowLeft, ChevronDown, ChevronUp, CircleCheck, Download, FileUp, GitBranch, Link2, PackageOpen, Printer, Send, ShieldAlert, SlidersHorizontal, StickyNote, Undo2 } from '@lucide/vue'
import { ArrowLeft, ChevronDown, ChevronUp, CircleCheck, Download, FileText, FileUp, GitBranch, History, Link2, MoreHorizontal, Package, PackageOpen, Printer, ReceiptText, Ruler, Send, ShieldAlert, SlidersHorizontal, StickyNote, Undo2 } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
@@ -367,14 +367,14 @@ const canReleaseInbound = computed(() => Boolean(
const isDropshipOrder = computed(() => detail.value?.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP')
const detailTabs = computed(() => [
{ key: 'basic', label: '基本信息', icon: '📋' },
{ key: 'cargo', label: '货品信息', icon: '📦' },
{ key: 'fees', label: '费用信息', icon: '💰' },
{ key: 'history', label: '状态历史', icon: '📝' },
{ key: 'measurement', label: '测量数据', icon: '📐' },
...(isDropshipOrder.value ? [{ key: 'dropship-print', label: '托盘打印', icon: '🖨️' }] : []),
{ key: 'other', label: '其他', icon: '🗂️' },
{ key: 'fulfillment', label: '拆分履约', icon: '🧩' },
{ key: 'basic', label: '基本信息', icon: FileText },
{ key: 'cargo', label: '货品信息', icon: Package },
{ key: 'fees', label: '费用信息', icon: ReceiptText },
{ key: 'history', label: '状态历史', icon: History },
{ key: 'measurement', label: '测量数据', icon: Ruler },
...(isDropshipOrder.value ? [{ key: 'dropship-print', label: '托盘打印', icon: Printer }] : []),
{ key: 'other', label: '其他', icon: MoreHorizontal },
{ key: 'fulfillment', label: '拆分履约', icon: GitBranch },
])
const customerMeasurementColumns = [
@@ -2485,18 +2485,8 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
<div v-else v-loading="detailLoading" class="order-detail-content">
<template v-if="detail">
<dl class="detail-overview density-detail-overview" aria-label="订单概览">
<div><dt>客户</dt><dd>{{ customerName(detail.summary) }}</dd></div>
<div><dt>渠道</dt><dd>{{ detail.summary.channelName || '未配置渠道' }}</dd></div>
<div><dt>总箱数</dt><dd>{{ detail.summary.totalPackageCount ?? '—' }} 箱 <small>({{ detail.order.cargoes.length }} 条货品)</small></dd></div>
<div><dt>实测毛重</dt><dd>{{ formatNumber(detail.summary.measurement?.actualGrossWeightKg) }} kg</dd></div>
<div><dt>实测体积</dt><dd>{{ formatNumber(detail.summary.measurement?.actualVolumeCbm, 3) }} m³</dd></div>
<div><dt>测量审核</dt><dd>{{ measurementStatusLabel(detail.summary.measurement?.reviewStatus) }}</dd></div>
<div><dt>最近更新</dt><dd>{{ formatDate(detail.summary.updatedAt) }}</dd></div>
</dl>
<div class="detail-tabs density-tabs" role="tablist" aria-label="订单详情模块">
<button v-for="tab in detailTabs" :id="`order-detail-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeDetailTab === tab.key }" :aria-selected="activeDetailTab === tab.key" :aria-controls="`order-detail-panel-${tab.key}`" :tabindex="activeDetailTab === tab.key ? 0 : -1" @click="selectDetailTab(tab.key)"><span class="density-tab-icon" aria-hidden="true">{{ tab.icon }}</span><span>{{ tab.label }}</span></button>
<button v-for="tab in detailTabs" :id="`order-detail-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeDetailTab === tab.key }" :aria-selected="activeDetailTab === tab.key" :aria-controls="`order-detail-panel-${tab.key}`" :tabindex="activeDetailTab === tab.key ? 0 : -1" @click="selectDetailTab(tab.key)"><component :is="tab.icon" class="density-tab-icon" :size="15" :stroke-width="1.9" aria-hidden="true" /><span>{{ tab.label }}</span></button>
</div>
<section :id="`order-detail-panel-${activeDetailTab}`" class="detail-panel" role="tabpanel" :aria-labelledby="`order-detail-tab-${activeDetailTab}`" tabindex="0">
@@ -3167,6 +3157,8 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
.page-intro :deep(.el-button) { flex: 0 0 auto; }
.orders-query-panel { min-width: 0; }
.orders-query-toolbar { border: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); }
.unified-orders-page .orders-status-toolbar :deep(.density-status-tabs > button) { min-height: 32px; padding-right: 8px; padding-left: 8px; }
.unified-orders-page .detail-tabs > button { height: 46px; min-height: 46px; padding-right: 20px; padding-left: 20px; }
.table-card { overflow: hidden; border-radius: var(--admin-radius, 4px); box-shadow: none; }
.coload-error { margin: var(--admin-space-3, 8px) var(--admin-space-5, 12px); padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border: 1px solid var(--el-color-danger-light-5); border-radius: var(--admin-radius, 4px); background: var(--el-color-danger-light-9); color: var(--el-color-danger); font-size: var(--admin-font-caption, 12px); }
.coload-error :deep(.api-error-meta) { margin-top: 5px; }