Files
admin-web/src/modules/overseas-warehouse/components/DispatchLooseboardExecution.vue
T
2026-11-02 17:15:37 +08:00

148 lines
11 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 { 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 { getLooseboardExecution, uploadLooseboardPhoto, saveLooseboardPhotos, readLooseboardPhoto, type LooseboardExecution, type LooseboardExecutionItem, type LooseboardPhoto, type LooseboardPhotoPhase } from '../api/dispatchLooseboardExecution'
const props = defineProps<{ dispatchNo: string }>()
const emit = defineEmits<{ saved: []; mode: [direct: boolean] }>()
const access = useAccessPreviewStore()
const snapshot = ref<LooseboardExecution | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const previews = ref<Record<string, string>>({})
const pending = ref<{ itemId: string; phase: LooseboardPhotoPhase; photos: LooseboardPhoto[] } | null>(null)
const dirty = computed(() => Boolean(pending.value?.photos.length))
const phases: { key: LooseboardPhotoPhase; label: string }[] = [{ key: 'PICKUP', label: '取货照片' }, { key: 'ARRIVAL', label: '送达照片' }]
function uploadReason(item: LooseboardExecutionItem, phase: LooseboardPhotoPhase): string {
if (busy.value) return '正在处理,请稍候'
if (pending.value) return '请先保存已上传照片'
if ((phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos).length >= 20) return '每个节点最多20张照片'
if (phase === 'PICKUP' ? item.canPickup : item.canArrive) return ''
if (phase === 'ARRIVAL' && !item.pickupPhotos.length) return '请先上传取货照片'
if (phase === 'PICKUP' && !snapshot.value?.direct && snapshot.value?.stage === 'LOADING') return '完成仓内拣货并确认装车后可上传'
return '当前状态不可上传'
}
const number = props.dispatchNo
let active = true
function accept(value: LooseboardExecution) {
if (!active || number !== props.dispatchNo || value.dispatchNo !== number) throw new Error('排车身份已变化,请重新打开')
snapshot.value = value; emit('mode', value.direct)
}
async function images() {
for (const item of snapshot.value?.items || []) for (const phase of phases) {
const photos = phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
await Promise.allSettled(photos.map(async photo => {
if (previews.value[photo.fileId]) return
const blob = await readLooseboardPhoto(number, item.itemId, phase.key, photo)
if (active) previews.value[photo.fileId] = URL.createObjectURL(blob)
}))
}
}
async function load() {
if (busy.value) return
busy.value = true; error.value = null
try { accept(await getLooseboardExecution(number)); await images() }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '散板履约资料读取失败') }
finally { busy.value = false }
}
async function save() {
if (!pending.value?.photos.length || !snapshot.value || busy.value) return
busy.value = true; error.value = null
const value = pending.value
try {
const item = snapshot.value.items.find(row => row.itemId === value.itemId)
if (!item) throw new Error('散板明细已变化,请重新打开')
const existing = value.phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
accept(await saveLooseboardPhotos(number, value.itemId, value.phase, { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileIds: [...existing, ...value.photos].map(photo => photo.fileId) }))
pending.value = null; await images(); ElMessage.success('散板照片已保存'); emit('saved')
} catch (cause) {
if (active) {
error.value = getApiErrorDetails(cause, '保存失败,已上传照片保留,请核对当前状态后重试')
try {
accept(await getLooseboardExecution(number)); await images()
const item = snapshot.value?.items.find(row => row.itemId === value.itemId)
const bound = (value.phase === 'PICKUP' ? item?.pickupPhotos : item?.arrivalPhotos) || []
if (value.photos.every(photo => bound.some(file => file.fileId === photo.fileId))) { pending.value = null; emit('saved') }
} catch { /* 保留照片和当前错误,用户可重新读取。 */ }
}
} finally { busy.value = false }
}
async function upload(itemId: string, phase: LooseboardPhotoPhase, event: Event) {
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); input.value = ''
const item = snapshot.value?.items.find(row => row.itemId === itemId)
if (!files.length || !item || uploadReason(item, phase) || !access.can(permission.dispatchEdit)) return
const existing = phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
if (files.length + existing.length > 20 || files.some(file => !['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('每个节点最多20张JPG或PNG,每张不超过10MiB'); return }
busy.value = true; error.value = null; pending.value = { itemId, phase, photos: [] }; let failed = false
try { for (const file of files) { const photo = await uploadLooseboardPhoto(number, itemId, phase, file); if (!active) return; pending.value.photos.push(photo) } }
catch (cause) { failed = true; error.value = getApiErrorDetails(cause, '照片上传失败,已成功上传的照片可继续保存'); if (!pending.value?.photos.length) pending.value = null }
finally { busy.value = false }
if (!failed && pending.value?.photos.length) await save()
}
async function remove(item: LooseboardExecutionItem, phase: LooseboardPhotoPhase, fileId: string) {
if (!snapshot.value || busy.value || pending.value || !access.can(permission.dispatchEdit) || !(phase === 'PICKUP' ? item.canPickup : item.canArrive)) return
busy.value = true; error.value = null
try {
const photos = phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
accept(await saveLooseboardPhotos(number, item.itemId, phase, { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileIds: photos.filter(photo => photo.fileId !== fileId).map(photo => photo.fileId) }))
await images(); ElMessage.success('照片已删除'); emit('saved')
} catch (cause) {
if (active) {
error.value = getApiErrorDetails(cause, '照片删除失败,请刷新后重试')
try { accept(await getLooseboardExecution(number)); await images() } catch { /* 保留原错误。 */ }
}
} finally { busy.value = false }
}
onMounted(() => { void load() })
onBeforeUnmount(() => { active = false; Object.values(previews.value).forEach(URL.revokeObjectURL) })
defineExpose({ busy, dirty })
</script>
<template>
<div v-loading="busy" class="looseboard-execution">
<el-button :disabled="busy" @click="load">刷新资料</el-button>
<LoadErrorState v-if="error" compact title="散板履约资料操作失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="pending"><span>已上传 {{ pending.photos.length }} 张,尚未保存。</span><el-button :disabled="busy" @click="save">保存已上传照片</el-button></div>
<section v-for="item in snapshot?.items || []" :key="item.itemId" class="looseboard-cargo">
<h3>{{ item.orderNo }} · {{ item.destination || '—' }} · {{ item.packages }} 箱</h3>
<div v-for="phase in phases" :key="phase.key" class="looseboard-node">
<h4>{{ phase.label }}</h4>
<label v-if="access.can(permission.dispatchEdit)" class="node-upload" :class="{ 'is-disabled': Boolean(uploadReason(item, phase.key)) }" :title="uploadReason(item, phase.key)">
上传{{ phase.label }}
<input type="file" multiple accept="image/jpeg,image/png" :aria-label="`${item.orderNo}上传${phase.label}`" :disabled="Boolean(uploadReason(item, phase.key))" @change="upload(item.itemId, phase.key, $event)" />
</label>
<span v-if="access.can(permission.dispatchEdit) && uploadReason(item, phase.key)" class="upload-reason">{{ uploadReason(item, phase.key) }}</span>
<p v-if="!(phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos).length">尚未上传{{ phase.label }}</p>
<div v-else class="node-photos">
<div v-for="photo in phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos" :key="photo.fileId" class="node-photo">
<el-image v-if="previews[photo.fileId]" :src="previews[photo.fileId]" :preview-src-list="[previews[photo.fileId]!]" preview-teleported fit="contain" class="node-image" :alt="phase.label" />
<button v-if="access.can(permission.dispatchEdit) && (phase.key === 'PICKUP' ? item.canPickup : item.canArrive)" type="button" class="node-photo-remove" :aria-label="`删除${phase.label}${photo.fileName}`" title="删除照片" :disabled="busy || Boolean(pending)" @click.stop="remove(item, phase.key, photo.fileId)"><span aria-hidden="true">×</span></button>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.looseboard-execution { display: flex; flex-direction: column; gap: 12px; }
.looseboard-cargo { border: 1px solid var(--cc-border, var(--el-border-color)); }
.looseboard-cargo > h3 { margin: 0; padding: 8px 12px; font-size: 14px; background: var(--el-fill-color-light); }
.looseboard-node { padding: 8px 12px; border-top: 1px solid var(--el-border-color-lighter); }
.looseboard-node h4 { display: inline-block; margin: 0 16px 8px 0; font-size: 14px; }
.looseboard-node p { color: var(--el-text-color-secondary); }
.node-photos { display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0 4px; }
.node-photo { position: relative; flex: 0 0 160px; width: 160px; height: 100px; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); background: var(--el-fill-color-light); }
.node-image { display: block; width: 100%; height: 100%; border-radius: inherit; }
.node-photo-remove { position: absolute; top: 4px; right: 4px; z-index: 1; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; background: var(--el-color-danger); color: var(--el-color-white); font-size: 20px; line-height: 1; cursor: pointer; }
.node-photo-remove:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.node-photo-remove:disabled { opacity: .5; cursor: not-allowed; }
.node-upload { position: relative; display: inline-flex; align-items: center; padding: 6px 12px; border: 1px solid var(--el-color-primary); border-radius: var(--el-border-radius-base); color: var(--el-color-primary); cursor: pointer; }
.node-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.node-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.upload-reason { margin-left: 8px; color: var(--el-text-color-secondary); font-size: 12px; }
.node-upload.is-disabled input { cursor: not-allowed; }
.node-upload.is-disabled { border-color: var(--el-border-color); color: var(--el-text-color-disabled); cursor: not-allowed; }
</style>