fix(dispatch): present loading evidence as a single-column list
This commit is contained in:
@@ -33,30 +33,34 @@ it('refreshes photos added or replaced by PDA without writing and notifies the p
|
||||
})))
|
||||
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.findAll('.loading-photo-row')).toHaveLength(25)
|
||||
for (const item of photos) expect(wrapper.text()).not.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.findAll('.loading-document-heading').map(row => row.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.findAll('.loading-photo-row')).toHaveLength(25)
|
||||
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)
|
||||
const table = wrapper.get('table[aria-label="装车照片"]')
|
||||
expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '照片类别', '文件名称', '图片预览', '保存时间'])
|
||||
expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(Object.values(api.loadingPhotoLabels).map(label => `${label}0/5 张`))
|
||||
const list = wrapper.get('[aria-label="装车照片"]')
|
||||
expect(wrapper.find('table').exists()).toBe(false)
|
||||
expect(list.findAll('h3').map(heading => heading.text())).toEqual(Object.values(api.loadingPhotoLabels))
|
||||
expect(list.findAll('.photo-count').map(count => count.text())).toEqual(Array(5).fill('0/5 张'))
|
||||
expect(list.findAll('.photo-empty')).toHaveLength(5)
|
||||
await upload(wrapper)
|
||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String), '1')
|
||||
expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '0', fileId: '101' }))
|
||||
expect(vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2].uploadKey).toBe(vi.mocked(api.uploadLoadingPhoto).mock.calls[0]![3])
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('图片读取失败')
|
||||
expect(table.get('tbody tr').text()).toContain('loading.png')
|
||||
expect(list.get('.loading-photo-row').attributes('aria-label')).toBe('司机驾驶证第1张')
|
||||
expect(list.text()).not.toContain('loading.png')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('stops a batch when re-review changes the source during upload instead of rebasing old files', async () => {
|
||||
@@ -84,7 +88,7 @@ it('shows an imported PDF license as an authenticated document and accepts an ex
|
||||
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"]')
|
||||
const input = wrapper.get('input[aria-label="更换司机驾驶证第1张"]')
|
||||
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' })] })
|
||||
@@ -93,19 +97,29 @@ it('shows an imported PDF license as an authenticated document and accepts an ex
|
||||
} 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` }))
|
||||
const photos = Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(101 + index), fileName: `photo-${index}.png`, savedAt: `2026-10-26T11:49:0${index}.978960362Z` }))
|
||||
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '5', photos }))
|
||||
const replacement = { ...photo, fileId: '999' }
|
||||
vi.mocked(api.uploadLoadingPhoto).mockResolvedValue(replacement)
|
||||
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '6', photos: photos.map(item => item.fileId === '103' ? replacement : item) }))
|
||||
const wrapper = setup(); await flushPromises()
|
||||
expect(wrapper.get('input[aria-label="上传司机驾驶证"]').attributes('disabled')).toBeDefined()
|
||||
for (const item of photos) expect(wrapper.text()).toContain(item.fileName)
|
||||
const input = wrapper.get('input[aria-label="更换photo-2.png"]')
|
||||
const rows = wrapper.findAll('.loading-photo-row')
|
||||
expect(rows).toHaveLength(5)
|
||||
rows.forEach((row, index) => {
|
||||
expect(row.attributes('aria-label')).toBe(`司机驾驶证第${index + 1}张`)
|
||||
expect(row.get('.photo-time').text()).toMatch(new RegExp(`保存时间:2026-10-26 \\d{2}:49:0${index}$`))
|
||||
expect(row.findAll('input[type="file"]')).toHaveLength(1)
|
||||
})
|
||||
for (const item of photos) expect(wrapper.text()).not.toContain(item.fileName)
|
||||
const input = rows[2]!.get('input[aria-label="更换司机驾驶证第3张"]')
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['new'], 'new.png', { type: 'image/png' })] })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '5', fileId: '999', replaceFileId: '103' }))
|
||||
expect(wrapper.text()).toContain('photo-0.png'); expect(wrapper.text()).toContain('photo-4.png')
|
||||
expect(wrapper.findAll('.loading-photo-row')).toHaveLength(5)
|
||||
expect(wrapper.findAll('.loading-photo-row')[0]!.get('.photo-time').text()).toMatch(/:49:00$/)
|
||||
expect(wrapper.findAll('.loading-photo-row')[2]!.get('.photo-time').text()).toBe('保存时间:—')
|
||||
expect(wrapper.findAll('.loading-photo-row')[4]!.get('.photo-time').text()).toMatch(/:49:04$/)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('saves a multiple selection with fresh versions and notifies the parent once', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -41,6 +42,9 @@ async function load() {
|
||||
}
|
||||
async function refresh() { if (await load()) emit('saved') }
|
||||
function photosFor(slot: LoadingPhotoSlot) { return snapshot.value?.photos.filter(photo => photo.slot === slot) || [] }
|
||||
function savedTime(value?: string | null) {
|
||||
return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
|
||||
}
|
||||
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
|
||||
@@ -88,36 +92,59 @@ onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(url
|
||||
defineExpose({ busy })
|
||||
</script>
|
||||
<template>
|
||||
<div v-loading="busy" class="density-dialog-sections">
|
||||
<div v-loading="busy" class="loading-documents">
|
||||
<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="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">
|
||||
<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="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>
|
||||
</tbody></table>
|
||||
<div class="loading-document-list" aria-label="装车照片">
|
||||
<section v-for="slot in loadingPhotoSlots" :key="slot" class="loading-document-category" :aria-label="loadingPhotoLabels[slot]">
|
||||
<header class="loading-document-heading">
|
||||
<h3>{{ loadingPhotoLabels[slot] }}</h3>
|
||||
<span class="photo-count">{{ photosFor(slot).length }}/5 张</span>
|
||||
<label v-if="editable" class="photo-upload category-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>
|
||||
</header>
|
||||
<ol v-if="photosFor(slot).length" class="loading-photo-list">
|
||||
<li v-for="(photo, index) in photosFor(slot)" :key="photo.fileId" class="loading-photo-row" :aria-label="`${loadingPhotoLabels[slot]}第${index + 1}张`">
|
||||
<span class="photo-index">{{ index + 1 }}</span>
|
||||
<div class="photo-preview">
|
||||
<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]" :alt="`${loadingPhotoLabels[slot]}第${index + 1}张`" :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>
|
||||
</div>
|
||||
<span class="photo-time">保存时间:{{ savedTime(photo.savedAt) }}</span>
|
||||
<label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">
|
||||
更换
|
||||
<input type="file" :accept="slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :aria-label="`更换${loadingPhotoLabels[slot]}第${index + 1}张`" :disabled="busy" @change="upload(slot, $event, photo.fileId)" />
|
||||
</label>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="photo-empty">未上传</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.loading-documents-table { table-layout: fixed; }
|
||||
.loading-documents-table td { overflow-wrap: anywhere; }
|
||||
.photo-actions { width: 110px; }
|
||||
.photo-category { width: 120px; }
|
||||
.photo-preview { width: 140px; }
|
||||
.photo-time { width: 190px; }
|
||||
.loading-photo-image { display: block; width: 100px; height: 64px; }
|
||||
.loading-documents { display: flex; flex-direction: column; gap: 12px; }
|
||||
.loading-documents > p { margin: 0; }
|
||||
.loading-document-list { border: 1px solid var(--cc-border, var(--el-border-color)); }
|
||||
.loading-document-category + .loading-document-category { border-top: 1px solid var(--cc-border, var(--el-border-color)); }
|
||||
.loading-document-heading { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: var(--el-fill-color-light); }
|
||||
.loading-document-heading h3 { margin: 0; font-size: 14px; }
|
||||
.photo-count, .photo-time, .photo-index, .photo-empty { color: var(--el-text-color-secondary); }
|
||||
.category-upload { margin-left: auto; }
|
||||
.loading-photo-list { display: flex; flex-direction: column; margin: 0; padding: 0 12px; list-style: none; }
|
||||
.loading-photo-row { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; padding: 8px 0; }
|
||||
.loading-photo-row + .loading-photo-row { border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.photo-index { width: 20px; text-align: center; }
|
||||
.photo-preview { display: flex; align-items: center; flex: 0 0 160px; min-height: 96px; }
|
||||
.loading-photo-image { display: block; width: 160px; height: 96px; }
|
||||
.photo-time { font-variant-numeric: tabular-nums; }
|
||||
.photo-empty { margin: 0; padding: 12px; }
|
||||
.photo-upload { position: relative; display: inline-flex; color: var(--el-color-primary); cursor: pointer; }
|
||||
.photo-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
||||
.photo-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
|
||||
Reference in New Issue
Block a user