Files
admin-web/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue
T
2026-11-03 16:15:24 +08:00

152 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 { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import dayjs from 'dayjs'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import { bindLoadingPhoto, getLoadingDocuments, loadingPhotoSlots, loadingPhotoLabels, readLoadingPhoto, uploadLoadingPhoto, type LoadingDocuments, type LoadingPhotoSlot } from '../api/dispatchLoadingDocuments'
const props = defineProps<{ dispatchNo: string }>()
const emit = defineEmits<{ saved: [] }>()
const auth = useAuthStore(), access = useAccessPreviewStore()
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 actor = auth.user?.id || '', number = props.dispatchNo
const editable = computed(() => access.can(permission.dispatchEdit) && snapshot.value?.editable === true && !needsRefresh.value)
let active = true
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('装车资料与排车单不一致')
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() {
const photos = snapshot.value?.photos || []
await Promise.allSettled(photos.map(async photo => {
if (images.value[photo.fileId]) return
const blob = await readLoadingPhoto(number, photo); ensureScope(); images.value[photo.fileId] = URL.createObjectURL(blob)
}))
}
async function load() {
if (busy.value) return
busy.value = true; error.value = null
try { accept(await getLoadingDocuments(number)); await previews(); return true }
catch (cause) { if (active) { needsRefresh.value = true; error.value = getApiErrorDetails(cause, '装车资料读取失败') } }
finally { if (active) busy.value = false }
}
async function refresh() { if (await load()) emit('saved') }
function photosFor(slot: LoadingPhotoSlot) { return snapshot.value?.photos.filter(photo => photo.slot === slot) || [] }
function savedTime(value?: string | null) {
return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
}
async function upload(slot: LoadingPhotoSlot, event: Event, replaceFileId?: string) {
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); input.value = ''
if (!files.length || busy.value || !editable.value) return
const reviewRound = snapshot.value!.reviewRound
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) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('请选择不超过10MiB的JPG或PNG,司机驾驶证也支持PDF'); return }
busy.value = true; error.value = null
let saved = 0, reconciled = false, bindingFailed = false, roundChanged = false
try {
ensureScope()
for (const file of files) {
const uploadKey = crypto.randomUUID()
const photo = await uploadLoadingPhoto(number, slot, file, uploadKey, reviewRound); ensureScope()
const latest = await getLoadingDocuments(number); accept(latest)
if (latest.reviewRound !== reviewRound) { reconciled = true; roundChanged = true; throw new Error('审核轮次已变化,请核对新车辆、司机后重新选择资料') }
if (!latest.editable) throw new Error('当前状态不允许修改照片')
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
try {
accept(await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, uploadKey, ...(replaceFileId ? { replaceFileId } : {}) }))
} 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, roundChanged ? '审核轮次已变化,请核对新车辆、司机后重新选择资料' : bindingFailed
? needsRefresh.value ? '保存结果尚未核实,请重新读取资料' : '保存响应未确认,已读取最新资料,请核对已保存图片后继续'
: '图片上传失败,已保存照片保留,请重新选择图片')
}
finally { busy.value = false }
if (active && (saved || reconciled)) emit('saved')
}
onMounted(() => { void load() })
onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(url => URL.revokeObjectURL(url)) })
defineExpose({ busy })
</script>
<template>
<div v-loading="busy" class="loading-documents">
<div><el-button :loading="busy" :disabled="busy" aria-label="刷新装车资料" @click="refresh">刷新</el-button></div>
<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>
<div class="loading-document-list" aria-label="装车照片">
<section v-for="slot in loadingPhotoSlots" :key="slot" class="loading-document-category" :aria-label="loadingPhotoLabels[slot]">
<header class="loading-document-heading">
<h3>{{ loadingPhotoLabels[slot] }}</h3>
<span class="photo-count">{{ photosFor(slot).length }}/5 张</span>
<label v-if="editable" class="photo-upload category-upload" :class="{ 'is-disabled': busy || photosFor(slot).length >= 5 }">
{{ photosFor(slot).length >= 5 ? '已满 5 张' : '上传资料' }}
<input type="file" multiple :accept="slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :aria-label="`上传${loadingPhotoLabels[slot]}`" :disabled="busy || photosFor(slot).length >= 5" @change="upload(slot, $event)" />
</label>
</header>
<ol v-if="photosFor(slot).length" class="loading-photo-list">
<li v-for="(photo, index) in photosFor(slot)" :key="photo.fileId" class="loading-photo-row" :aria-label="`${loadingPhotoLabels[slot]}第${index + 1}张`">
<span class="photo-index">{{ index + 1 }}</span>
<div class="photo-preview">
<a v-if="images[photo.fileId] && photo.contentType === 'application/pdf'" :href="images[photo.fileId]" target="_blank" rel="noopener noreferrer">查看驾驶证 PDF</a>
<el-image v-else-if="images[photo.fileId]" :src="images[photo.fileId]" :alt="`${loadingPhotoLabels[slot]}第${index + 1}张`" :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>
</div>
<span class="photo-time">保存时间:{{ savedTime(photo.savedAt) }}</span>
<label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">
更换
<input type="file" :accept="slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :aria-label="`更换${loadingPhotoLabels[slot]}第${index + 1}张`" :disabled="busy" @change="upload(slot, $event, photo.fileId)" />
</label>
</li>
</ol>
<p v-else class="photo-empty">未上传</p>
</section>
</div>
</div>
</template>
<style scoped>
.loading-documents { display: flex; flex-direction: column; gap: 12px; }
.loading-documents > p { margin: 0; }
.loading-document-list { border: 1px solid var(--cc-border, var(--el-border-color)); }
.loading-document-category + .loading-document-category { border-top: 1px solid var(--cc-border, var(--el-border-color)); }
.loading-document-heading { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: var(--el-fill-color-light); }
.loading-document-heading h3 { margin: 0; font-size: 14px; }
.photo-count, .photo-time, .photo-index, .photo-empty { color: var(--el-text-color-secondary); }
.category-upload { margin-left: auto; }
.loading-photo-list { display: flex; flex-direction: column; margin: 0; padding: 0 12px; list-style: none; }
.loading-photo-row { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; padding: 8px 0; }
.loading-photo-row + .loading-photo-row { border-top: 1px solid var(--el-border-color-lighter); }
.photo-index { width: 20px; text-align: center; }
.photo-preview { display: flex; align-items: center; flex: 0 0 160px; min-height: 96px; }
.loading-photo-image { display: block; width: 160px; height: 96px; }
.photo-time { font-variant-numeric: tabular-nums; }
.photo-empty { margin: 0; padding: 12px; }
.photo-upload { position: relative; display: inline-flex; color: var(--el-color-primary); cursor: pointer; }
.photo-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.photo-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.photo-upload.is-disabled { color: var(--el-text-color-disabled); cursor: not-allowed; }
.photo-upload.is-disabled input { cursor: not-allowed; }
</style>