Files
admin-web/src/modules/overseas-warehouse/pages/RelabelOrderDetailView.vue
T

157 lines
13 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 { computed, onMounted, ref, onBeforeUnmount } from 'vue'
import { 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 { getRelabelReviewTask, returnRelabelForRework } 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'
const route = useRoute()
const router = useRouter()
const orderId = computed(() => String(route.params.orderId || ''))
const detail = ref<RelabelDetailDto | null>(null)
const loading = ref(false)
const access = useAccessPreviewStore()
const returning = ref(false)
async function returnForRework() {
if (returning.value) return
returning.value = true
try {
const task = await getRelabelReviewTask(orderId.value)
const { value } = await ElMessageBox.prompt('填写退回原因,PDA 将展示此备注并重新逐箱作业、上传照片。', '退回重新贴标', { customClass: 'admin-density-confirm', 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 groups = [
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '资金状态', '创建时间', '更新时间'] },
{ title: '商品信息', fields: ['商品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 ({ 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: items.map((item) => text(item.skuCode)).join('、') || '—',
中文品名: items.map((item) => text(item.productNameZh)).join('、') || '—',
箱数: Number(value?.application.totalPackageCount || 0),
预算总价: '—',
贴标要求: 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) }
catch (error) { loadError.value = getApiErrorDetails(error, '换标单详情加载失败,请重试') }
finally { loading.value = false }
}
function back() { void router.push({ name: 'overseas-relabel-orders' }) }
onMounted(load)
onBeforeUnmount(() => { if (previewUrl.value) URL.revokeObjectURL(previewUrl.value) })
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) 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
try { detail.value = await uploadRelabelPdf(orderId.value, file, scope, itemId); ElMessage.success(scope === 'TOTAL' ? '总PDF上传成功' : '明细PDF上传成功') }
catch (error) { ElMessage.error(getApiErrorDetails(error, 'PDF上传失败').message) }
finally { uploading.value = false }
}
async function previewPdf(fileId: string | null | undefined, name: string, scope: 'TOTAL' | 'ITEM', itemId?: string) {
if (!fileId) return
try { const blob = await downloadRelabelPdf(orderId.value, fileId, scope, itemId); if (previewUrl.value) URL.revokeObjectURL(previewUrl.value); previewUrl.value = URL.createObjectURL(blob); previewTitle.value = name; previewOpen.value = true }
catch (error) { ElMessage.error(getApiErrorDetails(error, 'PDF预览加载失败').message) }
}
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 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="access.can(permission.relabelOrderStatus) && ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(detail?.application.status || '') && detail?.completionEvidence?.photoFileIds?.length" class="relabel-document-panel"><el-button type="warning" :loading="returning" @click="returnForRework"><Undo2 :size="15" />退回重新贴标</el-button></section>
<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 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>
<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">
<table class="density-table">
<thead><tr><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th><th>明细PDF</th></tr></thead>
<tbody>
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ text(item.skuCode) }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td>{{ text(item.newSkuCode) }}</td><td>{{ text(item.destinationFbaWarehouseCode || item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions density-actions"><strong>{{ item.relabelFileName }}</strong><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 class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></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" v-if="previewUrl" v-model="previewOpen" :title="previewTitle" width="min(900px, calc(100vw - 28px))" :show-footer="false"><div class="pdf-preview"><iframe :src="previewUrl" :title="`${previewTitle}预览`" /></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(--cc-text); font-size: var(--admin-font-section); }
.relabel-mark-list__head span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.relabel-mark-list__scroll { overflow-x: auto; border: 1px solid var(--cc-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(--cc-border); }
.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(--cc-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; border:1px solid var(--cc-border); border-radius:var(--admin-radius); background:#f5f7fa; }
.pdf-preview iframe { display:block; width:100%; height:min(72vh,760px); border:0; background:#fff; }
.empty-cell { height: var(--admin-empty-height); color: var(--cc-muted); text-align: center; }
.document-row strong { min-width: 0; overflow-wrap: anywhere; }
</style>