Files
admin-web/src/modules/inbounds/core/InboundHistoryPanel.vue
T
2026-10-20 23:34:11 +08:00

159 lines
12 KiB
Vue

<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import type { InboundDropshipPallet, InboundFileSummary, InboundInternalNote, InboundPalletLocation, InboundStatusHistory } from './types'
import { inboundHistoryActionLabel, inboundProcessStatusLabels, inboundStatusLabel } from '../status'
import { downloadDropshipPalletLabel } from './api'
import { ElMessage } from 'element-plus'
const props = withDefaults(defineProps<{
palletLocations: InboundPalletLocation[]
dropshipPallets?: InboundDropshipPallet[]
customerId?: string
inboundId?: string
canViewDropship?: boolean
files: InboundFileSummary[]
internalNotes: InboundInternalNote[]
statusHistory: InboundStatusHistory[]
publicRemark: string | null
sections?: Array<'dropship' | 'allocations' | 'files' | 'notes' | 'history' | 'remark'>
}>(), {
dropshipPallets: () => [],
canViewDropship: true,
sections: () => ['dropship', 'allocations', 'files', 'notes', 'history', 'remark'],
})
function shows(section: 'dropship' | 'allocations' | 'files' | 'notes' | 'history' | 'remark') {
return props.sections.includes(section)
}
function value(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function number(value: number | null) { return value === null ? '—' : value.toFixed(3) }
function date(value: string | null) {
if (!value) return '—'
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false })
}
function dropshipPalletStatus(value: string) {
return ({ OPEN: '清点组托中', SEALED: '已封托' } as Record<string, string>)[value] || value || '状态未知'
}
async function printPallet(pallet: InboundDropshipPallet) {
if (!props.customerId || !props.inboundId || !pallet.palletNo) return
try {
const blob = await downloadDropshipPalletLabel(props.customerId, props.inboundId, pallet.palletId)
const url = URL.createObjectURL(blob)
const link = document.createElement('a'); link.href = url; link.download = `${pallet.palletNo}-托盘唛.pdf`; link.click()
URL.revokeObjectURL(url)
} catch { ElMessage.error('托盘唛下载失败,请稍后重试') }
}
</script>
<template>
<div class="history-panel admin-workbench">
<section v-if="shows('dropship')" class="history-section">
<div class="section-heading"><h3>一件代发清点与组托结果</h3><span>{{ dropshipPallets.length }} 个托盘</span></div>
<el-alert v-if="!canViewDropship" title="当前账号缺少一件代发 PDA 查看权限,无法读取清点组托结果。" type="warning" :closable="false" show-icon />
<div v-else-if="dropshipPallets.length" class="dropship-pallet-list">
<article v-for="pallet in dropshipPallets" :key="pallet.palletId" class="dropship-pallet-card">
<header>
<div><strong>{{ value(pallet.palletNo) }}</strong><el-tag size="small" :type="pallet.status === 'SEALED' ? 'success' : 'warning'" effect="plain">{{ dropshipPalletStatus(pallet.status) }}</el-tag></div>
<el-button v-if="pallet.palletNo" link type="primary" @click="printPallet(pallet)">打印托盘码</el-button>
</header>
<dl class="dropship-pallet-summary">
<div><dt>箱数</dt><dd>{{ pallet.cartonCount }}</dd></div>
<div><dt>SKU 数</dt><dd>{{ pallet.skuCount }}</dd></div>
<div><dt>毛重</dt><dd>{{ number(pallet.weightKg) }} kg</dd></div>
<div><dt>体积</dt><dd>{{ number(pallet.volumeCbm) }} m³</dd></div>
<div><dt>差异箱数</dt><dd><el-tag size="small" :type="pallet.discrepancyCartons ? 'warning' : 'success'">{{ pallet.discrepancyCartons }}</el-tag></dd></div>
<div><dt>封托时间</dt><dd>{{ date(pallet.sealedAt) }}</dd></div>
</dl>
<div class="dropship-item-table">
<div class="dropship-item-row dropship-item-row--header"><span>入仓码</span><span>SKU / 货物</span><span>商品数量</span><span>箱数</span><span>箱码</span></div>
<div v-for="item in pallet.items" :key="`${pallet.palletId}-${item.inboundDetailId}`" class="dropship-item-row">
<span>{{ value(item.inboundCode) }}</span>
<span><strong>{{ value(item.skuCode) }}</strong><small>{{ value(item.cargoName) }}</small></span>
<span>{{ item.skuQuantity }}</span>
<span>{{ item.cartonCount }}</span>
<span>{{ item.cartonMarks.length ? item.cartonMarks.join('、') : '—' }}</span>
</div>
</div>
</article>
</div>
<p v-else class="muted-empty">暂无一件代发清点与组托结果</p>
</section>
<section v-if="shows('allocations')" class="history-section">
<div class="section-heading"><h3>托盘与库位</h3><span>{{ palletLocations.length }} 条分配</span></div>
<div v-if="palletLocations.length" class="allocation-list">
<div v-for="item in palletLocations" :key="`${item.allocationNo}-${item.palletId}-${item.locationId}`" class="allocation-row">
<div><strong>{{ value(item.palletCode) }}</strong><span>分配 {{ value(item.allocationNo) }}</span></div>
<div><span>{{ number(item.allocatedVolumeCbm) }} CBM</span><span>{{ value(item.allocatedPackageCount) }} 箱</span></div>
<div class="allocation-location">{{ value(item.locationCode) }}</div>
</div>
</div>
<p v-else class="muted-empty">暂无托盘库位分配记录</p>
</section>
<section v-if="shows('files')" class="history-section">
<div class="section-heading"><h3>文件记录</h3><span>{{ files.length }} 个文件</span></div>
<div v-if="files.length" class="simple-list"><div v-for="file in files" :key="`${file.fileId}-${file.fileVersion}`"><strong>{{ value(file.fileNameSnapshot) }}</strong><span>{{ value(file.fileType) }} · {{ date(file.uploadedAt) }}</span></div></div>
<p v-else class="muted-empty">暂无文件记录</p>
</section>
<section v-if="shows('notes')" class="history-section">
<div class="section-heading"><h3>内部备注</h3><span>{{ internalNotes.length }} 条</span></div>
<div v-if="internalNotes.length" class="simple-list"><div v-for="note in internalNotes" :key="String(note.id)"><strong>{{ value(note.createdByName || note.createdBy) }}</strong><span>{{ value(note.content) }} · {{ date(note.createdAt) }}</span></div></div>
<p v-else class="muted-empty">暂无内部备注</p>
</section>
<section v-if="shows('history')" class="history-section">
<div class="section-heading"><h3>状态历史</h3><span>{{ statusHistory.length }} 条</span></div>
<ol v-if="statusHistory.length" class="timeline"><li v-for="(history, index) in statusHistory" :key="`${history.occurredAt}-${index}`"><span class="timeline-dot" /><div><strong>{{ inboundHistoryActionLabel(history.action) }}</strong><span>{{ inboundStatusLabel(history.fromStatus, inboundProcessStatusLabels) }} → {{ inboundStatusLabel(history.toStatus, inboundProcessStatusLabels) }} · {{ value(history.operatorName) }}</span><small>{{ date(history.occurredAt) }}<template v-if="history.reason"> · {{ history.reason }}</template></small></div></li></ol>
<p v-else class="muted-empty">暂无状态历史</p>
</section>
<section v-if="shows('remark') && publicRemark" class="history-section remark-section"><div class="section-heading"><h3>公开备注</h3></div><p>{{ publicRemark }}</p></section>
</div>
</template>
<style scoped>
.history-panel { display: grid; gap: 24px; min-width: 0; }
.history-section { min-width: 0; padding-top: 16px; border-top: 1px solid var(--workbench-border); }
.history-section:first-child { padding-top: 0; border-top: 0; }
.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.section-heading h3 { margin: 0; color: #172033; font-size: 13px; }
.section-heading span { color: var(--workbench-muted); font-size: 12px; }
.allocation-list, .simple-list { overflow: hidden; border: 1px solid var(--workbench-border); border-radius: 6px; background: #fff; }
.allocation-row { display: grid; grid-template-columns: 1fr auto minmax(100px, 0.8fr); gap: 12px; align-items: center; padding: 10px 12px; border-bottom: 1px solid #edf0f4; font-size: 12px; }
.allocation-row:last-child, .simple-list > div:last-child { border-bottom: 0; }
.allocation-row > div, .simple-list > div { min-width: 0; }
.allocation-row span, .simple-list span { display: block; margin-top: 3px; color: var(--workbench-muted); }
.allocation-location { overflow-wrap: anywhere; color: var(--workbench-primary); text-align: right; }
.simple-list > div { padding: 10px 12px; border-bottom: 1px solid #edf0f4; font-size: 12px; }
.simple-list strong, .simple-list span { overflow-wrap: anywhere; }
.dropship-pallet-list { display: grid; gap: 12px; }
.dropship-pallet-card { min-width: 0; overflow: hidden; border: 1px solid var(--workbench-border); border-radius: 6px; background: #fff; }
.dropship-pallet-card > header { display: flex; min-height: 42px; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px; border-bottom: 1px solid #edf0f4; background: #f8fafc; }
.dropship-pallet-card > header > div { display: flex; min-width: 0; align-items: center; gap: 8px; }
.dropship-pallet-card > header strong { overflow-wrap: anywhere; color: #172033; font-size: 13px; }
.dropship-pallet-summary { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); margin: 0; border-bottom: 1px solid #edf0f4; }
.dropship-pallet-summary > div { min-width: 0; padding: 8px 12px; border-right: 1px solid #edf0f4; }
.dropship-pallet-summary > div:last-child { border-right: 0; }
.dropship-pallet-summary dt { color: var(--workbench-muted); font-size: 11px; }
.dropship-pallet-summary dd { margin: 3px 0 0; overflow-wrap: anywhere; color: var(--workbench-text); font-size: 12px; font-weight: 650; }
.dropship-item-table { min-width: 680px; }
.dropship-pallet-card { overflow-x: auto; }
.dropship-item-row { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(180px, 1.2fr) 100px 70px minmax(220px, 1.5fr); min-width: 800px; border-bottom: 1px solid #edf0f4; }
.dropship-item-row:last-child { border-bottom: 0; }
.dropship-item-row > span { min-width: 0; padding: 8px 12px; overflow-wrap: anywhere; color: var(--workbench-text); font-size: 12px; }
.dropship-item-row > span + span { border-left: 1px solid #edf0f4; }
.dropship-item-row small { display: block; margin-top: 3px; color: var(--workbench-muted); font-size: 11px; }
.dropship-item-row--header { background: #f8fafc; }
.dropship-item-row--header > span { color: var(--workbench-muted); font-size: 11px; font-weight: 650; }
.muted-empty { margin: 0; padding: 18px 12px; border: 1px dashed var(--workbench-border); border-radius: 6px; background: #f8fafc; color: var(--workbench-muted); font-size: 12px; text-align: center; }
.timeline { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.timeline li { display: grid; grid-template-columns: 16px 1fr; gap: 10px; min-width: 0; padding-bottom: 16px; }
.timeline-dot { width: 8px; height: 8px; margin-top: 5px; border: 2px solid var(--workbench-primary); border-radius: 50%; background: #fff; box-shadow: 0 0 0 3px color-mix(in srgb, var(--workbench-primary) 12%, transparent); }
.timeline li > div { min-width: 0; padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
.timeline strong, .timeline span, .timeline small { display: block; overflow-wrap: anywhere; }
.timeline span, .timeline small { margin-top: 3px; color: var(--workbench-muted); font-size: 12px; line-height: 1.5; }
.remark-section p { margin: 0; padding: 12px; border-radius: 6px; background: #f8fafc; color: var(--workbench-text); font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
@media (max-width: 720px) { .dropship-pallet-summary { grid-template-columns: 1fr; } .dropship-pallet-summary > div { border-right: 0; border-bottom: 1px solid #edf0f4; } .dropship-pallet-summary > div:last-child { border-bottom: 0; } }
@media (max-width: 480px) { .allocation-row { grid-template-columns: 1fr auto; } .allocation-location { grid-column: 1 / -1; text-align: left; } }
</style>