Merge pull request 'feat(admin): 同步档案照片带入与装车多图维护' (#29) from hotfix-gray-bugfix into main
This commit was merged in pull request #29.
This commit is contained in:
@@ -44,3 +44,5 @@
|
|||||||
- 报价、汇率、车辆、车队报价与出车记录导出统一使用 `../downloads/` 的 `useExportTask`,按当前筛选创建 XLSX 任务;按钮要求原业务导出权限和 `platform:download:list`。出车成本可见性由 File 根据账号权限冻结,不接受页面声明。CSV 仅用于车队报价导入模板。
|
- 报价、汇率、车辆、车队报价与出车记录导出统一使用 `../downloads/` 的 `useExportTask`,按当前筛选创建 XLSX 任务;按钮要求原业务导出权限和 `platform:download:list`。出车成本可见性由 File 根据账号权限冻结,不接受页面声明。CSV 仅用于车队报价导入模板。
|
||||||
|
|
||||||
- 拼车接货简化沿 `FleetCapacityOfferModels.Inquiry.progress` 展示缺项、是否保位、负责人及主要动作,`appointmentCoverage`只消费预约侧真实投影,不以承运成功冒充预约。`CapacityInquiryFulfillment.vue`从原登记按需读取本票履约,不授予销售整车读取权限;来货、核量、排入本车分别保持原写权限。`TripPreparationDialog.vue`和`TripDispatchSelector.vue`通过`index.ts`公开给排车/预约使用;前者提交原子`/fleet/trips/prepare`并衔接容量发布,后者只读取本仓合格候选。新版合同见`fleet-preparation-v1.yaml`及拼车唯一主契约。
|
- 拼车接货简化沿 `FleetCapacityOfferModels.Inquiry.progress` 展示缺项、是否保位、负责人及主要动作,`appointmentCoverage`只消费预约侧真实投影,不以承运成功冒充预约。`CapacityInquiryFulfillment.vue`从原登记按需读取本票履约,不授予销售整车读取权限;来货、核量、排入本车分别保持原写权限。`TripPreparationDialog.vue`和`TripDispatchSelector.vue`通过`index.ts`公开给排车/预约使用;前者提交原子`/fleet/trips/prepare`并衔接容量发布,后者只读取本仓合格候选。新版合同见`fleet-preparation-v1.yaml`及拼车唯一主契约。
|
||||||
|
|
||||||
|
- 车辆详情与司机详情通过 `ResourcePhotoPanel.vue` 维护常用照片,接口 `api/resourcePhotos.ts`/`fleet-resource-photos-v1.yaml`;车辆沿vehicle:edit、司机沿driver:upload,读取沿各自list。照片版本独立于档案字段版本,替换保留历史且不改变驾驶证审核;排车缺照片时由服务端复制为当趟证据。只在详情加载元数据、点击预览读取图片,不逐行请求。
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 海外通用资料原型核对矩阵
|
# 海外通用资料原型核对矩阵
|
||||||
|
|
||||||
|
## 档案照片复用(2026-10-26)
|
||||||
|
|
||||||
|
依据用户确认优化重复上传:车辆概览和司机详情增加常用照片维护,沿既有详情分区与620px居中预览;元数据按详情加载,原图点击才读。车辆编辑权限与司机上传权限分别控制替换。排车选档案后自动带入并保存当趟副本;沿用Admin详情、提示及表单规范。自动行为验证与类型检查单独报告,1920×1080、100%截图待人工确认。
|
||||||
|
|
||||||
## 散板需求只读展示(2026-10-21)
|
## 散板需求只读展示(2026-10-21)
|
||||||
|
|
||||||
依据本轮用户确认:仅保留内部余位需求池,人工联系下单;登记与后续拼车直接隐藏,不增加开关,不删除代码。当前范围唯一见[拼车主契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md#当前页面范围2026-10-21),覆盖下文此前开放登记的页面结论。
|
依据本轮用户确认:仅保留内部余位需求池,人工联系下单;登记与后续拼车直接隐藏,不增加开关,不删除代码。当前范围唯一见[拼车主契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md#当前页面范围2026-10-21),覆盖下文此前开放登记的页面结论。
|
||||||
@@ -367,3 +371,9 @@ S05补充源码核对:实际自有/外配API均经公共解包检查业务码
|
|||||||
| OwnedBusinessOperationsEditor、OutsourcedBusinessOperationsEditor | 自有/外配业务详情的居中运营登记弹框 | 表单补齐公共密度控件宽度;目的仓及预约号允许换行,实际到达时间列以受约束宽度占位,防止长业务编号撑宽 | 定向回归通过;待人工1920×1080、100%截图确认 |
|
| OwnedBusinessOperationsEditor、OutsourcedBusinessOperationsEditor | 自有/外配业务详情的居中运营登记弹框 | 表单补齐公共密度控件宽度;目的仓及预约号允许换行,实际到达时间列以受约束宽度占位,防止长业务编号撑宽 | 定向回归通过;待人工1920×1080、100%截图确认 |
|
||||||
|
|
||||||
依据本轮可用性反馈与本文件原操作矩阵;金额、日期时区、版本及未知结果重试逻辑均未修改。司机、车辆和运维编辑表单已有多列布局与公共底部确认入口,源码复查未据此扩大修改;真实可用性仍需截图确认。
|
依据本轮可用性反馈与本文件原操作矩阵;金额、日期时区、版本及未知结果重试逻辑均未修改。司机、车辆和运维编辑表单已有多列布局与公共底部确认入口,源码复查未据此扩大修改;真实可用性仍需截图确认。
|
||||||
|
|
||||||
|
## 装车资料多图(2026-10-26)
|
||||||
|
|
||||||
|
依据用户PDA截图标注,五类统一为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字。PDA每类最多5张,可多选追加、逐张更换;Admin排车资料仍沿五行表格展示,每类逐图预览/更换,自有和外配照片弹框展示全部当前图片,油耗保持单张。五类各至少1张与装车确认共同控制自动派送。PDA相关75项、后端47项、Admin照片编辑17项及照片弹框4项通过,定向类型检查通过;扩大到运营布局时另有2项既存关闭行为断言失败,未改动该运营代码。未部署,PDA真机及Admin人工截图待确认。
|
||||||
|
|
||||||
|
- 同日双端复核:Admin装车资料补齐多选上传;PDA/Admin保存响应异常时重读服务端当前照片,核对失败保留刷新入口,停止本批后续上传。旧图替换不影响同类其他图,批次已成功部分保留。Admin 6文件32项、PDA 3文件38项及定向类型检查通过;此前2项运营弹框过期断言已按仅busy期间锁定的现行规则修正并通过。后端本轮未改,沿用47项验证结果;未部署、未做真机/人工截图验收。
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { expect, it, vi } from 'vitest'
|
||||||
|
import { http } from '@/shared/api/http'
|
||||||
|
import { getResourcePhoto, uploadResourcePhoto, downloadResourcePhoto } from './resourcePhotos'
|
||||||
|
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
|
||||||
|
it('preserves long identifiers and versions and submits the correct resource scope', async () => {
|
||||||
|
const id = '9223372036854775701', fileId = '9223372036854775702', version = '9007199254740993'
|
||||||
|
const wire = { code: '200', data: { recordVersion: version, photo: { fileId, fileName: 'photo.png', contentType: 'image/png', sizeBytes: '3' } } }
|
||||||
|
vi.mocked(http.get).mockResolvedValue(wire); vi.mocked(http.post).mockResolvedValue(wire)
|
||||||
|
expect(await getResourcePhoto('VEHICLE', id)).toMatchObject({ recordVersion: version, photo: { fileId, sizeBytes: 3 } })
|
||||||
|
await uploadResourcePhoto('VEHICLE', id, version, new File(['png'], 'photo.png', { type: 'image/png' }))
|
||||||
|
expect(http.post).toHaveBeenCalledWith(`/platform/v1/last-leg/fleet/resource-photos/VEHICLE/${id}`, expect.any(FormData))
|
||||||
|
expect((vi.mocked(http.post).mock.calls[0]![1] as FormData).get('recordVersion')).toBe(version)
|
||||||
|
vi.mocked(http.get).mockResolvedValue(new Blob(['{"code":"A0403"}'], { type: 'application/json' }))
|
||||||
|
await expect(downloadResourcePhoto('VEHICLE', id, fileId)).rejects.toThrow('读取失败')
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||||
|
|
||||||
|
export type ResourcePhotoKind = 'VEHICLE' | 'DRIVER'
|
||||||
|
export interface ResourcePhoto { fileId: string; fileName: string; contentType: string; sizeBytes: number }
|
||||||
|
export interface ResourcePhotoDetail { recordVersion: string; photo: ResourcePhoto | null }
|
||||||
|
const path = (kind: ResourcePhotoKind, id: string) => `/platform/v1/last-leg/fleet/resource-photos/${kind}/${encodeURIComponent(id)}`
|
||||||
|
type Wire = { recordVersion: string | number; photo: (Omit<ResourcePhoto, 'sizeBytes'> & { sizeBytes: number | string }) | null }
|
||||||
|
function normalize(value: Wire): ResourcePhotoDetail {
|
||||||
|
return { recordVersion: String(value.recordVersion), photo: value.photo ? { ...value.photo, sizeBytes: Number(value.photo.sizeBytes) } : null }
|
||||||
|
}
|
||||||
|
export async function getResourcePhoto(kind: ResourcePhotoKind, id: string) {
|
||||||
|
return normalize(unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(kind, id))))
|
||||||
|
}
|
||||||
|
export async function uploadResourcePhoto(kind: ResourcePhotoKind, id: string, version: string, file: File) {
|
||||||
|
const body = new FormData(); body.append('recordVersion', version); body.append('file', file)
|
||||||
|
return normalize(unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(kind, id), body)))
|
||||||
|
}
|
||||||
|
export async function downloadResourcePhoto(kind: ResourcePhotoKind, id: string, fileId: string) {
|
||||||
|
const blob = await http.get<Blob, Blob>(`${path(kind, id)}/${encodeURIComponent(fileId)}/content`, { responseType: 'blob' })
|
||||||
|
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size || blob.size > 10 * 1024 * 1024) throw new Error('档案照片读取失败,请重新核对')
|
||||||
|
return blob
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ describe('OutsourcedBusiness presentation boundaries', () => {
|
|||||||
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true } })
|
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true } })
|
||||||
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
|
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
|
||||||
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
|
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('已上传')
|
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()
|
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 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
|
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
|
||||||
close(done); expect(done).not.toHaveBeenCalled()
|
close(done); expect(done).not.toHaveBeenCalled()
|
||||||
|
await wrapper.setProps({ busy: false }); close(done)
|
||||||
|
expect(done).toHaveBeenCalledTimes(1)
|
||||||
await wrapper.setProps({ beforeClose: guard }); close(done)
|
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>
|
<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">
|
<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" />
|
<slot name="state" />
|
||||||
|
|
||||||
<div class="outsourced-business-photo-grid">
|
<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()
|
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 props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
|
||||||
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
|
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
|
||||||
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
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)
|
const uploadable = computed(() => access.can(permission.outsourcedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
|
||||||
let actor = '', number = '', generation = 0
|
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 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('图片作业身份或排车单已变化,请重新打开') }
|
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
|
||||||
watch(busy, value => emit('busy', value))
|
watch(busy, value => emit('busy', value))
|
||||||
@@ -30,17 +30,18 @@ watch(open, value => {
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
|
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
|
||||||
onBeforeUnmount(() => { generation++; release() })
|
onBeforeUnmount(() => { generation++; release() })
|
||||||
async function preview(slot: OutsourcedBusinessPhotoSlot) {
|
async function preview(slot: OutsourcedBusinessPhotoSlot, fileId: string) {
|
||||||
const photo = savedPhoto(slot), current = generation
|
const photo = savedPhotos(slot).find(item => item.fileId === fileId), current = generation
|
||||||
if (!photo || reading.value) return
|
if (!photo || reading.value) return
|
||||||
reading.value = slot; error.value = null
|
reading.value = fileId; error.value = null
|
||||||
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
|
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, '图片读取失败') }
|
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
|
||||||
finally { if (current === generation) reading.value = null }
|
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 = ''
|
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||||
if (!file || busy.value || !uploadable.value) return
|
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) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
|
||||||
busy.value = true; error.value = null
|
busy.value = true; error.value = null
|
||||||
try {
|
try {
|
||||||
@@ -48,7 +49,7 @@ async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event) {
|
|||||||
const photo = await uploadOutsourcedBusinessPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
const photo = await uploadOutsourcedBusinessPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
||||||
const latest = await getOutsourcedBusiness(number); ensureScope()
|
const latest = await getOutsourcedBusiness(number); ensureScope()
|
||||||
if (!latest.row.actions.upload.allowed) throw new Error(latest.row.actions.upload.blockReason || '当前不允许上传照片')
|
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('照片已保存')
|
emit('saved', result); ElMessage.success('照片已保存')
|
||||||
} catch (cause) { error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
} catch (cause) { error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
||||||
finally { busy.value = false }
|
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">
|
<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 #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]>
|
<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" />
|
<div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId">
|
||||||
<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>
|
<img v-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="outsourced-business-photo" />
|
||||||
<p v-else>暂无已保存照片</p>
|
<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">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
|
<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>
|
</template>
|
||||||
</OutsourcedBusinessPhotosDialog>
|
</OutsourcedBusinessPhotosDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe('OwnedBusiness presentation boundaries', () => {
|
|||||||
const wrapper = mount(OwnedBusinessPhotosDialog, { global, props: { modelValue: true } })
|
const wrapper = mount(OwnedBusinessPhotosDialog, { global, props: { modelValue: true } })
|
||||||
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
|
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
|
||||||
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
|
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('已上传')
|
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()
|
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 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
|
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
|
||||||
close(done); expect(done).not.toHaveBeenCalled()
|
close(done); expect(done).not.toHaveBeenCalled()
|
||||||
|
await wrapper.setProps({ busy: false }); close(done)
|
||||||
|
expect(done).toHaveBeenCalledTimes(1)
|
||||||
await wrapper.setProps({ beforeClose: guard }); close(done)
|
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 open = defineModel<boolean>({ default: false })
|
||||||
const activeTab = defineModel<'loading' | 'fuel'>('activeTab', { default: 'loading' })
|
const activeTab = defineModel<'loading' | 'fuel'>('activeTab', { default: 'loading' })
|
||||||
// These are prototype presentation slots; the public file categories are not yet frozen.
|
// 展示槽位沿用既有引用,标签与排车装车资料一致。
|
||||||
const loadingPhotos = [
|
const loadingPhotos = [
|
||||||
{ slot: 'loading-scene', label: '装车现场' },
|
{ slot: 'loading-scene', label: '司机驾驶证' },
|
||||||
{ slot: 'loading-list', label: '装车清单' },
|
{ slot: 'loading-list', label: '车牌照片' },
|
||||||
{ slot: 'pre-departure-check', label: '出车前检查' },
|
{ slot: 'pre-departure-check', label: '装车完成' },
|
||||||
{ slot: 'driver-license', label: '驾驶证附件' },
|
{ slot: 'driver-license', label: '封车照片' },
|
||||||
{ slot: 'delivery-receipt', label: '签收凭证' },
|
{ slot: 'delivery-receipt', label: 'BOL司机签字' },
|
||||||
]
|
]
|
||||||
const fuelPhotos = [{ slot: 'fuel-start', label: '起始油耗表盘' }, { slot: 'fuel-end', label: '结束油耗表盘' }]
|
const fuelPhotos = [{ slot: 'fuel-start', label: '起始油耗表盘' }, { slot: 'fuel-end', label: '结束油耗表盘' }]
|
||||||
function close(done: () => void) {
|
function close(done: () => void) {
|
||||||
@@ -28,7 +28,7 @@ function close(done: () => void) {
|
|||||||
|
|
||||||
<template>
|
<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">
|
<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" />
|
<slot name="state" />
|
||||||
|
|
||||||
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
<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()
|
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 props = defineProps<{ detail: OwnedBusinessDetail | null }>()
|
||||||
const emit = defineEmits<{ saved: [value: OwnedBusinessDetail]; busy: [value: boolean] }>()
|
const emit = defineEmits<{ saved: [value: OwnedBusinessDetail]; busy: [value: boolean] }>()
|
||||||
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
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)
|
const uploadable = computed(() => access.can(permission.ownedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
|
||||||
let actor = '', number = '', generation = 0
|
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 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('图片作业身份或排车单已变化,请重新打开') }
|
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
|
||||||
watch(busy, value => emit('busy', value))
|
watch(busy, value => emit('busy', value))
|
||||||
@@ -30,17 +30,18 @@ watch(open, value => {
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
|
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
|
||||||
onBeforeUnmount(() => { generation++; release() })
|
onBeforeUnmount(() => { generation++; release() })
|
||||||
async function preview(slot: OwnedBusinessPhotoSlot) {
|
async function preview(slot: OwnedBusinessPhotoSlot, fileId: string) {
|
||||||
const photo = savedPhoto(slot), current = generation
|
const photo = savedPhotos(slot).find(item => item.fileId === fileId), current = generation
|
||||||
if (!photo || reading.value) return
|
if (!photo || reading.value) return
|
||||||
reading.value = slot; error.value = null
|
reading.value = fileId; error.value = null
|
||||||
try { const blob = await getOwnedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
|
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, '图片读取失败') }
|
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
|
||||||
finally { if (current === generation) reading.value = null }
|
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 = ''
|
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||||
if (!file || busy.value || !uploadable.value) return
|
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) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
|
||||||
busy.value = true; error.value = null
|
busy.value = true; error.value = null
|
||||||
try {
|
try {
|
||||||
@@ -48,7 +49,7 @@ async function upload(slot: OwnedBusinessPhotoSlot, event: Event) {
|
|||||||
const photo = await uploadOwnedBusinessPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
const photo = await uploadOwnedBusinessPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
||||||
const latest = await getOwnedBusiness(number); ensureScope()
|
const latest = await getOwnedBusiness(number); ensureScope()
|
||||||
if (!latest.row.actions.upload.allowed) throw new Error(latest.row.actions.upload.blockReason || '当前不允许上传照片')
|
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('照片已保存')
|
emit('saved', result); ElMessage.success('照片已保存')
|
||||||
} catch (cause) { error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
} catch (cause) { error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
||||||
finally { busy.value = false }
|
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">
|
<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 #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]>
|
<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" />
|
<div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId">
|
||||||
<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>
|
<img v-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="owned-business-photo" />
|
||||||
<p v-else>暂无已保存照片</p>
|
<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">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
|
<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>
|
</template>
|
||||||
</OwnedBusinessPhotosDialog>
|
</OwnedBusinessPhotosDialog>
|
||||||
</template>
|
</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>
|
||||||
@@ -18,3 +18,6 @@ export { normalizeTripDetail } from './api/trips'
|
|||||||
export type { TripDetail, TripAssignment, TripResourceRelease } from './tripTypes'
|
export type { TripDetail, TripAssignment, TripResourceRelease } from './tripTypes'
|
||||||
export type { PickupCandidate, PickupTask, PickupEvidence, PickupPlan, PickupCommand, PickupConfirmation } from './api/pickups'
|
export type { PickupCandidate, PickupTask, PickupEvidence, PickupPlan, PickupCommand, PickupConfirmation } from './api/pickups'
|
||||||
export { pickupStatusLabels, downloadPickupEvidence } from './api/pickups'
|
export { pickupStatusLabels, downloadPickupEvidence } from './api/pickups'
|
||||||
|
|
||||||
|
export { getResourcePhoto, downloadResourcePhoto } from './api/resourcePhotos'
|
||||||
|
export type { ResourcePhotoKind } from './api/resourcePhotos'
|
||||||
|
|||||||
@@ -21,16 +21,16 @@ export interface OutsourcedBusinessPage<T> { records: T[]; page: number; size: n
|
|||||||
export interface OutsourcedBusinessList extends OutsourcedBusinessPage<OutsourcedBusinessRow> { summary: OutsourcedBusinessSummary }
|
export interface OutsourcedBusinessList extends OutsourcedBusinessPage<OutsourcedBusinessRow> { summary: OutsourcedBusinessSummary }
|
||||||
export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OutsourcedBusinessPhoto[] }
|
export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OutsourcedBusinessPhoto[] }
|
||||||
export interface OutsourcedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OutsourcedBusinessOperations; reason: string | null }
|
export interface OutsourcedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OutsourcedBusinessOperations; reason: string | null }
|
||||||
export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string }
|
export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string }
|
||||||
export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
|
export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
|
||||||
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 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 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<OutsourcedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
|
export const outsourcedBusinessStages: Record<OutsourcedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
|
||||||
export const outsourcedBusinessActionLabels: Record<string,string> = { 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<string,string> = { 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 outsourcedBusinessPhotoRoles: { slot: OutsourcedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' }[] = [
|
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: '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' },
|
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' },
|
||||||
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' },
|
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: 'BOL司机签字', category: 'LOADING' },
|
||||||
]
|
]
|
||||||
export function outsourcedBusinessVersion(value: unknown): string {
|
export function outsourcedBusinessVersion(value: unknown): string {
|
||||||
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
|
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
|
||||||
|
|||||||
@@ -21,16 +21,16 @@ export interface OwnedBusinessPage<T> { records: T[]; page: number; size: number
|
|||||||
export interface OwnedBusinessList extends OwnedBusinessPage<OwnedBusinessRow> { summary: OwnedBusinessSummary }
|
export interface OwnedBusinessList extends OwnedBusinessPage<OwnedBusinessRow> { summary: OwnedBusinessSummary }
|
||||||
export interface OwnedBusinessDetail { row: OwnedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OwnedBusinessPhoto[] }
|
export interface OwnedBusinessDetail { row: OwnedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OwnedBusinessPhoto[] }
|
||||||
export interface OwnedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OwnedBusinessOperations; reason: string | null }
|
export interface OwnedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OwnedBusinessOperations; reason: string | null }
|
||||||
export interface OwnedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string }
|
export interface OwnedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string }
|
||||||
export type OwnedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
|
export type OwnedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
|
||||||
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 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 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<OwnedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
|
export const ownedBusinessStages: Record<OwnedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
|
||||||
export const ownedBusinessActionLabels: Record<string,string> = { 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<string,string> = { 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 ownedBusinessPhotoRoles: { slot: OwnedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' | 'FUEL' }[] = [
|
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: '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' },
|
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' },
|
||||||
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' }, { slot: 'FUEL_START', viewSlot: 'fuel-start', label: '起始油耗表盘', category: 'FUEL' }, { slot: 'FUEL_END', viewSlot: 'fuel-end', label: '结束油耗表盘', category: 'FUEL' },
|
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: 'BOL司机签字', category: 'LOADING' }, { slot: 'FUEL_START', viewSlot: 'fuel-start', label: '起始油耗表盘', category: 'FUEL' }, { slot: 'FUEL_END', viewSlot: 'fuel-end', label: '结束油耗表盘', category: 'FUEL' },
|
||||||
]
|
]
|
||||||
export function ownedBusinessVersion(value: unknown): string {
|
export function ownedBusinessVersion(value: unknown): string {
|
||||||
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
|
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import ResourcePhotoPanel from '../components/ResourcePhotoPanel.vue'
|
||||||
import '@/styles/adminWorkbench.css'
|
import '@/styles/adminWorkbench.css'
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
|
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
|
||||||
@@ -153,6 +154,7 @@ onBeforeUnmount(() => { detailActive = false; resetDetailRequests() })
|
|||||||
<div v-loading="loading">
|
<div v-loading="loading">
|
||||||
<template v-if="detail">
|
<template v-if="detail">
|
||||||
<FormSection class="density-form-section" title="基础信息"><dl class="density-fields"><div v-for="[label, value] in basic" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div><div class="wide-field"><dt>备注</dt><dd>{{ detail.details.remark || '—' }}</dd></div></dl></FormSection>
|
<FormSection class="density-form-section" title="基础信息"><dl class="density-fields"><div v-for="[label, value] in basic" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div><div class="wide-field"><dt>备注</dt><dd>{{ detail.details.remark || '—' }}</dd></div></dl></FormSection>
|
||||||
|
<FormSection class="density-form-section" title="司机照片"><ResourcePhotoPanel kind="DRIVER" :resource-id="detail.id" /></FormSection>
|
||||||
<FormSection class="density-form-section" title="驾驶资质"><dl class="density-fields"><div v-for="[label, value] in credentials" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl></FormSection>
|
<FormSection class="density-form-section" title="驾驶资质"><dl class="density-fields"><div v-for="[label, value] in credentials" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl></FormSection>
|
||||||
<FormSection class="density-form-section" title="证照与派车状态">
|
<FormSection class="density-form-section" title="证照与派车状态">
|
||||||
<div class="driver-document-actions"><StatusBadge :status="driverReviewLabels[detail.reviewStatus]" /><el-button v-if="detail.licenseFileId" @click="showLicense(detail.licenseFileId)">查看驾驶证</el-button><span v-else>尚未上传驾驶证</span><el-button v-if="access.can(permission.driverUpload)" :loading="busy" @click="fileInput?.click()">上传 / 替换驾驶证</el-button><input ref="fileInput" hidden type="file" accept="application/pdf,image/jpeg,image/png" @change="upload" /></div>
|
<div class="driver-document-actions"><StatusBadge :status="driverReviewLabels[detail.reviewStatus]" /><el-button v-if="detail.licenseFileId" @click="showLicense(detail.licenseFileId)">查看驾驶证</el-button><span v-else>尚未上传驾驶证</span><el-button v-if="access.can(permission.driverUpload)" :loading="busy" @click="fileInput?.click()">上传 / 替换驾驶证</el-button><input ref="fileInput" hidden type="file" accept="application/pdf,image/jpeg,image/png" @change="upload" /></div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import ResourcePhotoPanel from '../components/ResourcePhotoPanel.vue'
|
||||||
import '@/styles/adminWorkbench.css'
|
import '@/styles/adminWorkbench.css'
|
||||||
import '@/styles/adminDensity.css'
|
import '@/styles/adminDensity.css'
|
||||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||||
@@ -19,5 +20,5 @@ function back(){void router.push({name:'overseas-master-data-vehicles'})}
|
|||||||
watch(() => route.params.vehicleId, () => { activeTab.value='overview';void load() }, { immediate:true })
|
watch(() => route.params.vehicleId, () => { activeTab.value='overview';void load() }, { immediate:true })
|
||||||
onBeforeUnmount(() => { request++ })
|
onBeforeUnmount(() => { request++ })
|
||||||
</script>
|
</script>
|
||||||
<template><AdminPage class="admin-density admin-workbench" ><DetailPageLayout class="density-detail-layout surface-panel workbench-panel is-flush is-page-surface" aria-label="车辆详情"><template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.plateNumber || '—' }}</h1><StatusBadge v-if="detail" :status="vehicleStatusLabels[detail.status]" /></div></div></template><LoadErrorState v-if="error" title="车辆详情加载失败" :message="error.message" @retry="load"/><section v-loading="loading" class="detail-surface"><el-tabs v-if="detail" v-model="activeTab"><el-tab-pane label="车辆概览" name="overview" /><el-tab-pane label="保养及加油" name="care" /><el-tab-pane label="维修记录" name="REPAIR" /><el-tab-pane label="紧急故障" name="BREAKDOWN" /><el-tab-pane label="应急救援" name="RESCUE" /><el-tab-pane label="异常管理" name="EXCEPTION" /></el-tabs><template v-if="activeTab === 'overview'"><FormSection class="density-form-section" title="车辆信息"><dl v-if="detail" class="detail-grid density-fields"><div v-for="item in [['车牌号',detail.plateNumber],['车型',detail.model],['最大标准托位',detail.maxPalletSpaces??'待确认'],['最大载重(kg)',detail.maxLoadKg??'待确认'],['可用容积(m³)',detail.maxVolumeCbm??'待确认'],['油耗(L/100km)',detail.fuelConsumptionPer100Km],['当前公里码(km)',detail.mileageKm],['司机 / 外派单位',detail.driverOrDispatchCompany||'—'],['联系电话',detail.phone||'—'],['区域',detail.region==='OVERSEAS'?'海外':'国内'],['车辆归属',detail.ownership==='OWNED'?'自有车辆':'外派车辆'],['状态',vehicleStatusLabels[detail.status]],['更新时间',detail.updatedAt],['备注',detail.remark||'—']]" :key="item[0]" :class="{ 'wide-field': ['司机 / 外派单位', '备注'].includes(String(item[0])) }"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl></FormSection><FormSection class="density-form-section" title="派送订单明细"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="deliveries" /></FormSection><FormSection class="density-form-section" title="派送更换记录" :bordered="false"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="changes" /></FormSection></template><div v-if="detail && activeTab === 'care'" :key="detail.id" class="care-grid"><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="MAINTENANCE" /><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="FUEL" /></div><VehicleCareTable v-if="detail && ['REPAIR','BREAKDOWN','RESCUE','EXCEPTION'].includes(activeTab)" :key="`${detail.id}:${activeTab}`" :vehicle-id="detail.id" :plate="detail.plateNumber" :record-type="activeTab as VehicleCareType" /></section></DetailPageLayout></AdminPage></template>
|
<template><AdminPage class="admin-density admin-workbench" ><DetailPageLayout class="density-detail-layout surface-panel workbench-panel is-flush is-page-surface" aria-label="车辆详情"><template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.plateNumber || '—' }}</h1><StatusBadge v-if="detail" :status="vehicleStatusLabels[detail.status]" /></div></div></template><LoadErrorState v-if="error" title="车辆详情加载失败" :message="error.message" @retry="load"/><section v-loading="loading" class="detail-surface"><el-tabs v-if="detail" v-model="activeTab"><el-tab-pane label="车辆概览" name="overview" /><el-tab-pane label="保养及加油" name="care" /><el-tab-pane label="维修记录" name="REPAIR" /><el-tab-pane label="紧急故障" name="BREAKDOWN" /><el-tab-pane label="应急救援" name="RESCUE" /><el-tab-pane label="异常管理" name="EXCEPTION" /></el-tabs><template v-if="activeTab === 'overview'"><FormSection class="density-form-section" title="车辆信息"><dl v-if="detail" class="detail-grid density-fields"><div v-for="item in [['车牌号',detail.plateNumber],['车型',detail.model],['最大标准托位',detail.maxPalletSpaces??'待确认'],['最大载重(kg)',detail.maxLoadKg??'待确认'],['可用容积(m³)',detail.maxVolumeCbm??'待确认'],['油耗(L/100km)',detail.fuelConsumptionPer100Km],['当前公里码(km)',detail.mileageKm],['司机 / 外派单位',detail.driverOrDispatchCompany||'—'],['联系电话',detail.phone||'—'],['区域',detail.region==='OVERSEAS'?'海外':'国内'],['车辆归属',detail.ownership==='OWNED'?'自有车辆':'外派车辆'],['状态',vehicleStatusLabels[detail.status]],['更新时间',detail.updatedAt],['备注',detail.remark||'—']]" :key="item[0]" :class="{ 'wide-field': ['司机 / 外派单位', '备注'].includes(String(item[0])) }"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl></FormSection><FormSection v-if="detail" class="density-form-section" title="车辆图片"><ResourcePhotoPanel kind="VEHICLE" :resource-id="detail.id" /></FormSection><FormSection class="density-form-section" title="派送订单明细"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="deliveries" /></FormSection><FormSection class="density-form-section" title="派送更换记录" :bordered="false"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="changes" /></FormSection></template><div v-if="detail && activeTab === 'care'" :key="detail.id" class="care-grid"><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="MAINTENANCE" /><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="FUEL" /></div><VehicleCareTable v-if="detail && ['REPAIR','BREAKDOWN','RESCUE','EXCEPTION'].includes(activeTab)" :key="`${detail.id}:${activeTab}`" :vehicle-id="detail.id" :plate="detail.plateNumber" :record-type="activeTab as VehicleCareType" /></section></DetailPageLayout></AdminPage></template>
|
||||||
<style scoped>.care-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-6)}.detail-grid .wide-field { grid-column: span 2; }</style>
|
<style scoped>.care-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-6)}.detail-grid .wide-field { grid-column: span 2; }</style>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
- 排车调度/理货审核分区仅显示审核、详情;车辆图片、司机照片和驾驶证照片在维护车辆司机时通过既有本车附件接口上传,随资源编辑命令保存,司机照片使用 `driver.evidence.photoFileIds`。三类照片横向并排、名称显示在照片下方;审核弹框只读预览,合并展示资源照片及历史审核附件。维护权限与服务端 Gate 控制上传及保存,审核权限控制审核提交,列表查看权限控制读取。
|
- 排车调度/理货审核分区仅显示审核、详情;车辆图片与司机照片优先从所选主档自动带入,保存时复制为本趟附件;已有当趟照片保留,主档缺失或临时外配时通过既有本车附件接口补传。驾驶证沿主档引用且接受PDF/JPG/PNG;司机照片使用 `driver.evidence.photoFileIds`。三类照片横向并排、名称显示在照片下方;审核弹框只读预览,合并展示资源照片及历史审核附件。维护权限与服务端 Gate 控制上传及保存,审核权限控制审核提交,列表查看权限控制读取。
|
||||||
|
|
||||||
- 纯文案/纯展示只核对差异;业务逻辑变化选择直接对应测试,不因文档引用某测试就自动执行。
|
- 纯文案/纯展示只核对差异;业务逻辑变化选择直接对应测试,不因文档引用某测试就自动执行。
|
||||||
- 转运审核组件/请求逻辑变化:定向选择 `components/WarehouseTransferReviewDialog.test.ts`、`api/warehouseTransfers.test.ts`;入口、权限或状态变化再加 `pages/prototypeContract.test.ts`。
|
- 转运审核组件/请求逻辑变化:定向选择 `components/WarehouseTransferReviewDialog.test.ts`、`api/warehouseTransfers.test.ts`;入口、权限或状态变化再加 `pages/prototypeContract.test.ts`。
|
||||||
|
|||||||
@@ -463,3 +463,13 @@
|
|||||||
| 清关提柜详情 | DO/目的港清关附件 | 保留上传弹框,选中文件即上传保存;上传中防重复,失败保留文件可重试;目的港清关完成仍是独立业务动作 | 自动上传、防重复、失败重试共4项回归通过;待人工截图确认 |
|
| 清关提柜详情 | DO/目的港清关附件 | 保留上传弹框,选中文件即上传保存;上传中防重复,失败保留文件可重试;目的港清关完成仍是独立业务动作 | 自动上传、防重复、失败重试共4项回归通过;待人工截图确认 |
|
||||||
|
|
||||||
续修验证:修正与当前开船阶段、服务端到仓投影和默认白色分区不一致的旧测试,新增未开船订单只读回归;补齐尾程费用表单的可编辑表格标记、统一订单状态按钮的共享高度变量。Admin 7个文件265项定向检查全部通过,Vue类型检查通过。PDA 381项全量测试全部通过(失败重试按现行规则使用新键与当前输入);此前 H5 与 App 资源构建通过。自动检查不代替真实服务、PDA真机或人工视觉验收。
|
续修验证:修正与当前开船阶段、服务端到仓投影和默认白色分区不一致的旧测试,新增未开船订单只读回归;补齐尾程费用表单的可编辑表格标记、统一订单状态按钮的共享高度变量。Admin 7个文件265项定向检查全部通过,Vue类型检查通过。PDA 381项全量测试全部通过(失败重试按现行规则使用新键与当前输入);此前 H5 与 App 资源构建通过。自动检查不代替真实服务、PDA真机或人工视觉验收。
|
||||||
|
|
||||||
|
## 档案照片自动带入(2026-10-26)
|
||||||
|
|
||||||
|
依据用户确认优化重复上传:维护车辆/司机沿原三列照片布局,缺少本趟照片时展示所选主档照片,并显示保存后用于本趟;允许本趟补传覆盖默认来源。驾驶证允许PDF/JPG/PNG。普通派送及到仓取货均由服务端保存档案照片副本,历史排车保持原图。自动行为测试与类型检查单独报告;布局待人工1920×1080、100%截图确认。
|
||||||
|
|
||||||
|
## 装车资料多图(2026-10-26)
|
||||||
|
|
||||||
|
依据用户PDA截图标注,五类统一为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字。PDA每类最多5张,可多选追加、逐张更换;Admin排车资料仍沿五行表格展示,每类逐图预览/更换,自有和外配照片弹框展示全部当前图片,油耗保持单张。五类各至少1张与装车确认共同控制自动派送。PDA相关75项、后端47项、Admin照片编辑17项及照片弹框4项通过,定向类型检查通过;扩大到运营布局时另有2项既存关闭行为断言失败,未改动该运营代码。未部署,PDA真机及Admin人工截图待确认。
|
||||||
|
|
||||||
|
- 同日双端复核:Admin装车资料补齐多选上传;PDA/Admin保存响应异常时重读服务端当前照片,核对失败保留刷新入口,停止本批后续上传。旧图替换不影响同类其他图,批次已成功部分保留。Admin 6文件32项、PDA 3文件38项及定向类型检查通过;此前2项运营弹框过期断言已按仅busy期间锁定的现行规则修正并通过。后端本轮未改,沿用47项验证结果;未部署、未做真机/人工截图验收。
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import type { DispatchStage } from '../dispatchContracts'
|
|||||||
|
|
||||||
export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'] as const
|
export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'] as const
|
||||||
export type LoadingPhotoSlot = typeof loadingPhotoSlots[number]
|
export type LoadingPhotoSlot = typeof loadingPhotoSlots[number]
|
||||||
|
export const loadingPhotoLabels: Record<LoadingPhotoSlot, string> = { LOADING_SCENE: '司机驾驶证', LOADING_LIST: '车牌照片', PRE_DEPARTURE_CHECK: '装车完成', DRIVER_LICENSE: '封车照片', RECEIPT: 'BOL司机签字' }
|
||||||
export interface LoadingPhoto { slot: LoadingPhotoSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null }
|
export interface LoadingPhoto { slot: LoadingPhotoSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null }
|
||||||
export interface LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] }
|
export interface LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] }
|
||||||
export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string }
|
export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string }
|
||||||
const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/loading-documents`
|
const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/loading-documents`
|
||||||
export async function getLoadingDocuments(no: string) {
|
export async function getLoadingDocuments(no: string) {
|
||||||
return unwrapApiResponse(await http.get<ApiEnvelope<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(path(no)))
|
return unwrapApiResponse(await http.get<ApiEnvelope<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(path(no)))
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|||||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||||
import * as api from '../api/dispatches'
|
import * as api from '../api/dispatches'
|
||||||
import type { DispatchEvidenceFile } from '../dispatchContracts'
|
import type { DispatchEvidenceFile } from '../dispatchContracts'
|
||||||
import { downloadDriverLicense } from '@/modules/overseas-master-data'
|
import { downloadDriverLicense, getResourcePhoto, downloadResourcePhoto } from '@/modules/overseas-master-data'
|
||||||
vi.mock('@/modules/overseas-master-data', () => ({ downloadDriverLicense: vi.fn() }))
|
vi.mock('@/modules/overseas-master-data', () => ({ downloadDriverLicense: vi.fn(), getResourcePhoto: vi.fn(), downloadResourcePhoto: vi.fn() }))
|
||||||
vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
|
vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
|
||||||
const metadata = (fileId = '301', contentType = 'image/png'): DispatchEvidenceFile => ({ fileId, fileName: contentType === 'application/pdf' ? '本车证照.pdf' : '本车证据.png', contentType, sizeBytes: 3 })
|
const metadata = (fileId = '301', contentType = 'image/png'): DispatchEvidenceFile => ({ fileId, fileName: contentType === 'application/pdf' ? '本车证照.pdf' : '本车证据.png', contentType, sizeBytes: 3 })
|
||||||
const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHICLE' as const }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], template: '<section v-if="modelValue" role="dialog"><slot /><button @click="$emit(\'update:modelValue\', false)">关闭预览</button></section>' } } } }
|
const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHICLE' as const }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], template: '<section v-if="modelValue" role="dialog"><slot /><button @click="$emit(\'update:modelValue\', false)">关闭预览</button></section>' } } } }
|
||||||
const button = (wrapper: ReturnType<typeof mount>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
|
const button = (wrapper: ReturnType<typeof mount>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
|
||||||
it.each(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'] as const)('does not count a PDF or failed upload as a %s photo', async kind => {
|
it.each(['VEHICLE', 'DRIVER_PHOTO'] as const)('does not count a PDF or failed upload as a %s photo', async kind => {
|
||||||
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind, photoCard: true, modelValue: [] } }); await flushPromises()
|
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind, photoCard: true, modelValue: [] } }); await flushPromises()
|
||||||
await selectFile(wrapper, new File(['pdf'], 'document.pdf', { type: 'application/pdf' }))
|
await selectFile(wrapper, new File(['pdf'], 'document.pdf', { type: 'application/pdf' }))
|
||||||
expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
|
expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
|
||||||
@@ -154,3 +154,36 @@ it('rejects mismatched file identity and a blocked PDF popup without fetching co
|
|||||||
vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('301', 'application/pdf')); const open = vi.spyOn(window, 'open').mockReturnValueOnce(null)
|
vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('301', 'application/pdf')); const open = vi.spyOn(window, 'open').mockReturnValueOnce(null)
|
||||||
const blocked = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(blocked, '预览').trigger('click'); await flushPromises(); expect(blocked.text()).toContain('请允许本站打开PDF预览窗口'); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); blocked.unmount(); open.mockRestore()
|
const blocked = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(blocked, '预览').trigger('click'); await flushPromises(); expect(blocked.text()).toContain('请允许本站打开PDF预览窗口'); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); blocked.unmount(); open.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts a PDF driver license in the photo card without requiring another image', async () => {
|
||||||
|
vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302', 'application/pdf'))
|
||||||
|
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: [] } })
|
||||||
|
await flushPromises(); await selectFile(wrapper, new File(['pdf'], 'license.pdf', { type: 'application/pdf' }))
|
||||||
|
expect(api.uploadDispatchEvidence).toHaveBeenCalledOnce()
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['302']]); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('previews a master photo without submitting its foreign file ID as dispatch evidence', async () => {
|
||||||
|
vi.mocked(getResourcePhoto).mockResolvedValue({ recordVersion: '1', photo: metadata('81') })
|
||||||
|
vi.mocked(downloadResourcePhoto).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
|
||||||
|
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true, modelValue: [], masterResource: { kind: 'VEHICLE', id: '7' } } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('已带入档案照片')
|
||||||
|
expect(api.getDispatchEvidence).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
expect(wrapper.emitted('available')?.at(-1)).toEqual([true])
|
||||||
|
await wrapper.setProps({ modelValue: ['301'] }); await flushPromises()
|
||||||
|
expect(wrapper.text()).not.toContain('已带入档案照片')
|
||||||
|
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301')
|
||||||
|
expect(getResourcePhoto).toHaveBeenCalledOnce(); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('ignores late profile photo responses after changing the selected resource', async () => {
|
||||||
|
let finish!: (value: Awaited<ReturnType<typeof getResourcePhoto>>) => void
|
||||||
|
vi.mocked(getResourcePhoto).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||||
|
.mockResolvedValueOnce({ recordVersion: '0', photo: null })
|
||||||
|
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true, modelValue: [], masterResource: { kind: 'VEHICLE', id: '7' } } })
|
||||||
|
await wrapper.setProps({ masterResource: { kind: 'VEHICLE', id: '8' } }); await flushPromises()
|
||||||
|
finish({ recordVersion: '1', photo: metadata('81') }); await flushPromises()
|
||||||
|
expect(downloadResourcePhoto).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.text()).not.toContain('已带入档案照片')
|
||||||
|
expect(wrapper.emitted('available')?.at(-1)).toEqual([false]); wrapper.unmount()
|
||||||
|
})
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
|
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
|
||||||
import { downloadDriverLicense } from '@/modules/overseas-master-data'
|
import { downloadDriverLicense, getResourcePhoto, downloadResourcePhoto, type ResourcePhotoKind } from '@/modules/overseas-master-data'
|
||||||
import { DispatchInputError, type DispatchEvidenceFile, type DispatchEvidenceKind } from '../dispatchContracts'
|
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[]; 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[]; masterResource?: { kind: ResourcePhotoKind; id: string } | null; masterLicense?: { driverId: string; fileId: string } | null }>()
|
||||||
const ids = defineModel<string[]>({ required: true })
|
const ids = defineModel<string[]>({ required: true })
|
||||||
const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value])])
|
const profilePhotoId = ref<string | null>(null)
|
||||||
const emit = defineEmits<{ busy: [value: boolean] }>()
|
const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.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 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 imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
|
||||||
const thumbnails = ref<Record<string, string>>({})
|
const thumbnails = ref<Record<string, string>>({})
|
||||||
@@ -23,8 +24,8 @@ const previewId = ref(''), imageDecoding = ref(false), imageFailure = ref('')
|
|||||||
const previewBusy = computed(() => previewLoading.value || imageDecoding.value)
|
const previewBusy = computed(() => previewLoading.value || imageDecoding.value)
|
||||||
const label = computed(() => props.label || (props.kind === 'VEHICLE' ? '本趟车辆证据' : props.kind === 'DRIVER_PHOTO' ? '司机照片' : '本趟司机证照'))
|
const label = computed(() => props.label || (props.kind === 'VEHICLE' ? '本趟车辆证据' : props.kind === 'DRIVER_PHOTO' ? '司机照片' : '本趟司机证照'))
|
||||||
const types = ['application/pdf', 'image/jpeg', 'image/png'], maximum = 10 * 1024 * 1024, urls = new Set<string>()
|
const types = ['application/pdf', 'image/jpeg', 'image/png'], maximum = 10 * 1024 * 1024, urls = new Set<string>()
|
||||||
const uploadTypes = computed(() => (props.photoCard || ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind)) ? types.slice(1) : types)
|
const uploadTypes = computed(() => (props.kind !== 'DRIVER' && (props.photoCard || ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind))) ? types.slice(1) : types)
|
||||||
const uploadFormats = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? 'JPG、PNG' : 'PDF、JPG、PNG')
|
const uploadFormats = computed(() => uploadTypes.value.includes('application/pdf') ? 'PDF、JPG、PNG' : 'JPG、PNG')
|
||||||
let generation = 0, previewGeneration = 0, replacing: string | null = null, pendingPopup: Window | null = null
|
let generation = 0, previewGeneration = 0, replacing: string | null = null, pendingPopup: Window | null = null
|
||||||
function valid(value: DispatchEvidenceFile, expected?: string) {
|
function valid(value: DispatchEvidenceFile, expected?: string) {
|
||||||
if (!value || !/^[1-9]\d*$/.test(String(value.fileId)) || typeof value.fileId === 'number' && !Number.isSafeInteger(value.fileId) || expected && String(value.fileId) !== expected || !value.fileName?.trim() || !types.includes(value.contentType) || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes <= 0 || value.sizeBytes > maximum) throw new DispatchInputError('附件元数据未确认本车有效文件,请重新读取')
|
if (!value || !/^[1-9]\d*$/.test(String(value.fileId)) || typeof value.fileId === 'number' && !Number.isSafeInteger(value.fileId) || expected && String(value.fileId) !== expected || !value.fileName?.trim() || !types.includes(value.contentType) || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes <= 0 || value.sizeBytes > maximum) throw new DispatchInputError('附件元数据未确认本车有效文件,请重新读取')
|
||||||
@@ -44,13 +45,30 @@ function imageFailed(event: Event) {
|
|||||||
imageFailure.value = '图片无法解码或加载,请重新预览;若仍失败,请核对原附件是否损坏。'
|
imageFailure.value = '图片无法解码或加载,请重新预览;若仍失败,请核对原附件是否损坏。'
|
||||||
}
|
}
|
||||||
async function load() {
|
async function load() {
|
||||||
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...allIds.value]
|
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...new Set([...(props.referenceIds || []), ...ids.value])]
|
||||||
for (const url of Object.values(thumbnails.value)) { URL.revokeObjectURL(url); urls.delete(url) }
|
for (const url of Object.values(thumbnails.value)) { URL.revokeObjectURL(url); urls.delete(url) }
|
||||||
thumbnails.value = {}
|
thumbnails.value = {}
|
||||||
closePreview(); metadata.value = {}; error.value = null; masterBlob = null
|
closePreview(); metadata.value = {}; error.value = null; masterBlob = null; profilePhotoId.value = null; emit('available', false)
|
||||||
if (!no) return
|
if (!no) return
|
||||||
|
// 未保存且没有本趟照片时展示档案图片;保存由服务端复制,不提交跨归属文件ID。
|
||||||
|
if (!values.length && props.masterResource) {
|
||||||
|
const resource = props.masterResource
|
||||||
|
try {
|
||||||
|
const source = await getResourcePhoto(resource.kind, resource.id)
|
||||||
|
if (current !== generation) return
|
||||||
|
if (source.photo) {
|
||||||
|
const blob = await downloadResourcePhoto(resource.kind, resource.id, source.photo.fileId)
|
||||||
|
if (current !== generation) return
|
||||||
|
const file = valid(source.photo)
|
||||||
|
if (blob.size !== file.sizeBytes || blob.type !== file.contentType) throw new DispatchInputError('档案照片内容与元数据不一致')
|
||||||
|
metadata.value[file.fileId] = file; masterBlob = { fileId: file.fileId, blob }
|
||||||
|
profilePhotoId.value = file.fileId; values.push(file.fileId); emit('available', true)
|
||||||
|
}
|
||||||
|
} catch (cause) { if (current === generation) fail(cause, '档案照片读取失败,请重试') }
|
||||||
|
}
|
||||||
const master = props.kind === 'DRIVER' ? props.masterLicense : null
|
const master = props.kind === 'DRIVER' ? props.masterLicense : null
|
||||||
const results = await Promise.allSettled(values.map(async id => {
|
const results = await Promise.allSettled(values.map(async id => {
|
||||||
|
if (id === profilePhotoId.value && metadata.value[id]) return metadata.value[id]!
|
||||||
// 未保存的司机主档证照按司机身份读取;本车补传仍走排车文件归属。
|
// 未保存的司机主档证照按司机身份读取;本车补传仍走排车文件归属。
|
||||||
if (master && master.fileId === id) {
|
if (master && master.fileId === id) {
|
||||||
const blob = await downloadDriverLicense(master.driverId, id)
|
const blob = await downloadDriverLicense(master.driverId, id)
|
||||||
@@ -89,7 +107,7 @@ async function upload(event: Event) {
|
|||||||
}
|
}
|
||||||
function remove(id: string) { if (!props.readonly && !props.disabled && !uploading.value) ids.value = ids.value.filter(value => value !== id) }
|
function remove(id: string) { if (!props.readonly && !props.disabled && !uploading.value) ids.value = ids.value.filter(value => value !== id) }
|
||||||
async function readContent(no: string, kind: DispatchEvidenceKind, id: string, file: DispatchEvidenceFile) {
|
async function readContent(no: string, kind: DispatchEvidenceKind, id: string, file: DispatchEvidenceFile) {
|
||||||
const blob = kind === 'DRIVER' && masterBlob?.fileId === id ? masterBlob.blob : await getDispatchEvidenceContent(no, kind, id)
|
const blob = masterBlob?.fileId === id ? masterBlob.blob : await getDispatchEvidenceContent(no, kind, id)
|
||||||
const type = blob.type.split(';')[0]?.toLowerCase()
|
const type = blob.type.split(';')[0]?.toLowerCase()
|
||||||
if (type?.includes('json')) {
|
if (type?.includes('json')) {
|
||||||
let body: Partial<{ code: string; message: string; requestId: string; traceId: string }>
|
let body: Partial<{ code: string; message: string; requestId: string; traceId: string }>
|
||||||
@@ -115,7 +133,7 @@ async function preview(id: string) {
|
|||||||
} catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } }
|
} catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } }
|
||||||
finally { if (current === previewGeneration) previewLoading.value = false }
|
finally { if (current === previewGeneration) previewLoading.value = false }
|
||||||
}
|
}
|
||||||
watch(() => JSON.stringify([props.dispatchNo, props.kind, allIds.value, props.masterLicense]), load, { immediate: true })
|
watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value, props.referenceIds, props.masterLicense, props.masterResource]), load, { immediate: true })
|
||||||
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
|
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
@@ -130,6 +148,7 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="evidence-photo-empty">暂无照片</div>
|
<div v-else class="evidence-photo-empty">暂无照片</div>
|
||||||
|
<p v-if="profilePhotoId" class="density-dialog-note">已带入档案照片,保存后用于本趟排车</p>
|
||||||
<figcaption>{{ label }}<InlineHelp v-if="!readonly" title="附件上传说明"><p>{{ uploadFormats }},单文件最大10MiB;{{ saveHint || '上传后保存车辆信息才完成本车引用。' }}</p></InlineHelp></figcaption>
|
<figcaption>{{ label }}<InlineHelp v-if="!readonly" title="附件上传说明"><p>{{ uploadFormats }},单文件最大10MiB;{{ saveHint || '上传后保存车辆信息才完成本车引用。' }}</p></InlineHelp></figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ vi.mock('../api/dispatchLoadingDocuments', async importOriginal => ({ ...await i
|
|||||||
const photo: api.LoadingPhoto = { slot: 'LOADING_SCENE', fileId: '101', fileName: 'loading.png', contentType: 'image/png', savedAt: null }
|
const photo: api.LoadingPhoto = { slot: 'LOADING_SCENE', fileId: '101', fileName: 'loading.png', contentType: 'image/png', savedAt: null }
|
||||||
const data = (extra: Partial<api.LoadingDocuments> = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
|
const data = (extra: Partial<api.LoadingDocuments> = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
|
||||||
function setup() { return mount(DispatchLoadingDocuments, { props: { dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) }
|
function setup() { return mount(DispatchLoadingDocuments, { props: { dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) }
|
||||||
async function upload(wrapper: ReturnType<typeof setup>) {
|
async function upload(wrapper: ReturnType<typeof setup>, files = [new File(['image'], 'loading.png', { type: 'image/png' })]) {
|
||||||
const input = wrapper.get('input[aria-label="装车照片 1"]')
|
const input = wrapper.get('input[aria-label="上传司机驾驶证"]')
|
||||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['image'], 'loading.png', { type: 'image/png' })] })
|
Object.defineProperty(input.element, 'files', { configurable: true, value: files })
|
||||||
await input.trigger('change'); await flushPromises()
|
await input.trigger('change'); await flushPromises()
|
||||||
}
|
}
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -29,7 +29,7 @@ it('offers five whole-dispatch slots and only reports saved after binding succee
|
|||||||
expect(wrapper.findAll('input[type="file"]')).toHaveLength(5)
|
expect(wrapper.findAll('input[type="file"]')).toHaveLength(5)
|
||||||
const table = wrapper.get('table[aria-label="装车照片"]')
|
const table = wrapper.get('table[aria-label="装车照片"]')
|
||||||
expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '照片类别', '文件名称', '图片预览', '保存时间'])
|
expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '照片类别', '文件名称', '图片预览', '保存时间'])
|
||||||
expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(['装车照片 1', '装车照片 2', '装车照片 3', '装车照片 4', '装车照片 5'])
|
expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(Object.values(api.loadingPhotoLabels).map(label => `${label}0/5 张`))
|
||||||
await upload(wrapper)
|
await upload(wrapper)
|
||||||
expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String))
|
expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String))
|
||||||
expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '0', fileId: '101' }))
|
expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '0', fileId: '101' }))
|
||||||
@@ -38,6 +38,64 @@ it('offers five whole-dispatch slots and only reports saved after binding succee
|
|||||||
expect(table.get('tbody tr').text()).toContain('loading.png')
|
expect(table.get('tbody tr').text()).toContain('loading.png')
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
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 }))
|
||||||
|
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"]')
|
||||||
|
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')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('saves a multiple selection with fresh versions and notifies the parent once', async () => {
|
||||||
|
let latest = data(), id = 200
|
||||||
|
vi.mocked(api.getLoadingDocuments).mockImplementation(async () => latest)
|
||||||
|
vi.mocked(api.uploadLoadingPhoto).mockImplementation(async () => ({ ...photo, fileId: String(++id) }))
|
||||||
|
vi.mocked(api.bindLoadingPhoto).mockImplementation(async (_no, _slot, body) => {
|
||||||
|
latest = data({ recordVersion: String(Number(body.recordVersion) + 1), photos: [...latest.photos, { ...photo, fileId: body.fileId }] }); return latest
|
||||||
|
})
|
||||||
|
const wrapper = setup(); await flushPromises()
|
||||||
|
expect(wrapper.get('input[aria-label="上传司机驾驶证"]').attributes('multiple')).toBeDefined()
|
||||||
|
await upload(wrapper, Array.from({ length: 3 }, () => new File(['image'], 'photo.png', { type: 'image/png' })))
|
||||||
|
expect(api.bindLoadingPhoto).toHaveBeenCalledTimes(3)
|
||||||
|
expect(vi.mocked(api.bindLoadingPhoto).mock.calls.map(call => call[2].recordVersion)).toEqual(['0', '1', '2'])
|
||||||
|
expect(wrapper.emitted('saved')).toHaveLength(1); expect(wrapper.text()).toContain('3/5 张')
|
||||||
|
await upload(wrapper, Array.from({ length: 3 }, () => new File(['image'], 'photo.png', { type: 'image/png' })))
|
||||||
|
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(3)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('stops a batch after an unknown binding result and displays the server snapshot', async () => {
|
||||||
|
let latest = data(), id = 200, binds = 0
|
||||||
|
vi.mocked(api.getLoadingDocuments).mockImplementation(async () => latest)
|
||||||
|
vi.mocked(api.uploadLoadingPhoto).mockImplementation(async () => ({ ...photo, fileId: String(++id) }))
|
||||||
|
vi.mocked(api.bindLoadingPhoto).mockImplementation(async (_no, _slot, body) => {
|
||||||
|
latest = data({ recordVersion: String(++binds), photos: [...latest.photos, { ...photo, fileId: body.fileId }] })
|
||||||
|
if (binds === 2) throw new Error('保存响应超时')
|
||||||
|
return latest
|
||||||
|
})
|
||||||
|
const wrapper = setup(); await flushPromises()
|
||||||
|
await upload(wrapper, Array.from({ length: 3 }, () => new File(['image'], 'photo.png', { type: 'image/png' })))
|
||||||
|
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2); expect(api.bindLoadingPhoto).toHaveBeenCalledTimes(2)
|
||||||
|
expect(wrapper.text()).toContain('2/5 张'); expect(wrapper.text()).toContain('已读取最新资料')
|
||||||
|
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('requires a successful refresh after both binding and reconciliation fail', async () => {
|
||||||
|
vi.mocked(api.getLoadingDocuments).mockResolvedValueOnce(data()).mockResolvedValueOnce(data()).mockRejectedValueOnce(new Error('读取失败')).mockResolvedValue(data())
|
||||||
|
vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new Error('保存超时'))
|
||||||
|
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||||
|
expect(wrapper.text()).toContain('保存状态尚未核实')
|
||||||
|
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
|
||||||
|
wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
|
||||||
|
expect(wrapper.find('input[aria-label="上传司机驾驶证"]').exists()).toBe(true)
|
||||||
|
expect(api.bindLoadingPhoto).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||||
|
})
|
||||||
it('unlocks after binding failure and accepts a new upload with a fresh key', async () => {
|
it('unlocks after binding failure and accepts a new upload with a fresh key', async () => {
|
||||||
vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new Error('network unknown'))
|
vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new Error('network unknown'))
|
||||||
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
const wrapper = setup(); await flushPromises(); await upload(wrapper)
|
||||||
|
|||||||
@@ -7,18 +7,24 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||||
import { bindLoadingPhoto, getLoadingDocuments, loadingPhotoSlots, readLoadingPhoto, uploadLoadingPhoto, type LoadingDocuments, type LoadingPhotoSlot } from '../api/dispatchLoadingDocuments'
|
import { bindLoadingPhoto, getLoadingDocuments, loadingPhotoSlots, loadingPhotoLabels, readLoadingPhoto, uploadLoadingPhoto, type LoadingDocuments, type LoadingPhotoSlot } from '../api/dispatchLoadingDocuments'
|
||||||
|
|
||||||
const props = defineProps<{ dispatchNo: string }>()
|
const props = defineProps<{ dispatchNo: string }>()
|
||||||
const emit = defineEmits<{ saved: [] }>()
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
const auth = useAuthStore(), access = useAccessPreviewStore()
|
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||||||
const snapshot = ref<LoadingDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
const snapshot = ref<LoadingDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||||
|
const needsRefresh = ref(false)
|
||||||
const images = ref<Record<string, string>>({})
|
const images = ref<Record<string, string>>({})
|
||||||
const actor = auth.user?.id || '', number = props.dispatchNo
|
const actor = auth.user?.id || '', number = props.dispatchNo
|
||||||
const editable = computed(() => access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
|
const editable = computed(() => access.can(permission.dispatchEdit) && snapshot.value?.editable === true && !needsRefresh.value)
|
||||||
let active = true
|
let active = true
|
||||||
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
|
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
|
||||||
function accept(value: LoadingDocuments) { ensureScope(); if (value.dispatchNo !== number) throw new Error('装车资料与排车单不一致'); snapshot.value = value }
|
function accept(value: LoadingDocuments) {
|
||||||
|
ensureScope(); if (value.dispatchNo !== number) throw new Error('装车资料与排车单不一致')
|
||||||
|
const current = new Set(value.photos.map(photo => photo.fileId))
|
||||||
|
for (const [id, url] of Object.entries(images.value)) if (!current.has(id)) { URL.revokeObjectURL(url); delete images.value[id] }
|
||||||
|
snapshot.value = value; needsRefresh.value = false
|
||||||
|
}
|
||||||
async function previews() {
|
async function previews() {
|
||||||
const photos = snapshot.value?.photos || []
|
const photos = snapshot.value?.photos || []
|
||||||
await Promise.allSettled(photos.map(async photo => {
|
await Promise.allSettled(photos.map(async photo => {
|
||||||
@@ -30,25 +36,48 @@ async function load() {
|
|||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
busy.value = true; error.value = null
|
busy.value = true; error.value = null
|
||||||
try { accept(await getLoadingDocuments(number)); await previews() }
|
try { accept(await getLoadingDocuments(number)); await previews() }
|
||||||
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '装车资料读取失败') }
|
catch (cause) { if (active) { needsRefresh.value = true; error.value = getApiErrorDetails(cause, '装车资料读取失败') } }
|
||||||
finally { if (active) busy.value = false }
|
finally { if (active) busy.value = false }
|
||||||
}
|
}
|
||||||
async function upload(slot: LoadingPhotoSlot, event: Event) {
|
function photosFor(slot: LoadingPhotoSlot) { return snapshot.value?.photos.filter(photo => photo.slot === slot) || [] }
|
||||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
async function upload(slot: LoadingPhotoSlot, event: Event, replaceFileId?: string) {
|
||||||
if (!file || busy.value || !editable.value) return
|
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); input.value = ''
|
||||||
if (!['image/jpeg','image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
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 }
|
||||||
busy.value = true; error.value = null
|
busy.value = true; error.value = null
|
||||||
|
let saved = 0, reconciled = false, bindingFailed = false
|
||||||
try {
|
try {
|
||||||
ensureScope()
|
ensureScope()
|
||||||
const photo = await uploadLoadingPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
for (const file of files) {
|
||||||
const latest = await getLoadingDocuments(number); ensureScope(); snapshot.value = latest
|
const photo = await uploadLoadingPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
||||||
if (!latest.editable) throw new Error('当前状态不允许修改照片')
|
const latest = await getLoadingDocuments(number); accept(latest)
|
||||||
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
|
if (!latest.editable) throw new Error('当前状态不允许修改照片')
|
||||||
const result = await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
|
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
|
||||||
snapshot.value = result; emit('saved'); ElMessage.success('照片已保存')
|
try {
|
||||||
await previews()
|
accept(await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, ...(replaceFileId ? { replaceFileId } : {}) }))
|
||||||
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
|
} catch (cause) {
|
||||||
|
// 保存可能已经提交;先核对当前事实,不重放请求或继续后续图片。
|
||||||
|
bindingFailed = true
|
||||||
|
needsRefresh.value = true
|
||||||
|
try {
|
||||||
|
const current = await getLoadingDocuments(number); accept(current)
|
||||||
|
reconciled = current.recordVersion !== latest.recordVersion || current.dispatchRecordVersion !== latest.dispatchRecordVersion || current.stage !== latest.stage
|
||||||
|
await previews()
|
||||||
|
} catch { /* 保留原错误与刷新入口。 */ }
|
||||||
|
throw cause
|
||||||
|
}
|
||||||
|
saved++; await previews()
|
||||||
|
}
|
||||||
|
ElMessage.success(`已保存${saved}张照片`)
|
||||||
|
} catch (cause) {
|
||||||
|
if (active) error.value = getApiErrorDetails(cause, bindingFailed
|
||||||
|
? needsRefresh.value ? '保存结果尚未核实,请重新读取资料' : '保存响应未确认,已读取最新资料,请核对已保存图片后继续'
|
||||||
|
: '图片上传失败,已保存照片保留,请重新选择图片')
|
||||||
|
}
|
||||||
finally { busy.value = false }
|
finally { busy.value = false }
|
||||||
|
if (active && (saved || reconciled)) emit('saved')
|
||||||
}
|
}
|
||||||
onMounted(() => { void load() })
|
onMounted(() => { void load() })
|
||||||
onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(url => URL.revokeObjectURL(url)) })
|
onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(url => URL.revokeObjectURL(url)) })
|
||||||
@@ -56,17 +85,18 @@ defineExpose({ busy })
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div v-loading="busy" class="density-dialog-sections">
|
<div v-loading="busy" class="density-dialog-sections">
|
||||||
<p v-if="snapshot?.stage === 'LOADING'">可边装车边上传 5 张装车照片;装车确认完成且照片齐全后自动进入派送中。</p>
|
<p>可边装车边上传五类装车资料,每类至少 1 张、最多 5 张;装车确认完成且五类齐全后自动进入派送中。</p>
|
||||||
<p v-if="snapshot?.stage === 'AFTER_LOADING'">请上传 5 张装车照片,全部保存成功后自动进入派送中。</p>
|
<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>
|
<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>
|
<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, index) in loadingPhotoSlots" :key="slot">
|
<tr v-for="slot in loadingPhotoSlots" :key="slot">
|
||||||
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy" @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="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>装车照片 {{ index + 1 }}</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><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">
|
<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 />
|
<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 />
|
||||||
<el-button v-else link :disabled="busy" @click="load">图片读取失败,点击重试</el-button>
|
<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>
|
||||||
</template><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
|
</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>
|
<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>
|
</tr>
|
||||||
|
|||||||
@@ -250,8 +250,8 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
|||||||
<template #fees><DispatchVehicleMaintenanceFees :dispatch-no="currentNo" :deliveries="detail.documents.deliveries" /></template>
|
<template #fees><DispatchVehicleMaintenanceFees :dispatch-no="currentNo" :deliveries="detail.documents.deliveries" /></template>
|
||||||
<template #attachments>
|
<template #attachments>
|
||||||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||||||
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
<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="司机照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $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] : []" :master-license="unsavedMasterLicense" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
import TransportExecutionPanel from './TransportExecutionPanel.vue'
|
||||||
|
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||||
|
import { assignTransport, getTransport, type TransportDetail } from '../api/transportTasks'
|
||||||
|
import { getDispatchEvidence, getDispatchEvidenceContent } from '../api/dispatches'
|
||||||
|
import { getDriver, getResourcePhoto, downloadResourcePhoto, downloadDriverLicense } from '@/modules/overseas-master-data'
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||||
|
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), getDriver: vi.fn(), getResourcePhoto: vi.fn(), downloadResourcePhoto: vi.fn(), downloadDriverLicense: vi.fn(), pickupStatusLabels: { ASSIGNED: '待取货' } }))
|
||||||
|
vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn(), assignTransport: vi.fn(), transportOccupancy: vi.fn(), cancelTransport: vi.fn(), releaseTransport: vi.fn(), commandTransport: vi.fn(), uploadTransport: vi.fn(), downloadTransportEvidence: vi.fn(), transportLabels: { PICKUP: '到仓取货' } }))
|
||||||
|
vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
|
||||||
|
|
||||||
|
const facts = (): TransportDetail => ({ dispatchNo: 'PC1', taskType: 'PICKUP', dispatchRecordVersion: 9, execution: { trip: { status: 'PLANNED', recordVersion: 4, vehicleId: '1', vehiclePlate: 'CA-1', driverId: '2', driverName: '司机甲' } }, pickups: [{ customerId: 'C1', transferNo: 'WT1', packageCount: 5, status: 'ASSIGNED', evidence: [] }], completionEvidence: [] }) as unknown as TransportDetail
|
||||||
|
const photo = (fileId: string) => ({ fileId, fileName: `${fileId}.jpg`, contentType: 'image/jpeg', sizeBytes: 5 })
|
||||||
|
const blob = () => new Blob(['photo'], { type: 'image/jpeg' })
|
||||||
|
const mountPanel = () => mount(TransportExecutionPanel, { props: { dispatchNo: 'PC1', editResources: true }, global: { plugins: [ElementPlus] } })
|
||||||
|
const saveButton = (wrapper: ReturnType<typeof mountPanel>) => wrapper.findAll('button').find(button => button.text() === '保存车辆司机')!
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks()
|
||||||
|
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||||
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:resource-photo')
|
||||||
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||||
|
vi.mocked(getTransport).mockImplementation(async () => facts())
|
||||||
|
vi.mocked(getDriver).mockResolvedValue({ id: '2', licenseFileId: '33' } as Awaited<ReturnType<typeof getDriver>>)
|
||||||
|
vi.mocked(getResourcePhoto).mockImplementation(async kind => ({ recordVersion: '1', photo: photo(kind === 'VEHICLE' ? '91' : '92') }))
|
||||||
|
vi.mocked(downloadResourcePhoto).mockImplementation(async () => blob())
|
||||||
|
vi.mocked(downloadDriverLicense).mockImplementation(async () => blob())
|
||||||
|
vi.mocked(getDispatchEvidence).mockImplementation(async (_no, _kind, id) => photo(id))
|
||||||
|
vi.mocked(getDispatchEvidenceContent).mockImplementation(async () => blob())
|
||||||
|
})
|
||||||
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||||
|
|
||||||
|
it('keeps automatically loaded photos available after refreshing the open form', async () => {
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
try {
|
||||||
|
await flushPromises()
|
||||||
|
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
|
||||||
|
expect(getResourcePhoto).toHaveBeenCalledTimes(2)
|
||||||
|
await wrapper.get('[aria-label="刷新运输任务"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findAll('.evidence-photo-preview')).toHaveLength(3)
|
||||||
|
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
|
||||||
|
vi.mocked(assignTransport).mockResolvedValue(facts())
|
||||||
|
await saveButton(wrapper).trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(assignTransport).toHaveBeenCalledWith('PC1', expect.objectContaining({ reviewPhotos: { vehicleFileIds: [], driverPhotoFileIds: [], licenseFileIds: [] } }))
|
||||||
|
} finally { wrapper.unmount() }
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resubmits saved snapshots when reopening maintenance even if archive photos change', async () => {
|
||||||
|
const saved = facts()
|
||||||
|
saved.execution.trip.recordVersion = 5
|
||||||
|
saved.dispatchRecordVersion = 10
|
||||||
|
saved.resources = { vehicle: { vehicleId: '1', evidenceFileIds: ['101'] }, driver: { driverId: '2', evidence: { photoFileIds: ['102'], fileIds: ['33'], masterLicenseFileId: '33' } } } as TransportDetail['resources']
|
||||||
|
vi.mocked(assignTransport).mockResolvedValue(saved)
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
try {
|
||||||
|
await flushPromises()
|
||||||
|
await saveButton(wrapper).trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(assignTransport).toHaveBeenCalledTimes(1)
|
||||||
|
vi.mocked(getResourcePhoto).mockResolvedValue({ recordVersion: '2', photo: photo('999') })
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '维护车辆信息')!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
const reopened = wrapper.findAllComponents(DispatchEvidenceFiles)
|
||||||
|
expect(reopened[0]!.props('modelValue')).toEqual(['101'])
|
||||||
|
expect(reopened[1]!.props('modelValue')).toEqual(['102'])
|
||||||
|
expect(getResourcePhoto).toHaveBeenCalledTimes(2)
|
||||||
|
expect(getDispatchEvidence).toHaveBeenCalledWith('PC1', 'VEHICLE', '101')
|
||||||
|
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
|
||||||
|
await saveButton(wrapper).trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(assignTransport).toHaveBeenLastCalledWith('PC1', expect.objectContaining({ recordVersion: 5, dispatchRecordVersion: 10, reviewPhotos: { vehicleFileIds: ['101'], driverPhotoFileIds: ['102'], licenseFileIds: [] } }))
|
||||||
|
} finally { wrapper.unmount() }
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([0, 1])('requires a new photo when resource selector %s changes to an archive without a photo', async index => {
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
try {
|
||||||
|
await flushPromises()
|
||||||
|
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
|
||||||
|
vi.mocked(getResourcePhoto).mockResolvedValue({ recordVersion: '0', photo: null })
|
||||||
|
wrapper.findAllComponents({ name: 'ElSelect' })[index]!.vm.$emit('update:modelValue', '4')
|
||||||
|
await flushPromises()
|
||||||
|
expect(saveButton(wrapper).attributes('disabled')).toBeDefined()
|
||||||
|
expect(wrapper.findAllComponents(DispatchEvidenceFiles)[index]!.props('modelValue')).toEqual([])
|
||||||
|
expect(getResourcePhoto).toHaveBeenLastCalledWith(index === 0 ? 'VEHICLE' : 'DRIVER', '4')
|
||||||
|
await saveButton(wrapper).trigger('click')
|
||||||
|
expect(assignTransport).not.toHaveBeenCalled()
|
||||||
|
} finally { wrapper.unmount() }
|
||||||
|
})
|
||||||
@@ -22,9 +22,10 @@ const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), ve
|
|||||||
const occupancy = ref<Record<string, boolean>>({}), candidateBusy = ref(false)
|
const occupancy = ref<Record<string, boolean>>({}), candidateBusy = ref(false)
|
||||||
const vehiclePhotos = ref<string[]>([]), driverPhotos = ref<string[]>([]), licensePhotos = ref<string[]>([])
|
const vehiclePhotos = ref<string[]>([]), driverPhotos = ref<string[]>([]), licensePhotos = ref<string[]>([])
|
||||||
const masterLicense = ref<{ driverId: string; fileId: string } | null>(null), licenseLoading = ref(false)
|
const masterLicense = ref<{ driverId: string; fileId: string } | null>(null), licenseLoading = ref(false)
|
||||||
|
const masterPhotoAvailable = reactive({ vehicle: false, driver: false })
|
||||||
const photoBusy = reactive({ vehicle: false, driver: false, license: false })
|
const photoBusy = reactive({ vehicle: false, driver: false, license: false })
|
||||||
const activeMasterLicense = computed(() => licensePhotos.value.length ? null : masterLicense.value)
|
const activeMasterLicense = computed(() => licensePhotos.value.length ? null : masterLicense.value)
|
||||||
const photosComplete = computed(() => detail.value?.taskType !== 'PICKUP' || !!(vehiclePhotos.value.length && driverPhotos.value.length && (licensePhotos.value.length || activeMasterLicense.value)))
|
const photosComplete = computed(() => detail.value?.taskType !== 'PICKUP' || !!((vehiclePhotos.value.length || masterPhotoAvailable.vehicle) && (driverPhotos.value.length || masterPhotoAvailable.driver) && (licensePhotos.value.length || activeMasterLicense.value)))
|
||||||
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref<number>(), evidence = ref<PickupEvidence[]>([]), truckEvidence = ref<PickupEvidence[]>([])
|
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref<number>(), evidence = ref<PickupEvidence[]>([]), truckEvidence = ref<PickupEvidence[]>([])
|
||||||
const arrivalMode = ref(false)
|
const arrivalMode = ref(false)
|
||||||
const validPickupQuantity = computed(() => typeof actual.value === 'number' && Number.isInteger(actual.value) && actual.value >= 1 && actual.value <= 5000)
|
const validPickupQuantity = computed(() => typeof actual.value === 'number' && Number.isInteger(actual.value) && actual.value >= 1 && actual.value <= 5000)
|
||||||
@@ -43,7 +44,10 @@ function restoreVehiclePhotos() {
|
|||||||
const saved = detail.value?.resources?.vehicle
|
const saved = detail.value?.resources?.vehicle
|
||||||
vehiclePhotos.value = saved?.vehicleId === vehicleId.value ? [...saved.evidenceFileIds] : []
|
vehiclePhotos.value = saved?.vehicleId === vehicleId.value ? [...saved.evidenceFileIds] : []
|
||||||
}
|
}
|
||||||
watch(vehicleId, restoreVehiclePhotos, { flush: 'sync' })
|
// 同一资源刷新沿用照片组件的可用状态;切换资源时立即清空,等待新照片确认。
|
||||||
|
watch(vehicleId, () => { masterPhotoAvailable.vehicle = false }, { flush: 'sync' })
|
||||||
|
watch(driverId, () => { masterPhotoAvailable.driver = false }, { flush: 'sync' })
|
||||||
|
watch(() => [vehicleId.value, resourceFormOpen.value, detail.value] as const, restoreVehiclePhotos, { flush: 'sync' })
|
||||||
/** 切换司机后重读其主档驾驶证,迟到响应不得覆盖新司机或新单据。 */
|
/** 切换司机后重读其主档驾驶证,迟到响应不得覆盖新司机或新单据。 */
|
||||||
watch(() => [driverId.value, resourceFormOpen.value, detail.value] as const, async ([id, open], _, onCleanup) => {
|
watch(() => [driverId.value, resourceFormOpen.value, detail.value] as const, async ([id, open], _, onCleanup) => {
|
||||||
let current = true
|
let current = true
|
||||||
@@ -165,8 +169,8 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输操作正在提
|
|||||||
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="(value: boolean) => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
|
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="(value: boolean) => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
|
||||||
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="(value: boolean) => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
|
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="(value: boolean) => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
|
||||||
<section v-if="detail.taskType === 'PICKUP'" class="transport-review-photos" aria-label="调度审核照片">
|
<section v-if="detail.taskType === 'PICKUP'" class="transport-review-photos" aria-label="调度审核照片">
|
||||||
<DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :disabled="locked" @busy="photoBusy.vehicle = $event" />
|
<DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :master-resource="vehicleId ? { kind: 'VEHICLE', id: vehicleId } : null" @available="masterPhotoAvailable.vehicle = $event" :disabled="locked" @busy="photoBusy.vehicle = $event" />
|
||||||
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="locked" @busy="photoBusy.driver = $event" />
|
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driverId ? { kind: 'DRIVER', id: driverId } : null" @available="masterPhotoAvailable.driver = $event" :disabled="locked" @busy="photoBusy.driver = $event" />
|
||||||
<DispatchEvidenceFiles photo-card v-model="licensePhotos" :dispatch-no="dispatchNo" kind="DRIVER" label="驾驶证照片" :master-license="activeMasterLicense" :reference-ids="activeMasterLicense ? [activeMasterLicense.fileId] : []" :disabled="locked" @busy="photoBusy.license = $event" />
|
<DispatchEvidenceFiles photo-card v-model="licensePhotos" :dispatch-no="dispatchNo" kind="DRIVER" label="驾驶证照片" :master-license="activeMasterLicense" :reference-ids="activeMasterLicense ? [activeMasterLicense.fileId] : []" :disabled="locked" @busy="photoBusy.license = $event" />
|
||||||
<p class="transport-photo-hint workbench-meta">请补齐车辆图片、司机照片和驾驶证照片;保存后供调度员和理货员审核。</p>
|
<p class="transport-photo-hint workbench-meta">请补齐车辆图片、司机照片和驾驶证照片;保存后供调度员和理货员审核。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user