feat: add inbound hold evidence controls

This commit is contained in:
2026-09-07 12:21:44 +08:00
parent 60f430ced8
commit 189d1eae11
7 changed files with 382 additions and 14 deletions
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Download, Eye, GitBranch, History, Link2, ListChecks, MoreHorizontal, PackageOpen, Ruler, Send, ShieldAlert, SlidersHorizontal, StickyNote, Undo2, WalletCards } from '@lucide/vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Download, Eye, FileUp, GitBranch, History, Link2, ListChecks, MoreHorizontal, PackageOpen, Ruler, Send, ShieldAlert, SlidersHorizontal, StickyNote, Undo2, WalletCards } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
@@ -63,8 +63,10 @@ import {
getLatestInboundFee,
getInboundMeasurement,
getPlatformInboundHold,
downloadPlatformInboundHoldEvidence,
holdPlatformInbound,
releasePlatformInbound,
uploadPlatformInboundHoldEvidence,
saveInboundMeasurementDraft,
InboundFeePanel,
InboundMeasurementDialog,
@@ -174,6 +176,11 @@ const holdDialogOpen = ref(false)
const holdSaving = ref(false)
const holdAction = ref<'HOLD' | 'RELEASE'>('HOLD')
const holdReason = ref('')
const holdEvidenceFile = ref<File | null>(null)
const holdEvidenceUploadKey = ref(0)
const holdUploadedEvidenceFileId = ref('')
const holdEvidenceError = ref<ApiErrorDetails | null>(null)
const holdDownloadingFileId = ref('')
const relationOpen = ref(false)
const relationOrder = ref<UnifiedOrderResource | null>(null)
const measurementDetail = ref<InboundMeasurementDetail | null>(null)
@@ -1372,10 +1379,46 @@ function openHoldDialog(action: 'HOLD' | 'RELEASE') {
if (!allowed || !holdDetail.value) return
holdAction.value = action
holdReason.value = ''
holdEvidenceFile.value = null
holdUploadedEvidenceFileId.value = ''
holdEvidenceError.value = null
holdEvidenceUploadKey.value += 1
holdDialogOpen.value = true
}
const holdEvidenceMaxBytes = 10 * 1024 * 1024
const holdEvidenceExtensions = new Set(['txt', 'pdf', 'jpg', 'jpeg', 'png'])
function selectHoldEvidenceFile(upload: UploadFile) {
const file = upload.raw || null
holdEvidenceFile.value = null
holdUploadedEvidenceFileId.value = ''
holdEvidenceError.value = null
if (!file) return
const extension = file.name.split('.').pop()?.toLowerCase() || ''
if (!holdEvidenceExtensions.has(extension)) {
holdEvidenceError.value = { code: 'FILE_TYPE_INVALID', message: '关联文件仅支持 TXT、PDF、JPG、PNG' }
holdEvidenceUploadKey.value += 1
return
}
if (file.size <= 0 || file.size > holdEvidenceMaxBytes) {
holdEvidenceError.value = { code: 'FILE_SIZE_INVALID', message: '关联文件不能为空且不能超过 10MB' }
holdEvidenceUploadKey.value += 1
return
}
holdEvidenceFile.value = file
}
function removeHoldEvidenceFile() {
holdEvidenceFile.value = null
holdUploadedEvidenceFileId.value = ''
holdEvidenceError.value = null
}
async function submitHoldAction() {
if (holdSaving.value) return
const allowed = holdAction.value === 'HOLD' ? canHoldInbound.value : canReleaseInbound.value
if (!allowed) return
const reason = holdReason.value.trim()
const inboundId = normalizedInboundId.value
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
@@ -1384,9 +1427,28 @@ async function submitHoldAction() {
ElMessage.warning(holdAction.value === 'HOLD' ? '请填写扣货原因' : '请填写解除原因')
return
}
if (reason.length > 1000) {
ElMessage.warning('原因不能超过 1000 个字符')
return
}
holdSaving.value = true
try {
const command = { expectedVersion: holdDetail.value.version, reason }
if (holdEvidenceFile.value && !holdUploadedEvidenceFileId.value) {
try {
holdEvidenceError.value = null
const uploaded = await uploadPlatformInboundHoldEvidence(tenantId, inboundId, holdEvidenceFile.value)
holdUploadedEvidenceFileId.value = uploaded.fileId
} catch (error) {
holdEvidenceError.value = getApiErrorDetails(error, '关联文件上传失败,请稍后重试')
ElMessage.error(holdEvidenceError.value.message)
return
}
}
const command = {
expectedVersion: holdDetail.value.version,
reason,
...(holdUploadedEvidenceFileId.value ? { evidenceFileId: holdUploadedEvidenceFileId.value } : {}),
}
holdDetail.value = holdAction.value === 'HOLD'
? await holdPlatformInbound(tenantId, inboundId, command)
: await releasePlatformInbound(tenantId, inboundId, command)
@@ -1400,6 +1462,32 @@ async function submitHoldAction() {
}
}
async function downloadHoldEvidence(fileId: string) {
const inboundId = normalizedInboundId.value
const tenantId = normalizedIdentifier(detail.value?.summary.tenantId)
if (!fileId || !inboundId || !tenantId || holdDownloadingFileId.value || !can('platform:firstleg:inbound:list')) return
holdDownloadingFileId.value = fileId
try {
const blob = await downloadPlatformInboundHoldEvidence(tenantId, inboundId, fileId)
const extension = blob.type === 'application/pdf' ? '.pdf'
: blob.type === 'image/png' ? '.png'
: blob.type === 'image/jpeg' ? '.jpg'
: blob.type === 'text/plain' ? '.txt' : ''
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `扣货凭证${extension}`
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '扣货凭证下载失败,请稍后重试').message)
} finally {
holdDownloadingFileId.value = ''
}
}
async function loadCustomers() {
customerLoading.value = true
customerLoadError.value = null
@@ -2318,7 +2406,7 @@ onBeforeUnmount(() => {
<template v-else>
<p v-if="holdDetail?.actionBlockReason && !canHoldInbound && !canReleaseInbound" class="hold-block-reason">{{ holdDetail.actionBlockReason }}</p>
<h3 class="hold-history-title">扣货历史</h3>
<div v-loading="holdLoading" class="hold-history-table"><el-table :data="holdDetail?.history || []" border stripe empty-text="暂无扣货历史"><el-table-column label="操作" width="110"><template #default="{ row }"><el-tag :type="row.action === 'HOLD' ? 'danger' : 'success'" effect="plain">{{ row.action === 'HOLD' ? '扣货' : '解除扣货' }}</el-tag></template></el-table-column><el-table-column prop="reason" label="原因" min-width="260" /><el-table-column label="凭证" width="130"><template #default="{ row }">{{ row.evidenceFileId ? '已关联凭证' : '—' }}</template></el-table-column><el-table-column label="操作人" width="150"><template #default="{ row }">{{ row.operatorName || '系统' }}</template></el-table-column><el-table-column label="操作时间" width="180"><template #default="{ row }">{{ formatDate(row.operatedAt) }}</template></el-table-column></el-table></div>
<div v-loading="holdLoading" class="hold-history-table"><el-table :data="holdDetail?.history || []" border stripe empty-text="暂无扣货历史"><el-table-column label="操作" width="110"><template #default="{ row }"><el-tag :type="row.action === 'HOLD' ? 'danger' : 'success'" effect="plain">{{ row.action === 'HOLD' ? '扣货' : '解除扣货' }}</el-tag></template></el-table-column><el-table-column prop="reason" label="原因" min-width="260" /><el-table-column label="凭证" width="150"><template #default="{ row }"><el-button v-if="row.evidenceFileId" link type="primary" :loading="holdDownloadingFileId === row.evidenceFileId" @click="downloadHoldEvidence(row.evidenceFileId)"><Download :size="14" />下载凭证</el-button><span v-else>—</span></template></el-table-column><el-table-column label="操作人" width="150"><template #default="{ row }">{{ row.operatorName || '系统' }}</template></el-table-column><el-table-column label="操作时间" width="180"><template #default="{ row }">{{ formatDate(row.operatedAt) }}</template></el-table-column></el-table></div>
</template>
</template>
</section>
@@ -2502,9 +2590,9 @@ onBeforeUnmount(() => {
<el-input v-model="noteContent" type="textarea" :rows="5" maxlength="2000" show-word-limit placeholder="请输入仅平台内部可见的备注" aria-label="内部备注内容" />
</AppDialog>
<AppDialog v-model="holdDialogOpen" :title="holdAction === 'HOLD' ? '确认扣货' : '确认解除扣货'" :confirm-text="holdAction === 'HOLD' ? '确认扣货' : '确认解除'" :confirm-type="holdAction === 'HOLD' ? 'danger' : 'primary'" :confirm-loading="holdSaving" width="min(600px, calc(100vw - 28px))" destroy-on-close @confirm="submitHoldAction">
<AppDialog v-model="holdDialogOpen" :title="holdAction === 'HOLD' ? '确认扣货' : '确认解除扣货'" :confirm-text="holdAction === 'HOLD' ? '确认扣货' : '确认解除'" :confirm-type="holdAction === 'HOLD' ? 'danger' : 'primary'" :confirm-loading="holdSaving" :confirm-disabled="!holdReason.trim() || holdReason.trim().length > 1000" width="min(600px, calc(100vw - 28px))" destroy-on-close @confirm="submitHoldAction">
<el-alert :title="holdAction === 'HOLD' ? '扣货后服务端将阻止后续实物履约操作。' : '解除后是否可继续履约仍以服务端状态为准。'" :type="holdAction === 'HOLD' ? 'warning' : 'info'" :closable="false" show-icon />
<el-form label-position="top" class="hold-command-form"><el-form-item :label="holdAction === 'HOLD' ? '扣货原因' : '解除原因'" required><el-input v-model="holdReason" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="holdAction === 'HOLD' ? '请输入扣货原因' : '请输入解除扣货原因'" aria-label="扣货操作原因" /></el-form-item><p class="hold-evidence-hint">历史记录中的已有凭证可在扣货历史中查看关联状态。</p></el-form>
<el-form label-position="top" class="hold-command-form"><el-form-item :label="holdAction === 'HOLD' ? '扣货原因' : '解除原因'" required><el-input v-model="holdReason" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="holdAction === 'HOLD' ? '请输入扣货原因' : '请输入解除扣货原因'" aria-label="扣货操作原因" /></el-form-item><el-form-item label="关联文件(选填)"><el-upload :key="holdEvidenceUploadKey" :auto-upload="false" :limit="1" accept=".txt,.pdf,.jpg,.jpeg,.png,text/plain,application/pdf,image/jpeg,image/png" :disabled="holdSaving" :on-change="selectHoldEvidenceFile" :on-remove="removeHoldEvidenceFile"><el-button :disabled="holdSaving"><FileUp :size="15" />选择文件</el-button><template #tip><div class="el-upload__tip">仅支持 TXT、PDF、JPG、PNG,单文件不超过 10MB;服务端将校验文件真实内容。</div></template></el-upload></el-form-item><LoadErrorState v-if="holdEvidenceError" compact title="关联文件处理失败" :message="holdEvidenceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="holdEvidenceError" /></template></LoadErrorState></el-form>
</AppDialog>
<AppDialog v-model="approvalOpen" title="审核订单" confirm-text="审核通过" confirm-type="success" :confirm-loading="approvalSaving" destroy-on-close @confirm="approveOrder" @cancel="closeApproval">