Files
admin-web/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue
T
2026-10-20 23:34:11 +08:00

134 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 '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, ref } from 'vue'
import { FileUp } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { downloadOceanBookingDocument, invalidateOceanBookingDocument, uploadOceanBookingDocument } from '../api/platformOceanBookings'
import type { OceanBookingDocument, OceanBookingDocumentType, OceanBookingStatus } from '../types'
const props = defineProps<{ bookingId: string; bookingNo?: string | null; bookingVersion: number; bookingStatus: OceanBookingStatus; documents: OceanBookingDocument[]; canManage: boolean; manageReason?: string }>()
const emit = defineEmits<{ updated: [documents: OceanBookingDocument[], bookingVersion: number] }>()
const busyType = ref<OceanBookingDocumentType | ''>('')
const busyDocumentId = ref('')
const error = ref<ApiErrorDetails | null>(null)
const maxFileBytes = 50 * 1024 * 1024
const types: Array<{ value: OceanBookingDocumentType; label: string; description: string }> = [
{ value: 'BOOKING_CONFIRMATION', label: '订舱确认文件', description: '用于确认已订妥舱位及放舱前资料完整性' },
{ value: 'BILL_OF_LADING', label: '提单文件', description: '用于提单号和拖车派车前资料校验' },
]
const editable = computed(() => props.bookingStatus === 'DRAFT' && props.canManage)
const validDocuments = computed(() => props.documents.filter((item) => item.valid))
const invalidDocuments = computed(() => props.documents.filter((item) => !item.valid))
function documentDialogTitle(action: string) { const code = props.bookingNo?.trim(); return code ? `${action} · ${code}` : action }
function documentsOfType(type: OceanBookingDocumentType) { return validDocuments.value.filter((item) => item.documentType === type) }
function fileSize(value: number | null) {
if (value == null) return '大小未知'
if (value < 1024) return `${value} B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
function saveBlob(blob: Blob, name: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = name || '订舱资料'
anchor.click()
URL.revokeObjectURL(url)
}
async function download(item: OceanBookingDocument) {
if (busyDocumentId.value || busyType.value) return
busyDocumentId.value = item.id
error.value = null
try {
saveBlob(await downloadOceanBookingDocument(props.bookingId, item.id), item.fileName)
} catch (cause) {
error.value = getApiErrorDetails(cause, '订舱资料下载失败,请稍后重试')
} finally {
busyDocumentId.value = ''
}
}
async function invalidate(item: OceanBookingDocument, expectedVersion = props.bookingVersion) {
const result = await invalidateOceanBookingDocument(props.bookingId, item.id, expectedVersion)
const nextDocuments = props.documents.map((document) => document.id === item.id ? { ...document, valid: false, invalidatedAt: new Date().toISOString() } : document)
emit('updated', nextDocuments, result.bookingVersion)
return { documents: nextDocuments, version: result.bookingVersion }
}
async function remove(item: OceanBookingDocument) {
if (busyDocumentId.value || busyType.value || !editable.value) return
busyDocumentId.value = item.id
error.value = null
try {
await ElMessageBox.confirm(`确认作废“${item.fileName}”?文件历史仍会保留。`, documentDialogTitle('作废订舱资料'), { customClass: 'admin-density-confirm', confirmButtonText: '确认作废', cancelButtonText: '取消', type: 'warning' })
await invalidate(item)
ElMessage.success('订舱资料已作废')
} catch (cause) {
if (cause === 'cancel' || cause === 'close') return
error.value = getApiErrorDetails(cause, '订舱资料作废失败,请稍后重试')
} finally {
busyDocumentId.value = ''
}
}
async function selectFile(event: Event, type: OceanBookingDocumentType) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || !editable.value || busyType.value || busyDocumentId.value) return
if (file.size > maxFileBytes) {
error.value = { message: '单个订舱资料文件不能超过 50MB,请重新选择' }
return
}
const previous = documentsOfType(type)
busyType.value = type
error.value = null
try {
if (previous.length) {
await ElMessageBox.confirm(`将上传“${file.name}”并作废当前 ${previous.length} 份同类型文件,是否继续?`, documentDialogTitle('替换订舱资料'), { customClass: 'admin-density-confirm', confirmButtonText: '确认替换', cancelButtonText: '取消', type: 'warning' })
}
const uploaded = await uploadOceanBookingDocument(props.bookingId, props.bookingVersion, type, file)
let documents = [...props.documents, uploaded.document]
let version = uploaded.bookingVersion
emit('updated', documents, version)
for (const item of previous) {
const invalidated = await invalidateOceanBookingDocument(props.bookingId, item.id, version)
version = invalidated.bookingVersion
documents = documents.map((document) => document.id === item.id ? { ...document, valid: false, invalidatedAt: new Date().toISOString() } : document)
emit('updated', documents, version)
}
ElMessage.success(previous.length ? '订舱资料已替换' : '订舱资料已上传')
} catch (cause) {
if (cause === 'cancel' || cause === 'close') return
error.value = getApiErrorDetails(cause, '订舱资料上传失败,请稍后重试')
} finally {
busyType.value = ''
}
}
</script>
<template>
<section class="booking-documents workbench-panel is-compact admin-density admin-workbench" aria-label="订舱资料">
<div class="booking-documents__heading"><h2>订舱资料<InlineHelp title="订舱资料说明"><p>文件统一归属当前订舱;放舱要求至少一份有效的订舱确认文件或提单文件。</p></InlineHelp></h2><el-tag v-if="bookingStatus === 'RELEASED'" type="info">已放舱 · 永久只读</el-tag></div>
<el-alert v-if="bookingStatus === 'DRAFT' && !editable && manageReason" type="warning" :closable="false" show-icon :title="manageReason" />
<el-alert v-if="error" type="error" :closable="true" show-icon @close="error = null"><template #title>{{ error.message }}</template><ApiErrorMeta :details="error" /></el-alert>
<div class="booking-document-types">
<article v-for="type in types" :key="type.value" class="booking-document-card">
<div class="booking-document-card__heading"><h3>{{ type.label }}<InlineHelp :title="`${type.label}说明`"><p>{{ type.description }}</p><p>任意格式,单文件不超过 50MB</p></InlineHelp></h3><label v-if="editable" class="booking-document-upload" :class="{ 'is-disabled': Boolean(busyType || busyDocumentId) }"><FileUp :size="15" /><span>{{ busyType === type.value ? '上传处理中' : (documentsOfType(type.value).length ? '替换文件' : '上传文件') }}</span><input type="file" :disabled="Boolean(busyType || busyDocumentId)" @change="selectFile($event, type.value)" /></label></div>
<p v-if="!documentsOfType(type.value).length" class="booking-document-empty" role="status">暂无有效文件</p>
<ul v-else class="booking-document-list"><li v-for="item in documentsOfType(type.value)" :key="item.id"><div><strong :title="item.fileName">{{ item.fileName }}</strong><small>{{ fileSize(item.sizeBytes) }} · {{ item.uploadedByName || '—' }} · {{ item.uploadedAt }}</small></div><div class="booking-document-actions density-actions"><el-button class="density-action" size="small" plain :loading="busyDocumentId === item.id" @click="download(item)">下载</el-button><el-button data-tone="danger" class="density-action" v-if="editable" size="small" type="danger" plain :loading="busyDocumentId === item.id" @click="remove(item)">作废</el-button></div></li></ul>
</article>
</div>
<details v-if="invalidDocuments.length" class="booking-document-history"><summary>查看已作废资料({{ invalidDocuments.length }})</summary><ul><li v-for="item in invalidDocuments" :key="item.id"><span>{{ item.fileName }}</span><small>{{ item.invalidatedByName || '—' }} · {{ item.invalidatedAt || '—' }}</small></li></ul></details>
</section>
</template>
<style scoped>
.booking-documents__heading,.booking-document-card__heading,.booking-document-list li,.booking-document-actions{display:flex;align-items:center}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{justify-content:space-between;gap:var(--admin-space-5)}.booking-documents__heading{margin-bottom:var(--admin-space-5)}.booking-documents h2,.booking-document-card h3{margin:0}.booking-documents__heading h2{font-size:var(--admin-font-section)}.booking-document-types{display:grid;grid-template-columns:1fr;gap:0}.booking-document-card{min-width:0;border:0;border-top:1px solid var(--workbench-border);border-radius:0;padding:var(--admin-space-3) 0}.booking-document-card h3{font-size:var(--admin-font-section)}.booking-document-upload{display:flex;flex:0 0 auto;align-items:center;gap:var(--admin-space-2);min-height:var(--admin-control-height);padding:0 var(--admin-space-5);border:1px solid var(--workbench-primary);border-radius:var(--admin-radius);color:var(--workbench-primary);font-size:var(--admin-font-caption);cursor:pointer}.booking-document-upload.is-disabled{cursor:not-allowed;opacity:.55}.booking-document-upload input{position:absolute;width:1px;height:1px;overflow:hidden;opacity:0}.booking-document-list,.booking-document-history ul{display:grid;gap:var(--admin-space-3);margin:var(--admin-space-5) 0 0;padding:0;list-style:none}.booking-document-list li{min-width:0;border-radius:0;background:transparent;border-bottom:1px solid var(--workbench-border);padding:var(--admin-space-2) 0}.booking-document-list li>div:first-child{display:grid;min-width:0;gap:3px}.booking-document-list strong{white-space:normal;overflow-wrap:anywhere}.booking-document-list small,.booking-document-history small{color:var(--workbench-muted);font-size:var(--admin-font-caption)}.booking-document-actions{flex:0 0 auto}.booking-document-actions :deep(.el-button){margin-left:0}.booking-document-history{margin-top:var(--admin-space-5);color:var(--workbench-muted);font-size:var(--admin-font-caption)}.booking-document-history summary{cursor:pointer}.booking-document-history li{display:flex;justify-content:space-between;gap:var(--admin-space-5);padding:7px var(--admin-space-4);border-bottom:1px solid var(--workbench-border)}@media(max-width:900px){.booking-document-types{grid-template-columns:1fr;gap:0}}@media(max-width:560px){.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{align-items:flex-start;flex-direction:column}.booking-document-actions{width:100%}}
.booking-document-history li span{min-width:0;overflow-wrap:anywhere}
.booking-documents__heading h2,.booking-document-card__heading h3{display:flex;align-items:center;gap:var(--admin-space-1)}
.booking-document-empty{margin:var(--admin-space-3) 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}
</style>