Merge remote-tracking branch 'origin/release/test' into release/test

# Conflicts:
#	src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts
This commit is contained in:
2026-11-05 16:30:48 +08:00
12 changed files with 84 additions and 80 deletions
@@ -23,13 +23,33 @@ it.each(['VEHICLE', 'DRIVER_PHOTO'] as const)('does not count a PDF or failed up
expect(wrapper.emitted('busy')?.at(-1)).toEqual([false])
wrapper.unmount()
})
it('previews an unsaved master license through its driver and keeps uploaded license files on the dispatch route', async () => {
it('uses the uploaded license before the master fallback and restores the fallback after removal', 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(downloadDriverLicense).not.toHaveBeenCalled()
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()
expect(wrapper.findAll('figure img')).toHaveLength(1)
await wrapper.setProps({ modelValue: [] }); await flushPromises()
expect(downloadDriverLicense).toHaveBeenCalledWith('2', '33')
expect(wrapper.findAll('figure img')).toHaveLength(1); wrapper.unmount()
})
it('keeps saved trip licenses without an additional master image or changes to stored references', async () => {
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: ['33', '301'], referenceIds: ['33'], fallbackFileId: '33' } }); await flushPromises()
expect(wrapper.findAll('figure img')).toHaveLength(1)
expect(api.getDispatchEvidence).not.toHaveBeenCalledWith('PC/1', 'DRIVER', '33')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
})
it.each(['tiny', 'broken'])('shows a recovery message instead of a blank %s thumbnail', async failure => {
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true } }); await flushPromises()
const image = wrapper.get('figure img')
Object.defineProperties(image.element, { naturalWidth: { value: 1 }, naturalHeight: { value: 1 } })
await image.trigger(failure === 'tiny' ? 'load' : 'error')
expect(wrapper.find('figure img').exists()).toBe(false)
expect(wrapper.text()).toContain(failure === 'tiny' ? '原图仅 1 × 1 像素' : '照片加载失败')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
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() })
@@ -11,14 +11,21 @@ import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/sh
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
import { downloadDriverLicense, getResourcePhoto, downloadResourcePhoto, type ResourcePhotoKind } 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[]; masterResource?: { kind: ResourcePhotoKind; id: string } | null; masterLicense?: { driverId: string; fileId: string } | null }>()
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[]; fallbackFileId?: string | null; masterResource?: { kind: ResourcePhotoKind; id: string } | null; masterLicense?: { driverId: string; fileId: string } | null }>()
const ids = defineModel<string[]>({ required: true })
const profilePhotoId = ref<string | null>(null)
const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value, ...(profilePhotoId.value ? [profilePhotoId.value] : [])])])
// 主档仅作缺图时的默认来源;已有本趟驾驶证时不再额外展示主档图片。
const selectedIds = computed(() => {
const values = [...new Set([...(props.referenceIds || []), ...ids.value])]
const fallback = props.fallbackFileId || props.masterLicense?.fileId
return props.kind === 'DRIVER' && fallback && values.some(id => id !== fallback) ? values.filter(id => id !== fallback) : values
})
const allIds = computed(() => [...new Set([...selectedIds.value, ...(profilePhotoId.value ? [profilePhotoId.value] : [])])])
const emit = defineEmits<{ busy: [value: boolean]; available: [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>>({})
const thumbnailFailures = 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)
@@ -45,9 +52,9 @@ function imageFailed(event: Event) {
imageFailure.value = '图片无法解码或加载,请重新预览;若仍失败,请核对原附件是否损坏。'
}
async function load() {
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...new Set([...(props.referenceIds || []), ...ids.value])]
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...selectedIds.value]
for (const url of Object.values(thumbnails.value)) { URL.revokeObjectURL(url); urls.delete(url) }
thumbnails.value = {}
thumbnails.value = {}; thumbnailFailures.value = {}
closePreview(); metadata.value = {}; error.value = null; masterBlob = null; profilePhotoId.value = null; emit('available', false)
if (!no) return
// 未保存且没有本趟照片时展示档案图片;保存由服务端复制,不提交跨归属文件ID。
@@ -89,6 +96,14 @@ async function load() {
} catch (cause) { if (current === generation) fail(cause, '照片读取失败,请重试') }
}))
}
function thumbnailLoaded(id: string, event: Event) {
const image = event.target as HTMLImageElement
if (image.getAttribute('src') !== thumbnails.value[id]) return
if (image.naturalWidth <= 1 && image.naturalHeight <= 1) thumbnailFailures.value[id] = '原图仅 1 × 1 像素,请替换有效照片'
}
function thumbnailFailed(id: string, event: Event) {
if ((event.target as HTMLImageElement).getAttribute('src') === thumbnails.value[id]) thumbnailFailures.value[id] = '照片加载失败,请重新读取或替换'
}
function choose(id: string | null = null) { if (props.readonly || props.disabled || uploading.value || !props.dispatchNo) return; replacing = id; input.value?.click() }
async function upload(event: Event) {
const element = event.target as HTMLInputElement, file = element.files?.[0]; element.value = ''
@@ -133,7 +148,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, ids.value, props.referenceIds, props.masterLicense, props.masterResource]), load, { immediate: true })
watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value, props.referenceIds, props.fallbackFileId, props.masterLicense, props.masterResource]), load, { immediate: true })
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
</script>
<template>
@@ -142,7 +157,7 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
<figure v-if="photoCard" class="evidence-photo-card">
<div v-if="allIds.length" class="evidence-photo-list">
<div v-for="id in allIds" :key="id" class="evidence-photo-item">
<button type="button" class="evidence-photo-preview" :aria-label="'预览' + label + ':' + (metadata[id]?.fileName || id)" :disabled="!metadata[id] || previewLoading" @click="preview(id)"><img v-if="thumbnails[id]" :src="thumbnails[id]" :alt="label + ':' + metadata[id]?.fileName"><span v-else>{{ metadata[id]?.contentType === 'application/pdf' ? '查看 PDF' : '照片待加载' }}</span></button>
<button type="button" class="evidence-photo-preview" :aria-label="'预览' + label + ':' + (metadata[id]?.fileName || id)" :disabled="!metadata[id] || previewLoading" @click="preview(id)"><img v-if="thumbnails[id] && !thumbnailFailures[id]" :src="thumbnails[id]" :alt="label + ':' + metadata[id]?.fileName" @load="thumbnailLoaded(id, $event)" @error="thumbnailFailed(id, $event)"><span v-else>{{ thumbnailFailures[id] || (metadata[id]?.contentType === 'application/pdf' ? '查看 PDF' : '照片待加载') }}</span></button>
<p class="density-dialog-note evidence-photo-name" :title="metadata[id]?.fileName">{{ metadata[id]?.fileName || '附件资料待核实' }}</p>
<div v-if="!readonly && ids.includes(id)" class="density-actions evidence-photo-actions"><el-button size="small" :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button size="small" :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></div>
</div>
@@ -22,7 +22,7 @@ it.each([['本趟承运公司', '555-0123'], [null, null]])('shows continuous ma
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'; value.vehicle.carrierName = carrierName; value.driver.mode = 'MASTER'; value.driver.driverId = 'driver-1'; value.driver.phone = phone
const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous: true }, global: { plugins: [ElementPlus] } }); await flushPromises()
const field = (label: string) => wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)!.getComponent({ name: 'ElInput' })
expect(field('拖车公司名称').props('modelValue')).toBe(carrierName || '—'); expect(field('拖车公司名称').props('readonly')).toBe(true)
expect(wrapper.findAllComponents({ name: 'ElFormItem' }).map(item => item.props('label'))).not.toContain('拖车公司名称')
expect(field('联系电话').props('modelValue')).toBe(phone || '—'); expect(field('联系电话').props('readonly')).toBe(true)
expect(wrapper.text()).toContain('预计任务结束')
expect(listVehicles).toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled()
@@ -56,8 +56,8 @@ it('shows the newly selected authorized vehicle company without copying it into
const selected = wrapper.emitted('update:vehicle')!.at(-1)![0] as typeof value.vehicle
expect(selected).toMatchObject({ vehicleId: 'vehicle-2', carrierName: null })
await wrapper.setProps({ vehicle: selected })
const company = wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === '拖车公司名称')!.getComponent({ name: 'ElInput' })
expect(company.props('modelValue')).toBe('新承运公司'); expect(company.props('readonly')).toBe(true); wrapper.unmount()
expect(wrapper.findAllComponents({ name: 'ElFormItem' }).map(item => item.props('label'))).not.toContain('拖车公司名称')
wrapper.unmount()
})
it.each([false, true])('renders known business labels without internal IDs (continuous=%s)', async continuous => {
const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = '900719925474099301'; value.driver.mode = 'MASTER'; value.driver.driverId = '900719925474099302'
@@ -10,7 +10,6 @@ const driver = defineModel<Driver>('driver', { required: true })
const props = defineProps<{ disabled: boolean; continuous?: boolean; dispatchNo?: string }>()
const access = useAccessPreviewStore(), vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), error = ref('')
/** 维护弹窗只读展示本趟快照或已读取的当前主档资料,不回写主档字段。 */
const masterCarrierName = computed(() => vehicle.value.carrierName?.trim() || vehicles.value.find(row => row.id === vehicle.value.vehicleId)?.driverOrDispatchCompany?.trim() || '—')
const masterDriverPhone = computed(() => driver.value.phone?.trim() || drivers.value.find(row => row.id === driver.value.driverId)?.details.phone?.trim() || '—')
const currentVehicle = ref<VehicleDetail | null>(null), vehicleUnavailable = ref(false)
const vehicleOptions = computed(() => [...vehicles.value, ...(currentVehicle.value && !vehicles.value.some(row => row.id === currentVehicle.value?.id) ? [currentVehicle.value] : [])])
@@ -63,7 +62,6 @@ function selectDriver(id: string) {
<tr><td><el-form-item label="拖车公司名称"><el-input v-model="vehicle.carrierName" :disabled="disabled" placeholder="请输入承运公司" /></el-form-item></td><td><el-form-item v-if="vehicle.capacity" label="实际托位容量"><el-input-number v-model="vehicle.capacity.palletSpaces" :disabled="disabled" :min="1" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
<tr v-if="vehicle.capacity"><td><el-form-item label="最大载重(kg)"><el-input-number v-model="vehicle.capacity.maxLoadKg" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td><td><el-form-item label="最大容积(m³)"><el-input-number v-model="vehicle.capacity.maxVolumeCbm" :disabled="disabled" :min="0.001" placeholder="审核前核实" controls-position="right" /></el-form-item></td></tr>
</template>
<tr v-if="vehicle.mode === 'MASTER'"><td colspan="2"><el-form-item label="拖车公司名称"><el-input :model-value="masterCarrierName" readonly /></el-form-item></td></tr>
<tr><td><el-form-item label="司机来源"><el-select v-model="driver.mode" :disabled="disabled" @change="changeDriver"><el-option label="司机档案" value="MASTER" /><el-option label="临时外配司机" value="EXTERNAL" /></el-select></el-form-item></td><td><el-form-item v-if="driver.mode === 'MASTER'" label="司机"><el-select v-model="driver.driverId" filterable remote :remote-method="searchDrivers" :disabled="disabled" placeholder="按姓名搜索" @focus="searchDrivers()" @change="selectDriver"><el-option v-if="driver.driverId && !drivers.some(row => row.id === driver.driverId)" :value="driver.driverId" :label="driver.name || '已关联司机(待核实)'" /><el-option v-for="item in drivers" :key="item.id" :value="item.id" :label="item.details.name" /></el-select></el-form-item><el-form-item v-else label="司机姓名"><el-input v-model="driver.name" :disabled="disabled" placeholder="请输入本趟司机姓名" /></el-form-item></td></tr>
<tr v-if="driver.mode === 'MASTER'"><td colspan="2"><el-form-item label="联系电话"><el-input :model-value="masterDriverPhone" readonly /></el-form-item></td></tr>
<tr v-if="driver.mode === 'EXTERNAL'"><td><el-form-item label="联系电话"><el-input v-model="driver.phone" :disabled="disabled" placeholder="请输入联系电话" /></el-form-item></td><td><el-form-item v-if="driver.evidence" label="驾驶证号"><el-input v-model="driver.evidence.licenseNo" :disabled="disabled" placeholder="审核前补齐证照资料" /></el-form-item></td></tr>
@@ -164,6 +164,7 @@ describe('dispatch workflow native layout', () => {
departedAt: '2026-07-01T15:00:00Z', plannedDepartureAt: '2026-07-01T16:00:00Z', estimatedArrivalAt: '2026-07-01T17:00:00Z',
} })
expect(dialog.find('[aria-label="发车时间时区换算"]').exists()).toBe(false)
expect(field(dialog, '发车时间')).toBeUndefined()
expect(field(dialog, '计划发车时间').get('[aria-label="计划发车时间时区换算"]').text()).toBe('美东2026-07-01 12:00:00美西2026-07-01 09:00:00')
expect(field(dialog, '预计到达时间').get('[aria-label="预计到达时间时区换算"]').text()).toBe('美东2026-07-01 13:00:00美西2026-07-01 10:00:00')
expect(field(dialog, '预计任务结束').get('[aria-label="预计任务结束时区换算"]').text()).toBe('美东2026-11-01 05:00:00美西2026-11-01 02:00:00')
@@ -215,7 +216,7 @@ describe('dispatch workflow native layout', () => {
await field(dialog,'车牌号').get('input').setValue('CA-PRESERVED')
dialog.vm.$emit('confirm');await flushPromises()
const original=structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1])
expect(dialog.text()).toContain('workflow-request')
expect((wrapper.vm as unknown as { error: { requestId: string } }).error.requestId).toBe('workflow-request')
expect(field(dialog,'车牌号').get('input').attributes('disabled')).toBeUndefined()
expect((field(dialog,'车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED')
await field(dialog,'车牌号').get('input').setValue('CA-CHANGED')
@@ -275,7 +275,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车牌图片" :master-resource="vehicle.mode === 'MASTER' && vehicle.vehicleId ? { kind: 'VEHICLE', id: vehicle.vehicleId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driver.mode === 'MASTER' && driver.driverId ? { kind: 'DRIVER', id: driver.driverId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :reference-ids="masterLicenseReference ? [masterLicenseReference] : []" :master-license="unsavedMasterLicense" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :reference-ids="masterLicenseReference ? [masterLicenseReference] : []" :fallback-file-id="masterLicenseReference" :master-license="unsavedMasterLicense" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
</div>
</template>
</DispatchVehicleMaintenanceForm>