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

154 lines
12 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 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>
<el-skeleton v-if="loading && !snapshot" :rows="6" animated />
<template v-if="snapshot">
<section v-for="group in visibleGroups" :key="group.category" class="document-group density-dialog-section" :data-category="group.category" :aria-label="group.label">
<header>
<h3 class="density-dialog-section__title">{{ group.label }}<small>{{ group.files.length }} 个文件</small></h3>
<el-upload v-if="canEdit" :key="uploadKey" class="document-group-upload" :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>
<el-form v-if="group.category === 'BOL'" class="density-form document-bol-form" label-position="left" label-width="80px" :disabled="busy || conflict || loading">
<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>
</el-form>
<p v-if="!group.files.length" class="density-dialog-note">尚未上传文件</p>
<table v-else class="density-table document-files"><thead><tr><th scope="col" class="document-file-actions">操作</th><th scope="col">文件名称</th></tr></thead><tbody>
<tr v-for="file in group.files" :key="file.fileId" class="document-file">
<td><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" :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><div class="document-file-name"><LineClampText :content="file.fileName" :lines="2" class="document-file-label" /><el-tag v-if="!isSaved(group.category, file.fileId)" class="document-file-pending" type="warning" size="small">待保存</el-tag></div></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 { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--admin-space-3); margin: 0; }
.document-group-upload { margin-left: auto; }
.document-bol-form { max-width: 460px; margin-bottom: var(--admin-space-4); }
.document-files { table-layout: fixed; }
.document-file-actions { width: 180px; }
.document-file-name { display: flex; align-items: center; gap: var(--admin-space-3); }
.document-file-label { flex: 1; min-width: 0; }
.document-file-pending { flex-shrink: 0; }
</style>