Merge release/test and preserve pickup execution and photo workflows

This commit is contained in:
2026-10-21 17:35:43 +08:00
39 changed files with 1093 additions and 120 deletions
@@ -17,6 +17,8 @@ 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 thumbnails = ref<Record<string, string>>({})
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(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? types.slice(1) : types)
@@ -27,7 +29,18 @@ function valid(value: DispatchEvidenceFile, expected?: string) {
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 = '' } }
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 = [...allIds.value]
for (const url of Object.values(thumbnails.value)) { URL.revokeObjectURL(url); urls.delete(url) }
@@ -78,6 +91,7 @@ async function readContent(no: string, kind: DispatchEvidenceKind, id: string, f
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 }
@@ -86,7 +100,7 @@ async function preview(id: string) {
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 imageUrl.value = url
else { imageDecoding.value = true; imageUrl.value = url }
} catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } }
finally { if (current === previewGeneration) previewLoading.value = false }
}
@@ -111,8 +125,11 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
<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 :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 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>
@@ -122,11 +139,12 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
.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; }
.evidence-photo-card { margin: 0; text-align: center; }
.evidence-photo-list { display: grid; gap: var(--admin-space-3); }
.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>