Files
client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue
T

342 lines
15 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 { computed, nextTick, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, FileText, LockKeyhole, Pencil, Trash2, Upload } from '@lucide/vue'
import {
MERCHANT_CUSTOMS_STATUS_LABELS,
deleteMerchantCustomsDocument,
downloadMerchantCustomsCompletion,
downloadMerchantCustomsSource,
getMerchantCustomsForOrder,
merchantCustomsErrorMessage,
replaceMerchantCustomsDocument,
updateMerchantCustomsDocumentInbounds,
uploadMerchantCustomsDocument,
validateMerchantCustomsFile,
type MerchantCustomsContainer,
type MerchantCustomsDocument,
} from '@/modules/first-leg/api/customs'
const props = defineProps<{
modelValue: boolean
orderId: string
canEdit: boolean
initialDetail?: MerchantCustomsContainer | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
updated: [value: MerchantCustomsContainer]
}>()
const detail = ref<MerchantCustomsContainer | null>(null)
const loading = ref(false)
const errorMessage = ref('')
const selectedInboundIds = ref<string[]>([])
const editingDocument = ref<MerchantCustomsDocument | null>(null)
const editingInboundIds = ref<string[]>([])
const replacingDocument = ref<MerchantCustomsDocument | null>(null)
const uploadInput = ref<HTMLInputElement>()
const replaceInput = ref<HTMLInputElement>()
const mutating = ref(false)
const downloadingKey = ref('')
const taxRefundInbounds = computed(() => detail.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND') ?? [])
const locked = computed(() => Boolean(detail.value?.startedAt) || detail.value?.status === 'DECLARING' || detail.value?.status === 'COMPLETED')
const merchantStatusLabel = computed(() => detail.value?.merchantStatus
? MERCHANT_CUSTOMS_STATUS_LABELS[detail.value.merchantStatus]
: '待上传资料')
const statusTagType = computed(() => ({
WAITING_UPLOAD: 'warning',
DOCUMENT_UPLOADED: 'success',
DECLARING: 'primary',
CUSTOMS_COMPLETED: 'success',
} as const)[detail.value?.merchantStatus ?? 'WAITING_UPLOAD'])
function initializeSelection(value: MerchantCustomsContainer) {
const currentOrderIds = value.inbounds
.filter((item) => item.taxMode === 'TAX_REFUND' && item.orderId === props.orderId)
.map((item) => item.inboundId)
selectedInboundIds.value = currentOrderIds
}
async function loadDetail() {
if (!props.orderId) return
loading.value = true
errorMessage.value = ''
try {
const result = await getMerchantCustomsForOrder(props.orderId)
detail.value = result
initializeSelection(result)
emit('updated', result)
} catch (error) {
detail.value = null
errorMessage.value = merchantCustomsErrorMessage(error, '退税报关资料加载失败')
} finally {
loading.value = false
}
}
watch(() => props.modelValue, (open) => {
if (!open) return
detail.value = props.initialDetail ?? null
if (detail.value) initializeSelection(detail.value)
void loadDetail()
})
function close() {
emit('update:modelValue', false)
}
function chooseUpload() {
if (!selectedInboundIds.value.length) {
ElMessage.warning('请先选择至少一个退税入仓码')
return
}
uploadInput.value?.click()
}
async function handleUploadFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || !detail.value) return
try {
validateMerchantCustomsFile(file)
mutating.value = true
await uploadMerchantCustomsDocument(detail.value.loadingPlanId, selectedInboundIds.value, file)
ElMessage.success('资料上传成功')
await loadDetail()
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '资料上传失败'))
} finally {
mutating.value = false
}
}
function beginEditInbounds(document: MerchantCustomsDocument) {
editingDocument.value = document
editingInboundIds.value = [...document.inboundIds]
}
async function saveInbounds() {
if (!editingDocument.value || !editingInboundIds.value.length) {
ElMessage.warning('请至少保留一个关联入仓码')
return
}
try {
mutating.value = true
await updateMerchantCustomsDocumentInbounds(editingDocument.value, editingInboundIds.value)
editingDocument.value = null
ElMessage.success('关联入仓码已更新')
await loadDetail()
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '关联入仓码更新失败'))
} finally {
mutating.value = false
}
}
async function chooseReplacement(document: MerchantCustomsDocument) {
replacingDocument.value = document
editingInboundIds.value = [...document.inboundIds]
await nextTick()
replaceInput.value?.click()
}
async function handleReplacementFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
const document = replacingDocument.value
replacingDocument.value = null
if (!file || !document) return
try {
validateMerchantCustomsFile(file)
mutating.value = true
await replaceMerchantCustomsDocument(document, editingInboundIds.value, file)
ElMessage.success('资料已覆盖并生成新版本')
await loadDetail()
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '资料覆盖失败'))
} finally {
mutating.value = false
}
}
async function removeDocument(document: MerchantCustomsDocument) {
try {
await ElMessageBox.confirm(`确认删除误传资料“${document.source.fileName}”吗?历史版本仍会由系统保留。`, '删除资料', {
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
})
mutating.value = true
await deleteMerchantCustomsDocument(document)
ElMessage.success('资料已删除')
await loadDetail()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(merchantCustomsErrorMessage(error, '资料删除失败'))
} finally {
mutating.value = false
}
}
function saveDownload(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
async function downloadFile(document: MerchantCustomsDocument, kind: 'source' | 'completion') {
const key = `${document.documentId}:${kind}`
downloadingKey.value = key
try {
const result = kind === 'source'
? await downloadMerchantCustomsSource(document)
: await downloadMerchantCustomsCompletion(document)
saveDownload(result.blob, result.fileName)
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '文件下载失败'))
} finally {
if (downloadingKey.value === key) downloadingKey.value = ''
}
}
function formatDateTime(value: string | null | undefined) {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
</script>
<template>
<el-dialog
:model-value="modelValue"
class="customs-documents-dialog"
title="退税报关资料"
width="920px"
append-to-body
destroy-on-close
@close="close"
>
<el-skeleton v-if="loading && !detail" :rows="6" animated />
<div v-else-if="errorMessage" class="customs-error" role="alert">
<strong>退税报关资料加载失败</strong>
<span>{{ errorMessage }}</span>
<el-button :loading="loading" @click="loadDetail">重新加载</el-button>
</div>
<template v-else-if="detail">
<header class="customs-summary">
<div>
<span>柜次</span>
<strong>{{ detail.containerNo || detail.cabinetNo || detail.planNo || '—' }}</strong>
</div>
<div>
<span>资料覆盖</span>
<strong>{{ detail.coveredTaxRefundCount }} / {{ detail.taxRefundCount }} 票</strong>
</div>
<el-tag :type="statusTagType" effect="light">{{ merchantStatusLabel }}</el-tag>
</header>
<div v-if="locked" class="customs-locked" role="status">
<LockKeyhole :size="18" />
<div><strong>报关资料已锁定</strong><span>报关开始后仅可查看和下载,不提供退回或解锁。</span></div>
</div>
<section class="customs-section">
<div class="customs-section-title">
<div><h3>上传资料</h3><p>支持任意格式,单个文件不超过 50MB;可关联同柜内当前企业的一票或多票退税入仓码。</p></div>
<el-button v-if="canEdit && !locked" type="primary" :loading="mutating" @click="chooseUpload"><Upload :size="16" />选择文件上传</el-button>
</div>
<el-checkbox-group v-model="selectedInboundIds" class="customs-inbound-options" :disabled="!canEdit || locked" aria-label="选择资料关联入仓码">
<el-checkbox v-for="item in taxRefundInbounds" :key="item.inboundId" :value="item.inboundId">
<span class="customs-inbound-code">{{ item.inboundCode }}</span>
<!-- <small>{{ item.orderNo }}</small> -->
</el-checkbox>
</el-checkbox-group>
<input ref="uploadInput" class="customs-hidden-input" type="file" @change="handleUploadFile">
<input ref="replaceInput" class="customs-hidden-input" type="file" @change="handleReplacementFile">
</section>
<section class="customs-section">
<div class="customs-section-title">
<div><h3>资料版本历史</h3><p>每份当前资料展示服务端版本、关联票和上传时间;覆盖及删除操作由服务端保留不可变历史。</p></div>
</div>
<el-empty v-if="!detail.documents.length" description="尚未上传退税报关资料" :image-size="54" />
<div v-else class="customs-document-list">
<article v-for="item in detail.documents" :key="item.documentId" class="customs-document-card">
<div class="customs-document-main">
<FileText :size="20" />
<div>
<strong>{{ item.source.fileName }}</strong>
<span>版本 V{{ item.source.version }} · {{ formatSize(item.source.sizeBytes) }} · {{ formatDateTime(item.source.uploadedAt) }}</span>
<small>关联入仓码:{{ item.inboundCodes.join('、') || '—' }}</small>
</div>
</div>
<div class="customs-document-actions">
<el-button link type="primary" :loading="downloadingKey === `${item.documentId}:source`" @click="downloadFile(item, 'source')"><Download :size="15" />源文件</el-button>
<el-button v-if="detail.status === 'COMPLETED' && item.completion" link type="success" :loading="downloadingKey === `${item.documentId}:completion`" @click="downloadFile(item, 'completion')"><Download :size="15" />完成文件</el-button>
<template v-if="canEdit && !locked && !item.frozen">
<el-button link type="primary" :disabled="mutating" @click="beginEditInbounds(item)"><Pencil :size="15" />改关联</el-button>
<el-button link type="primary" :disabled="mutating" @click="chooseReplacement(item)"><Upload :size="15" />覆盖</el-button>
<el-button link type="danger" :disabled="mutating" @click="removeDocument(item)"><Trash2 :size="15" />删除</el-button>
</template>
</div>
<div v-if="editingDocument?.documentId === item.documentId" class="customs-scope-editor">
<el-checkbox-group v-model="editingInboundIds" aria-label="修改资料关联入仓码">
<el-checkbox v-for="inbound in taxRefundInbounds" :key="inbound.inboundId" :value="inbound.inboundId">{{ inbound.inboundCode }}</el-checkbox>
</el-checkbox-group>
<div><el-button @click="editingDocument = null">取消</el-button><el-button type="primary" :loading="mutating" @click="saveInbounds">保存关联</el-button></div>
</div>
</article>
</div>
</section>
</template>
<template #footer><el-button @click="close">关闭</el-button></template>
</el-dialog>
</template>
<style scoped>
.customs-error { display: grid; gap: 10px; justify-items: start; padding: 22px; border: 1px solid #f1b8b8; background: #fff7f7; color: #9f2d2d; }
.customs-error span { overflow-wrap: anywhere; font-size: 12px; }
.customs-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)) auto; align-items: center; gap: 18px; padding: 16px 18px; border: 1px solid var(--border); background: #f8faf9; }
.customs-summary div { min-width: 0; }
.customs-summary span { display: block; color: var(--muted); font-size: 11px; }
.customs-summary strong { display: block; margin-top: 5px; overflow-wrap: anywhere; color: var(--navy); font-size: 14px; }
.customs-locked { display: flex; align-items: flex-start; gap: 10px; margin-top: 14px; padding: 13px 15px; border: 1px solid #d5dde1; background: #f5f7f8; color: var(--muted); }
.customs-locked strong, .customs-locked span { display: block; }
.customs-locked strong { color: var(--navy); font-size: 12px; }
.customs-locked span { margin-top: 3px; font-size: 11px; }
.customs-section { margin-top: 18px; }
.customs-section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 12px; }
.customs-section-title h3 { margin: 0; color: var(--navy); font-size: 14px; }
.customs-section-title p { margin: 5px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
.customs-inbound-options { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 13px 15px; border: 1px solid var(--border); }
.customs-inbound-options :deep(.el-checkbox) { margin-right: 0; }
.customs-inbound-code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.customs-inbound-options small { margin-left: 7px; color: var(--muted); }
.customs-hidden-input { display: none; }
.customs-document-list { display: grid; gap: 10px; }
.customs-document-card { padding: 14px 16px; border: 1px solid var(--border); }
.customs-document-main { display: flex; align-items: flex-start; gap: 11px; min-width: 0; }
.customs-document-main > svg { flex: 0 0 20px; color: var(--primary); }
.customs-document-main > div { min-width: 0; }
.customs-document-main strong, .customs-document-main span, .customs-document-main small { display: block; overflow-wrap: anywhere; }
.customs-document-main strong { color: var(--navy); font-size: 13px; }
.customs-document-main span, .customs-document-main small { margin-top: 4px; color: var(--muted); font-size: 11px; }
.customs-document-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 2px; margin-top: 8px; }
.customs-scope-editor { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--border); }
.customs-scope-editor :deep(.el-checkbox) { margin-right: 16px; }
</style>