From a9dc19a86bbb869c9356b96598860c9045180769 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 18:52:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/outsourcedBusiness.test.ts | 9 +++++ .../api/outsourcedBusiness.ts | 4 +- .../api/ownedBusiness.test.ts | 9 +++++ .../overseas-master-data/api/ownedBusiness.ts | 4 +- .../OutsourcedBusinessPhotosEditor.test.ts | 2 +- .../OutsourcedBusinessPhotosEditor.vue | 11 +++--- .../components/OwnedBusinessPhotosEditor.vue | 11 +++--- .../outsourcedBusinessTypes.ts | 4 +- .../ownedBusinessTypes.ts | 4 +- .../api/dispatchLoadingDocuments.ts | 2 +- .../DispatchLoadingDocuments.test.ts | 39 +++++++++++++++++++ .../components/DispatchLoadingDocuments.vue | 14 ++++--- 12 files changed, 88 insertions(+), 25 deletions(-) diff --git a/src/modules/overseas-master-data/api/outsourcedBusiness.test.ts b/src/modules/overseas-master-data/api/outsourcedBusiness.test.ts index c609e5f2..2b2ebc2c 100644 --- a/src/modules/overseas-master-data/api/outsourcedBusiness.test.ts +++ b/src/modules/overseas-master-data/api/outsourcedBusiness.test.ts @@ -12,6 +12,15 @@ beforeEach(() => vi.clearAllMocks()) const base = '/platform/v1/last-leg/fleet/outsourced-business' const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null } describe('outsourced business HTTP contract', () => { + it('reads an imported PDF only for the driver license category and rejects a mismatched response', async () => { + const license: OutsourcedBusinessPhoto = { ...photo, slot: 'LOADING_SCENE', category: 'LOADING', contentType: 'application/pdf' } + const blob = new Blob(['%PDF-1.4'], { type: 'application/pdf' }) + vi.mocked(http.get).mockResolvedValue(blob) + await expect(getOutsourcedBusinessPhotoContent('DS/1', license)).resolves.toBe(blob) + await expect(getOutsourcedBusinessPhotoContent('DS/1', { ...license, slot: 'LOADING_LIST' })).rejects.toThrow('格式无效') + vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' })) + await expect(getOutsourcedBusinessPhotoContent('DS/1', license)).rejects.toThrow('格式无效') + }) it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified pooled %s vehicles in lists, details and write responses', async ownership => { const detail = outsourcedBusinessFixture(); detail.row.vehicle.ownership = ownership vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail }) diff --git a/src/modules/overseas-master-data/api/outsourcedBusiness.ts b/src/modules/overseas-master-data/api/outsourcedBusiness.ts index 602c3e6e..bd2c6ce5 100644 --- a/src/modules/overseas-master-data/api/outsourcedBusiness.ts +++ b/src/modules/overseas-master-data/api/outsourcedBusiness.ts @@ -44,7 +44,7 @@ export async function saveOutsourcedBusinessOperations(no: string, body: Outsour } export async function uploadOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, file: File, key: string) { requireSlot(slot) - if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片') + if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || file.size <= 0) throw new Error('请选择JPG/PNG,司机驾驶证也支持PDF') const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key) const value = unwrapApiResponse(await http.post, ApiEnvelope>(`${path(no)}/photos/${slot}/uploads`, form)) if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致') @@ -68,7 +68,7 @@ export async function getOutsourcedBusinessPhotoContent(no: string, photo: Outso try { const blob = await http.get(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: outsourcedBusinessVersion(revisionVersion) } }) await downloadError(blob, 200) - if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效') + if (!(blob instanceof Blob) || !(['image/jpeg','image/png'].includes(blob.type) || photo.slot === 'LOADING_SCENE' && blob.type === 'application/pdf') || blob.type !== photo.contentType || !blob.size) throw new Error('资料内容为空或格式无效') return blob } catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause } } diff --git a/src/modules/overseas-master-data/api/ownedBusiness.test.ts b/src/modules/overseas-master-data/api/ownedBusiness.test.ts index 1bfc4efa..7fd40ae3 100644 --- a/src/modules/overseas-master-data/api/ownedBusiness.test.ts +++ b/src/modules/overseas-master-data/api/ownedBusiness.test.ts @@ -12,6 +12,15 @@ beforeEach(() => vi.clearAllMocks()) const base = '/platform/v1/last-leg/fleet/owned-business' const photo: OwnedBusinessPhoto = { slot: 'FUEL_START', category: 'FUEL', fileId: '9007199254740993', fileName: 'fuel.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null } describe('owned business HTTP contract', () => { + it('reads an imported PDF only for the driver license category and rejects a mismatched response', async () => { + const license: OwnedBusinessPhoto = { ...photo, slot: 'LOADING_SCENE', category: 'LOADING', contentType: 'application/pdf' } + const blob = new Blob(['%PDF-1.4'], { type: 'application/pdf' }) + vi.mocked(http.get).mockResolvedValue(blob) + await expect(getOwnedBusinessPhotoContent('DS/1', license)).resolves.toBe(blob) + await expect(getOwnedBusinessPhotoContent('DS/1', { ...license, slot: 'LOADING_LIST' })).rejects.toThrow('格式无效') + vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' })) + await expect(getOwnedBusinessPhotoContent('DS/1', license)).rejects.toThrow('格式无效') + }) it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified non-pooled %s vehicles in lists, details and write responses', async ownership => { const detail = ownedBusinessFixture(); detail.row.vehicle.ownership = ownership; detail.row.vehicle.vehicleId = null vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail }) diff --git a/src/modules/overseas-master-data/api/ownedBusiness.ts b/src/modules/overseas-master-data/api/ownedBusiness.ts index 6bf4c37f..8df39c0e 100644 --- a/src/modules/overseas-master-data/api/ownedBusiness.ts +++ b/src/modules/overseas-master-data/api/ownedBusiness.ts @@ -38,7 +38,7 @@ export async function saveOwnedBusinessOperations(no: string, body: OwnedBusines return value } export async function uploadOwnedBusinessPhoto(no: string, slot: OwnedBusinessPhotoSlot, file: File, key: string) { - if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片') + if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || file.size <= 0) throw new Error('请选择JPG/PNG,司机驾驶证也支持PDF') const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key) const value = unwrapApiResponse(await http.post, ApiEnvelope>(`${path(no)}/photos/${slot}/uploads`, form)) if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致') @@ -60,7 +60,7 @@ export async function getOwnedBusinessPhotoContent(no: string, photo: OwnedBusin try { const blob = await http.get(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: ownedBusinessVersion(revisionVersion) } }) await downloadError(blob, 200) - if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效') + if (!(blob instanceof Blob) || !(['image/jpeg','image/png'].includes(blob.type) || photo.slot === 'LOADING_SCENE' && blob.type === 'application/pdf') || blob.type !== photo.contentType || !blob.size) throw new Error('资料内容为空或格式无效') return blob } catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause } } diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosEditor.test.ts b/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosEditor.test.ts index 07159bb9..2d6ea33e 100644 --- a/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosEditor.test.ts +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosEditor.test.ts @@ -57,7 +57,7 @@ describe('outsourced business per-slot photo save',()=>{ const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}] const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises() expect(calls.content).not.toHaveBeenCalled() - await wrapper.findAll('button').find(button=>button.text()==='查看图片')!.trigger('click');await flushPromises() + await wrapper.findAll('button').find(button=>button.text()==='查看资料')!.trigger('click');await flushPromises() expect(calls.content).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',detail.photos[0]);expect(wrapper.find('img').attributes('src')).toBe('blob:outsourced-loading') await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount() }) diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosEditor.vue b/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosEditor.vue index c0656066..dedf315f 100644 --- a/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosEditor.vue +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosEditor.vue @@ -42,7 +42,7 @@ async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event, replaceFi const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = '' if (!file || busy.value || !uploadable.value) return if (!replaceFileId && savedPhotos(slot).length >= (slot.startsWith('FUEL_') ? 1 : 5)) return - if (!['image/jpeg','image/png'].includes(file.type) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; return } + if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size) { error.value = { message: '请选择JPG/PNG,司机驾驶证也支持PDF' }; return } busy.value = true; error.value = null try { ensureScope() @@ -62,12 +62,13 @@ function beforeClose(done: () => void) { if (!busy.value) done() } diff --git a/src/modules/overseas-master-data/components/OwnedBusinessPhotosEditor.vue b/src/modules/overseas-master-data/components/OwnedBusinessPhotosEditor.vue index fd37a12a..5eb472c8 100644 --- a/src/modules/overseas-master-data/components/OwnedBusinessPhotosEditor.vue +++ b/src/modules/overseas-master-data/components/OwnedBusinessPhotosEditor.vue @@ -42,7 +42,7 @@ async function upload(slot: OwnedBusinessPhotoSlot, event: Event, replaceFileId? const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = '' if (!file || busy.value || !uploadable.value) return if (!replaceFileId && savedPhotos(slot).length >= (slot.startsWith('FUEL_') ? 1 : 5)) return - if (!['image/jpeg','image/png'].includes(file.type) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; return } + if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size) { error.value = { message: '请选择JPG/PNG,司机驾驶证也支持PDF' }; return } busy.value = true; error.value = null try { ensureScope() @@ -62,12 +62,13 @@ function beforeClose(done: () => void) { if (!busy.value) done() } diff --git a/src/modules/overseas-master-data/outsourcedBusinessTypes.ts b/src/modules/overseas-master-data/outsourcedBusinessTypes.ts index b0f6bbee..056b8d08 100644 --- a/src/modules/overseas-master-data/outsourcedBusinessTypes.ts +++ b/src/modules/overseas-master-data/outsourcedBusinessTypes.ts @@ -5,7 +5,7 @@ export type OutsourcedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PR export interface OutsourcedBusinessMoney { currency: 'CNY' | 'USD'; amount: string } export interface OutsourcedBusinessArrival { destinationKey: string; actualArrivedAt: string | null } export interface OutsourcedBusinessOperations { money: OutsourcedBusinessMoney | null; arrivals: OutsourcedBusinessArrival[] } -export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null } +export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png' | 'application/pdf'; sizeBytes: string; uploadedAt: string; savedAt: string | null } export interface OutsourcedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null } export interface OutsourcedBusinessRow { dispatchNo: string; stage: OutsourcedBusinessStage; plannedDepartureAt: string | null; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null @@ -26,7 +26,7 @@ export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIN export interface OutsourcedBusinessCommand { dispatchNo: string; action: OutsourcedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean } export interface OutsourcedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OutsourcedBusinessOperations | null; photos: OutsourcedBusinessPhoto[] } export const outsourcedBusinessStages: Record = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' } -export const outsourcedBusinessActionLabels: Record = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' } +export const outsourcedBusinessActionLabels: Record = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片', INVALIDATE_RESOURCE_PHOTOS: '车辆或司机资料变更,原凭证失效' } export const outsourcedBusinessPhotoRoles: { slot: OutsourcedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' }[] = [ { slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' }, { slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' }, diff --git a/src/modules/overseas-master-data/ownedBusinessTypes.ts b/src/modules/overseas-master-data/ownedBusinessTypes.ts index ecb4eba2..42499d69 100644 --- a/src/modules/overseas-master-data/ownedBusinessTypes.ts +++ b/src/modules/overseas-master-data/ownedBusinessTypes.ts @@ -5,7 +5,7 @@ export type OwnedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PRE_DEP export interface OwnedBusinessMoney { currency: 'CNY' | 'USD'; amount: string } export interface OwnedBusinessArrival { destinationKey: string; actualArrivedAt: string | null } export interface OwnedBusinessOperations { money: OwnedBusinessMoney | null; actualDistanceMiles: string | null; fuelConsumptionLitresPer100Km: string | null; arrivals: OwnedBusinessArrival[] } -export interface OwnedBusinessPhoto { slot: OwnedBusinessPhotoSlot; category: 'LOADING' | 'FUEL'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null } +export interface OwnedBusinessPhoto { slot: OwnedBusinessPhotoSlot; category: 'LOADING' | 'FUEL'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png' | 'application/pdf'; sizeBytes: string; uploadedAt: string; savedAt: string | null } export interface OwnedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null } export interface OwnedBusinessRow { dispatchNo: string; stage: OwnedBusinessStage; plannedDepartureAt: string; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null @@ -26,7 +26,7 @@ export type OwnedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHO export interface OwnedBusinessCommand { dispatchNo: string; action: OwnedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OwnedBusinessDetail | OwnedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean } export interface OwnedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OwnedBusinessOperations | null; photos: OwnedBusinessPhoto[] } export const ownedBusinessStages: Record = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' } -export const ownedBusinessActionLabels: Record = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' } +export const ownedBusinessActionLabels: Record = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片', INVALIDATE_RESOURCE_PHOTOS: '车辆或司机资料变更,原凭证失效' } export const ownedBusinessPhotoRoles: { slot: OwnedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' | 'FUEL' }[] = [ { slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' }, { slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' }, diff --git a/src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts b/src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts index c739294c..028642e9 100644 --- a/src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts +++ b/src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts @@ -23,6 +23,6 @@ export async function bindLoadingPhoto(no: string, slot: LoadingPhotoSlot, body: } export async function readLoadingPhoto(no: string, photo: LoadingPhoto) { const blob = await http.get(`${path(no)}/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' }) - if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size) throw new Error('图片读取失败,请重新核对') + if (!(blob instanceof Blob) || !(['image/jpeg', 'image/png'].includes(blob.type) || photo.slot === 'LOADING_SCENE' && blob.type === 'application/pdf') || blob.type !== photo.contentType || !blob.size) throw new Error('资料读取失败,请重新核对') return blob } diff --git a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts index 75d7e413..293e4c96 100644 --- a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts @@ -24,6 +24,26 @@ beforeEach(() => { vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '1', photos: [photo] })) vi.mocked(api.readLoadingPhoto).mockRejectedValue(new Error('preview unavailable')) }) +it('refreshes photos added or replaced by PDA without writing and notifies the parent to reload the stage', async () => { + can.mockReturnValue(false) + vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ photos: [photo] })) + const wrapper = setup(); await flushPromises() + const photos = api.loadingPhotoSlots.flatMap((slot, category) => Array.from({ length: 5 }, (_, index) => ({ + ...photo, slot, fileId: `${category + 2}${index}`, fileName: `pda-${category}-${index}.png`, + }))) + vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '26', stage: 'DELIVERING', complete: true, photos })) + await wrapper.get('[aria-label="刷新装车资料"]').trigger('click'); await flushPromises() + for (const item of photos) expect(wrapper.text()).toContain(item.fileName) + expect(wrapper.text()).not.toContain(photo.fileName) + expect(wrapper.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(Object.values(api.loadingPhotoLabels).map(label => `${label}5/5 张`)) + expect(wrapper.emitted('saved')).toHaveLength(1) + expect(api.uploadLoadingPhoto).not.toHaveBeenCalled(); expect(api.bindLoadingPhoto).not.toHaveBeenCalled() + vi.mocked(api.getLoadingDocuments).mockRejectedValueOnce(new Error('刷新失败')) + await wrapper.get('[aria-label="刷新装车资料"]').trigger('click'); await flushPromises() + expect(wrapper.text()).toContain(photos[0]!.fileName) + expect(wrapper.emitted('saved')).toHaveLength(1) + wrapper.unmount() +}) it('offers five whole-dispatch slots and only reports saved after binding succeeds', async () => { const wrapper = setup(); await flushPromises() expect(wrapper.findAll('input[type="file"]')).toHaveLength(5) @@ -38,6 +58,25 @@ it('offers five whole-dispatch slots and only reports saved after binding succee expect(table.get('tbody tr').text()).toContain('loading.png') wrapper.unmount() }) +it('shows an imported PDF license as an authenticated document and accepts an exact PDF replacement', async () => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:license') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + const license = { ...photo, fileName: 'license.pdf', contentType: 'application/pdf' } + vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ photos: [license] })) + vi.mocked(api.readLoadingPhoto).mockResolvedValue(new Blob(['%PDF-1.4'], { type: 'application/pdf' })) + const wrapper = setup() + try { + await flushPromises() + expect(wrapper.get('a').attributes('href')).toBe('blob:license') + expect(wrapper.findComponent({ name: 'ElImage' }).exists()).toBe(false) + const input = wrapper.get('input[aria-label="更换license.pdf"]') + expect(input.attributes('accept')).toContain('application/pdf') + expect(wrapper.get('input[aria-label="上传车牌照片"]').attributes('accept')).not.toContain('application/pdf') + Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['%PDF-1.4'], 'new.pdf', { type: 'application/pdf' })] }) + await input.trigger('change'); await flushPromises() + expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ replaceFileId: '101' })) + } finally { wrapper.unmount(); vi.restoreAllMocks() } +}) it('shows every photo in a category, stops at five and replaces the selected image only', async () => { const photos = Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(101 + index), fileName: `photo-${index}.png` })) vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '5', photos })) diff --git a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue index 24ce66b5..84c47626 100644 --- a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue +++ b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue @@ -35,17 +35,18 @@ async function previews() { async function load() { if (busy.value) return busy.value = true; error.value = null - try { accept(await getLoadingDocuments(number)); await previews() } + try { accept(await getLoadingDocuments(number)); await previews(); return true } catch (cause) { if (active) { needsRefresh.value = true; error.value = getApiErrorDetails(cause, '装车资料读取失败') } } finally { if (active) busy.value = false } } +async function refresh() { if (await load()) emit('saved') } function photosFor(slot: LoadingPhotoSlot) { return snapshot.value?.photos.filter(photo => photo.slot === slot) || [] } async function upload(slot: LoadingPhotoSlot, event: Event, replaceFileId?: string) { const input = event.target as HTMLInputElement, files = Array.from(input.files || []); input.value = '' if (!files.length || busy.value || !editable.value) return const capacity = replaceFileId ? 1 : 5 - photosFor(slot).length if (files.length > capacity) { ElMessage.warning(`本次最多可选择${capacity}张图片`); return } - if (files.some(file => !['image/jpeg','image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return } + if (files.some(file => !(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('请选择不超过10MiB的JPG或PNG,司机驾驶证也支持PDF'); return } busy.value = true; error.value = null let saved = 0, reconciled = false, bindingFailed = false try { @@ -86,17 +87,20 @@ defineExpose({ busy })