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

188 lines
11 KiB
Vue
Raw Normal View History

2026-08-21 18:16:42 +08:00
<script setup lang="ts">
import { inquiryReturnTarget } from '@/modules/overseas-master-data'
import { physicalWeightKg } from '../dispatchPhysicalWeight'
2026-09-14 20:03:27 +08:00
import '@/styles/adminDensity.css'
2026-08-21 18:16:42 +08:00
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-21 18:16:42 +08:00
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
2026-08-21 18:16:42 +08:00
interface DetailField { label: string; value: string | number }
interface RelatedOrderRow {
orderNo: string
weight: number | null
2026-08-21 18:16:42 +08:00
palletCount: number
fbaWarehouse: string
volume: number
containerNo: string
}
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
2026-08-21 18:16:42 +08:00
const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
const detail = ref<DispatchSummaryDto | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const actionBlockReasons = computed(() => {
const actions = detail.value?.actions
if (!actions) return detail.value ? ['操作资格暂未返回,请刷新详情'] : []
return [...new Set([actions.editBlockReason, actions.cancelBlockReason, actions.removePalletBlockReason, actions.transferBlockReason].filter(Boolean))]
})
function openTrip() {
if (!detail.value?.workflow || !access.can('platform:fleet:trip:list')) return
void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.value.workflow.tripNo }, query: { tab: 'overview' } })
}
function openOutbound() {
if (!detail.value?.actions?.outboundNo || !access.can(permission.outboundList)) return
void router.push({ name: 'overseas-outbounds', query: { documentNo: detail.value.actions.outboundNo } })
}
2026-08-21 18:16:42 +08:00
const dispatchStatusLabels: Record<string, string> = {
PENDING_DEPARTURE: '待发车',
PICKING: '拣货中',
READY_TO_DEPART: '待发车(拣货完成)',
PARTIAL_READY_TO_DEPART: '待发车(部分完成)',
DEPARTED: '已发车',
PARTIAL_DEPARTED: '已发车(部分完成)',
CANCELLED: '已取消',
}
const appointmentStatusLabels: Record<string, string> = {
PENDING: '待预约',
BOOKED: '已预约',
RESERVED: '已预约',
CONFIRMED: '已确认',
CANCELLED: '已取消',
}
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function dispatchStatus(value: unknown) {
const code = String(value ?? '').trim()
return dispatchStatusLabels[code] || text(code)
}
function appointmentStatus(value: unknown) {
const code = String(value ?? '').trim()
return appointmentStatusLabels[code] || text(code)
}
function orderReferences(item: DispatchItemDto) {
const references = String(item.orderRefs || '').split(/[,,;;\s]+/).map((value) => value.trim()).filter(Boolean)
return references.length ? references : [text(item.orderNo)]
}
const vehicleFields = computed<DetailField[]>(() => [
{ label: '车牌号', value: text(detail.value?.vehiclePlate) },
{ label: '司机', value: text(detail.value?.driverName) },
{ label: '联系电话', value: text(detail.value?.driverPhone) },
{ label: '拖车公司名称', value: text(detail.value?.carrierName) },
{ label: '车辆状态', value: dispatchStatus(detail.value?.status) },
])
const dispatchFields = computed<DetailField[]>(() => [
{ label: '路线', value: text(detail.value?.route) },
{ label: '发车时间', value: formatOverseasDateTime(detail.value?.departAt) },
{ label: '预计到达时间', value: formatOverseasDateTime(detail.value?.estimatedArrivalAt) },
{ label: 'FBA预约', value: appointmentStatus(detail.value?.fbaAppointmentStatus) },
{ label: '车辆预约', value: appointmentStatus(detail.value?.vehicleAppointmentStatus) },
])
const documentFields = computed<DetailField[]>(() => [
{ label: '排车单号', value: text(detail.value?.dispatchNo || dispatchNo.value) },
{ label: '关联订单', value: text(detail.value?.orderNos) },
{ label: '创建时间', value: formatOverseasDateTime(detail.value?.createdAt) },
{ label: '更新时间', value: '—' },
{ label: '备注', value: '—' },
...(detail.value?.partialCompletionReason
? [{ label: '部分完成原因', value: text(detail.value.partialCompletionReason) }]
: []),
])
const relatedOrders = computed<RelatedOrderRow[]>(() => {
const grouped = new Map<string, { pallets: Set<string>; destinations: Set<string>; weight: number | null; volume: number }>()
2026-08-21 18:16:42 +08:00
for (const item of detail.value?.items || []) {
for (const orderNo of orderReferences(item)) {
const current = grouped.get(orderNo) || { pallets: new Set<string>(), destinations: new Set<string>(), weight: 0, volume: 0 }
2026-08-21 18:16:42 +08:00
current.pallets.add(String(item.warehousePalletId || item.planPalletId || item.id))
const destination = text(item.fbaWarehouseCode || item.destinationWarehouseCode || item.destinationWarehouseName)
if (destination !== '—') current.destinations.add(destination)
const itemWeight = physicalWeightKg(item.grossWeightKg)
current.weight = current.weight === null || itemWeight === null ? null : current.weight + itemWeight
2026-08-21 18:16:42 +08:00
current.volume += Number(item.volumeCbm || 0)
grouped.set(orderNo, current)
}
}
return [...grouped.entries()].map(([orderNo, value]) => ({
orderNo,
weight: value.weight === null ? null : Number(value.weight.toFixed(3)),
2026-08-21 18:16:42 +08:00
palletCount: value.pallets.size,
fbaWarehouse: [...value.destinations].join('、') || '—',
volume: Number(value.volume.toFixed(3)),
containerNo: '—',
}))
})
const orderColumns: DataTableColumn[] = [
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 },
{ key: 'weight', prop: 'weight', label: '重量(kg)', minWidth: 130, align: 'right' },
2026-08-21 18:16:42 +08:00
{ key: 'palletCount', prop: 'palletCount', label: '托数', minWidth: 100, align: 'right' },
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: '目的FBA仓', minWidth: 150 },
{ key: 'volume', prop: 'volume', label: '体积(m³)', minWidth: 130, align: 'right' },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 170 },
]
async function load() {
if (!dispatchNo.value) {
loadError.value = { message: '详情缺少排车单号,请从排车列表重新进入' }
return
}
loading.value = true
loadError.value = null
try { detail.value = await getDispatch(dispatchNo.value) }
catch (error) { loadError.value = getApiErrorDetails(error, '排车详情加载失败,请重试') }
finally { loading.value = false }
}
function back() { void router.push(inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) }
2026-08-21 18:16:42 +08:00
onMounted(load)
</script>
<template>
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="inquiryReturnTarget(route.query) ? '返回原拼车登记' : undefined" :back-label="inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表'" domain="排车详情" :pending="false" :status-text="detail ? dispatchStatus(detail.status) : '加载中'" @back="back">
2026-08-21 18:16:42 +08:00
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
<section v-if="detail" class="detail-section" aria-label="排车操作提示"><p v-for="reason in actionBlockReasons" :key="String(reason)">{{ reason }}</p><div class="density-actions"><el-button v-if="detail.workflow && access.can('platform:fleet:trip:list')" class="density-action" @click="openTrip">处理原车次 {{ detail.workflow.tripNo }}</el-button><el-button v-if="detail.actions?.outboundNo && access.can(permission.outboundList)" class="density-action" @click="openOutbound">查看原出库 {{ detail.actions.outboundNo }}</el-button></div></section>
2026-08-21 18:16:42 +08:00
<div class="summary-grid">
2026-09-14 20:03:27 +08:00
<section class="detail-section" aria-labelledby="dispatch-vehicle-heading"><h2 id="dispatch-vehicle-heading">车辆信息</h2><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="detail-section" aria-labelledby="dispatch-info-heading"><h2 id="dispatch-info-heading">排车信息</h2><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="detail-section document-section" aria-labelledby="dispatch-document-heading"><h2 id="dispatch-document-heading">单据信息</h2><dl class="detail-fields density-fields"><div v-for="field in documentFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
2026-08-21 18:16:42 +08:00
</div>
<section class="detail-section order-section" aria-labelledby="dispatch-orders-heading"><h2 id="dispatch-orders-heading">关联订单明细</h2><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="orderNo" empty-text="暂无关联订单明细" /></section>
</div>
</OverseasDetailShell>
</template>
<style scoped>
2026-09-14 20:03:27 +08:00
.detail-section { min-width: 0; padding: var(--admin-space-5); }
2026-08-21 18:16:42 +08:00
.summary-grid > .detail-section:nth-child(2) { border-right: 0; }
2026-09-02 14:39:34 +08:00
.document-section { grid-column: 1 / -1; }
2026-09-14 20:03:27 +08:00
.order-section h2 { margin-right: var(--admin-space-1); margin-left: var(--admin-space-1); }
2026-08-21 18:16:42 +08:00
@media (max-width: 960px) {
.summary-grid { grid-template-columns: 1fr; }
.summary-grid > .detail-section, .summary-grid > .detail-section:nth-child(2) { border-right: 0; }
.document-section { grid-column: auto; }
2026-09-02 14:39:34 +08:00
.detail-fields, .document-section .detail-fields { grid-template-columns: 1fr; }
2026-08-21 18:16:42 +08:00
.detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); }
}
2026-09-14 20:03:27 +08:00
.dispatch-detail { min-width: 0; min-height: 0; }
.summary-grid { display: grid; grid-template-columns: minmax(0, 1fr); border: 0; border-bottom: 0; background: #fff; }
.summary-grid > .detail-section { border-right: 0; border-bottom: 1px solid var(--cc-border); }
.detail-section h2 { margin: 0 0 var(--admin-space-5); color: var(--cc-text); font-size: var(--admin-font-section); }
.order-section { overflow: hidden; border: 0; border-top: 0; background: #fff; }
2026-08-21 18:16:42 +08:00
</style>