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 { 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'
|
2026-10-26 17:12:38 +08:00
|
|
|
|
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)
|
2026-10-26 17:12:38 +08:00
|
|
|
|
const needsRefresh = ref(false)
|
2026-10-22 23:10:09 +08:00
|
|
|
|
const images = ref<Record<string, string>>({})
|
|
|
|
|
|
const actor = auth.user?.id || '', number = props.dispatchNo
|
2026-10-26 17:12:38 +08:00
|
|
|
|
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('资料操作身份已变化,请重新打开') }
|
2026-10-26 17:12:38 +08:00
|
|
|
|
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() {
|
2026-10-22 23:10:09 +08:00
|
|
|
|
if (busy.value) return
|
2026-10-21 16:58:38 +08:00
|
|
|
|
busy.value = true; error.value = null
|
|
|
|
|
|
try { accept(await getLoadingDocuments(number)); await previews() }
|
2026-10-26 17:12:38 +08:00
|
|
|
|
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 17:12:38 +08:00
|
|
|
|
function photosFor(slot: LoadingPhotoSlot) { return snapshot.value?.photos.filter(photo => photo.slot === slot) || [] }
|
|
|
|
|
|
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 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) || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
|
2026-10-21 16:58:38 +08:00
|
|
|
|
busy.value = true; error.value = null
|
2026-10-26 17:12:38 +08:00
|
|
|
|
let saved = 0, reconciled = false, bindingFailed = false
|
2026-10-21 16:58:38 +08:00
|
|
|
|
try {
|
|
|
|
|
|
ensureScope()
|
2026-10-26 17:12:38 +08:00
|
|
|
|
for (const file of files) {
|
|
|
|
|
|
const photo = await uploadLoadingPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
|
|
|
|
|
|
const latest = await getLoadingDocuments(number); accept(latest)
|
|
|
|
|
|
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, ...(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, bindingFailed
|
|
|
|
|
|
? needsRefresh.value ? '保存结果尚未核实,请重新读取资料' : '保存响应未确认,已读取最新资料,请核对已保存图片后继续'
|
|
|
|
|
|
: '图片上传失败,已保存照片保留,请重新选择图片')
|
|
|
|
|
|
}
|
2026-10-22 23:10:09 +08:00
|
|
|
|
finally { busy.value = false }
|
2026-10-26 17:12:38 +08:00
|
|
|
|
if (active && (saved || reconciled)) emit('saved')
|
2026-10-21 16:58:38 +08:00
|
|
|
|
}
|
2026-10-22 23:10:09 +08:00
|
|
|
|
onMounted(() => { void load() })
|
2026-10-21 18:51:10 +08:00
|
|
|
|
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="density-dialog-sections">
|
2026-10-26 17:12:38 +08:00
|
|
|
|
<p>可边装车边上传五类装车资料,每类至少 1 张、最多 5 张;装车确认完成且五类齐全后自动进入派送中。</p>
|
|
|
|
|
|
<p v-if="needsRefresh">保存状态尚未核实,请先重新读取资料后再上传。</p>
|
2026-10-22 23:10:09 +08:00
|
|
|
|
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="true" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
2026-10-22 11:56:54 +08:00
|
|
|
|
<table class="density-table loading-documents-table" aria-label="装车照片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">照片类别</th><th>文件名称</th><th class="photo-preview">图片预览</th><th class="photo-time">保存时间</th></tr></thead><tbody>
|
2026-10-26 17:12:38 +08:00
|
|
|
|
<tr v-for="slot in loadingPhotoSlots" :key="slot">
|
|
|
|
|
|
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy || photosFor(slot).length >= 5 }">{{ photosFor(slot).length >= 5 ? '已满 5 张' : '上传图片' }}<input type="file" multiple accept="image/jpeg,image/png" :aria-label="`上传${loadingPhotoLabels[slot]}`" :disabled="busy || photosFor(slot).length >= 5" @change="upload(slot, $event)" /></label><span v-else>—</span></td>
|
|
|
|
|
|
<td>{{ loadingPhotoLabels[slot] }}<div>{{ photosFor(slot).length }}/5 张</div></td>
|
2026-10-22 11:56:54 +08:00
|
|
|
|
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.fileName }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">未上传</span></td>
|
|
|
|
|
|
<td><template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
|
2026-10-21 16:58:38 +08:00
|
|
|
|
<el-image v-if="images[photo.fileId]" :src="images[photo.fileId]" :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>
|
2026-10-26 17:12:38 +08:00
|
|
|
|
<label v-if="editable" class="photo-upload">更换<input type="file" accept="image/jpeg,image/png" :aria-label="`更换${photo.fileName}`" :disabled="busy" @change="upload(slot, $event, photo.fileId)" /></label>
|
2026-10-22 11:56:54 +08:00
|
|
|
|
</template><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
|
|
|
|
|
|
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.savedAt?.replace('T', ' ') || '—' }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody></table>
|
2026-10-21 16:58:38 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<style scoped>
|
2026-10-22 11:56:54 +08:00
|
|
|
|
.loading-documents-table { table-layout: fixed; }
|
|
|
|
|
|
.loading-documents-table td { overflow-wrap: anywhere; }
|
|
|
|
|
|
.photo-actions { width: 110px; }
|
|
|
|
|
|
.photo-category { width: 120px; }
|
|
|
|
|
|
.photo-preview { width: 140px; }
|
|
|
|
|
|
.photo-time { width: 190px; }
|
|
|
|
|
|
.loading-photo-image { display: block; width: 100px; height: 64px; }
|
|
|
|
|
|
.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>
|