Files
admin-web/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue
T
2026-10-26 13:43:08 +08:00

234 lines
21 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/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onActivated, onMounted, ref, onBeforeUnmount, watch } from 'vue'
import { CheckCircle2, Eye, FileUp, Undo2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { changeRelabelOrderStatus, confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework, getRelabelQuantities, type RelabelQuantity } from '../api/relabelOrders'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { downloadRelabelCompletionPhoto, downloadRelabelPdf, getRelabelOrder, uploadRelabelPdf, type RelabelDetailDto } from '../api/relabelOrders'
import { formatOverseasDateTime } from '../lastMilePresentation'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import RelabelDocumentsPanel from '../components/RelabelDocumentsPanel.vue'
import { BusinessDocumentFees } from '@/modules/overseas-business'
import { relabelSkuCodes } from '../utils/relabelSkuDisplay'
const route = useRoute()
const router = useRouter()
const orderId = computed(() => String(route.params.orderId || ''))
const detail = ref<RelabelDetailDto | null>(null)
const quantity = ref<RelabelQuantity | null>(null)
const currentPallets = ref<string[]>([])
const sourceMissing = ref(false)
const quantityMessage = ref('')
const loading = ref(false)
const access = useAccessPreviewStore()
const returning = ref(false)
const confirming = ref(false)
const voiding = ref(false)
const canVoid = computed(() => !!detail.value && !['READY_FOR_OUTBOUND', 'COMPLETED', 'CANCELLED'].includes(detail.value.application.status || ''))
async function voidOrder() {
if (voiding.value || confirming.value || returning.value || !canVoid.value || !access.can(permission.relabelOrderStatus)) return
voiding.value = true
try {
await ElMessageBox.confirm(`换标单 ${detail.value?.application.relabelNo || orderId.value}:确认作废?作废后停止换标作业,保留原单和操作记录。`, '作废换标单', { customClass: 'admin-density-confirm admin-workbench is-dialog', type: 'warning', confirmButtonText: '确认作废', cancelButtonText: '取消' })
await changeRelabelOrderStatus(orderId.value, 'CANCELLED', '平台作废换标单')
ElMessage.success('换标单已作废'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '换标单作废失败,请重试').message) }
finally { voiding.value = false }
}
async function confirmCompletion() {
if (confirming.value || returning.value || voiding.value) return
confirming.value = true
try {
await ElMessageBox.confirm(`换标单 ${detail.value?.application.relabelNo || orderId.value}:确认仓库上传的换标结果无误并继续后续流程吗?`, '换标完成确认', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(orderId.value)
ElMessage.success('已确认换标完成,订单已进入后续流程'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '换标完成确认失败,请刷新后重试').message) }
finally { confirming.value = false }
}
async function returnForRework() {
if (returning.value || confirming.value || voiding.value) return
returning.value = true
try {
const task = await getRelabelReviewTask(orderId.value)
const { value } = await ElMessageBox.prompt(`换标单 ${detail.value?.application.relabelNo || orderId.value}:填写退回原因,PDA 将展示此备注并重新逐箱作业、上传照片。`, '退回重新贴标', { customClass: 'admin-density-confirm admin-workbench is-dialog', inputType: 'textarea', inputValidator: (value) => Boolean(value?.trim()) && value.trim().length <= 1000 || '请填写 1 至 1000 字退回原因', confirmButtonText: '确认退回' })
await returnRelabelForRework(orderId.value, task.version, value.trim(), crypto.randomUUID())
ElMessage.success('已退回 PDA 重新贴标'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '退回失败,请刷新后重试').message) }
finally { returning.value = false }
}
const loadError = ref<ApiErrorDetails | null>(null)
const uploading = ref(false)
const previewUrl = ref('')
const previewTitle = ref('')
const previewOpen = ref(false)
const previewLoading = ref(false), previewError = ref<ApiErrorDetails | null>(null), uploadError = ref<ApiErrorDetails | null>(null)
const previewRequest = ref<{ no: string; fileId: string; name: string; scope: 'TOTAL' | 'ITEM'; itemId?: string } | null>(null)
let previewGeneration = 0
function resetPreview() {
previewGeneration++; previewLoading.value = false; previewError.value = null; previewRequest.value = null
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
watch(previewOpen, visible => { if (!visible) resetPreview() })
watch(orderId, () => { previewOpen.value = false; resetPreview() })
const groups = [
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '资金状态', '创建时间', '更新时间'] },
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '贴标件数', 'SKU种数', '当前关联托盘', '本轮目标托盘', '原托盘快照', '预算总价'] },
{ title: '作业要求', fields: ['贴标要求', '上传文件', '备注'] },
{ title: '原换标收费', fields: ['原换标费', '资金异常说明'] },
]
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function status(value: unknown) {
return ({
PENDING_REVIEW: '待审核',
PENDING_PROCESSING: '待处理',
PROCESSING: '处理中',
CUSTOMER_CONFIRM_PENDING: '待客户确认',
READY_FOR_OUTBOUND: '换标完成',
COMPLETED: '换标完成',
REJECTED: '已驳回',
CANCELLED: '已作废',
} as Record<string, string>)[String(value || '')] || text(value)
}
function walletStatus(value: unknown) { return ({ NOT_REQUIRED: '无需冻结或扣款', FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || text(value) }
const referenceValue = computed(() => text(detail.value?.application.relabelNo || '换标单号待补充'))
const fieldValues = computed<Record<string, string | number>>(() => {
const value = detail.value
const items = value?.items || []
return {
换标单号: text(value?.application.relabelNo),
关联订单: text(value?.application.customerOrderNo || value?.application.lastlegOrderNo),
状态: status(value?.application.status),
资金状态: walletStatus(value?.application.walletStatus),
创建时间: formatOverseasDateTime(value?.application.createdAt),
更新时间: value?.history.length ? formatOverseasDateTime(value.history[value.history.length - 1]?.createdAt) : '—',
商品SKU: [...new Set(items.flatMap(relabelSkuCodes))].join('、') || '—',
中文品名: items.map((item) => text(item.productNameZh)).join('、') || '—',
箱数: quantity.value?.cartonCount ?? '未提供',
贴标件数: quantity.value?.labelPieceCount ?? '未提供/不适用',
SKU种数: quantity.value?.skuCount ?? '未提供',
当前关联托盘: currentPallets.value.length ? `${currentPallets.value.length} 托:${currentPallets.value.join('、')}${sourceMissing.value ? '(部分来源未提供)' : ''}` : '未提供',
本轮目标托盘: quantity.value?.targetPalletCount == null ? '未提供/不适用' : `${quantity.value.targetPalletCount} 托(本轮各任务最新绑定)`,
原托盘快照: quantity.value?.sourcePalletCount == null ? '未提供(缺少可信快照)' : `${quantity.value.sourcePalletCount} 托(首次可信来源)`,
预算总价: '—',
贴标要求: items.map((item) => text(item.labelRequirement)).join(';') || '—',
上传文件: items.map((item) => text(item.relabelFileName)).filter((item) => item !== '—').join('、') || '—',
备注: text(value?.remark),
原换标费: value?.application.chargeAmount == null ? '—' : `${Number(value.application.chargeAmount).toFixed(2)} ${value.application.chargeCurrency || 'USD'}`,
资金异常说明: text(value?.application.walletErrorMessage),
}
})
async function load() {
if (!orderId.value) { loadError.value = { message: '详情缺少换标单号,请从换标单列表重新进入' }; return }
loading.value = true; loadError.value = null
try {
detail.value = await getRelabelOrder(orderId.value)
quantity.value = null; quantityMessage.value = ''; currentPallets.value = []; sourceMissing.value = false
try {
const result = await getRelabelQuantities({ page: 1, size: 1, relabelNo: orderId.value })
quantity.value = result.orders[orderId.value] || null
if (quantity.value?.duplicateCarton) quantityMessage.value = '该单存在重复箱唛关联,箱数已按箱唛去重,贴标件数待核查。'
const task = await getRelabelReviewTask(orderId.value)
currentPallets.value = [...new Set((task.items || []).map(item => item.sourcePalletCode).filter((value): value is string => Boolean(value)))]
sourceMissing.value = (task.items || []).some(item => !item.sourcePalletCode)
} catch { quantityMessage.value = '部分数量或托盘资料加载失败,未将缺失值显示为0。' }
}
catch (error) { loadError.value = getApiErrorDetails(error, '换标单详情加载失败,请重试') }
finally { loading.value = false }
}
function back() { void router.push({ name: 'overseas-relabel-orders' }) }
onMounted(load)
onActivated(() => { if (!loading.value) void load() })
onBeforeUnmount(resetPreview)
async function uploadPdf(event: Event, scope: 'TOTAL' | 'ITEM', itemId?: string) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file || !detail.value || uploading.value || !access.can(permission.relabelOrderCreate)) return
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) { ElMessage.warning('只支持PDF文件'); return }
if (file.size > 50 * 1024 * 1024) { ElMessage.warning('PDF文件不能超过50MB'); return }
uploading.value = true; uploadError.value = null
const no = orderId.value
try { const result = await uploadRelabelPdf(no, file, scope, itemId); if (no === orderId.value) { detail.value = result; ElMessage.success(scope === 'TOTAL' ? '总PDF上传成功' : '明细PDF上传成功') } }
catch (error) { if (no === orderId.value) uploadError.value = getApiErrorDetails(error, 'PDF上传失败') }
finally { uploading.value = false }
}
async function previewPdf(fileId: string | null | undefined, name: string, scope: 'TOTAL' | 'ITEM', itemId?: string) {
if (!fileId) return
const sequence = ++previewGeneration, no = orderId.value
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''; previewTitle.value = name; previewRequest.value = { no, fileId, name, scope, itemId }
previewOpen.value = true; previewLoading.value = true; previewError.value = null
try {
const blob = await downloadRelabelPdf(no, fileId, scope, itemId)
if (sequence !== previewGeneration || no !== orderId.value || !previewOpen.value) return
previewUrl.value = URL.createObjectURL(blob)
} catch (error) { if (sequence === previewGeneration && no === orderId.value && previewOpen.value) previewError.value = getApiErrorDetails(error, 'PDF预览加载失败') }
finally { if (sequence === previewGeneration) previewLoading.value = false }
}
async function previewCompletionPhoto(fileId: string) {
try { const blob = await downloadRelabelCompletionPhoto(orderId.value, fileId); const url = URL.createObjectURL(blob); window.open(url, '_blank', 'noopener,noreferrer'); window.setTimeout(() => URL.revokeObjectURL(url), 60_000) }
catch (error) { ElMessage.error(getApiErrorDetails(error, '完工照片加载失败').message) }
}
</script>
<template>
<OverseasReadOnlyDetail workbench class="admin-density" dense title="换标单详情" reference-label="换标单号" :reference-value="referenceValue" back-label="返回换标单" domain="换标单详情" :groups="groups" :field-values="fieldValues" :pending="false" :status-text="detail ? status(detail.application.status) : '加载中'" :loading="loading" :show-content="!loadError" @back="back">
<template #state><LoadErrorState v-if="loadError" title="换标单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #content-after>
<section v-if="detail" class="relabel-document-panel" aria-label="换标单费用">
<BusinessDocumentFees business-type="RELABEL_ORDER" :business-no="detail.application.relabelNo" :business-id="detail.application.id" />
</section>
<section v-if="access.can(permission.relabelOrderStatus) && canVoid" class="relabel-document-panel relabel-review-actions"><el-button type="danger" :loading="voiding" :disabled="confirming || returning" @click="voidOrder">作废</el-button></section>
<section v-if="access.can(permission.relabelOrderStatus) && ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(detail?.application.status || '') && detail?.completionEvidence?.photoFileIds?.length" class="relabel-document-panel relabel-review-actions"><el-button v-if="detail?.application.status === 'CUSTOMER_CONFIRM_PENDING'" type="primary" :loading="confirming" :disabled="returning || voiding" @click="confirmCompletion"><CheckCircle2 :size="15" />确认换标完成</el-button><el-button type="warning" :loading="returning" :disabled="confirming || voiding" @click="returnForRework"><Undo2 :size="15" />退回重新贴标</el-button></section>
<LoadErrorState v-if="uploadError" title="换标PDF操作失败" :message="uploadError.message" :show-retry="false"><template #details><ApiErrorMeta :details="uploadError" /></template></LoadErrorState>
<RelabelDocumentsPanel v-if="detail" :relabel-no="detail.application.relabelNo" :detail="detail" /><section class="relabel-document-panel" aria-label="PDA换标完工证据"><div class="relabel-mark-list__head"><h2>PDA 换标完工证据</h2><span>{{ detail?.completionEvidence?.reviewerName ? `复核人:${detail.completionEvidence.reviewerName}` : '等待仓库作业' }}</span></div><div v-if="detail?.completionEvidence?.photoFileIds?.length" class="document-row"><strong>共 {{ detail.completionEvidence.photoFileIds.length }} 张完工照片</strong><span class="document-actions density-actions"><el-button class="density-action" data-tone="neutral" v-for="(fileId, index) in detail.completionEvidence.photoFileIds" :key="fileId" size="small" @click="previewCompletionPhoto(fileId)"><Eye :size="14" />照片 {{ index + 1 }}</el-button></span></div><div v-else class="attachment-empty">暂未上传完工照片</div></section>
<section class="relabel-document-panel" aria-label="总换标文件"><div class="relabel-mark-list__head"><h2>总换标 PDF</h2><label v-if="access.can(permission.relabelOrderCreate)" class="file-picker"><FileUp :size="15" />{{ uploading ? '上传中' : (detail?.application.totalFileName ? '更换PDF' : '上传PDF') }}<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'TOTAL')" /></label></div><div v-if="detail?.application.totalFileId" class="document-row"><strong>{{ detail.application.totalFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(detail.application.totalFileId, detail.application.totalFileName || '总换标PDF', 'TOTAL')"><Eye :size="14" />直接预览</el-button></div><div v-else class="attachment-empty">暂未上传总换标PDF</div></section>
<p v-if="quantityMessage" role="status">{{ quantityMessage }}</p>
<section class="relabel-mark-list" aria-label="箱唛换标信息">
<div class="relabel-mark-list__head"><h2>箱唛换标信息</h2><span>共 {{ detail?.items.length || 0 }} 条</span></div>
<div class="relabel-mark-list__scroll workbench-evidence">
<table class="density-table">
<thead><tr><th class="relabel-pdf-actions">操作(明细PDF)</th><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th></tr></thead>
<tbody>
<tr v-for="item in detail?.items || []" :key="item.id"><td><span v-if="item.relabelFileName" class="document-actions density-actions"><LineClampText :content="item.relabelFileName" /><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else-if="access.can(permission.relabelOrderCreate)" class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label><span v-else>—</span></td><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ relabelSkuCodes(item).join('、') || '—' }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td><template v-if="item.skuItems?.length"><div v-for="(sku, index) in item.skuItems" :key="index">{{ sku.skuCode }} → {{ sku.newSkuCode }} × {{ sku.quantity }} 件</div></template><span v-else>{{ text(item.newSkuCode) }}</span></td><td>{{ text(item.destinationFbaWarehouseCode) }}</td></tr>
<tr v-if="!detail?.items.length"><td class="empty-cell" colspan="8">暂无箱唛换标信息</td></tr>
</tbody>
</table>
</div>
</section>
</template>
</OverseasReadOnlyDetail>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="previewOpen" :title="(previewRequest?.no || orderId) + ' · ' + previewTitle" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-footer="false"><div class="density-dialog-sections"><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取换标PDF…</p><LoadErrorState v-if="previewError" title="换标PDF预览失败" :message="previewError.message" :loading="previewLoading" @retry="previewRequest && previewPdf(previewRequest.fileId, previewRequest.name, previewRequest.scope, previewRequest.itemId)"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><div v-if="previewUrl" class="pdf-preview"><iframe :src="previewUrl" :title="`${previewTitle}预览`" /></div></div></AppDialog>
</template>
<style scoped>
.relabel-mark-list { min-width: 0; grid-column: 1 / -1; padding: var(--admin-space-5); }
.relabel-mark-list__head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--admin-space-5); }
.relabel-mark-list__head h2 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section); }
.relabel-mark-list__head span { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.relabel-mark-list__scroll { overflow-x: auto; border: 1px solid var(--workbench-border); border-radius: var(--admin-radius); }
table { min-width: 1120px; }
.mark-code { color: #245b9e; font-weight: 600; }
.relabel-document-panel { grid-column: 1 / -1; padding: var(--admin-space-5); border-bottom: 1px solid var(--workbench-border); }
.relabel-review-actions { display:flex; justify-content:flex-end; gap:var(--admin-space-3); }
.document-row { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-5); border:1px solid var(--workbench-border); border-radius:var(--admin-radius); }
.file-picker, .inline-file-picker { display:inline-flex; align-items:center; gap:var(--admin-space-2); color:var(--el-color-primary); cursor:pointer; }
.file-picker input, .inline-file-picker input { display:none; }
.pdf-preview { overflow: hidden; }
.pdf-preview iframe { display:block; width:100%; height:min(72vh,760px); border:0; }
.relabel-pdf-actions { width: 220px; }
.document-actions > .line-clamp-text { max-width: 160px; }
.empty-cell { height: var(--admin-empty-height); color: var(--workbench-muted); text-align: center; }
.document-row strong { min-width: 0; overflow-wrap: anywhere; }
</style>