2026-07-31 13:43:16 +08:00
< script setup lang = "ts" >
import { computed , onMounted , reactive , ref } from 'vue'
import { ElMessage , ElMessageBox } from 'element-plus'
import { Eye , FilePenLine , PackageCheck , RotateCcw , Search } from '@lucide/vue'
import {
acceptPlatformFirstLegOrder ,
addPlatformFirstLegOrderNote ,
approvePlatformFirstLegOrder ,
cancelPlatformFirstLegOrder ,
completePlatformFirstLegOrder ,
getPlatformFirstLegOrder ,
listPlatformFirstLegOrders ,
startPlatformFirstLegOrder ,
updatePlatformFirstLegOrderExpress ,
type PlatformFirstLegOrderDetailResource ,
type PlatformFirstLegOrderResource ,
type PlatformFirstLegOrderStatus ,
} from '@/api/platformFirstLegOrders'
import { getApiErrorDetails , type ApiErrorDetails } from '@/api/http'
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
import MetricStrip from '@/components/MetricStrip.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { useAccessPreviewStore } from '@/stores/accessPreview'
const TENANT_SESSION_KEY = 'cc-admin-first-leg-order-tenant-id'
const access = useAccessPreviewStore ()
const tenantId = ref ( sessionStorage . getItem ( TENANT_SESSION_KEY ) || '' )
const loadedTenantId = ref ( '' )
const orders = ref < PlatformFirstLegOrderResource [] >([])
const total = ref ( 0 )
const page = ref ( 1 )
const pageSize = 20
const loading = ref ( false )
const actionLoadingId = ref ( '' )
const loadError = ref < ApiErrorDetails | null >( null )
const detailOpen = ref ( false )
const detailLoading = ref ( false )
const detail = ref < PlatformFirstLegOrderDetailResource | null >( null )
const noteDialogOpen = ref ( false )
const expressDialogOpen = ref ( false )
const selectedOrder = ref < PlatformFirstLegOrderResource | null >( null )
const noteContent = ref ( '' )
const noteSaving = ref ( false )
const expressSaving = ref ( false )
const expressForm = reactive ({ expressCompany : '' , expressTrackingNo : '' })
const filters = reactive < { keyword : string ; status : PlatformFirstLegOrderStatus | '' ; dateRange : [ string , string ] | [] } > ({
keyword : '' ,
status : '' ,
dateRange : [],
})
const tenantReady = computed (() => Boolean ( loadedTenantId . value ) && tenantId . value . trim () === loadedTenantId . value )
const metrics = computed (() => [
{ label : '订单总数' , value : total . value , note : loadedTenantId . value ? `租户 ${ loadedTenantId . value } ` : '等待选择租户' },
{ label : '待审核' , value : orders . value . filter (( item ) => item . status === 'SUBMITTED' ). length , note : '当前页数据' },
{ label : '履约处理中' , value : orders . value . filter (( item ) => [ 'APPROVED' , 'ACCEPTED' , 'PROCESSING' ]. includes ( item . status )). length , note : '当前页数据' },
{ label : '已完成' , value : orders . value . filter (( item ) => item . status === 'COMPLETED' ). length , note : '当前页数据' },
])
const statusOptions : Array < { value : PlatformFirstLegOrderStatus ; label : string } > = [
{ value : 'DRAFT' , label : '草稿' },
{ value : 'SUBMITTED' , label : '待审核' },
{ value : 'APPROVED' , label : '已审核' },
{ value : 'ACCEPTED' , label : '已受理' },
{ value : 'PROCESSING' , label : '处理中' },
{ value : 'COMPLETED' , label : '已完成' },
{ value : 'CANCELLED' , label : '已取消' },
{ value : 'REJECTED' , label : '已驳回(旧契约)' },
]
function statusLabel ( status : PlatformFirstLegOrderStatus ) {
return statusOptions . find (( item ) => item . value === status ) ? . label || status
}
function transportLabel ( order : PlatformFirstLegOrderResource ) {
if ( order . transportMode === 'SEA' ) return order . containerMode === 'FULL_CONTAINER' ? '海运整箱 FCL' : '海运拼箱 LCL'
if ( order . transportMode === 'AIR' ) return '空运'
if ( order . transportMode === 'RAIL' ) return '铁路运输'
return '陆运'
}
function formattedDate ( value : string | null | undefined ) {
return value ? value . replace ( 'T' , ' ' ) : '-'
}
function can ( action : string ) {
return access . can ( action )
}
function canCancel ( order : PlatformFirstLegOrderResource ) {
return can ( 'platform:order:cancel' ) && [ 'DRAFT' , 'SUBMITTED' , 'APPROVED' , 'ACCEPTED' , 'REJECTED' ]. includes ( order . status )
}
function actionLabel ( order : PlatformFirstLegOrderResource ) {
if ( order . status === 'SUBMITTED' && can ( 'platform:order:approve' )) return '审核通过'
if ( order . status === 'APPROVED' && can ( 'platform:order:accept' )) return '受理订单'
if ( order . status === 'ACCEPTED' && can ( 'platform:order:process' )) return '开始处理'
if ( order . status === 'PROCESSING' && can ( 'platform:order:complete' )) return '完成订单'
return ''
}
async function loadOrders ( resetPage = false ) {
const targetTenantId = tenantId . value . trim ()
loadError . value = null
if ( ! /^\d+$/ . test ( targetTenantId ) || targetTenantId === '0' ) {
loadError . value = { message : '请输入有效的目标租户 ID 后查询订单' }
return
}
if ( resetPage ) page . value = 1
loading . value = true
try {
const result = await listPlatformFirstLegOrders ( targetTenantId , {
keyword : filters . keyword . trim () || undefined ,
status : filters . status || undefined ,
createdFrom : filters . dateRange . length === 2 ? ` ${ filters . dateRange [ 0 ] } T00:00:00` : undefined ,
createdTo : filters . dateRange . length === 2 ? ` ${ filters . dateRange [ 1 ] } T23:59:59` : undefined ,
page : page . value ,
size : pageSize ,
})
orders . value = result . records
total . value = result . total
loadedTenantId . value = targetTenantId
sessionStorage . setItem ( TENANT_SESSION_KEY , targetTenantId )
} catch ( error ) {
orders . value = []
total . value = 0
loadError . value = getApiErrorDetails ( error , '订单列表加载失败,请检查 Gateway、租户范围和订单权限' )
} finally {
loading . value = false
}
}
function resetFilters () {
filters . keyword = ''
filters . status = ''
filters . dateRange = []
void loadOrders ( true )
}
function handlePageChange ( nextPage : number ) {
page . value = nextPage
void loadOrders ()
}
async function openDetail ( order : PlatformFirstLegOrderResource ) {
if ( ! tenantReady . value ) return
selectedOrder . value = order
detail . value = null
detailOpen . value = true
detailLoading . value = true
try {
detail . value = await getPlatformFirstLegOrder ( loadedTenantId . value , String ( order . id ))
} catch ( error ) {
const details = getApiErrorDetails ( error , '订单详情加载失败' )
ElMessage . error ( details . message )
} finally {
detailLoading . value = false
}
}
async function refreshAfterAction ( orderId : string ) {
await loadOrders ()
if ( detailOpen . value && selectedOrder . value ? . id === orderId ) {
detail . value = await getPlatformFirstLegOrder ( loadedTenantId . value , orderId )
}
}
async function runLifecycleAction ( order : PlatformFirstLegOrderResource ) {
if ( ! tenantReady . value ) return
const label = actionLabel ( order )
if ( ! label ) return
try {
await ElMessageBox . confirm ( `确认 ${ label } “ ${ order . orderNo || order . id } ”吗?该操作会写入订单状态历史。` , label , {
confirmButtonText : `确认 ${ label } ` ,
cancelButtonText : '取消' ,
type : 'warning' ,
})
actionLoadingId . value = String ( order . id )
if ( order . status === 'SUBMITTED' ) await approvePlatformFirstLegOrder ( loadedTenantId . value , String ( order . id ))
if ( order . status === 'APPROVED' ) await acceptPlatformFirstLegOrder ( loadedTenantId . value , String ( order . id ))
if ( order . status === 'ACCEPTED' ) await startPlatformFirstLegOrder ( loadedTenantId . value , String ( order . id ))
if ( order . status === 'PROCESSING' ) await completePlatformFirstLegOrder ( loadedTenantId . value , String ( order . id ))
await refreshAfterAction ( String ( order . id ))
ElMessage . success ( ` ${ label } 已完成` )
} catch ( error ) {
if ( error === 'cancel' || error === 'close' ) return
ElMessage . error ( getApiErrorDetails ( error , ` ${ label } 失败,请稍后重试` ). message )
} finally {
actionLoadingId . value = ''
}
}
async function cancelOrder ( order : PlatformFirstLegOrderResource ) {
if ( ! tenantReady . value ) return
try {
const result = await ElMessageBox . prompt ( '请填写取消原因。后端要求该字段必填,并将写入订单状态历史。' , '取消订单' , {
confirmButtonText : '确认取消' ,
cancelButtonText : '返回' ,
inputPattern : /\S+/ ,
inputErrorMessage : '请填写取消原因' ,
inputPlaceholder : '例如:客户取消本次运输安排' ,
})
actionLoadingId . value = String ( order . id )
await cancelPlatformFirstLegOrder ( loadedTenantId . value , String ( order . id ), result . value . trim ())
await refreshAfterAction ( String ( order . id ))
ElMessage . success ( '订单已取消' )
} catch ( error ) {
if ( error === 'cancel' || error === 'close' ) return
ElMessage . error ( getApiErrorDetails ( error , '取消订单失败,请稍后重试' ). message )
} finally {
actionLoadingId . value = ''
}
}
function openExpress ( order : PlatformFirstLegOrderResource ) {
selectedOrder . value = order
expressForm . expressCompany = order . expressCompany || ''
expressForm . expressTrackingNo = order . expressTrackingNo || ''
expressDialogOpen . value = true
}
async function saveExpress () {
if ( ! selectedOrder . value || ! tenantReady . value ) return
if ( ! expressForm . expressCompany . trim () || ! expressForm . expressTrackingNo . trim ()) {
ElMessage . warning ( '请填写快递公司和快递单号' )
return
}
expressSaving . value = true
try {
await updatePlatformFirstLegOrderExpress ( loadedTenantId . value , String ( selectedOrder . value . id ), {
expressCompany : expressForm . expressCompany . trim (),
expressTrackingNo : expressForm . expressTrackingNo . trim (),
})
expressDialogOpen . value = false
await refreshAfterAction ( String ( selectedOrder . value . id ))
ElMessage . success ( '履约快递信息已保存' )
} catch ( error ) {
ElMessage . error ( getApiErrorDetails ( error , '履约快递信息保存失败' ). message )
} finally {
expressSaving . value = false
}
}
function openNote ( order : PlatformFirstLegOrderResource ) {
selectedOrder . value = order
noteContent . value = ''
noteDialogOpen . value = true
}
async function saveNote () {
if ( ! selectedOrder . value || ! tenantReady . value ) return
if ( ! noteContent . value . trim ()) {
ElMessage . warning ( '请填写内部备注' )
return
}
noteSaving . value = true
try {
await addPlatformFirstLegOrderNote ( loadedTenantId . value , String ( selectedOrder . value . id ), noteContent . value . trim ())
noteDialogOpen . value = false
await refreshAfterAction ( String ( selectedOrder . value . id ))
ElMessage . success ( '内部备注已保存' )
} catch ( error ) {
ElMessage . error ( getApiErrorDetails ( error , '内部备注保存失败' ). message )
} finally {
noteSaving . value = false
}
}
onMounted (() => {
if ( tenantId . value ) void loadOrders ()
})
</ script >
< template >
< div class = "business-page" >
< MetricStrip :items = "metrics" />
< section class = "surface-panel filter-panel" aria -label =" 头程订单筛选 " >
< div class = "filter-fields server-order-filter-fields" >
< el-input v -model .trim =" tenantId " clearable placeholder = "目标租户 ID" aria -label =" 目标租户 ID " @keyup.enter ="loadOrders(true)" />
< el-input v -model .trim =" filters.keyword " clearable placeholder = "订单号或客户名称" aria -label =" 订单关键词 " @keyup.enter ="loadOrders(true)" >
< template # prefix >< Search :size = "16" /></ template >
</ el-input >
< el-select v-model = "filters.status" clearable placeholder="全部状态" aria-label="订单状态" >
< el-option v-for = "item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</ el-select >
< el-date-picker v-model = "filters.dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="创建开始" end-placeholder="创建结束" aria-label="订单创建日期" />
</ div >
< div class = "filter-actions" >
< el-button type = "primary" plain :loading = "loading" @click ="loadOrders(true)" >< Search :size = "16" /> 查询租户 </ el-button >
< el-button :disabled = "loading" @click ="resetFilters" >< RotateCcw :size = "16" /> 重置筛选 </ el-button >
</ div >
</ section >
< el-alert v-if = "loadError" type="error" :closable="false" show-icon role="alert" >
< template # title >< div class = "api-error-alert-title" >< span >{{ loadError . message }}</ span >< ApiErrorMeta :details = "loadError" /></ div ></ template >
< el-button v-if = "tenantId" link type="danger" @click="loadOrders(true)" > 重新加载 </ el -button >
</ el-alert >
< el-alert v-else type = "info" :closable = "false" show -icon >
2026-07-31 15:34:35 +08:00
当前页面已直连 Order 服务 。 后端尚未提供平台代客创建 、 平台编辑及履约任务查询接口 , 因此相关操作暂不可用 。
2026-07-31 13:43:16 +08:00
</ el-alert >
< section class = "surface-panel table-panel" >
< header class = "panel-heading" >< div >< h2 > 头程订单 </ h2 >< p >{{ loadedTenantId ? `当前数据范围:租户 ${ loadedTenantId } ` : '输入目标租户 ID 后读取 Order 服务真实数据' }}</ p ></ div ></ header >
< el-table v-if = "loading || orders.length" v-loading="loading" :data="orders" row-key="id" >
< el-table-column label = "操作" min -width =" 200 " fixed = "left" >
< template # default = "{ row }" >< div class = "table-actions" >< el-button link type = "primary" @click ="openDetail(row as PlatformFirstLegOrderResource)" >< Eye :size = "15" /> 详情 </ el-button >< el-button v-if = "actionLabel(row as PlatformFirstLegOrderResource)" link type="success" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="runLifecycleAction(row as PlatformFirstLegOrderResource)">{{ actionLabel(row as PlatformFirstLegOrderResource) }}</el-button><el-button v-if="canCancel(row as PlatformFirstLegOrderResource)" link type="danger" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="cancelOrder(row as PlatformFirstLegOrderResource)">取消</el-button><el-button v-if="can('platform:order:fulfillment') && !['DRAFT', 'REJECTED', 'CANCELLED'].includes((row as PlatformFirstLegOrderResource).status)" link type="primary" @click="openExpress(row as PlatformFirstLegOrderResource)"><PackageCheck :size="15" />快递</el-button><el-button v-if="can('platform:order:note')" link type="primary" @click="openNote(row as PlatformFirstLegOrderResource)"><FilePenLine :size="15" /> 备注 </ el-button ></ div ></ template >
</ el-table-column >
< el-table-column label = "订单信息" min -width =" 210 " >< template # default = "{ row }" >< div class = "primary-cell" >< strong >{{ ( row as PlatformFirstLegOrderResource ). orderNo || '-' }}</ strong >< span >{{ ( row as PlatformFirstLegOrderResource ). referenceId || '无客户参考号' }} · {{ formattedDate (( row as PlatformFirstLegOrderResource ). createdAt ) }}</ span ></ div ></ template ></ el-table-column >
< el-table-column label = "客户" min -width =" 190 " show -overflow-tooltip >< template # default = "{ row }" >< div class = "primary-cell" >< strong >{{ ( row as PlatformFirstLegOrderResource ). customerName }}</ strong >< span > 租户 {{ ( row as PlatformFirstLegOrderResource ). tenantId }}</ span ></ div ></ template ></ el-table-column >
< el-table-column label = "运输方案" min -width =" 180 " >< template # default = "{ row }" >< div class = "primary-cell" >< strong >{{ transportLabel ( row as PlatformFirstLegOrderResource ) }}</ strong >< span >{{ ( row as PlatformFirstLegOrderResource ). channelName || '未填写渠道' }}</ span ></ div ></ template ></ el-table-column >
< el-table-column label = "目的地" min -width =" 150 " >< template # default = "{ row }" >< div class = "primary-cell" >< strong >{{ ( row as PlatformFirstLegOrderResource ). destinationWarehouseName || '-' }}</ strong >< span >{{ ( row as PlatformFirstLegOrderResource ). destinationCountryCode || '-' }}</ span ></ div ></ template ></ el-table-column >
< el-table-column label = "平台收货仓" min -width =" 180 " >< template # default = "{ row }" >< div class = "primary-cell" >< strong >{{ ( row as PlatformFirstLegOrderResource ). receivingWarehouseName || '-' }}</ strong >< span >{{ ( row as PlatformFirstLegOrderResource ). receivingWarehouseAddress || '-' }}</ span ></ div ></ template ></ el-table-column >
< el-table-column label = "履约快递" min -width =" 160 " >< template # default = "{ row }" >< div class = "primary-cell" >< strong >{{ ( row as PlatformFirstLegOrderResource ). expressCompany || '未维护' }}</ strong >< span >{{ ( row as PlatformFirstLegOrderResource ). expressTrackingNo || '—' }}</ span ></ div ></ template ></ el-table-column >
< el-table-column label = "状态" width = "120" >< template # default = "{ row }" >< StatusBadge : status = "statusLabel((row as PlatformFirstLegOrderResource).status)" /></ template ></ el-table-column >
</ el-table >
< el-empty v-else : description = "loadedTenantId ? '当前租户没有符合条件的头程订单' : '请先输入目标租户 ID 并查询'" />
< footer class = "table-footer" >< span > 共 {{ total }} 条 </ span >< el-pagination v -model:current-page = "page" :page-size = "pageSize" :total = "total" layout = "prev, pager, next" @ current -change =" handlePageChange " /></ footer >
</ section >
< el-drawer v-model = "detailOpen" title="订单详情" size="760px" class="business-drawer" >
< div v-loading = "detailLoading" >
< template v-if = "detail" >
< div class = "order-detail-head" >< div >< span >{{ transportLabel ( detail . order ) }}</ span >< h2 >{{ detail . order . orderNo || detail . order . id }}</ h2 >< p >{{ detail . order . customerName }} · {{ detail . order . referenceId || '无客户参考号' }}</ p ></ div >< StatusBadge :status = "statusLabel(detail.order.status)" /></ div >
< div class = "detail-section" >< h3 > 订单概要 </ h3 >< el-descriptions :column = "2" border >< el-descriptions-item label = "目标仓 / 国家" >{{ detail . order . destinationWarehouseName || '-' }} · {{ detail . order . destinationCountryCode || '-' }}</ el-descriptions-item >< el-descriptions-item label = "平台收货仓" >{{ detail . order . receivingWarehouseName || '-' }}</ el-descriptions-item >< el-descriptions-item label = "收货地址" :span = "2" >{{ detail . order . receivingWarehouseAddress || '-' }}</ el-descriptions-item >< el-descriptions-item label = "渠道" >{{ detail . order . channelName || '-' }} ( {{ detail . order . channelCode || '-' }} ) </ el-descriptions-item >< el-descriptions-item label = "预计出运" >{{ detail . order . expectedShipDate || '-' }}</ el-descriptions-item >< el-descriptions-item label = "客户备注" :span = "2" >{{ detail . order . customerRemark || '-' }}</ el-descriptions-item ></ el-descriptions ></ div >
< div class = "detail-section" >< h3 > 货物明细 </ h3 >< el-table :data = "detail.cargoes" size = "small" empty -text =" 暂无货物明细 " >< el-table-column prop = "itemNo" label = "#" width = "52" />< el-table-column prop = "cargoName" label = "品名" min -width =" 150 " />< el-table-column prop = "packageType" label = "包装" min -width =" 100 " />< el-table-column prop = "packageCount" label = "件/箱" width = "86" />< el-table-column label = "毛重 / 体积" min -width =" 150 " >< template # default = "{ row }" >{{ row . grossWeightKg }} kg / {{ row . volumeCbm }} m³ </ template ></ el-table-column ></ el-table ></ div >
< div class = "detail-section" >< h3 > 收发货方 </ h3 >< div class = "party-grid" >< div v-for = "party in detail.parties" :key="party.id" >< span >{{ party . partyType === 'SENDER' ? '发货方' : '收货方' }}</ span >< strong >{{ party . companyName }}</ strong >< p >{{ party . contactName }} · {{ party . phone }}</ p >< p >{{ [ party . province , party . city , party . district , party . addressLine ]. filter ( Boolean ). join ( ' ' ) }}</ p ></ div ></ div ></ div >
< div class = "detail-section" >< h3 > 履约快递 </ h3 >< el-descriptions :column = "2" border >< el-descriptions-item label = "快递公司" >{{ detail . order . expressCompany || '-' }}</ el-descriptions-item >< el-descriptions-item label = "快递单号" >{{ detail . order . expressTrackingNo || '-' }}</ el-descriptions-item ></ el-descriptions ></ div >
< div class = "detail-section" >< h3 > 内部备注 </ h3 >< el-empty v-if = "!detail.internalNotes.length" description="暂无内部备注" :image-size="52" /><el-timeline v-else><el-timeline-item v-for="note in detail.internalNotes" :key="note.id" :timestamp="formattedDate(note.createdAt)" placement="top" >< p >{{ note . content }}</ p ></ el-timeline-item ></ el-timeline ></ div >
< div class = "detail-section" >< h3 > 状态历史 </ h3 >< el-timeline >< el-timeline-item v-for = "item in detail.statusHistory" :key="item.id" :timestamp="`${formattedDate(item.occurredAt)} · ${item.operatorName || '系统'}`" placement="top" >< strong >{{ statusLabel ( item . toStatus ) }}</ strong >< p >{{ item . reason || item . action || '状态已更新。' }}</ p ></ el-timeline-item ></ el-timeline ></ div >
</ template >
< el-empty v-else-if = "!detailLoading" description="订单详情暂不可用" />
</ div >
< template # footer >< div class = "drawer-footer" >< el-button @click ="detailOpen = false" > 关闭 </ el -button ></ div ></ template >
</ el-drawer >
< el-dialog v-model = "expressDialogOpen" title="维护履约快递" width="520px" >
< el-alert title = "当前后端契约只接收快递公司名称和快递单号;快递主数据关联待后端补充。" type = "info" :closable = "false" show -icon />
< el-form label -position =" top " class = "delivery-form" >< el-form-item label = "快递公司" required >< el-input v -model .trim =" expressForm.expressCompany " maxlength = "64" placeholder = "例如 顺丰速运" /></ el-form-item >< el-form-item label = "快递单号" required >< el-input v -model .trim =" expressForm.expressTrackingNo " maxlength = "128" placeholder = "请输入完整快递单号" /></ el-form-item ></ el-form >
< template # footer >< el-button :disabled = "expressSaving" @click ="expressDialogOpen = false" > 取消 </ el -button >< el-button type = "primary" :loading = "expressSaving" @click ="saveExpress" > 保存快递信息 </ el -button ></ template >
</ el-dialog >
< el-dialog v-model = "noteDialogOpen" title="添加内部备注" width="520px" >
< el-form label -position =" top " >< el-form-item label = "内部备注" required >< el-input v -model .trim =" noteContent " maxlength = "2000" type = "textarea" :rows = "4" show -word-limit placeholder = "仅平台内部可见" /></ el-form-item ></ el-form >
< template # footer >< el-button :disabled = "noteSaving" @click ="noteDialogOpen = false" > 取消 </ el -button >< el-button type = "primary" :loading = "noteSaving" @click ="saveNote" > 保存备注 </ el -button ></ template >
</ el-dialog >
</ div >
</ template >
< style scoped >
. server - order - filter - fields { grid - template - columns : minmax ( 150 px , .65 fr ) minmax ( 220 px , 1.2 fr ) minmax ( 150 px , .75 fr ) minmax ( 260 px , 1.1 fr ); }
. party - grid p { margin : 4 px 0 0 ; color : var ( -- cc - muted ); font - size : 12 px ; line - height : 1.6 ; overflow - wrap : anywhere ; }
@ media ( max - width : 720 px ) { . server - order - filter - fields { grid - template - columns : 1 fr ; } }
</ style >