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

147 lines
14 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, ref, watch } from 'vue'
import { ArrowLeft } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { getDeliveryOrder, type DeliveryOrderDetail, type DeliveryFeeRow } from '../api/deliveryOrders'
import { coveredServiceLabels } from '../feeCoverageTypes'
const route = useRoute()
const router = useRouter()
const detail = ref<DeliveryOrderDetail | null>(null)
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const activeTab = ref('order')
const deliveryNo = computed(() => String(route.params.deliveryNo || '').trim())
const customerId = computed(() => String(route.query.customerId || '').trim())
const statusLabels: Record<string, string> = { WAIT_DELIVERY: '待派送', DELIVERING: '派送中', DELIVERED: '派送完成', SIGNED: '已签收', REJECTED: '已拒签', RETURN_PENDING: '退货处理中', CANCELLED: '已取消' }
const methodLabels: Record<string, string> = { PICKUP: '普通自提', AMAZON_PICKUP: '亚马逊自提', FBA_PICKUP: 'FBA自提', CUSTOMER_PICKUP: '客户自提', TRUCK: '平台卡车', TRUCK_PALLET: '卡派打托', TRUCK_FLOOR: '卡派地板', EXPRESS: '快递', OUTSOURCED: '外配', DEDICATED_TRUCK: '专车直送', LTL: '零担派送', SELF_DELIVERY: '自送', THIRD_PARTY: '第三方配送' }
const pickupLabels: Record<string, string> = { ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提', DOOR_PICKUP: '上门提货', SELF_PICKUP: '自提' }
const loadingLabels: Record<string, string> = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装', CARTON: '散箱' }
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', PRIVATE_LAST_MILE_DELIVERY: '私人仓尾程派送费', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库', CANCEL_OUTBOUND_REASON: '取消原因', CONFIRM_EXTERNAL_FREIGHT: '确认外接运费' }
const resultLabels: Record<string, string> = { CREATED: '已创建' }
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
function feeStatus(fee: DeliveryFeeRow) {
if (fee.voidStatus === 'CONFIRMED') return '已作废'
if (fee.voidStatus === 'PENDING_FINANCE') return '作废待财务确认'
if (fee.voidStatus === 'FINANCE_ATTENTION') return '作废待财务处理'
if (fee.sourceStatus === 'SUPERSEDED') return '已替代'
return ({ APPROVED: '业务已审核', PENDING: '待审核', DRAFT: '草稿', REJECTED: '已驳回' } as Record<string, string>)[fee.reviewStatus || ''] || '—'
}
function displayPerson(value: unknown) { const text = String(value ?? '').trim(); return text && !/^\d+$/.test(text) ? text : '—' }
function quantity(value: number | null | undefined, unit: string) { return value === null || value === undefined ? '—' : `${value} ${unit}` }
function customerConfirmation() { const signedAt = detail.value?.order.signedAt; return signedAt ? `已签收 · ${formatOverseasDateTime(signedAt)}` : '—' }
function back() { void router.push({ name: 'overseas-delivery-orders' }) }
async function load() {
if (!deliveryNo.value || !customerId.value) { detail.value = null; error.value = { message: '缺少派送单客户范围,无法安全查询详情' }; return }
loading.value = true; error.value = null
try { detail.value = await getDeliveryOrder(deliveryNo.value, customerId.value) }
catch (cause) { detail.value = null; error.value = getApiErrorDetails(cause, '派送单详情加载失败') }
finally { loading.value = false }
}
watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
</script>
<template>
<AdminPage class="admin-density">
<DetailPageLayout aria-label="派送单详情">
<template #header>
<el-breadcrumb separator=">" class="detail-breadcrumb">
<el-breadcrumb-item>首页</el-breadcrumb-item><el-breadcrumb-item>海外派送</el-breadcrumb-item>
<el-breadcrumb-item :to="{ name: 'overseas-delivery-orders' }">派送单列表</el-breadcrumb-item><el-breadcrumb-item>详情</el-breadcrumb-item>
</el-breadcrumb>
<div class="detail-heading">
<el-button class="density-back" link aria-label="返回派送单列表" @click="back"><ArrowLeft :size="16" />返回</el-button>
<div class="detail-title"><div class="title-line"><h1>派送单详情</h1><StatusBadge v-if="detail" :status="statusLabels[detail.order.status] || detail.order.status" /></div><p>派送单号 {{ deliveryNo }}</p></div>
</div>
</template>
<LoadErrorState v-if="error" title="派送单详情加载失败" :message="error.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="error" /></template>
</LoadErrorState>
<section v-else v-loading="loading" class="surface-panel delivery-detail">
<el-tabs v-model="activeTab" class="detail-tabs density-element-tabs density-contained-tabs">
<el-tab-pane name="order"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>订单信息</span></template>
<section class="tab-content" aria-label="订单信息">
<dl v-if="detail" class="info-grid density-fields">
<div class="info-item"><dt>派送单号</dt><dd>{{ show(detail.order.deliveryNo) }}</dd></div><div class="info-item"><dt>状态</dt><dd>{{ statusLabels[detail.order.status] || detail.order.status }}</dd></div>
<div class="info-item"><dt>仓库</dt><dd>{{ show(detail.order.overseasWarehouseName) }}</dd></div><div class="info-item"><dt>目的 FBA 仓</dt><dd>{{ show(detail.order.destinationWarehouseCode || detail.order.destinationWarehouseName) }}</dd></div>
<div class="info-item"><dt>运单号</dt><dd>{{ show(detail.order.waybillNo) }}</dd></div><div class="info-item"><dt>派送人</dt><dd>{{ show(detail.order.carrierName) }}</dd></div>
<div class="info-item"><dt>派送时间</dt><dd>{{ formatOverseasDateTime(detail.order.dispatchedAt || detail.order.plannedDeliveryAt) }}</dd></div><div class="info-item"><dt>提货方式</dt><dd>{{ pickupLabels[detail.order.pickupMethod || ''] || show(detail.order.pickupMethod) }}</dd></div>
<div class="info-item"><dt>装车类型</dt><dd>{{ loadingLabels[detail.order.loadingType || ''] || show(detail.order.loadingType) }}</dd></div><div class="info-item"><dt>派送方式</dt><dd>{{ methodLabels[detail.order.deliveryMethod || ''] || show(detail.order.deliveryMethod) }}</dd></div>
<div class="info-item"><dt>司机/车队</dt><dd>{{ show(detail.order.driverName) }}</dd></div>
<div class="info-item"><dt>车牌号</dt><dd>{{ show(detail.order.vehiclePlate) }}</dd></div><div class="info-item"><dt>总箱数</dt><dd>{{ quantity(detail.order.packageCount, '箱') }}</dd></div>
<div class="info-item"><dt>总重量</dt><dd>{{ quantity(detail.order.totalWeightKg, 'kg') }}</dd></div><div class="info-item"><dt>总体积</dt><dd>{{ quantity(detail.order.totalVolumeCbm, 'm³') }}</dd></div>
<div class="info-item"><dt>预计到达时间</dt><dd>{{ formatOverseasDateTime(detail.order.expectedArrivalAt) }}</dd></div><div class="info-item"><dt>客户确认</dt><dd>{{ customerConfirmation() }}</dd></div>
<div class="info-item info-item--wide"><dt>备注</dt><dd>{{ show(detail.order.remark) }}</dd></div>
</dl>
</section>
</el-tab-pane>
<el-tab-pane name="sku"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>{{ `SKU明细 (${detail?.skus.length || 0})` }}</span></template>
<section class="tab-content" aria-label="SKU明细">
<el-table :data="detail?.skus || []" border stripe empty-text="暂无SKU明细">
<el-table-column prop="inboundCode" label="来源单号 / 入库码" min-width="160" /><el-table-column prop="customerName" label="客户" min-width="180" />
<el-table-column prop="destinationWarehouseCode" label="目的仓编码" min-width="140" /><el-table-column prop="receivingAddress" label="收货地址" min-width="240" />
<el-table-column prop="shipmentNo" label="货件编号" min-width="160" /><el-table-column prop="fbaTrackingNo" label="FBA追踪号" min-width="160" />
<el-table-column prop="skuCode" label="SKU" min-width="140" /><el-table-column prop="productName" label="品名" min-width="160" />
<el-table-column prop="packageCount" label="箱数" width="90" align="right" /><el-table-column v-if="detail?.skus.some(item => item.productQuantity != null)" prop="productQuantity" label="件数" width="90" align="right" /><el-table-column prop="weightKg" label="重量(kg)" width="110" align="right" /><el-table-column prop="volumeCbm" label="体积(m³)" width="110" align="right" />
</el-table>
</section>
</el-tab-pane>
<el-tab-pane name="fees"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>{{ `费用明细 (${detail?.fees.length || 0})` }}</span></template>
<section class="tab-content" aria-label="派送单费用明细">
<template v-if="detail?.feeCoverage?.references.length">
<p>本次已确认的原费用覆盖({{ formatOverseasDateTime(detail.feeCoverage.confirmedAt) }}):原费用金额供核对,不计作本次新增。</p>
<p v-for="reference in detail.feeCoverage.references" :key="`${reference.source.financeFeeId}-${reference.services.join('-')}`">{{ detail.feeCoverage.cargo.find(cargo => cargo.sourceInboundId === reference.source.sourceInboundId)?.inboundCode || reference.source.inboundCode || '—' }} · {{ reference.source.feeItemName || '—' }} · {{ reference.source.currency }} {{ reference.source.amount }} · 版本 {{ reference.source.sourceVersion }} · 已含{{ reference.services.map(service => coveredServiceLabels[service]).join('、') }}</p>
</template>
<el-table :data="detail?.fees || []" border stripe empty-text="暂无费用明细">
<el-table-column type="index" label="序号" width="80" /><el-table-column label="应收/应付" width="120"><template #default="scope">{{ scope.row.feeDirection === 'PAYABLE' ? '应付' : scope.row.feeDirection === 'RECEIVABLE' ? '应收' : show(scope.row.feeDirection) }}</template></el-table-column>
<el-table-column label="费用类型" min-width="180"><template #default="scope">{{ feeLabels[scope.row.feeType] || scope.row.feeType }}</template></el-table-column><el-table-column prop="currency" label="币种" width="100" />
<el-table-column prop="amount" label="金额" min-width="140" align="right" /><el-table-column label="费用状态" min-width="165"><template #default="scope">{{ feeStatus(scope.row as DeliveryFeeRow) }}</template></el-table-column><el-table-column prop="remark" label="备注" min-width="240" />
</el-table>
</section>
</el-tab-pane>
<el-tab-pane name="operations"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>{{ `操作记录 (${detail?.operations.length || 0})` }}</span></template>
<section class="tab-content" aria-label="操作记录">
<el-table :data="detail?.operations || []" border stripe empty-text="暂无操作记录">
<el-table-column label="操作时间" min-width="180"><template #default="scope">{{ formatOverseasDateTime(scope.row.operatedAt) }}</template></el-table-column>
<el-table-column label="操作节点" min-width="180"><template #default="scope">{{ operationLabels[scope.row.operationNode] || scope.row.operationNode }}</template></el-table-column>
<el-table-column label="操作人" min-width="160"><template #default="scope">{{ displayPerson(scope.row.operatorName) }}</template></el-table-column><el-table-column label="处理结果" min-width="280"><template #default="scope">{{ resultLabels[scope.row.result] || scope.row.result }}</template></el-table-column>
</el-table>
</section>
</el-tab-pane>
</el-tabs>
</section>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.detail-breadcrumb { margin-bottom: var(--admin-space-5); }.detail-heading { display: flex; align-items: center; gap: var(--admin-space-5); }.title-line { display: flex; align-items: center; gap: var(--admin-space-4); }.tab-content h2 { margin: 0 0 var(--admin-space-5); font-size: var(--admin-font-section); line-height: 22px; }
@media (max-width: 980px) { .info-grid { grid-template-columns: 1fr; }.info-item, .info-item:nth-child(odd), .info-item:nth-child(even) { padding: 0; }.info-item--wide { grid-column: auto; } }
:deep(.detail-page-layout) { gap: 0; background: var(--admin-surface); border: 1px solid var(--cc-border); }
:deep(.detail-page-layout__header) { padding: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
.detail-title { min-width: 0; display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
.detail-title p { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-title); font-weight: 600; overflow-wrap: anywhere; }
.title-line h1 { margin: 0; font-size: var(--admin-font-section); line-height: 28px; font-weight: 500; }
.delivery-detail { min-height: 0; overflow: hidden; border: 0; border-radius: 0; }
.tab-content { width: 100%; padding: 0; box-sizing: border-box; }
.info-item--wide { grid-column: 1 / -1; }
</style>