2026-08-19 16:45:51 +08:00
< script setup lang = "ts" >
2026-08-26 11:10:22 +08:00
import { computed , onMounted , reactive , ref } from 'vue'
import { Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
2026-08-19 16:45:51 +08:00
import { useRoute , useRouter } from 'vue-router'
2026-08-26 11:10:22 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-20 09:10:36 +08:00
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-26 11:10:22 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
2026-08-19 16:45:51 +08:00
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
2026-08-20 09:10:36 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
2026-08-26 11:10:22 +08:00
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { createLastMileOrderFee , getLastMileOrder , type LastMileOrderDetailDto } from '../api/lastMileOrders'
2026-08-20 09:10:36 +08:00
import { formatOverseasDateTime , lastMileOrderTypeLabel , lastMileStatusLabel } from '../lastMilePresentation'
2026-08-26 11:10:22 +08:00
import { overseasWarehousePermissions as permission } from '../permissions'
2026-08-19 16:45:51 +08:00
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
2026-08-27 15:56:13 +08:00
import OrderReferenceText from '../components/OrderReferenceText.vue'
2026-08-19 16:45:51 +08:00
2026-08-20 09:10:36 +08:00
interface DetailField { label : string ; value : string | number }
interface CargoRow { id : string ; sku : string ; productName : string ; pallets : number ; cartons : number ; quantity : number ; weight : number ; volume : number }
2026-08-26 11:10:22 +08:00
interface DocumentRow { type : string ; number : string ; createdAt : string ; quote : string }
interface FeeRow { id : string ; item : string ; currency : string ; amount : string ; status : string ; description : string }
interface LogRow { id : string ; occurredAt : string ; node : string ; operator : string ; result : string ; sortAt : string }
2026-08-20 09:10:36 +08:00
2026-08-19 16:45:51 +08:00
const route = useRoute ()
const router = useRouter ()
2026-08-26 11:10:22 +08:00
const access = useAccessPreviewStore ()
2026-08-20 09:10:36 +08:00
const orderId = computed (() => String ( route . params . orderId || '' ))
const detail = ref < LastMileOrderDetailDto | null >( null )
const loading = ref ( false )
2026-08-26 11:10:22 +08:00
const saving = ref ( false )
2026-08-20 09:10:36 +08:00
const loadError = ref < ApiErrorDetails | null >( null )
2026-08-26 11:10:22 +08:00
const mutationError = ref < ApiErrorDetails | null >( null )
const activeTab = ref ( 'basic' )
const feeDialogOpen = ref ( false )
const feeForm = reactive ({ item : '' , currency : 'USD' , amount : 0 , status : 'PENDING_REVIEW' , description : '' })
2026-08-20 09:10:36 +08:00
function text ( value : unknown ) { return String ( value ?? '' ). trim () || '—' }
function yesNo ( value : boolean | null | undefined ) { return value == null ? '—' : value ? '是' : '否' }
2026-08-26 11:10:22 +08:00
function feeStatusLabel ( value : string ) { return ({ PENDING_REVIEW : '待审核' , APPROVED : '审核通过' , REJECTED : '已驳回' } as Record < string , string >)[ value ] || value }
2026-08-20 09:10:36 +08:00
2026-08-30 17:43:03 +08:00
const referenceValue = computed (() => text ( detail . value ? . order . orderNo || '订单编码待补充' ))
2026-08-20 09:10:36 +08:00
const orderFields = computed < DetailField [] >(() => {
const order = detail . value ? . order
return [
2026-08-22 12:24:15 +08:00
{ label : '订单类型' , value : text ( order ? . orderTypeName || lastMileOrderTypeLabel ( order ? . orderType )) },
2026-08-26 11:10:22 +08:00
{ label : '入仓码' , value : text ( order ? . inboundCode ) },
2026-08-20 09:10:36 +08:00
{ label : '货主' , value : text ( order ? . customerName ) },
{ label : '海外仓' , value : text ( order ? . overseasWarehouseName ) },
{ label : '转仓目的仓' , value : text ( order ? . transferWarehouseName ) },
2026-08-26 11:10:22 +08:00
{ label : '目的仓' , value : text ( order ? . fbaWarehouseCode || order ? . transferWarehouseName ) },
{ label : '是否暂扣' , value : yesNo ( order ? . detained ) },
{ label : '是否异常' , value : yesNo ( order ? . abnormal ) },
2026-08-20 09:10:36 +08:00
{ label : '异常原因' , value : order ? . abnormal ? text ( order . subOrderStatus ) : '—' },
2026-08-26 11:10:22 +08:00
{ label : '备注(客户)' , value : text ( order ? . customerRemark ) },
2026-08-20 09:10:36 +08:00
]
})
2026-08-26 11:10:22 +08:00
const containerFields = computed < DetailField [] >(() => {
2026-08-20 09:10:36 +08:00
const order = detail . value ? . order
return [
2026-08-26 11:10:22 +08:00
{ label : '运输渠道' , value : text ( order ? . orderTypeName || lastMileOrderTypeLabel ( order ? . orderType )) },
{ label : '集装箱号' , value : text ( order ? . containerNo ) },
2026-08-20 09:10:36 +08:00
{ label : '托数' , value : Number ( order ? . palletCount || 0 ) },
{ label : '箱数' , value : Number ( order ? . packageCount || 0 ) },
2026-08-26 11:10:22 +08:00
{ label : '体积(m³)' , value : Number ( order ? . volumeCbm || 0 ) },
2026-08-20 09:10:36 +08:00
{ label : '预计到港时间' , value : formatOverseasDateTime ( order ? . estimatedArrivalAt ) },
{ label : '实际到港时间' , value : formatOverseasDateTime ( order ? . actualArrivalAt ) },
2026-08-26 11:10:22 +08:00
{ label : '预计到仓时间' , value : formatOverseasDateTime ( order ? . estimatedWarehouseAt ) },
{ label : '清关状态' , value : text ( order ? . containerStatus ) },
2026-08-20 09:10:36 +08:00
]
})
const planFields = computed < DetailField [] >(() => {
const order = detail . value ? . order
return [
{ label : '入仓码' , value : text ( order ? . inboundCode ) },
{ label : '计划入库仓' , value : text ( order ? . transferWarehouseName || order ? . overseasWarehouseName ) },
{ label : '合托批次' , value : text ( order ? . palletMergeFlag ) },
2026-08-26 11:10:22 +08:00
{ label : '计划托数' , value : Number ( order ? . palletCount || 0 ) },
{ label : '入库单号' , value : text ( order ? . inboundOrderNo ) },
2026-08-20 09:10:36 +08:00
{ label : '换标状态' , value : lastMileStatusLabel ( order ? . relabelStatus ) },
]
})
const documents = computed < DocumentRow [] >(() => {
const order = detail . value ? . order
if ( ! order ) return []
2026-08-26 11:10:22 +08:00
return [[ '入库单' , order . inboundOrderNo ], [ '出库单(只读关联)' , order . outboundOrderNo ], [ '派送单(只读关联)' , order . deliveryOrderNo ], [ '换标单' , order . relabelOrderNo ]]
. filter (( item ) => item [ 1 ]). map (([ type , number ]) => ({ type : String ( type ), number : String ( number ), createdAt : '—' , quote : '—' }))
2026-08-20 09:10:36 +08:00
})
const cargo = computed < CargoRow [] >(() => ( detail . value ? . cargo || []). map (( item ) => ({
id : String ( item . id ), sku : text ( item . skuCode ), productName : text ( item . productNameZh ), pallets : Number ( item . palletCount || 0 ),
cartons : Number ( item . packageCount || 0 ), quantity : Number ( item . productQuantity || 0 ), weight : Number ( item . grossWeightKg || 0 ), volume : Number ( item . volumeCbm || 0 ),
})))
2026-08-26 11:10:22 +08:00
const fees = computed < FeeRow [] >(() => ( detail . value ? . fees || []). map (( item ) => ({
id : String ( item . id ), item : item . feeItem , currency : item . currency , amount : ` ${ item . currency } ${ Number ( item . amount ). toFixed ( 2 ) } ` ,
status : feeStatusLabel ( item . status ), description : text ( item . supplierDescription ),
2026-08-20 09:10:36 +08:00
})))
2026-08-26 11:10:22 +08:00
const logs = computed < LogRow [] >(() => {
const statusRows = ( detail . value ? . statusHistory || []). map (( item ) => ({
id : `status- ${ item . id } ` , occurredAt : formatOverseasDateTime ( item . createdAt ), sortAt : item . createdAt || '' , node : lastMileStatusLabel ( item . toStatus ), operator : '系统' ,
result : text ( item . remark || ` ${ lastMileStatusLabel ( item . fromStatus ) } → ${ lastMileStatusLabel ( item . toStatus ) } ` ),
}))
const operationRows = ( detail . value ? . operationHistory || []). map (( item ) => ({
id : `operation- ${ item . id } ` , occurredAt : formatOverseasDateTime ( item . createdAt ), sortAt : item . createdAt || '' , node : item . operationNode , operator : text ( item . operatorName ), result : text ( item . result ),
}))
return [... statusRows , ... operationRows ]. sort (( left , right ) => right . sortAt . localeCompare ( left . sortAt ))
})
2026-08-19 16:45:51 +08:00
const cargoColumns : DataTableColumn [] = [
2026-08-20 09:10:36 +08:00
{ key : 'sku' , prop : 'sku' , label : 'SKU' , minWidth : 150 }, { key : 'productName' , prop : 'productName' , label : '中文品名' , minWidth : 180 },
2026-08-26 11:10:22 +08:00
{ key : 'pallets' , prop : 'pallets' , label : '托数' , width : 100 , align : 'right' }, { key : 'cartons' , prop : 'cartons' , label : '箱数' , width : 100 , align : 'right' },
{ key : 'quantity' , prop : 'quantity' , label : '件数' , width : 100 , align : 'right' }, { key : 'weight' , prop : 'weight' , label : '重量(kg)' , width : 120 , align : 'right' },
{ key : 'volume' , prop : 'volume' , label : '体积(m³)' , width : 120 , align : 'right' },
]
const documentColumns : DataTableColumn [] = [
{ key : 'type' , prop : 'type' , label : '单据类型' , minWidth : 150 }, { key : 'number' , prop : 'number' , label : '单据编号' , minWidth : 200 },
{ key : 'createdAt' , prop : 'createdAt' , label : '生成时间' , minWidth : 170 }, { key : 'quote' , prop : 'quote' , label : '报价' , minWidth : 150 },
]
const feeColumns : DataTableColumn [] = [
{ key : 'item' , prop : 'item' , label : '费用项目' , minWidth : 180 }, { key : 'currency' , prop : 'currency' , label : '币种' , width : 120 },
{ key : 'amount' , prop : 'amount' , label : '金额' , minWidth : 160 }, { key : 'status' , prop : 'status' , label : '状态' , width : 140 },
{ key : 'description' , prop : 'description' , label : '供应商/说明' , minWidth : 260 },
2026-08-19 16:45:51 +08:00
]
const logColumns : DataTableColumn [] = [
2026-08-26 11:10:22 +08:00
{ key : 'occurredAt' , prop : 'occurredAt' , label : '时间' , minWidth : 180 }, { key : 'node' , prop : 'node' , label : '操作节点' , minWidth : 180 },
{ key : 'operator' , prop : 'operator' , label : '操作人' , minWidth : 150 }, { key : 'result' , prop : 'result' , label : '处理结果' , minWidth : 320 },
2026-08-19 16:45:51 +08:00
]
2026-08-20 09:10:36 +08:00
async function load () {
2026-08-26 11:10:22 +08:00
if ( ! orderId . value ) { loadError . value = { message : '详情缺少尾程订单号' }; return }
loading . value = true ; loadError . value = null
2026-08-21 11:20:09 +08:00
try { detail . value = await getLastMileOrder ( orderId . value ) }
2026-08-26 11:10:22 +08:00
catch ( error ) { loadError . value = error instanceof Error && error . message === '当前环境未签发海外访问令牌' ? { message : error . message } : getApiErrorDetails ( error , '尾程订单详情加载失败,请重试' ) }
2026-08-20 09:10:36 +08:00
finally { loading . value = false }
2026-08-19 16:45:51 +08:00
}
2026-08-26 11:10:22 +08:00
function openFeeDialog () {
Object . assign ( feeForm , { item : '' , currency : 'USD' , amount : 0 , status : 'PENDING_REVIEW' , description : '' })
mutationError . value = null ; feeDialogOpen . value = true
}
async function saveFee () {
if ( ! feeForm . item . trim () || feeForm . amount <= 0 ) { ElMessage . warning ( '请填写费用项目和大于 0 的金额' ); return }
saving . value = true ; mutationError . value = null
try {
await createLastMileOrderFee ( orderId . value , { feeItem : feeForm . item . trim (), currency : feeForm . currency , amount : feeForm . amount , status : feeForm . status , supplierDescription : feeForm . description . trim () || undefined })
feeDialogOpen . value = false ; ElMessage . success ( '费用已新增' ); await load (); activeTab . value = 'fees'
} catch ( error ) { mutationError . value = getApiErrorDetails ( error , '费用新增失败,请重试' ) }
finally { saving . value = false }
}
2026-08-20 09:10:36 +08:00
function back () { void router . push ({ name : 'overseas-last-mile-orders' }) }
onMounted ( load )
2026-08-19 16:45:51 +08:00
</ script >
< template >
2026-08-20 09:10:36 +08:00
< OverseasDetailShell title = "尾程订单详情" reference -label =" 尾程单号 " :reference-value = "referenceValue" back -label =" 返回尾程订单 " domain = "尾程订单详情" :pending = "false" : status -text =" detail ? lastMileStatusLabel ( detail.order.currentStatus ) : ' 加载中 '" @back ="back" >
2026-08-27 15:56:13 +08:00
< template # reference >< OrderReferenceText :value = "referenceValue" /></ template >
2026-08-26 11:10:22 +08:00
< OperationErrorToast :details = "mutationError" />
2026-08-20 09:10:36 +08:00
< LoadErrorState v-if = "loadError" title="尾程订单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></ template ></ LoadErrorState >
2026-08-26 11:10:22 +08:00
< section v-else v-loading = "loading" class="detail-surface" :aria-busy="loading" >
< el-tabs v-model = "activeTab" class="detail-tabs" >
< el-tab-pane label = "基本信息" name = "basic" >
< div class = "basic-grid" >
< section class = "info-panel" >< h2 > 订单信息 </ h2 >< dl class = "detail-fields" >< div v-for = "field in orderFields" :key="field.label" >< dt >{{ field . label }}</ dt >< dd >{{ field . value }}</ dd ></ div ></ dl ></ section >
< section class = "info-panel" >< h2 > 货柜信息 </ h2 >< dl class = "detail-fields" >< div v-for = "field in containerFields" :key="field.label" >< dt >{{ field . label }}</ dt >< dd >{{ field . value }}</ dd ></ div ></ dl ></ section >
</ div >
</ el-tab-pane >
< el-tab-pane label = "货物信息" name = "cargo" >< section class = "table-panel" >< DataTable :data = "cargo" :columns = "cargoColumns" column -settings-mode =" hide " row -key =" id " empty -text =" 暂无货物明细 " >< template # toolbar -start >< h2 > 货物信息 </ h2 ></ template ></ DataTable ></ section ></ el-tab-pane >
< el-tab-pane label = "计划信息" name = "plan" >
< section class = "info-panel plan-panel" >< h2 > 计划信息 </ h2 >< dl class = "detail-fields plan-fields" >< div v-for = "field in planFields" :key="field.label" >< dt >{{ field . label }}</ dt >< dd >{{ field . value }}</ dd ></ div ></ dl ></ section >
< section class = "table-panel related-panel" >< DataTable :data = "documents" :columns = "documentColumns" column -settings-mode =" hide " empty -text =" 暂无关联单据 " >< template # toolbar -start >< h2 > 关联单据 </ h2 ></ template ></ DataTable ></ section >
</ el-tab-pane >
< el-tab-pane label = "费用明细" name = "fees" >< section class = "table-panel" >< DataTable :data = "fees" :columns = "feeColumns" column -settings-mode =" hide " row -key =" id " empty -text =" 暂无费用明细 " >< template # toolbar -start >< h2 > 费用明细 </ h2 ></ template >< template # toolbar -actions >< el-button v-if = "access.can(permission.lastMileOrderTransfer)" type="primary" @click="openFeeDialog"><Plus :size="16" /> 新增费用 </ el-button ></ template ></ DataTable ></ section ></ el-tab-pane >
< el-tab-pane label = "操作记录" name = "logs" >< section class = "table-panel" >< DataTable :data = "logs" :columns = "logColumns" column -settings-mode =" hide " row -key =" id " empty -text =" 暂无操作记录 " >< template # toolbar -start >< h2 > 操作记录 </ h2 ></ template ></ DataTable ></ section ></ el-tab-pane >
</ el-tabs >
</ section >
< AppDialog v-model = "feeDialogOpen" title="新增费用" width="min(720px, calc(100vw - 28px))" confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee" >
< el-form label -position =" top " >< el-row :gutter = "20" >
< el-col :span = "12" >< el-form-item label = "费用项目" required >< el-input v-model = "feeForm.item" maxlength="128" placeholder="如 派送费" /></ el-form-item ></ el-col >
< el-col :span = "12" >< el-form-item label = "供应商/说明" >< el-input v-model = "feeForm.description" maxlength="500" placeholder="请输入供应商或费用说明" /></ el-form-item ></ el-col >
< el-col :span = "12" >< el-form-item label = "币种" required >< el-select v-model = "feeForm.currency"><el-option v-for="currency in ['USD', 'CNY', 'EUR', 'GBP', 'CAD', 'MXN']" :key="currency" :label="currency" :value="currency" /></ el-select ></ el-form-item ></ el-col >
< el-col :span = "12" >< el-form-item label = "金额" required >< el-input-number v-model = "feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" /></ el-form-item ></ el-col >
< el-col :span = "24" >< el-form-item label = "状态" required >< el-select v-model = "feeForm.status"><el-option label="待审核" value="PENDING_REVIEW" /><el-option label="审核通过" value="APPROVED" /><el-option label="已驳回" value="REJECTED" /></ el-select ></ el-form-item ></ el-col >
</ el-row ></ el-form >
</ AppDialog >
2026-08-19 16:45:51 +08:00
</ OverseasDetailShell >
</ template >
< style scoped >
2026-08-26 11:10:22 +08:00
. detail - surface { min - width : 0 ; min - height : 300 px ; margin - top : 16 px ; border : 1 px solid var ( -- cc - border ); border - radius : 8 px ; background : # fff ; overflow : hidden ; }
. detail - tabs : deep (. el - tabs__header ) { margin : 0 ; padding : 0 20 px ; background : # f8fafc ; }
. detail - tabs : deep (. el - tabs__nav - wrap :: after ) { height : 1 px ; background : var ( -- cc - border ); }
. detail - tabs : deep (. el - tabs__item ) { height : 54 px ; padding : 0 22 px ; color : var ( -- cc - muted ); font - size : 15 px ; }
. detail - tabs : deep (. el - tabs__item . is - active ) { color : var ( -- el - color - primary ); }
. detail - tabs : deep (. el - tabs__content ) { padding : 20 px ; }
. basic - grid { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) minmax ( 0 , 1 fr ); gap : 20 px ; }
. info - panel , . table - panel { min - width : 0 ; border : 1 px solid var ( -- cc - border ); border - radius : 8 px ; background : # fff ; overflow : hidden ; }
. info - panel h2 { margin : 0 ; padding : 16 px 20 px ; border - bottom : 1 px solid var ( -- cc - border ); color : var ( -- cc - text ); font - size : 16 px ; }
. detail - fields { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); margin : 0 ; padding : 8 px 20 px 16 px ; }
. detail - fields > div { display : grid ; min - width : 0 ; min - height : 58 px ; grid - template - columns : minmax ( 100 px , 34 % ) minmax ( 0 , 1 fr ); align - items : center ; border - bottom : 1 px solid var ( -- cc - border ); }
. detail - fields dt { color : var ( -- cc - muted ); font - size : 13 px ; }
. detail - fields dd { min - width : 0 ; margin : 0 ; color : var ( -- cc - text ); font - weight : 600 ; overflow - wrap : anywhere ; }
. plan - panel { margin - bottom : 20 px ; }
. plan - fields { grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr )); }
. plan - fields > div { grid - template - columns : minmax ( 90 px , 42 % ) minmax ( 0 , 1 fr ); }
. related - panel { margin - top : 20 px ; }
. table - panel : deep (. data - table__toolbar ) { min - height : 58 px ; }
@ media ( max - width : 1100 px ) { . basic - grid { grid - template - columns : 1 fr ; } . plan - fields { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); } }
@ media ( max - width : 720 px ) { . detail - tabs : deep (. el - tabs__header ) { padding : 0 10 px ; } . detail - tabs : deep (. el - tabs__content ) { padding : 12 px ; } . detail - tabs : deep (. el - tabs__item ) { padding : 0 12 px ; } . detail - fields , . plan - fields { grid - template - columns : 1 fr ; } }
2026-08-19 16:45:51 +08:00
</ style >