148 lines
13 KiB
Vue
148 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminWorkbench.css'
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, downloadDeliveryDocument, type DeliveryDocuments, type DeliveryDocumentCategory, type DeliveryDocumentFile } from '../api/deliveryDocuments'
|
||
|
||
const props = defineProps<{ deliveryNo: string; customerId: string; embedded?: boolean; categories?: DeliveryDocumentCategory[] }>()
|
||
const open = defineModel<boolean>({ default: false })
|
||
const emit = defineEmits<{ saved: [] }>()
|
||
const access = useAccessPreviewStore()
|
||
const snapshot = ref<DeliveryDocuments | null>(null)
|
||
const draft = ref<Array<{ category: DeliveryDocumentCategory; label: string; files: DeliveryDocumentFile[] }>>([])
|
||
const bolNo = ref('')
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const uploading = ref(false)
|
||
const downloading = ref(false)
|
||
const uploadKey = ref(0)
|
||
const error = ref<ApiErrorDetails | null>(null)
|
||
const conflict = ref(false)
|
||
const canEdit = computed(() => access.can(permission.deliveryDocuments) && Boolean(snapshot.value?.allowedActions.includes('MANAGE_DOCUMENTS')))
|
||
const busy = computed(() => saving.value || uploading.value)
|
||
const uploadedCount = computed(() => visibleGroups.value.filter(item => item.files.length > 0).length)
|
||
const visibleGroups = computed(() => draft.value.filter(item => !props.categories || props.categories.includes(item.category)))
|
||
const dirty = computed(() => Boolean(snapshot.value) && (bolNo.value !== (snapshot.value?.bolNo || '') || draft.value.some(group => JSON.stringify(group.files.map(file => file.fileId)) !== JSON.stringify(snapshot.value?.categories.find(item => item.category === group.category)?.files.map(file => file.fileId) || []))))
|
||
defineExpose({ busy, dirty, canEdit, loading, conflict, save })
|
||
let sequence = 0
|
||
onBeforeUnmount(() => { sequence++ })
|
||
function isCurrent(current: number, deliveryNo: string, customerId: string) { return current === sequence && open.value && props.deliveryNo === deliveryNo && props.customerId === customerId }
|
||
let attempt: { body: string; key: string } | null = null
|
||
async function load() {
|
||
const current = ++sequence
|
||
loading.value = true; saving.value = false; uploading.value = false; downloading.value = false; error.value = null; conflict.value = false; snapshot.value = null; draft.value = []; attempt = null
|
||
try {
|
||
const result = await getDeliveryDocuments(props.deliveryNo, props.customerId)
|
||
if (current !== sequence || !open.value) return
|
||
snapshot.value = result; bolNo.value = result.bolNo || ''
|
||
draft.value = deliveryDocumentCategories.map(item => ({ ...item, files: (result.categories.find(group => group.category === item.category)?.files || []).map(file => ({ ...file })) }))
|
||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '派送资料加载失败') }
|
||
finally { if (current === sequence) loading.value = false }
|
||
}
|
||
async function reload() {
|
||
if (busy.value) return
|
||
if (snapshot.value && canEdit.value) {
|
||
try { await ElMessageBox.confirm('重新加载将放弃本次未保存的资料修改,是否继续?', '重新加载资料', { customClass: 'admin-density-confirm admin-workbench is-dialog', type: 'warning' }) } catch { return }
|
||
}
|
||
await load()
|
||
}
|
||
watch(() => [open.value, props.deliveryNo, props.customerId] as const, ([visible]) => {
|
||
if (visible && props.deliveryNo && props.customerId) void load()
|
||
else { sequence++; snapshot.value = null; draft.value = []; error.value = null; bolNo.value = ''; attempt = null }
|
||
}, { immediate: true })
|
||
async function upload(category: DeliveryDocumentCategory, file: UploadFile, replaceId?: string) {
|
||
if (!canEdit.value || busy.value || conflict.value || !file.raw) return
|
||
const group = draft.value.find(item => item.category === category)
|
||
if (!group || (!replaceId && group.files.length >= 20)) { ElMessage.warning('每类最多上传20个文件'); return }
|
||
if (file.raw.size === 0 || file.raw.size > 52428800) { ElMessage.warning('文件不能为空,单个文件不能超过50MiB'); return }
|
||
uploading.value = true; error.value = null
|
||
const current = sequence; const { deliveryNo, customerId } = props
|
||
try {
|
||
const result = await uploadDeliveryDocument(deliveryNo, customerId, category, file.raw)
|
||
if (!isCurrent(current, deliveryNo, customerId)) return
|
||
const index = replaceId ? group.files.findIndex(item => item.fileId === replaceId) : -1
|
||
if (index >= 0) group.files.splice(index, 1, result)
|
||
else group.files.push(result)
|
||
} catch (cause) { if (isCurrent(current, deliveryNo, customerId)) error.value = getApiErrorDetails(cause, '资料上传失败') }
|
||
finally { if (isCurrent(current, deliveryNo, customerId)) { uploading.value = false; uploadKey.value++ } }
|
||
}
|
||
function remove(category: DeliveryDocumentCategory, fileId: string) {
|
||
if (!canEdit.value || busy.value || conflict.value) return
|
||
const group = draft.value.find(item => item.category === category)
|
||
if (group) group.files = group.files.filter(file => file.fileId !== fileId)
|
||
}
|
||
function isSaved(category: DeliveryDocumentCategory, fileId: string) { return snapshot.value?.categories.find(item => item.category === category)?.files.some(file => file.fileId === fileId) }
|
||
async function download(category: DeliveryDocumentCategory, file: DeliveryDocumentFile) {
|
||
if (downloading.value || !isSaved(category, file.fileId)) return
|
||
downloading.value = true
|
||
const current = sequence; const { deliveryNo, customerId } = props
|
||
try {
|
||
const blob = await downloadDeliveryDocument(deliveryNo, customerId, category, file.fileId)
|
||
if (!isCurrent(current, deliveryNo, customerId)) return
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click()
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||
} catch (cause) { if (isCurrent(current, deliveryNo, customerId)) error.value = getApiErrorDetails(cause, '文件下载失败') }
|
||
finally { if (isCurrent(current, deliveryNo, customerId)) downloading.value = false }
|
||
}
|
||
async function save() {
|
||
if (!open.value || !canEdit.value || !snapshot.value || busy.value || conflict.value || loading.value) return
|
||
const body = { expectedVersion: snapshot.value.version, bolNo: bolNo.value.trim() || null, documents: draft.value.map(item => ({ category: item.category, fileIds: item.files.map(file => file.fileId) })) }
|
||
const serialized = JSON.stringify(body)
|
||
if (attempt?.body !== serialized) attempt = { body: serialized, key: crypto.randomUUID() }
|
||
saving.value = true; error.value = null
|
||
const current = sequence; const { deliveryNo, customerId } = props
|
||
try {
|
||
const result = await saveDeliveryDocuments(deliveryNo, customerId, { ...body, idempotencyKey: attempt.key })
|
||
if (!isCurrent(current, deliveryNo, customerId)) return
|
||
const hasReceipt = body.documents.some(item => item.category === 'RECEIPT' && item.fileIds.length > 0)
|
||
if (props.embedded) {
|
||
snapshot.value = result; bolNo.value = result.bolNo || ''; attempt = null
|
||
draft.value = deliveryDocumentCategories.map(item => ({ ...item, files: (result.categories.find(group => group.category === item.category)?.files || []).map(file => ({ ...file })) }))
|
||
} else open.value = false
|
||
emit('saved'); ElMessage.success(hasReceipt ? '签收文件已保存,派送单已自动完成' : '派送资料已保存')
|
||
} catch (cause) { if (isCurrent(current, deliveryNo, customerId)) { error.value = getApiErrorDetails(cause, '派送资料保存失败'); conflict.value = error.value.code === 'A0409' } }
|
||
finally { if (current === sequence) saving.value = false }
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<component :is="embedded ? 'div' : AppDialog" class="admin-density-dialog admin-workbench is-dialog" v-model="open" :title="embedded ? undefined : '派送单所需资料 · ' + (deliveryNo || '—')" width="min(1040px, calc(100vw - 40px))" fullscreenable confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy || loading" :confirm-disabled="conflict || loading" @confirm="save">
|
||
<div class="density-dialog-sections">
|
||
<LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :loading="busy || loading" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||
<section v-if="!embedded" class="density-dialog-section" data-tone="summary" aria-label="派送资料摘要"><h3 class="density-dialog-section__title">资料核对</h3><table class="density-table"><tbody><tr><th scope="row">派送单号</th><td>{{ deliveryNo || '—' }}</td><th scope="row">资料类别</th><td>{{ snapshot ? uploadedCount + '/' + visibleGroups.length + ' 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section>
|
||
<template v-if="snapshot">
|
||
<section v-if="!categories || categories.includes('BOL')" class="density-dialog-section" data-tone="work" aria-label="BOL资料登记"><h3 class="density-dialog-section__title">BOL资料</h3><el-form class="density-form" label-position="left" label-width="80px" :disabled="busy || conflict || loading"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="BOL号"><el-input v-if="canEdit" v-model="bolNo" :disabled="busy || conflict || loading" /><span v-else>{{ bolNo || '—' }}</span></el-form-item></td></tr></tbody></table></el-form></section>
|
||
<section v-for="group in visibleGroups" :key="group.category" class="document-group density-dialog-section" data-tone="source" :data-category="group.category">
|
||
<header><h3 class="density-dialog-section__title">{{ group.label }}</h3><el-tag :type="group.files.length ? 'success' : 'info'">{{ group.files.length ? '已上传' : '待上传' }}</el-tag><span>{{ group.files.length }}/20</span><el-upload v-if="canEdit" :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict || group.files.length >= 20" :on-change="(file: UploadFile) => upload(group.category, file)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict || group.files.length >= 20">上传</el-button></el-upload></header>
|
||
<p v-if="!group.files.length" class="density-dialog-note">尚未上传文件</p>
|
||
<table v-else class="density-table document-files"><thead><tr><th class="document-file-actions">操作</th><th>文件名称</th><th class="document-file-state">保存状态</th><th class="document-file-time">上传时间</th></tr></thead><tbody>
|
||
<tr v-for="file in group.files" :key="file.fileId" class="document-file">
|
||
<td><div class="density-actions"><el-button link :aria-label="'下载 ' + file.fileName" :disabled="downloading || !isSaved(group.category, file.fileId)" @click="download(group.category, file)">下载</el-button><template v-if="canEdit"><el-upload :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict" :on-change="(uploadFile: UploadFile) => upload(group.category, uploadFile, file.fileId)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict">替换</el-button></el-upload><el-button class="density-action" data-tone="danger" :disabled="busy || conflict" @click="remove(group.category, file.fileId)">删除</el-button></template></div></td>
|
||
<td><LineClampText :content="file.fileName" /></td><td>{{ isSaved(group.category, file.fileId) ? '已保存' : '待保存' }}</td><td>{{ file.uploadedAt?.replace('T', ' ') || '—' }}</td>
|
||
</tr>
|
||
</tbody></table>
|
||
</section>
|
||
</template>
|
||
</div>
|
||
</component>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.document-group header { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); margin-bottom: var(--admin-space-3); }
|
||
.document-group header h3 { margin: 0; }
|
||
.document-group header > :last-child { margin-left: auto; }
|
||
.document-files { table-layout: fixed; }
|
||
.document-file-actions { width: 220px; }
|
||
.document-file-time { width: 170px; }
|
||
.document-file-state { width: 90px; }
|
||
</style>
|