feat(admin): synchronize fleet photos and multi-image loading documents
This commit is contained in:
@@ -24,7 +24,7 @@ describe('OutsourcedBusiness presentation boundaries', () => {
|
||||
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true } })
|
||||
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
|
||||
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
|
||||
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证'])
|
||||
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['司机驾驶证', '车牌照片', '装车完成', '封车照片', 'BOL司机签字'])
|
||||
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
|
||||
})
|
||||
|
||||
@@ -37,12 +37,15 @@ describe('OutsourcedBusiness presentation boundaries', () => {
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
|
||||
it('unlocks operations closing when the request ends and still respects the owner guard', async () => {
|
||||
const done = vi.fn(), guard = vi.fn()
|
||||
const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
|
||||
const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, busy: true } })
|
||||
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
|
||||
close(done); expect(done).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ busy: false }); close(done)
|
||||
expect(done).toHaveBeenCalledTimes(1)
|
||||
await wrapper.setProps({ beforeClose: guard }); close(done)
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ function close(done: () => void) {
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
|
||||
<p class="outsourced-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</InlineHelp></p>
|
||||
<p class="outsourced-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">装车资料每类最多5张,支持 JPG、PNG,可逐张替换。此处补充照片不改变装车状态。</InlineHelp></p>
|
||||
<slot name="state" />
|
||||
|
||||
<div class="outsourced-business-photo-grid">
|
||||
|
||||
@@ -62,3 +62,16 @@ describe('outsourced business per-slot photo save',()=>{
|
||||
await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders all saved images and replaces only the chosen file', async () => {
|
||||
const detail=outsourcedBusinessFixture()
|
||||
detail.photos=[{...photo,savedAt:'2026-10-26T00:00:00Z'},{...photo,fileId:'222',fileName:'second.png',savedAt:'2026-10-26T00:00:00Z'}]
|
||||
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}})
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('loading.png');expect(wrapper.text()).toContain('second.png')
|
||||
const input=wrapper.findAll('input[type="file"]')[1]!
|
||||
Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['new'],'new.png',{type:'image/png'})]})
|
||||
await input.trigger('change');await flushPromises()
|
||||
expect(calls.bind).toHaveBeenLastCalledWith(expect.any(String),'LOADING_SCENE',expect.objectContaining({replaceFileId:'222'}))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -16,10 +16,10 @@ const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
|
||||
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const urls = ref<Partial<Record<OutsourcedBusinessPhotoSlot,string>>>({}), reading = ref<OutsourcedBusinessPhotoSlot | null>(null)
|
||||
const urls = ref<Record<string,string>>({}), reading = ref<string | null>(null)
|
||||
const uploadable = computed(() => access.can(permission.outsourcedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
|
||||
let actor = '', number = '', generation = 0
|
||||
function savedPhoto(slot: OutsourcedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
|
||||
function savedPhotos(slot: OutsourcedBusinessPhotoSlot) { return props.detail?.photos.filter(photo => photo.slot === slot && photo.savedAt) || [] }
|
||||
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
|
||||
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
|
||||
watch(busy, value => emit('busy', value))
|
||||
@@ -30,17 +30,18 @@ watch(open, value => {
|
||||
}, { immediate: true })
|
||||
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
|
||||
onBeforeUnmount(() => { generation++; release() })
|
||||
async function preview(slot: OutsourcedBusinessPhotoSlot) {
|
||||
const photo = savedPhoto(slot), current = generation
|
||||
async function preview(slot: OutsourcedBusinessPhotoSlot, fileId: string) {
|
||||
const photo = savedPhotos(slot).find(item => item.fileId === fileId), current = generation
|
||||
if (!photo || reading.value) return
|
||||
reading.value = slot; error.value = null
|
||||
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
|
||||
reading.value = fileId; error.value = null
|
||||
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[fileId] = URL.createObjectURL(blob) }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
|
||||
finally { if (current === generation) reading.value = null }
|
||||
}
|
||||
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event) {
|
||||
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event, replaceFileId?: string) {
|
||||
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 }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
@@ -48,7 +49,7 @@ async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event) {
|
||||
const photo = await uploadOutsourcedBusinessPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
||||
const latest = await getOutsourcedBusiness(number); ensureScope()
|
||||
if (!latest.row.actions.upload.allowed) throw new Error(latest.row.actions.upload.blockReason || '当前不允许上传照片')
|
||||
const result = await bindOutsourcedBusinessPhoto(number, slot, { recordVersion: outsourcedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
|
||||
const result = await bindOutsourcedBusinessPhoto(number, slot, { recordVersion: outsourcedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, ...(replaceFileId ? { replaceFileId } : {}) }); ensureScope()
|
||||
emit('saved', result); ElMessage.success('照片已保存')
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
||||
finally { busy.value = false }
|
||||
@@ -60,10 +61,13 @@ function beforeClose(done: () => void) { if (!busy.value) done() }
|
||||
<OutsourcedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
|
||||
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
|
||||
<template v-for="role in outsourcedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
|
||||
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="outsourced-business-photo" />
|
||||
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
|
||||
<p v-else>暂无已保存照片</p>
|
||||
<label v-if="uploadable" class="outsourced-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
|
||||
<div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId">
|
||||
<img v-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="outsourced-business-photo" />
|
||||
<p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看图片</el-button>
|
||||
<label v-if="uploadable" class="outsourced-business-upload">替换图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label>
|
||||
</div>
|
||||
<p v-if="!savedPhotos(role.slot).length">暂无已保存照片</p>
|
||||
<label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="outsourced-business-upload">上传图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
|
||||
</template>
|
||||
</OutsourcedBusinessPhotosDialog>
|
||||
</template>
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('OwnedBusiness presentation boundaries', () => {
|
||||
const wrapper = mount(OwnedBusinessPhotosDialog, { global, props: { modelValue: true } })
|
||||
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
|
||||
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
|
||||
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证', '起始油耗表盘', '结束油耗表盘'])
|
||||
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['司机驾驶证', '车牌照片', '装车完成', '封车照片', 'BOL司机签字', '起始油耗表盘', '结束油耗表盘'])
|
||||
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
|
||||
})
|
||||
|
||||
@@ -37,12 +37,15 @@ describe('OwnedBusiness presentation boundaries', () => {
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
|
||||
it('unlocks operations closing when the request ends and still respects the owner guard', async () => {
|
||||
const done = vi.fn(), guard = vi.fn()
|
||||
const wrapper = mount(OwnedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
|
||||
const wrapper = mount(OwnedBusinessOperationsDialog, { global, props: { modelValue: true, busy: true } })
|
||||
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
|
||||
close(done); expect(done).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ busy: false }); close(done)
|
||||
expect(done).toHaveBeenCalledTimes(1)
|
||||
await wrapper.setProps({ beforeClose: guard }); close(done)
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,13 +10,13 @@ const props = defineProps<{
|
||||
}>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const activeTab = defineModel<'loading' | 'fuel'>('activeTab', { default: 'loading' })
|
||||
// These are prototype presentation slots; the public file categories are not yet frozen.
|
||||
// 展示槽位沿用既有引用,标签与排车装车资料一致。
|
||||
const loadingPhotos = [
|
||||
{ slot: 'loading-scene', label: '装车现场' },
|
||||
{ slot: 'loading-list', label: '装车清单' },
|
||||
{ slot: 'pre-departure-check', label: '出车前检查' },
|
||||
{ slot: 'driver-license', label: '驾驶证附件' },
|
||||
{ slot: 'delivery-receipt', label: '签收凭证' },
|
||||
{ slot: 'loading-scene', label: '司机驾驶证' },
|
||||
{ slot: 'loading-list', label: '车牌照片' },
|
||||
{ slot: 'pre-departure-check', label: '装车完成' },
|
||||
{ slot: 'driver-license', label: '封车照片' },
|
||||
{ slot: 'delivery-receipt', label: 'BOL司机签字' },
|
||||
]
|
||||
const fuelPhotos = [{ slot: 'fuel-start', label: '起始油耗表盘' }, { slot: 'fuel-end', label: '结束油耗表盘' }]
|
||||
function close(done: () => void) {
|
||||
@@ -28,7 +28,7 @@ function close(done: () => void) {
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
|
||||
<p class="owned-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</InlineHelp></p>
|
||||
<p class="owned-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">装车资料每类最多5张,油耗每类1张;支持 JPG、PNG,可逐张替换。此处补充照片不改变装车状态。</InlineHelp></p>
|
||||
<slot name="state" />
|
||||
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||||
|
||||
@@ -50,3 +50,16 @@ describe('owned business per-slot photo save',()=>{
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders all saved images and replaces only the chosen file', async () => {
|
||||
const detail=ownedBusinessFixture()
|
||||
detail.photos=[{...photo,savedAt:'2026-10-26T00:00:00Z'},{...photo,fileId:'222',fileName:'second.png',savedAt:'2026-10-26T00:00:00Z'}]
|
||||
const wrapper=mount(OwnedBusinessPhotosEditor,{global,props:{modelValue:true,detail}})
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('loading.png');expect(wrapper.text()).toContain('second.png')
|
||||
const input=wrapper.findAll('input[type="file"]')[1]!
|
||||
Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['new'],'new.png',{type:'image/png'})]})
|
||||
await input.trigger('change');await flushPromises()
|
||||
expect(calls.bind).toHaveBeenLastCalledWith(expect.any(String),'LOADING_SCENE',expect.objectContaining({replaceFileId:'222'}))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -16,10 +16,10 @@ const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ detail: OwnedBusinessDetail | null }>()
|
||||
const emit = defineEmits<{ saved: [value: OwnedBusinessDetail]; busy: [value: boolean] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const urls = ref<Partial<Record<OwnedBusinessPhotoSlot,string>>>({}), reading = ref<OwnedBusinessPhotoSlot | null>(null)
|
||||
const urls = ref<Record<string,string>>({}), reading = ref<string | null>(null)
|
||||
const uploadable = computed(() => access.can(permission.ownedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
|
||||
let actor = '', number = '', generation = 0
|
||||
function savedPhoto(slot: OwnedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
|
||||
function savedPhotos(slot: OwnedBusinessPhotoSlot) { return props.detail?.photos.filter(photo => photo.slot === slot && photo.savedAt) || [] }
|
||||
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
|
||||
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
|
||||
watch(busy, value => emit('busy', value))
|
||||
@@ -30,17 +30,18 @@ watch(open, value => {
|
||||
}, { immediate: true })
|
||||
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
|
||||
onBeforeUnmount(() => { generation++; release() })
|
||||
async function preview(slot: OwnedBusinessPhotoSlot) {
|
||||
const photo = savedPhoto(slot), current = generation
|
||||
async function preview(slot: OwnedBusinessPhotoSlot, fileId: string) {
|
||||
const photo = savedPhotos(slot).find(item => item.fileId === fileId), current = generation
|
||||
if (!photo || reading.value) return
|
||||
reading.value = slot; error.value = null
|
||||
try { const blob = await getOwnedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
|
||||
reading.value = fileId; error.value = null
|
||||
try { const blob = await getOwnedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[fileId] = URL.createObjectURL(blob) }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
|
||||
finally { if (current === generation) reading.value = null }
|
||||
}
|
||||
async function upload(slot: OwnedBusinessPhotoSlot, event: Event) {
|
||||
async function upload(slot: OwnedBusinessPhotoSlot, event: Event, replaceFileId?: string) {
|
||||
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 }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
@@ -48,7 +49,7 @@ async function upload(slot: OwnedBusinessPhotoSlot, event: Event) {
|
||||
const photo = await uploadOwnedBusinessPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
||||
const latest = await getOwnedBusiness(number); ensureScope()
|
||||
if (!latest.row.actions.upload.allowed) throw new Error(latest.row.actions.upload.blockReason || '当前不允许上传照片')
|
||||
const result = await bindOwnedBusinessPhoto(number, slot, { recordVersion: ownedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
|
||||
const result = await bindOwnedBusinessPhoto(number, slot, { recordVersion: ownedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, ...(replaceFileId ? { replaceFileId } : {}) }); ensureScope()
|
||||
emit('saved', result); ElMessage.success('照片已保存')
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
||||
finally { busy.value = false }
|
||||
@@ -60,10 +61,13 @@ function beforeClose(done: () => void) { if (!busy.value) done() }
|
||||
<OwnedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
|
||||
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
|
||||
<template v-for="role in ownedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
|
||||
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="owned-business-photo" />
|
||||
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
|
||||
<p v-else>暂无已保存照片</p>
|
||||
<label v-if="uploadable" class="owned-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
|
||||
<div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId">
|
||||
<img v-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="owned-business-photo" />
|
||||
<p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看图片</el-button>
|
||||
<label v-if="uploadable" class="owned-business-upload">替换图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label>
|
||||
</div>
|
||||
<p v-if="!savedPhotos(role.slot).length">暂无已保存照片</p>
|
||||
<label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="owned-business-upload">上传图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
|
||||
</template>
|
||||
</OwnedBusinessPhotosDialog>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import ResourcePhotoPanel from './ResourcePhotoPanel.vue'
|
||||
import * as api from '../api/resourcePhotos'
|
||||
const security = vi.hoisted(() => ({ writable: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => security.writable }) }))
|
||||
vi.mock('../api/resourcePhotos', () => ({ getResourcePhoto: vi.fn(), uploadResourcePhoto: vi.fn(), downloadResourcePhoto: vi.fn() }))
|
||||
const photo = { fileId: '81', fileName: 'driver.png', contentType: 'image/png', sizeBytes: 3 }
|
||||
const options = { props: { kind: 'DRIVER' as const, resourceId: '7' }, global: { directives: { loading: () => undefined }, stubs: {
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
|
||||
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /></div>' },
|
||||
} } }
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); security.writable = true
|
||||
vi.mocked(api.getResourcePhoto).mockResolvedValue({ recordVersion: '2', photo })
|
||||
URL.createObjectURL = vi.fn().mockReturnValue('blob:photo'); URL.revokeObjectURL = vi.fn()
|
||||
})
|
||||
it('keeps reading independent from upload permission and downloads only on preview', async () => {
|
||||
security.writable = false
|
||||
vi.mocked(api.downloadResourcePhoto).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
|
||||
const wrapper = mount(ResourcePhotoPanel, options); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('上传 / 替换')
|
||||
expect(api.downloadResourcePhoto).not.toHaveBeenCalled()
|
||||
await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(api.downloadResourcePhoto).toHaveBeenCalledWith('DRIVER', '7', '81')
|
||||
wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:photo')
|
||||
})
|
||||
it('submits the independent photo version and releases busy state after failure', async () => {
|
||||
vi.mocked(api.uploadResourcePhoto).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409'))
|
||||
.mockResolvedValueOnce({ recordVersion: '3', photo })
|
||||
const wrapper = mount(ResourcePhotoPanel, options); await flushPromises()
|
||||
const input = wrapper.get('input[type="file"]'), file = new File(['png'], 'driver.png', { type: 'image/png' })
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(api.uploadResourcePhoto).toHaveBeenCalledWith('DRIVER', '7', '2', file)
|
||||
expect(wrapper.text()).toContain('版本冲突')
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(api.uploadResourcePhoto).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not apply an upload result to a different resource', async () => {
|
||||
let finish!: (value: api.ResourcePhotoDetail) => void
|
||||
vi.mocked(api.uploadResourcePhoto).mockImplementation(() => new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mount(ResourcePhotoPanel, options); await flushPromises()
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', { value: [new File(['png'], 'driver.png', { type: 'image/png' })] })
|
||||
await input.trigger('change')
|
||||
vi.mocked(api.getResourcePhoto).mockResolvedValue({ recordVersion: '0', photo: null })
|
||||
await wrapper.setProps({ resourceId: '8' }); await flushPromises()
|
||||
finish({ recordVersion: '3', photo }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('尚未上传司机照片')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getResourcePhoto, uploadResourcePhoto, downloadResourcePhoto, type ResourcePhotoKind, type ResourcePhotoDetail } from '../api/resourcePhotos'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
|
||||
const props = defineProps<{ kind: ResourcePhotoKind; resourceId: string }>()
|
||||
const access = useAccessPreviewStore(), detail = ref<ResourcePhotoDetail | null>(null), loading = ref(false), busy = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), input = ref<HTMLInputElement | null>(null), previewOpen = ref(false), url = ref('')
|
||||
const label = computed(() => props.kind === 'VEHICLE' ? '车辆图片' : '司机照片')
|
||||
const editable = computed(() => access.can(props.kind === 'VEHICLE' ? permission.vehicleEdit : permission.driverUpload))
|
||||
let generation = 0
|
||||
function closePreview() { previewOpen.value = false; if (url.value) URL.revokeObjectURL(url.value); url.value = '' }
|
||||
async function load() {
|
||||
const current = ++generation; closePreview(); detail.value = null; loading.value = true; error.value = null; busy.value = false
|
||||
try { const result = await getResourcePhoto(props.kind, props.resourceId); if (current === generation) detail.value = result }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '档案照片加载失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
async function upload(event: Event) {
|
||||
const element = event.target as HTMLInputElement, file = element.files?.[0]; element.value = ''
|
||||
if (!file || !detail.value || busy.value || !editable.value) return
|
||||
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择非空 JPG 或 PNG,最大 10MiB'); return }
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
const saved = await uploadResourcePhoto(props.kind, props.resourceId, detail.value.recordVersion, file)
|
||||
if (current !== generation) return
|
||||
detail.value = saved; closePreview(); ElMessage.success(`${label.value}已保存,新排车会自动带入`)
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '照片保存未完成,请重新读取后重试') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
async function preview() {
|
||||
if (!detail.value?.photo || busy.value) return
|
||||
const current = generation; busy.value = true; error.value = null
|
||||
try {
|
||||
const blob = await downloadResourcePhoto(props.kind, props.resourceId, detail.value.photo.fileId)
|
||||
if (current !== generation) return
|
||||
closePreview(); url.value = URL.createObjectURL(blob); previewOpen.value = true
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '照片预览失败') }
|
||||
finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
watch(() => [props.kind, props.resourceId], load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++; closePreview() })
|
||||
</script>
|
||||
<template>
|
||||
<section v-loading="loading" :aria-label="label">
|
||||
<LoadErrorState v-if="error" title="档案照片操作未完成" :message="error.message" compact @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div class="density-actions">
|
||||
<el-button v-if="detail?.photo" :disabled="busy" @click="preview">查看{{ label }}</el-button><span v-else-if="detail">尚未上传{{ label }}</span>
|
||||
<el-button v-if="editable" :disabled="!detail || busy" :loading="busy" @click="input?.click()">上传 / 替换{{ label }}</el-button>
|
||||
<input ref="input" hidden type="file" accept="image/jpeg,image/png" :aria-label="'上传' + label" @change="upload">
|
||||
</div>
|
||||
<p class="density-dialog-note">排车选择此档案后自动带入照片;替换不影响已保存的排车资料。JPG、PNG,最大 10MiB。</p>
|
||||
<AppDialog :model-value="previewOpen" class="admin-density-dialog" :title="label" width="620px" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview"><img v-if="url" class="resource-photo-preview" :src="url" :alt="label"></AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.resource-photo-preview { display: block; width: 100%; max-height: 60vh; object-fit: contain; }</style>
|
||||
Reference in New Issue
Block a user