优化
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user