This commit is contained in:
2026-10-26 18:52:21 +08:00
parent 22242701e4
commit a9dc19a86b
12 changed files with 88 additions and 25 deletions
@@ -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<Blob, Blob>(`${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
}
@@ -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 }))
@@ -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 })
<template>
<div v-loading="busy" class="density-dialog-sections">
<p>可边装车边上传五类装车资料,每类至少 1 张、最多 5 张;装车确认完成且五类齐全后自动进入派送中。</p>
<p>司机驾驶证、车牌照片在理货审核通过后从本趟资料自动带入,可补充或更换;驾驶证支持 PDF。</p>
<div><el-button :loading="busy" :disabled="busy" aria-label="刷新装车资料" @click="refresh">刷新</el-button></div>
<p v-if="needsRefresh">保存状态尚未核实,请先重新读取资料后再上传。</p>
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="true" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table class="density-table loading-documents-table" aria-label="装车照片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">照片类别</th><th>文件名称</th><th class="photo-preview">图片预览</th><th class="photo-time">保存时间</th></tr></thead><tbody>
<tr v-for="slot in loadingPhotoSlots" :key="slot">
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy || photosFor(slot).length >= 5 }">{{ photosFor(slot).length >= 5 ? '已满 5 张' : '上传图片' }}<input type="file" multiple accept="image/jpeg,image/png" :aria-label="`上传${loadingPhotoLabels[slot]}`" :disabled="busy || photosFor(slot).length >= 5" @change="upload(slot, $event)" /></label><span v-else>—</span></td>
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy || photosFor(slot).length >= 5 }">{{ photosFor(slot).length >= 5 ? '已满 5 张' : '上传资料' }}<input type="file" multiple :accept="slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :aria-label="`上传${loadingPhotoLabels[slot]}`" :disabled="busy || photosFor(slot).length >= 5" @change="upload(slot, $event)" /></label><span v-else>—</span></td>
<td>{{ loadingPhotoLabels[slot] }}<div>{{ photosFor(slot).length }}/5 张</div></td>
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.fileName }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">未上传</span></td>
<td><template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
<el-image v-if="images[photo.fileId]" :src="images[photo.fileId]" :preview-src-list="[images[photo.fileId]!]" fit="contain" class="loading-photo-image" preview-teleported />
<a v-if="images[photo.fileId] && photo.contentType === 'application/pdf'" :href="images[photo.fileId]" target="_blank" rel="noopener noreferrer">查看驾驶证 PDF</a>
<el-image v-else-if="images[photo.fileId]" :src="images[photo.fileId]" :preview-src-list="[images[photo.fileId]!]" fit="contain" class="loading-photo-image" preview-teleported />
<el-button v-else link :disabled="busy" @click="load">图片读取失败,点击重试</el-button>
<label v-if="editable" class="photo-upload">更换<input type="file" accept="image/jpeg,image/png" :aria-label="`更换${photo.fileName}`" :disabled="busy" @change="upload(slot, $event, photo.fileId)" /></label>
<label v-if="editable" class="photo-upload">更换<input type="file" :accept="slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :aria-label="`更换${photo.fileName}`" :disabled="busy" @change="upload(slot, $event, photo.fileId)" /></label>
</template><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.savedAt?.replace('T', ' ') || '—' }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
</tr>