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

116 lines
8.1 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 { 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, readLoadingPhoto, recoverLoadingPhoto, uploadLoadingPhoto, type BindLoadingPhoto, type LoadingDocuments, type LoadingPhoto, 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)
interface Pending { slot: LoadingPhotoSlot; key: string; photo?: LoadingPhoto; binding?: BindLoadingPhoto; conflict?: boolean }
const pending = ref<Pending | null>(null), corrupted = ref(false), images = ref<Record<string, string>>({})
const actor = auth.user?.id || '', number = props.dispatchNo, storageKey = `dispatch-loading-documents:${actor}:${number}`
const editable = computed(() => !corrupted.value && access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
let active = true
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
function persist() { if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
function accept(value: LoadingDocuments) { ensureScope(); if (value.dispatchNo !== number) throw new Error('装车资料与排车单不一致'); snapshot.value = value }
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() {
busy.value = true; error.value = null
try { accept(await getLoadingDocuments(number)); await previews() }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '装车资料读取失败') }
finally { if (active) busy.value = false }
}
async function bind() {
const item = pending.value
if (!item?.photo || !snapshot.value) return
if (item.photo.slot !== item.slot) throw new Error('上传结果与装车照片框不一致')
if (!item.binding) { item.binding = { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: item.photo.fileId }; persist() }
try {
const result = await bindLoadingPhoto(number, item.slot, item.binding)
accept(result); pending.value = null; persist(); emit('saved')
ElMessage.success(result.stage === 'DELIVERING' && result.complete ? '装车资料已齐全,本车已进入派送中' : '装车照片已保存')
await previews()
} catch (cause) {
const failure = getApiErrorDetails(cause, '装车照片保存失败')
if (pending.value && /^(?:A0|HTTP_)?409$/.test(String(failure.code))) { pending.value.conflict = true; persist() }
throw cause
}
}
async function recover() {
if (busy.value || !editable.value || !pending.value) return
busy.value = true; error.value = null
try {
ensureScope()
if (pending.value.conflict) { accept(await getLoadingDocuments(number)); pending.value.binding = undefined; pending.value.conflict = false; persist() }
if (!pending.value.photo) { pending.value.photo = await recoverLoadingPhoto(number, pending.value.key); ensureScope(); persist() }
await bind()
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '原上传或保存结果尚未确认') }
finally { if (active) busy.value = false }
}
async function upload(slot: LoadingPhotoSlot, event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || pending.value || !editable.value) return
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
busy.value = true; error.value = null
try {
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
pending.value.photo = await uploadLoadingPhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
2026-10-21 17:49:04 +08:00
} catch (cause) {
if (active) {
error.value = getApiErrorDetails(cause, '装车照片上传或保存失败,请核对原请求')
// 文件校验明确拒绝时尚未创建上传意图,允许重新选择;未知结果仍保留原请求。
if (pending.value && !pending.value.photo && /^(?:A0|HTTP_)?400$/.test(String(error.value.code))) { pending.value = null; persist() }
}
}
2026-10-21 16:58:38 +08:00
finally { if (active) busy.value = false }
}
onMounted(() => {
try {
const raw = sessionStorage.getItem(storageKey)
if (raw) { const value = JSON.parse(raw) as Pending; if (!loadingPhotoSlots.includes(value.slot) || typeof value.key !== 'string' || !value.key) throw new Error('原装车照片请求记录无法读取'); pending.value = value }
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '原装车照片请求记录无法读取') }
if (!corrupted.value) void load()
})
onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(URL.revokeObjectURL) })
defineExpose({ busy })
</script>
<template>
<div v-loading="busy" class="density-dialog-sections">
2026-10-21 17:22:25 +08:00
<p v-if="snapshot?.stage === 'LOADING'">可边装车边上传 5 张装车照片;装车确认完成且照片齐全后自动进入派送中。</p>
2026-10-21 16:58:38 +08:00
<p v-if="snapshot?.stage === 'AFTER_LOADING'">请上传 5 张装车照片,全部保存成功后自动进入派送中。</p>
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="pending" role="status">有装车照片尚未确认保存。<el-button :disabled="busy || !editable" @click="recover">{{ pending.conflict ? '刷新并重试保存' : '核对原请求并继续保存' }}</el-button></div>
<div class="loading-photo-grid">
<section v-for="(slot, index) in loadingPhotoSlots" :key="slot" class="density-dialog-section loading-photo-card">
<h3 class="density-dialog-section__title">装车照片 {{ index + 1 }}</h3>
<template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
<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>
</template>
<div v-if="!snapshot?.photos.some(item => item.slot === slot)" class="loading-photo-empty">未上传</div>
<label v-if="editable">上传/替换图片<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy || Boolean(pending)" @change="upload(slot, $event)" /></label>
</section>
</div>
</div>
</template>
<style scoped>
.loading-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
.loading-photo-image, .loading-photo-empty { width: 100%; height: 150px; margin-bottom: var(--admin-space-3); }
.loading-photo-empty { display: grid; place-items: center; background: var(--cc-bg-soft, #f3f7ff); color: var(--cc-text-secondary, #64748b); }
.loading-photo-card input { display: block; margin-top: var(--admin-space-2); max-width: 100%; }
</style>