134 lines
11 KiB
Vue
134 lines
11 KiB
Vue
<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>
|