187 lines
17 KiB
Vue
187 lines
17 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminWorkbench.css'
|
||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||
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, 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[]; masterResource?: { kind: ResourcePhotoKind; id: string } | null; masterLicense?: { driverId: string; fileId: string } | null }>()
|
||
const ids = defineModel<string[]>({ required: true })
|
||
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>>({})
|
||
let masterBlob: { fileId: string; blob: Blob } | null = null
|
||
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.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('附件元数据未确认本车有效文件,请重新读取')
|
||
return { ...value, fileId: String(value.fileId) }
|
||
}
|
||
function fail(cause: unknown, fallback: string) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : fallback) }
|
||
function closePreview() { previewGeneration++; pendingPopup?.close(); pendingPopup = null; imageOpen.value = false; previewLoading.value = false; imageDecoding.value = false; imageFailure.value = ''; if (imageUrl.value) { URL.revokeObjectURL(imageUrl.value); urls.delete(imageUrl.value); imageUrl.value = '' } }
|
||
function imageLoaded(event: Event) {
|
||
const image = event.target as HTMLImageElement
|
||
if (!imageOpen.value || image.getAttribute('src') !== imageUrl.value) return
|
||
imageDecoding.value = false
|
||
if (image.naturalWidth <= 1 && image.naturalHeight <= 1) imageFailure.value = '原图片仅为 1 × 1 像素,无法查看有效内容,请核对上传的原附件。'
|
||
}
|
||
function imageFailed(event: Event) {
|
||
if (!imageOpen.value || (event.target as HTMLImageElement).getAttribute('src') !== imageUrl.value) return
|
||
imageDecoding.value = false
|
||
imageFailure.value = '图片无法解码或加载,请重新预览;若仍失败,请核对原附件是否损坏。'
|
||
}
|
||
async function load() {
|
||
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; 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)
|
||
if (current === generation) masterBlob = { fileId: id, blob }
|
||
return valid({ fileId: id, fileName: '驾驶证照片', contentType: blob.type, sizeBytes: blob.size }, id)
|
||
}
|
||
return valid(await getDispatchEvidence(no, kind, id), id)
|
||
}))
|
||
if (current !== generation) return
|
||
results.forEach(result => { if (result.status === 'fulfilled') metadata.value[result.value.fileId] = result.value; else fail(result.reason, '附件资料读取失败,请重试;原引用已保留') })
|
||
if (props.photoCard) await Promise.all(values.map(async id => {
|
||
const file = metadata.value[id]
|
||
if (!file || !file.contentType.startsWith('image/')) return
|
||
try {
|
||
const blob = await readContent(no, kind, id, file)
|
||
if (current !== generation) return
|
||
const url = URL.createObjectURL(blob); urls.add(url); thumbnails.value[id] = url
|
||
} catch (cause) { if (current === generation) fail(cause, '照片读取失败,请重试') }
|
||
}))
|
||
}
|
||
function choose(id: string | null = null) { if (props.readonly || props.disabled || uploading.value || !props.dispatchNo) return; replacing = id; input.value?.click() }
|
||
async function upload(event: Event) {
|
||
const element = event.target as HTMLInputElement, file = element.files?.[0]; element.value = ''
|
||
if (!file || props.readonly || props.disabled || uploading.value) return
|
||
error.value = null
|
||
if (!uploadTypes.value.includes(file.type) || file.size <= 0 || file.size > maximum) { fail(new DispatchInputError(`请选择10MiB以内的${uploadFormats.value}文件`), '附件不符合要求'); return }
|
||
const current = generation, no = props.dispatchNo, kind = props.kind, replaceId = replacing
|
||
uploading.value = true; emit('busy', true)
|
||
try {
|
||
const value = valid(await uploadDispatchEvidence(no, kind, file))
|
||
if (current !== generation) return
|
||
if (replaceId && !ids.value.includes(replaceId)) throw new DispatchInputError('原附件选择已变化,请重新核对')
|
||
ids.value = [...new Set(replaceId ? ids.value.map(id => id === replaceId ? value.fileId : id) : [...ids.value, value.fileId])]
|
||
} catch (cause) { if (current === generation) fail(cause, '上传结果未确认,原附件引用保留,请核对后重试') }
|
||
finally { uploading.value = false; emit('busy', false) }
|
||
}
|
||
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 = 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 }>
|
||
try { body = JSON.parse(await blob.text()) as typeof body } catch { throw new DispatchInputError('附件内容返回错误信息,请重新读取') }
|
||
throw new ApiResponseError(body.message || '附件读取失败', body.code || 'INVALID_FILE_CONTENT', undefined, undefined, body.requestId, body.traceId)
|
||
}
|
||
if (type !== file.contentType || blob.size <= 0 || blob.size > maximum || blob.size !== file.sizeBytes) throw new DispatchInputError('附件内容类型或大小与本车元数据不一致')
|
||
return blob
|
||
}
|
||
async function preview(id: string) {
|
||
const file = metadata.value[id]; if (!file || previewLoading.value) return
|
||
closePreview(); const current = ++previewGeneration, scope = generation
|
||
previewId.value = id
|
||
error.value = null; previewLoading.value = true
|
||
if (file.contentType === 'application/pdf') { pendingPopup = window.open('', '_blank'); if (!pendingPopup) { previewLoading.value = false; fail(new DispatchInputError('请允许本站打开PDF预览窗口'), 'PDF预览未打开'); return } }
|
||
else { imageName.value = file.fileName; imageOpen.value = true }
|
||
try {
|
||
const blob = await readContent(props.dispatchNo, props.kind, id, file)
|
||
if (current !== previewGeneration || scope !== generation) return
|
||
const url = URL.createObjectURL(blob); urls.add(url)
|
||
if (file.contentType === 'application/pdf') { pendingPopup!.location.href = url; pendingPopup = null; window.setTimeout(() => { URL.revokeObjectURL(url); urls.delete(url) }, 60_000) }
|
||
else { imageDecoding.value = true; imageUrl.value = url }
|
||
} catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } }
|
||
finally { if (current === previewGeneration) previewLoading.value = false }
|
||
}
|
||
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>
|
||
<section class="dispatch-evidence density-dialog-section" :data-tone="photoCard ? undefined : 'source'" :class="{ 'dispatch-evidence--readonly': readonly, 'dispatch-evidence--photos': photoCard }" :aria-label="label"><h4 v-if="!photoCard" class="density-dialog-section__title">{{ label }}<InlineHelp title="附件上传说明"><p>{{ uploadFormats }},单文件最大10MiB;{{ saveHint || '上传后保存车辆信息才完成本车引用。' }}</p></InlineHelp></h4>
|
||
<LoadErrorState v-if="error" title="本趟附件操作未完成" :message="error.message" compact retry-text="重新读取资料" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||
<figure v-if="photoCard" class="evidence-photo-card">
|
||
<div v-if="allIds.length" class="evidence-photo-list">
|
||
<div v-for="id in allIds" :key="id" class="evidence-photo-item">
|
||
<button type="button" class="evidence-photo-preview" :aria-label="'预览' + label + ':' + (metadata[id]?.fileName || id)" :disabled="!metadata[id] || previewLoading" @click="preview(id)"><img v-if="thumbnails[id]" :src="thumbnails[id]" :alt="label + ':' + metadata[id]?.fileName"><span v-else>{{ metadata[id]?.contentType === 'application/pdf' ? '查看 PDF' : '照片待加载' }}</span></button>
|
||
<p class="density-dialog-note evidence-photo-name" :title="metadata[id]?.fileName">{{ metadata[id]?.fileName || '附件资料待核实' }}</p>
|
||
<div v-if="!readonly && ids.includes(id)" class="density-actions evidence-photo-actions"><el-button size="small" :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button size="small" :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></div>
|
||
</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>
|
||
<table v-if="allIds.length" class="density-table dispatch-evidence-table"><thead><tr><th class="dispatch-evidence__actions">操作</th><th>附件名称</th></tr></thead><tbody><tr v-for="id in allIds" :key="id"><td class="dispatch-evidence__actions"><div class="density-actions"><el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly && ids.includes(id)"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></div></td><td><LineClampText :content="metadata[id]?.fileName || '附件资料待核实'" /></td></tr></tbody></table>
|
||
<p v-else class="density-dialog-note">暂无本趟附件。</p>
|
||
</template>
|
||
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" :accept="uploadTypes.join(',')" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button class="evidence-upload-button" type="primary" plain :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button></template>
|
||
<AppDialog append-to-body class="admin-density-dialog admin-workbench is-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewBusy" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
|
||
<div class="evidence-preview" :aria-busy="previewBusy">
|
||
<LoadErrorState v-if="imageFailure" title="图片无法预览" :message="imageFailure" compact retry-text="重新预览" @retry="preview(previewId)" />
|
||
<img v-else-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName" @load="imageLoaded" @error="imageFailed">
|
||
</div>
|
||
</AppDialog>
|
||
</section>
|
||
</template>
|
||
<style scoped>
|
||
.dispatch-evidence-table { table-layout: fixed; }
|
||
.dispatch-evidence__actions { width: 190px; }
|
||
.dispatch-evidence--readonly .dispatch-evidence__actions { width: 76px; }
|
||
.dispatch-evidence > .el-button { margin-top: var(--admin-space-3); }
|
||
.evidence-input { display: none; }
|
||
.dispatch-evidence--photos { min-width: 0; }
|
||
.evidence-photo-card { margin: 0; text-align: center; }
|
||
.evidence-photo-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); align-items: start; gap: var(--admin-space-3); }
|
||
.evidence-photo-item { min-width: 0; }
|
||
.evidence-photo-name { margin: var(--admin-space-2) 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.evidence-photo-actions { justify-content: center; }
|
||
.dispatch-evidence--photos > .evidence-upload-button { width: 100%; }
|
||
.evidence-photo-preview, .evidence-photo-empty { box-sizing: border-box; width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); color: var(--el-text-color-secondary); }
|
||
.evidence-photo-preview { padding: 0; cursor: pointer; }
|
||
.evidence-photo-preview img { width: 100%; height: 100%; object-fit: contain; }
|
||
.evidence-photo-card figcaption { margin-top: var(--admin-space-3); font-weight: 600; }
|
||
.evidence-preview { display: grid; place-items: center; height: min(60vh, 600px); min-width: 0; }
|
||
.evidence-image { display: block; width: 100%; height: 100%; min-height: 0; object-fit: contain; }
|
||
</style>
|