Files
admin-web/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue
T
2026-10-21 15:10:54 +08:00

99 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { DispatchInputError, type DispatchEvidenceFile, type DispatchEvidenceKind } from '../dispatchContracts'
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; label?: string; saveHint?: string; referenceIds?: string[] }>()
const ids = defineModel<string[]>({ required: true })
const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value])])
const emit = defineEmits<{ busy: [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 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_PHOTO' ? types.slice(1) : types)
const uploadFormats = computed(() => props.kind === 'DRIVER_PHOTO' ? 'JPG、PNG' : 'PDF、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; if (imageUrl.value) { URL.revokeObjectURL(imageUrl.value); urls.delete(imageUrl.value); imageUrl.value = '' } }
async function load() {
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...allIds.value]
closePreview(); metadata.value = {}; error.value = null
if (!no) return
const results = await Promise.allSettled(values.map(async id => 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, '附件资料读取失败,请重试;原引用已保留') })
}
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 preview(id: string) {
const file = metadata.value[id]; if (!file || previewLoading.value) return
closePreview(); const current = ++previewGeneration, scope = generation
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 getDispatchEvidenceContent(props.dispatchNo, props.kind, id)
if (current !== previewGeneration || scope !== generation) return
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('附件内容类型或大小与本车元数据不一致')
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 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, allIds.value]), 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="source" :class="{ 'dispatch-evidence--readonly': readonly }" :aria-label="label"><h4 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>
<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 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 :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="previewLoading" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
<div class="density-dialog-sections"><section class="density-dialog-section" data-tone="source" aria-label="本车附件预览信息"><h3 class="density-dialog-section__title">附件信息</h3><table class="density-table"><tbody><tr><th scope="row">排车单号</th><td>{{ dispatchNo || '—' }}</td></tr><tr><th scope="row">附件名称</th><td>{{ imageName || '—' }}</td></tr></tbody></table></section><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取本车附件…</p><img v-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName"></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; }
.evidence-image { display: block; max-width: 100%; max-height: 72vh; margin: auto; object-fit: contain; }
</style>