feat(admin): synchronize fleet photos and multi-image loading documents

This commit is contained in:
2026-10-26 17:12:38 +08:00
parent e399f3f76f
commit acb90007de
29 changed files with 556 additions and 95 deletions
@@ -9,12 +9,13 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
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'
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 allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value])])
const emit = defineEmits<{ busy: [value: boolean] }>()
const profilePhotoId = ref<string | null>(null)
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 imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
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 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 uploadTypes = computed(() => (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 uploadTypes = computed(() => (props.kind !== 'DRIVER' && (props.photoCard || ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind))) ? types.slice(1) : types)
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
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('附件元数据未确认本车有效文件,请重新读取')
@@ -44,13 +45,30 @@ function imageFailed(event: Event) {
imageFailure.value = '图片无法解码或加载,请重新预览;若仍失败,请核对原附件是否损坏。'
}
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) }
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
// 未保存且没有本趟照片时展示档案图片;保存由服务端复制,不提交跨归属文件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 results = await Promise.allSettled(values.map(async id => {
if (id === profilePhotoId.value && metadata.value[id]) return metadata.value[id]!
// 未保存的司机主档证照按司机身份读取;本车补传仍走排车文件归属。
if (master && master.fileId === 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) }
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()
if (type?.includes('json')) {
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, '本车附件预览失败,原引用已保留') } }
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) })
</script>
<template>
@@ -130,6 +148,7 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
</div>
</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>
</figure>
<template v-else>