Files
admin-web/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue
T

152 lines
10 KiB
Vue
Raw Normal View History

2026-10-21 16:58:38 +08:00
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import dayjs from 'dayjs'
2026-10-21 16:58:38 +08:00
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'
2026-10-21 16:58:38 +08:00
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)
2026-10-21 16:58:38 +08:00
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
}
2026-10-21 16:58:38 +08:00
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
2026-10-21 16:58:38 +08:00
busy.value = true; error.value = null
2026-10-26 18:52:21 +08:00
try { accept(await getLoadingDocuments(number)); await previews(); return true }
catch (cause) { if (active) { needsRefresh.value = true; error.value = getApiErrorDetails(cause, '装车资料读取失败') } }
2026-10-21 16:58:38 +08:00
finally { if (active) busy.value = false }
}
2026-10-26 18:52:21 +08:00
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 }
2026-10-26 18:52:21 +08:00
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 }
2026-10-21 16:58:38 +08:00
busy.value = true; error.value = null
let saved = 0, reconciled = false, bindingFailed = false, roundChanged = false
2026-10-21 16:58:38 +08:00
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')
2026-10-21 16:58:38 +08:00
}
onMounted(() => { void load() })
onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(url => URL.revokeObjectURL(url)) })
2026-10-21 16:58:38 +08:00
defineExpose({ busy })
</script>
<template>
<div v-loading="busy" class="loading-documents">
2026-10-26 18:52:21 +08:00
<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>
2026-10-21 16:58:38 +08:00
</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; }
2026-10-22 11:56:54 +08:00
.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; }
2026-10-21 16:58:38 +08:00
</style>