@@ -0,0 +1,925 @@
< script setup lang = "ts" >
import { computed , nextTick , onBeforeUnmount , onMounted , reactive , ref , watch } from 'vue'
import { onBeforeRouteLeave , useRoute , useRouter } from 'vue-router'
import { ElMessage , ElMessageBox , type FormInstance , type FormItemRule , type FormRules } from 'element-plus'
import { AlertTriangle , Box , Eye , FilePlus2 , MapPin , PackageCheck , Pencil , Plus , Send , Trash2 } from '@lucide/vue'
import { FIRST _LEG _ORDER _STATUSES , FIRST _LEG _TRANSPORT _MODES , type FirstLegOrderStatus } from '@chongchong/shared'
import QueryToolbar , { type QueryField , type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable , { type DataTableColumn } from '@/components/common/DataTable.vue'
import FirstLegOrderEditorShell from '@/components/first-leg/FirstLegOrderEditorShell.vue'
import { useFirstLegOrdersStore } from '@/stores/firstLegOrders'
import { useTenantAdministrationStore } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth'
import { createFirstLegPartySnapshot , type FirstLegPartyRole } from '@/data/firstLegParties'
import { firstLegPartySource } from '@/data/firstLegPartySource'
import type { FirstLegOrder , FirstLegOrderDraft } from '@/types/firstLegOrder'
import {
canClientEditFirstLegOrder ,
createEmptyCargoItem ,
getFirstLegStatusLabel ,
getFirstLegStatusTone ,
getFirstLegTransportModeLabel ,
summarizeFirstLegCargo ,
validateFirstLegOrderDraft ,
} from '@/utils/firstLegOrder'
const store = useFirstLegOrdersStore ( )
const tenantStore = useTenantAdministrationStore ( )
const auth = useAuthStore ( )
const route = useRoute ( )
const router = useRouter ( )
const isCreatePage = computed ( ( ) => route . name === 'first-leg-order-create' )
const companyApproved = computed ( ( ) => tenantStore . company . auditStatus === 'ACTIVE' )
const canCreateOrder = computed ( ( ) => auth . hasPermission ( 'order:first-leg:create' ) )
const canEditOrder = computed ( ( ) => auth . hasPermission ( 'order:first-leg:edit' ) )
const availableWarehouses = computed ( ( ) => store . warehouses )
const availableShippers = ref ( firstLegPartySource . listAvailable ( 'SHIPPER' ) )
const availableConsignees = ref ( firstLegPartySource . listAvailable ( 'CONSIGNEE' ) )
const filters = ref < QueryModel > ( { keyword : '' , status : '' , transportMode : '' , createdRange : [ ] } )
const appliedFilters = ref < QueryModel > ( { ... filters . value } )
const queryFields : QueryField [ ] = [
{ key : 'keyword' , type : 'input' , placeholder : '订单号、参考号、路线或品名' , props : { 'aria-label' : '订单关键词' } } ,
{ key : 'status' , type : 'select' , placeholder : '全部状态' , options : FIRST _LEG _ORDER _STATUSES . map ( ( item ) => ( { label : item . label , value : item . code } ) ) , props : { 'aria-label' : '订单状态' } } ,
{ key : 'transportMode' , type : 'select' , placeholder : '全部运输方式' , options : FIRST _LEG _TRANSPORT _MODES . map ( ( item ) => ( { label : item . label , value : item . code } ) ) , props : { 'aria-label' : '运输方式' } } ,
{ key : 'createdRange' , type : 'daterange' , placeholder : '订单创建日期' , props : { 'aria-label' : '订单创建日期' } } ,
]
const channelOptions = [
{ code : 'SEA-US-GENERAL' , name : '美线普货专线' } ,
{ code : 'SEA-US-SENSITIVE' , name : '美线敏感货专线' } ,
{ code : 'SEA-US-EXPRESS' , name : '美线快线' } ,
{ code : 'SEA-US-FBA' , name : '美线 FBA 直送' } ,
]
const orderColumns : DataTableColumn [ ] = [
{ key : 'actions' , label : '操作' , width : 180 , fixed : 'left' , slotName : 'actions' } ,
{ key : 'order' , label : '订单' , minWidth : 190 , slotName : 'order' } ,
{ key : 'route' , label : '运输路线' , minWidth : 190 , slotName : 'route' } ,
{ key : 'warehouse' , label : '收货仓' , prop : 'receivingWarehouseName' , minWidth : 120 } ,
{ key : 'cargo' , label : '货物' , minWidth : 150 , slotName : 'cargo' } ,
{ key : 'status' , label : '状态' , width : 120 , slotName : 'status' } ,
{ key : 'operator' , label : '负责人' , width : 110 , slotName : 'operator' } ,
{ key : 'updatedAt' , label : '更新时间' , width : 155 , slotName : 'updatedAt' } ,
]
const tablePage = ref ( 1 )
const tablePageSize = ref ( 10 )
const editorOpen = ref ( false )
const orderFormRef = ref < FormInstance > ( )
const detailOpen = ref ( false )
const editingId = ref < string > ( )
const openingEditor = ref ( false )
const detailId = ref < string > ( )
const saving = ref ( false )
const detailLoading = ref ( false )
const detailError = ref ( '' )
const supportingDataLoading = ref ( false )
const supportingDataErrors = ref < string [ ] > ( [ ] )
const editorBaseline = ref ( '' )
let cargoSequence = 0
const orderFormRules : FormRules = {
transportMode : [ { required : true , message : '请选择运输方式' , trigger : 'change' } ] ,
businessType : [ { required : true , message : '请选择业务类型' , trigger : 'change' } ] ,
origin : [ { required : true , message : '请输入起运地' , trigger : 'blur' } ] ,
destination : [ { required : true , message : '请输入目的地' , trigger : 'blur' } ] ,
receivingWarehouseId : [ { required : true , message : '请选择平台收货仓' , trigger : 'change' } ] ,
destinationCountryCode : [
{ required : true , message : '请输入目的国代码' , trigger : 'blur' } ,
{ pattern : /^[A-Za-z]{2}$/ , message : '请输入两位英文字母,例如 US' , trigger : 'blur' } ,
] ,
destinationWarehouseName : [ { required : true , message : '请输入目的仓名称' , trigger : 'blur' } ] ,
channelCode : [ { required : true , message : '请输入渠道编码' , trigger : 'blur' } ] ,
channelName : [ { required : true , message : '请输入渠道名称' , trigger : 'blur' } ] ,
pickupMethod : [ { required : true , message : '请选择提货方式' , trigger : 'change' } ] ,
taxMode : [ { required : true , message : '请选择报关模式' , trigger : 'change' } ] ,
destinationType : [ { required : true , message : '请选择目的仓类型' , trigger : 'change' } ] ,
shipperPartyId : [ { required : true , message : '请选择发货单位' , trigger : 'change' } ] ,
consigneePartyId : [ { required : true , message : '请选择收货单位' , trigger : 'change' } ] ,
}
function requiredTextRules ( message : string ) : FormItemRule [ ] {
return [ { required : true , message , trigger : 'blur' } ]
}
function requiredSelectRules ( message : string ) : FormItemRule [ ] {
return [ { required : true , message , trigger : 'change' } ]
}
function positiveNumberRules ( message : string , integer = false ) : FormItemRule [ ] {
return [ { type : integer ? 'integer' : 'number' , min : integer ? 1 : 0.001 , message , trigger : 'change' } ]
}
function formatDateTime ( value : string ) {
const date = new Date ( value )
if ( Number . isNaN ( date . getTime ( ) ) ) return value || '-'
return date . toLocaleString ( 'zh-CN' , {
year : 'numeric' ,
month : '2-digit' ,
day : '2-digit' ,
hour : '2-digit' ,
minute : '2-digit' ,
hour12 : false ,
} )
}
function emptyDraft ( ) : FirstLegOrderDraft {
return {
referenceNo : '' ,
transportMode : 'FCL' ,
businessType : 'EXPORT' ,
origin : '' ,
destination : '' ,
receivingWarehouseId : '' ,
expectedDepartureDate : '' ,
shipperPartyId : '' ,
shipperPartySnapshot : null ,
shipper : '' ,
consigneePartyId : '' ,
consigneePartySnapshot : null ,
consignee : '' ,
priority : 'NORMAL' ,
customerRemark : '' ,
tradeTerm : 'FOB' ,
destinationCountryCode : 'US' ,
destinationWarehouseName : '' ,
pickupMethod : 'SELF_EXPRESS' ,
channelCode : 'SEA-US-FBA' ,
channelName : '美线 FBA 直送' ,
taxMode : 'BUY_DECLARATION' ,
customsInspection : false ,
destinationType : 'FBA' ,
amazonWarehouseCode : '' ,
shipmentId : '' ,
items : [ createEmptyCargoItem ( ` new-cargo- ${ ++ cargoSequence } ` ) ] ,
}
}
const form = reactive < FirstLegOrderDraft > ( emptyDraft ( ) )
const serializedDraft = computed ( ( ) => JSON . stringify ( form ) )
const editorDirty = computed ( ( ) => ( editorOpen . value || isCreatePage . value ) && serializedDraft . value !== editorBaseline . value )
const filteredOrders = computed ( ( ) => store . orders . filter ( ( order ) => {
const keywordValue = appliedFilters . value . keyword
const statusValue = appliedFilters . value . status
const transportModeValue = appliedFilters . value . transportMode
const createdRangeValue = appliedFilters . value . createdRange
const keyword = typeof keywordValue === 'string' ? keywordValue . trim ( ) . toLowerCase ( ) : ''
const status = typeof statusValue === 'string' ? statusValue as FirstLegOrderStatus | '' : ''
const transportMode = typeof transportModeValue === 'string' ? transportModeValue : ''
const createdRange = Array . isArray ( createdRangeValue ) ? createdRangeValue . filter ( ( value ) : value is string => typeof value === 'string' ) : [ ]
const matchesKeyword = ! keyword || [ order . orderNo , order . referenceNo , order . origin , order . destination , order . items . map ( ( item ) => item . cargoNameCn ) . join ( ' ' ) ]
. filter ( Boolean )
. some ( ( value ) => String ( value ) . toLowerCase ( ) . includes ( keyword ) )
const matchesStatus = ! status || order . status === status
const matchesMode = ! transportMode || order . transportMode === transportMode
const matchesDate = createdRange . length !== 2 || ( order . createdAt . slice ( 0 , 10 ) >= createdRange [ 0 ] && order . createdAt . slice ( 0 , 10 ) <= createdRange [ 1 ] )
return matchesKeyword && matchesStatus && matchesMode && matchesDate
} ) )
const pagedOrders = computed ( ( ) => {
const start = ( tablePage . value - 1 ) * tablePageSize . value
return filteredOrders . value . slice ( start , start + tablePageSize . value )
} )
const detailOrder = computed ( ( ) => detailId . value ? store . getById ( detailId . value ) : undefined )
const detailSummary = computed ( ( ) => detailOrder . value ? summarizeFirstLegCargo ( detailOrder . value . items ) : null )
const selectedWarehouse = computed ( ( ) => availableWarehouses . value . find ( ( item ) => item . id === form . receivingWarehouseId ) )
const hasActiveFilters = computed ( ( ) => {
const values = appliedFilters . value
return Boolean (
( typeof values . keyword === 'string' && values . keyword . trim ( ) )
|| values . status
|| values . transportMode
|| ( Array . isArray ( values . createdRange ) && values . createdRange . length ) ,
)
} )
const emptyDescription = computed ( ( ) => hasActiveFilters . value ? '没有符合筛选条件的头程订单' : '还没有头程订单' )
async function loadPageData ( ) {
supportingDataLoading . value = true
supportingDataErrors . value = [ ]
availableShippers . value = [ ]
availableConsignees . value = [ ]
const companyRequest = auth . hasPermission ( 'company:profile:view' )
? tenantStore . loadCompanyContext ( )
: Promise . resolve ( )
const [ ordersResult , partiesResult , companyResult ] = await Promise . allSettled ( [
store . load ( ) ,
firstLegPartySource . load ( ) ,
companyRequest ,
] )
if ( partiesResult . status === 'fulfilled' ) {
availableShippers . value = firstLegPartySource . listAvailable ( 'SHIPPER' )
availableConsignees . value = firstLegPartySource . listAvailable ( 'CONSIGNEE' )
} else {
supportingDataErrors . value . push ( ` 往来单位加载失败: ${ errorMessage ( partiesResult . reason ) } ` )
}
if ( companyResult . status === 'rejected' ) {
supportingDataErrors . value . push ( ` 企业资料加载失败: ${ errorMessage ( companyResult . reason ) } ` )
}
if ( ordersResult . status === 'rejected' && ! store . loadError ) {
supportingDataErrors . value . push ( ` 订单与仓库加载失败: ${ errorMessage ( ordersResult . reason ) } ` )
}
supportingDataLoading . value = false
}
function errorMessage ( error : unknown ) {
return error instanceof Error ? error . message : '服务暂时不可用'
}
function handleBeforeUnload ( event : BeforeUnloadEvent ) {
if ( ! editorDirty . value ) return
event . preventDefault ( )
event . returnValue = ''
}
onMounted ( ( ) => {
window . addEventListener ( 'beforeunload' , handleBeforeUnload )
void loadPageData ( )
} )
watch ( isCreatePage , ( isCreate , wasCreate ) => {
if ( ! isCreate || wasCreate ) return
editingId . value = undefined
replaceForm ( emptyDraft ( ) )
resetEditorValidation ( )
} , { immediate : true } )
onBeforeUnmount ( ( ) => window . removeEventListener ( 'beforeunload' , handleBeforeUnload ) )
onBeforeRouteLeave ( async ( ) => {
if ( ! editorDirty . value ) return true
return confirmDiscardChanges ( )
} )
function markEditorClean ( ) {
editorBaseline . value = serializedDraft . value
}
function resetEditorValidation ( ) {
void nextTick ( ( ) => orderFormRef . value ? . clearValidate ( ) )
}
function cloneDraft ( draft : FirstLegOrderDraft ) : FirstLegOrderDraft {
return {
... draft ,
shipperPartySnapshot : draft . shipperPartySnapshot ? { ... draft . shipperPartySnapshot } : null ,
consigneePartySnapshot : draft . consigneePartySnapshot ? { ... draft . consigneePartySnapshot } : null ,
items : draft . items . map ( ( item ) => ( { ... item } ) ) ,
}
}
function replaceForm ( draft : FirstLegOrderDraft ) {
Object . assign ( form , cloneDraft ( draft ) )
markEditorClean ( )
}
function draftFromOrder ( order : FirstLegOrder ) : FirstLegOrderDraft {
return {
referenceNo : order . referenceNo ,
transportMode : order . transportMode ,
businessType : order . businessType ,
origin : order . origin ,
destination : order . destination ,
receivingWarehouseId : order . receivingWarehouseId ,
expectedDepartureDate : order . expectedDepartureDate ,
shipperPartyId : order . shipperPartyId ,
shipperPartySnapshot : order . shipperPartySnapshot ? { ... order . shipperPartySnapshot } : null ,
shipper : order . shipper ,
consigneePartyId : order . consigneePartyId ,
consigneePartySnapshot : order . consigneePartySnapshot ? { ... order . consigneePartySnapshot } : null ,
consignee : order . consignee ,
priority : order . priority ,
customerRemark : order . customerRemark ,
tradeTerm : order . tradeTerm ,
destinationCountryCode : order . destinationCountryCode ,
destinationWarehouseName : order . destinationWarehouseName ,
pickupMethod : order . pickupMethod ,
channelCode : order . channelCode ,
channelName : order . channelName ,
taxMode : order . taxMode ,
customsInspection : order . customsInspection ,
destinationType : order . destinationType ,
amazonWarehouseCode : order . amazonWarehouseCode ,
shipmentId : order . shipmentId ,
items : order . items . map ( ( item ) => ( { ... item } ) ) ,
}
}
function selectParty ( role : FirstLegPartyRole , id : string ) {
const party = firstLegPartySource . findAvailable ( id , role )
if ( ! party ) return
const snapshot = createFirstLegPartySnapshot ( party , role )
if ( role === 'SHIPPER' ) {
form . shipperPartyId = party . id
form . shipperPartySnapshot = snapshot
form . shipper = party . name
form . origin = [ snapshot . province , snapshot . city ] . filter ( Boolean ) . join ( ' ' ) || snapshot . countryRegion || snapshot . name
} else {
form . consigneePartyId = party . id
form . consigneePartySnapshot = snapshot
form . consignee = party . name
form . destination = [ snapshot . province , snapshot . city ] . filter ( Boolean ) . join ( ' ' ) || snapshot . countryRegion || snapshot . name
if ( /^[A-Za-z]{2}$/ . test ( snapshot . countryRegion ) ) form . destinationCountryCode = snapshot . countryRegion . toUpperCase ( )
}
}
function clearParty ( role : FirstLegPartyRole ) {
if ( role === 'SHIPPER' ) {
form . shipperPartyId = ''
form . shipperPartySnapshot = null
form . shipper = ''
form . origin = ''
} else {
form . consigneePartyId = ''
form . consigneePartySnapshot = null
form . consignee = ''
form . destination = ''
}
}
function selectChannel ( code : string ) {
const channel = channelOptions . find ( ( item ) => item . code === code )
form . channelName = channel ? . name ? ? ''
}
function syncAmazonWarehouse ( value : string ) {
if ( form . destinationType === 'FBA' ) form . destinationWarehouseName = value . trim ( )
}
function selectShipper ( id : string ) {
selectParty ( 'SHIPPER' , id )
}
function selectConsignee ( id : string ) {
selectParty ( 'CONSIGNEE' , id )
}
function clearShipper ( ) {
clearParty ( 'SHIPPER' )
}
function clearConsignee ( ) {
clearParty ( 'CONSIGNEE' )
}
async function loadSelectedWarehouse ( id : string | number | boolean | undefined ) {
const warehouseId = typeof id === 'string' ? id : typeof id === 'number' ? String ( id ) : ''
if ( ! warehouseId ) return
try {
await store . loadWarehouseDetail ( warehouseId )
} catch ( error ) {
ElMessage . error ( error instanceof Error ? error . message : '仓库地址与收货时间加载失败' )
}
}
async function openCreate ( ) {
if ( availableWarehouses . value . length === 0 ) {
ElMessage . warning ( '当前没有可用的平台收货仓,请联系平台维护仓库后再创建订单' )
return
}
editingId . value = undefined
replaceForm ( emptyDraft ( ) )
resetEditorValidation ( )
await router . push ( { name : 'first-leg-order-create' } )
}
async function openEdit ( order : FirstLegOrder ) {
if ( openingEditor . value || ! canEditOrder . value || ! canClientEditFirstLegOrder ( order . status ) ) return
openingEditor . value = true
try {
const detailedOrder = await store . loadDetail ( order . id )
const draft = draftFromOrder ( detailedOrder )
const warehouseAvailable = availableWarehouses . value . some ( ( item ) => item . id === detailedOrder . receivingWarehouseId )
if ( ! warehouseAvailable ) {
draft . receivingWarehouseId = ''
ElMessage . warning ( '原平台收货仓当前不可用,请重新选择可用收货仓' )
}
editingId . value = detailedOrder . id
replaceForm ( draft )
if ( warehouseAvailable ) void loadSelectedWarehouse ( detailedOrder . receivingWarehouseId )
detailOpen . value = false
editorOpen . value = true
resetEditorValidation ( )
} catch ( error ) {
ElMessage . error ( error instanceof Error ? error . message : '订单详情加载失败,暂时无法编辑' )
} finally {
openingEditor . value = false
}
}
async function openDetail ( order : FirstLegOrder ) {
detailId . value = order . id
detailOpen . value = true
detailLoading . value = true
detailError . value = ''
try {
await store . loadDetail ( order . id )
} catch ( error ) {
detailError . value = error instanceof Error ? error . message : '订单详情加载失败'
ElMessage . error ( detailError . value )
} finally {
detailLoading . value = false
}
}
async function retryDetail ( ) {
const order = detailId . value ? store . getById ( detailId . value ) : undefined
if ( order ) await openDetail ( order )
}
async function confirmDiscardChanges ( ) {
try {
await ElMessageBox . confirm ( '当前订单还有未保存的修改。离开后这些内容将丢失。' , '放弃未保存的修改?' , {
type : 'warning' ,
confirmButtonText : '放弃修改' ,
cancelButtonText : '继续编辑' ,
} )
return true
} catch ( error ) {
if ( error === 'cancel' || error === 'close' ) return false
throw error
}
}
async function requestCloseEditor ( done : ( ) => void ) {
if ( saving . value ) return
if ( ! editorDirty . value || await confirmDiscardChanges ( ) ) done ( )
}
function closeEditor ( ) {
void requestCloseEditor ( ( ) => {
editorOpen . value = false
if ( isCreatePage . value ) {
markEditorClean ( )
void router . push ( { name : 'first-leg-orders' } )
}
} )
}
function addCargoItem ( ) {
form . items . push ( createEmptyCargoItem ( ` new-cargo- ${ ++ cargoSequence } ` ) )
resetEditorValidation ( )
}
function removeCargoItem ( index : number ) {
if ( form . items . length === 1 ) {
ElMessage . warning ( '订单至少需要保留一条货物明细' )
return
}
form . items . splice ( index , 1 )
resetEditorValidation ( )
}
async function validateEditorForm ( ) {
let fieldsValid = false
try {
fieldsValid = await orderFormRef . value ? . validate ( ) ? ? false
} catch {
fieldsValid = false
}
if ( ! fieldsValid ) {
ElMessage . warning ( '请检查并完善表单中标红的必填信息' )
return false
}
const errors = validateFirstLegOrderDraft ( form )
if ( errors . length > 0 ) {
ElMessage . warning ( errors [ 0 ] )
return false
}
return true
}
async function saveDraft ( ) {
if ( saving . value ) return
if ( ! await validateEditorForm ( ) ) return
saving . value = true
try {
const order = await store . saveDraft ( cloneDraft ( form ) , tenantStore . company . companyName , editingId . value )
editingId . value = order . id
markEditorClean ( )
editorOpen . value = false
if ( isCreatePage . value ) await router . push ( { name : 'first-leg-orders' } )
ElMessage . success ( '订单草稿已保存' )
} catch ( error ) {
ElMessage . error ( error instanceof Error ? error . message : '草稿保存失败' )
} finally {
saving . value = false
}
}
async function submitOrder ( ) {
if ( saving . value ) return
if ( ! companyApproved . value ) {
try {
await ElMessageBox . confirm ( '企业资料审核通过后才能提交订单。您可以继续保存草稿,或前往企业资料核对并补充信息。' , '暂时无法提交订单' , {
type : 'warning' ,
confirmButtonText : '前往企业资料' ,
cancelButtonText : '继续编辑草稿' ,
} )
markEditorClean ( )
editorOpen . value = false
await router . push ( '/company/profile' )
} catch ( error ) {
if ( error === 'cancel' || error === 'close' ) return
throw error
}
return
}
if ( ! await validateEditorForm ( ) ) return
try {
saving . value = true
const order = await store . submit ( cloneDraft ( form ) , tenantStore . company . companyName , editingId . value , tenantStore . company . auditStatus )
markEditorClean ( )
editorOpen . value = false
if ( isCreatePage . value ) await router . push ( { name : 'first-leg-orders' } )
detailId . value = order . id
detailOpen . value = true
ElMessage . success ( '订单已提交,等待平台审核' )
} catch ( error ) {
ElMessage . error ( error instanceof Error ? error . message : '订单提交失败' )
} finally {
saving . value = false
}
}
async function searchOrders ( model : QueryModel ) {
const createdRange = model . createdRange
appliedFilters . value = {
... model ,
createdRange : Array . isArray ( createdRange ) ? createdRange . filter ( ( value ) : value is string => typeof value === 'string' ) : [ ] ,
}
tablePage . value = 1
try {
await store . load ( )
} catch {
// Store keeps the recoverable error state rendered below the query toolbar.
}
}
< / script >
< template >
< section class = "first-leg-page" : class = "{ 'is-create-route': isCreatePage }" >
< template v-if = "!isCreatePage" >
< QueryToolbar v-model = "filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="头程订单查询" @search="searchOrders" / >
< div class = "client-card order-table-card" >
< div class = "order-table-toolbar" >
< div > < strong > 订单列表 < / strong > < span > 共 { { filteredOrders . length } } 条 < / span > < / div >
< el-button v-if = "auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" / > 创建订单 < / el-button >
< / div >
< div v-if = "!companyApproved && !supportingDataErrors.some((message) => message.startsWith('企业资料'))" class="order-eligibility is-pending" role="status" >
< AlertTriangle :size = "20" / >
< div > < strong > { { canCreateOrder ? '企业资料待审核,当前仅可保存订单草稿' : '企业资料待审核,当前订单为只读' } } < / strong > < span > { { canCreateOrder ? '平台审核通过后即可提交;您仍可创建、编辑和保存草稿。' : '您当前拥有只读权限,可以继续查看订单进展。' } } < / span > < / div >
< el-button v-if = "!companyApproved && auth.hasPermission('company:profile:view')" link type="primary" @click="router.push('/company/profile')" > 查看企业资料 < / el -button >
< / div >
< div v-if = "supportingDataErrors.length" class="supporting-data-error" role="alert" >
< AlertTriangle :size = "20" / >
< div > < strong > 部分订单配套数据加载失败 < / strong > < span v-for = "message in supportingDataErrors" :key="message" > {{ message }} < / span > < / div >
< el -button : loading = "supportingDataLoading || store.loading" @click ="loadPageData" > 重新加载 < / el -button >
< / div >
< div v-if = "store.loadError" class="order-load-state" role="alert" >
< AlertTriangle :size = "24" / >
< div > < strong > 订单数据加载失败 < / strong > < span > { { store . loadError } } < / span > < / div >
< el-button :loading = "store.loading" @click ="loadPageData" > 重新加载 < / el -button >
< / div >
< template v-else >
< div class = "desktop-order-table" >
< DataTable v -model :page = "tablePage" v -model :page-size = "tablePageSize" :data = "pagedOrders" :columns = "orderColumns" :loading = "store.loading" :total = "filteredOrders.length" : page -sizes = " [ 10 , 20 , 50 ] " row -key = " id " empty -text = " 暂无头程订单 " >
< template # actions = "{ row }" >
< div class = "order-row-actions" >
< el-button link type = "primary" @click ="openDetail(row as FirstLegOrder)" > < Eye :size = "15" / > 详情 < / el-button >
< el-button v-if = "canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" / > 编辑 < / el-button >
< / div >
< / template >
< template # order = "{ row }" >
< button class = "order-number-link" type = "button" @click ="openDetail(row as FirstLegOrder)" > {{ ( row as FirstLegOrder ) .orderNo | | ' 未提交草稿 ' }} < / button >
< span class = "order-reference" > { { ( row as FirstLegOrder ) . referenceNo || '无客户参考号' } } < / span >
< / template >
< template # route = "{ row }" > < strong > { { ( row as FirstLegOrder ) . origin } } → { { ( row as FirstLegOrder ) . destination } } < / strong > < span class = "order-reference" > { { getFirstLegTransportModeLabel ( ( row as FirstLegOrder ) . transportMode ) } } < / span > < / template >
< template # cargo = "{ row }" > { { ( row as FirstLegOrder ) . items [ 0 ] ? . cargoNameCn || '-' } } < span v-if = "(row as FirstLegOrder).items.length > 1" class="order-reference" > 等 {{ ( row as FirstLegOrder ) .items.length }} 项 < / span > < / template >
< template # status = "{ row }" > < el-tag : type = "getFirstLegStatusTone((row as FirstLegOrder).status)" effect = "light" > { { getFirstLegStatusLabel ( ( row as FirstLegOrder ) . status ) } } < / el-tag > < / template >
< template # operator = "{ row }" > { { ( row as FirstLegOrder ) . operatorName || '-' } } < / template >
< template # updatedAt = "{ row }" > { { formatDateTime ( ( row as FirstLegOrder ) . updatedAt ) } } < / template >
< template # empty >
< el-empty :description = "emptyDescription" >
< el-button v-if = "auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" / > { { availableWarehouses . length ? '创建第一笔订单' : '暂无可用收货仓' } } < / el-button >
< / el-empty >
< / template >
< / DataTable >
< / div >
< div v-loading = "store.loading" class="mobile-order-surface" >
< div v-if = "filteredOrders.length" class="mobile-order-list" >
< article v-for = "order in filteredOrders" :key="order.id" class="mobile-order-card" >
< header > < button type = "button" @click ="openDetail(order)" > {{ order.orderNo | | ' 未提交草稿 ' }} < / button > < el -tag :type = "getFirstLegStatusTone(order.status)" effect = "light" > { { getFirstLegStatusLabel ( order . status ) } } < / el-tag > < / header >
< span class = "order-reference" > { { order . referenceNo || '无客户参考号' } } < / span >
< dl > < div > < dt > 运输路线 < / dt > < dd > { { order . origin } } → { { order . destination } } < / dd > < / div > < div > < dt > 收货仓 < / dt > < dd > { { order . receivingWarehouseName } } < / dd > < / div > < div > < dt > 货物 < / dt > < dd > { { order . items [ 0 ] ? . cargoNameCn || '-' } } < span v-if = "order.items.length > 1" > 等 {{ order.items.length }} 项 < / span > < / dd > < / div > < div > < dt > 负责人 < / dt > < dd > { { order . operatorName || '-' } } < / dd > < / div > < / dl >
< footer > < span > 更新于 { { formatDateTime ( order . updatedAt ) } } < / span > < div > < el-button link type = "primary" @click ="openDetail(order)" > < Eye :size = "15" / > 详情 < / el-button > < el-button v-if = "canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" :loading="openingEditor" @click="openEdit(order)"><Pencil :size="15" / > 编辑 < / el-button > < / div > < / footer >
< / article >
< / div >
< el-empty v-else :description = "emptyDescription" >
< el-button v-if = "auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" / > { { availableWarehouses . length ? '创建第一笔订单' : '暂无可用收货仓' } } < / el-button >
< / el-empty >
< / div >
< / template >
< / div >
< / template >
< div v-if = "isCreatePage && supportingDataErrors.length" class="supporting-data-error" role="alert" >
< AlertTriangle :size = "20" / >
< div > < strong > 下单配套数据加载失败 < / strong > < span v-for = "message in supportingDataErrors" :key="message" > {{ message }} < / span > < / div >
< el -button : loading = "supportingDataLoading || store.loading" @click ="loadPageData" > 重新加载 < / el -button >
< / div >
< el-alert v-if = "isCreatePage && !supportingDataLoading && availableWarehouses.length === 0" title="当前没有可用的平台收货仓" description="请联系平台维护并启用收货仓后再创建订单。" type="warning" :closable="false" show -icon / >
< FirstLegOrderEditorShell v-model = "editorOpen" :page="isCreatePage" :title="editingId ? '编辑订单草稿' : '创建头程订单'" :before-close="requestCloseEditor" @back="closeEditor" >
< el -form ref = "orderFormRef" v-loading = "isCreatePage && (supportingDataLoading || store.loading)" :model="form" :rules="orderFormRules" label-position="top" class="order-form prototype-order-form" scroll -to -error >
< section class = "order-form-section prototype-section" >
< div class = "order-section-heading" > < div > < h3 > 下单信息 < / h3 > < p > 选择送货方式 、 收货仓和运输渠道 。 < / p > < / div > < / div >
< div class = "order-form-grid" >
< el-form-item label = "取货方式" prop = "pickupMethod" required class = "form-span-full" > < el-radio-group v-model = "form.pickupMethod" class="segmented-options"><el-radio-button value="SELF_EXPRESS">自送货 · 快递</el-radio-button><el-radio-button value="PICKUP">我司提货</el-radio-button><el-radio-button value="SELF_TRUCK" > 自送货 · 卡车 < / el -radio -button > < / el-radio-group > < / el-form-item >
< el-form-item label = "平台收货仓" prop = "receivingWarehouseId" required class = "form-span-full" > < el-radio-group v-model = "form.receivingWarehouseId" class="warehouse-options" @change="loadSelectedWarehouse"><el-radio-button v-for="warehouse in availableWarehouses" :key="warehouse.id" :value="warehouse.id" > {{ warehouse.city ? ` $ { warehouse.name } · $ { warehouse.city } ` : warehouse.name }} < / el -radio -button > < / el-radio-group > < / el-form-item >
< el-form-item label = "渠道" prop = "channelCode" required > < el-select v-model = "form.channelCode" placeholder="请选择运输渠道" @change="selectChannel"><el-option v-if="form.channelCode && !channelOptions.some((channel) => channel.code === form.channelCode)" :label="form.channelName || form.channelCode" :value="form.channelCode" /><el-option v-for="channel in channelOptions" :key="channel.code" :label="channel.name" :value="channel.code" / > < / el-select > < / el-form-item >
< el-form-item label = "买单 / 退税" prop = "taxMode" required > < el-radio-group v-model = "form.taxMode"><el-radio value="BUY_DECLARATION">买单报关</el-radio><el-radio value="TAX_REFUND" > 退税报关 < / el -radio > < / el-radio-group > < / el-form-item >
< el-form-item label = "运输方式" prop = "transportMode" required > < el-radio-group v-model = "form.transportMode"><el-radio v-for="item in FIRST_LEG_TRANSPORT_MODES" :key="item.code" :value="item.code" > {{ item.label }} < / el -radio > < / el-radio-group > < / el-form-item >
< el-form-item label = "是否商检" prop = "customsInspection" required > < el-radio-group v-model = "form.customsInspection"><el-radio :value="true">是</el-radio><el-radio :value="false" > 否 < / el -radio > < / el-radio-group > < / el-form-item >
< el-form-item label = "期望出货日期" > < el-date-picker v-model = "form.expectedDepartureDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择期望出货日期" / > < / el-form-item >
< el-form-item label = "订单备注" > < el-input v-model = "form.customerRemark" maxlength="1000" placeholder="选填,例如:入仓前请电话联系" / > < / el-form-item >
< / div >
< div v-if = "selectedWarehouse" class="selected-warehouse" aria-label="所选收货仓详情"><MapPin :size="17" / > < div > < span > { { selectedWarehouse . address ? ` ${ selectedWarehouse . province } ${ selectedWarehouse . city } · ${ selectedWarehouse . address } ` : '仓库地址详情暂不可用' } } < / span > < small > { { selectedWarehouse . businessHours ? ` 收货时间: ${ selectedWarehouse . businessHours } ` : '收货时间暂不可用' } } < / small > < / div > < / div >
< / section >
< section class = "order-form-section prototype-section destination-section" >
< div class = "order-section-heading" > < div > < h3 > 发货与目的地 < / h3 > < p > 单位信息会保存为本订单快照 , 后续主数据变更不会影响本单 。 < / p > < / div > < / div >
< el-form-item label = "目的仓类型" prop = "destinationType" required > < el-radio-group v-model = "form.destinationType" class="segmented-options"><el-radio-button value="FBA">FBA 仓</el-radio-button><el-radio-button value="OVERSEAS">海外仓</el-radio-button><el-radio-button value="PRIVATE" > 私人仓 < / el -radio -button > < / el-radio-group > < / el-form-item >
< div class = "party-select-grid" >
< div class = "party-panel" >
< h4 > 发货信息 < / h4 >
< el-alert v-if = "availableShippers.length === 0" title="暂无可用发货单位" description="请先在平台往来单位中启用“发货单位”类型。" type="warning" :closable="false" show -icon / >
< el-form-item label = "发货单位" prop = "shipperPartyId" required > < el-select v-model = "form.shipperPartyId" filterable clearable placeholder="请选择发货单位" @change="selectShipper" @clear="clearShipper"><el-option v-if="form.shipperPartySnapshot && !availableShippers.some((party) => party.id === form.shipperPartyId)" :label="`${form.shipperPartySnapshot.name} · 历史快照`" :value="form.shipperPartyId" disabled /><el-option v-for="party in availableShippers" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" / > < / el-select > < / el-form-item >
< div v-if = "form.shipperPartySnapshot" class="party-contact-grid" >
< el -form -item label = "联系人" prop = "shipperPartySnapshot.contactName" required :rules = "requiredTextRules('请输入发货联系人姓名')" > < el-input v-model = "form.shipperPartySnapshot.contactName" maxlength="50" placeholder="请输入联系人" / > < / el-form-item >
< el-form-item label = "联系电话" prop = "shipperPartySnapshot.phone" required :rules = "requiredTextRules('请输入发货联系电话')" > < el-input v-model = "form.shipperPartySnapshot.phone" maxlength="30" placeholder="请输入联系电话" / > < / el-form-item >
< el-form-item label = "发货地址" prop = "shipperPartySnapshot.address" required :rules = "requiredTextRules('请输入本单实际发货地址')" class = "form-span-full" > < el-input v-model = "form.shipperPartySnapshot.address" maxlength="300" placeholder="请输入本单实际发货地址" / > < / el-form-item >
< / div >
< / div >
< div class = "party-panel" >
< h4 > 目的地信息 < / h4 >
< el-alert v-if = "availableConsignees.length === 0" title="暂无可用收货单位" description="请先在平台往来单位中启用“收货单位”类型。" type="warning" :closable="false" show -icon / >
< el-form-item label = "收货单位" prop = "consigneePartyId" required > < el-select v-model = "form.consigneePartyId" filterable clearable placeholder="请选择收货单位" @change="selectConsignee" @clear="clearConsignee"><el-option v-if="form.consigneePartySnapshot && !availableConsignees.some((party) => party.id === form.consigneePartyId)" :label="`${form.consigneePartySnapshot.name} · 历史快照`" :value="form.consigneePartyId" disabled /><el-option v-for="party in availableConsignees" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" / > < / el-select > < / el-form-item >
< div v-if = "form.consigneePartySnapshot" class="party-contact-grid" >
< el -form -item label = "收件人" prop = "consigneePartySnapshot.contactName" required :rules = "requiredTextRules('请输入收货联系人姓名')" > < el-input v-model = "form.consigneePartySnapshot.contactName" maxlength="50" placeholder="请输入收件人姓名" / > < / el-form-item >
< el-form-item label = "联系电话" prop = "consigneePartySnapshot.phone" required :rules = "requiredTextRules('请输入收货联系电话')" > < el-input v-model = "form.consigneePartySnapshot.phone" maxlength="30" placeholder="请输入收件人电话" / > < / el-form-item >
< el-form-item label = "收件地址" prop = "consigneePartySnapshot.address" required :rules = "requiredTextRules('请输入本单实际收货地址')" class = "form-span-full" > < el-input v-model = "form.consigneePartySnapshot.address" maxlength="300" placeholder="请输入收件详细地址" / > < / el-form-item >
< / div >
< / div >
< / div >
< div class = "destination-fields" >
< el-form-item label = "目的国代码" prop = "destinationCountryCode" required > < el-input v-model = "form.destinationCountryCode" maxlength="2" placeholder="例如:US" / > < / el-form-item >
< el-form-item v-if = "form.destinationType === 'FBA'" label="亚马逊仓代码" prop="amazonWarehouseCode" required :rules="requiredTextRules('请输入 Amazon 仓库编码')"><el-input v-model="form.amazonWarehouseCode" maxlength="32" placeholder="例如:ONT8" @input="syncAmazonWarehouse" / > < / el-form-item >
< el-form-item v-else label = "目的仓名称" prop = "destinationWarehouseName" required > < el-input v-model = "form.destinationWarehouseName" maxlength="128" placeholder="请输入海外仓或私人仓名称" / > < / el-form-item >
< el-form-item v-if = "form.destinationType === 'FBA'" label="货件编号" prop="shipmentId" required :rules="requiredTextRules('请输入 Shipment ID')"><el-input v-model="form.shipmentId" maxlength="64" placeholder="Shipment ID" / > < / el-form-item >
< el-form-item label = "跟踪编号" prop = "referenceNo" : required = "form.destinationType === 'FBA'" : rules = "form.destinationType === 'FBA' ? requiredTextRules('FBA 订单请输入 Reference ID') : undefined" > < el-input v-model = "form.referenceNo" maxlength="100" :placeholder="form.destinationType === 'FBA' ? 'Reference ID' : '选填,贵司内部跟踪编号'" / > < / el-form-item >
< / div >
< / section >
< section class = "order-form-section prototype-section cargo-section" >
< div class = "order-section-heading" > < div > < h3 > 货物明细 < / h3 > < p > 先填写常用字段 , 申报和整柜信息可按需展开 。 < / p > < / div > < el-button @click ="addCargoItem" > < Plus :size = "16" / > 添加货物 < / el-button > < / div >
< article v-for = "(item, index) in form.items" :key="item.id" class="cargo-entry" >
< header > < strong > 货物 { { index + 1 } } < / strong > < el-button link type = "danger" : aria -label = " ` 删除货物 $ { index + 1 } ` " @click ="removeCargoItem(index)" > < Trash2 :size = "16" / > 删除 < / el-button > < / header >
< div class = "cargo-primary-grid" >
< el-form-item label = "SKU" :prop = "`items.${index}.sku`" : required = "form.destinationType === 'FBA'" : rules = "form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入客户 SKU') : undefined" > < el-input v-model = "item.sku" placeholder="SKU NO." / > < / el-form-item >
< el-form-item label = "中文品名" :prop = "`items.${index}.cargoNameCn`" required :rules = "requiredTextRules('请输入中文品名')" > < el-input v-model = "item.cargoNameCn" placeholder="例如:蓝牙音箱" / > < / el-form-item >
< el-form-item label = "英文品名" > < el-input v-model = "item.cargoNameEn" placeholder="Bluetooth speakers" / > < / el-form-item >
< el-form-item v-if = "form.destinationType === 'FBA'" label="UPC" :prop="`items.${index}.upc`" required :rules="requiredTextRules('FBA 货物请输入 UPC')"><el-input v-model="item.upc" maxlength="32" placeholder="UPC NO." / > < / el-form-item >
< el-form-item label = "包装类型" :prop = "`items.${index}.packageType`" required :rules = "requiredSelectRules('请选择包装类型')" > < el-select v-model = "item.packageType"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" / > < / el-select > < / el-form-item >
< el-form-item label = "总数量" :prop = "`items.${index}.pieces`" required : rules = "positiveNumberRules('件数必须为大于 0 的整数', true)" > < el-input-number v-model = "item.pieces" :min="1" controls-position="right" / > < / el-form-item >
< el-form-item label = "箱数" > < el-input-number v-model = "item.packageCount" :min="0" controls-position="right" / > < / el-form-item >
< el-form-item label = "总毛重 kg" :prop = "`items.${index}.grossWeightKg`" required : rules = "positiveNumberRules('毛重必须大于 0')" > < el-input-number v-model = "item.grossWeightKg" :min="0" :precision="2" controls-position="right" / > < / el-form-item >
< el-form-item label = "总体积 m³" :prop = "`items.${index}.volumeCbm`" required : rules = "positiveNumberRules('体积必须大于 0')" > < el-input-number v-model = "item.volumeCbm" :min="0" :precision="3" controls-position="right" / > < / el-form-item >
< / div >
< el-collapse class = "cargo-more" > < el-collapse-item title = "更多申报信息" > < div class = "cargo-secondary-grid" >
< el-form-item label = "HS Code" > < el-input v-model = "item.hsCode" maxlength="20" placeholder="例如:8518210000" / > < / el-form-item >
< el-form-item label = "申报货值" > < el-input-number v-model = "item.declaredValue" :min="0" :precision="2" controls-position="right" / > < / el-form-item >
< el-form-item label = "币种" > < el-input v-model = "item.currency" maxlength="3" placeholder="例如:USD" / > < / el-form-item >
< el-form-item label = "危险品" > < el-switch v-model = "item.dangerousGoods" / > < / el-form-item >
< template v-if = "form.transportMode === 'FCL'"><el-form-item label="箱型" :prop="`items.${index}.containerType`" required :rules="requiredSelectRules('请选择箱型')"><el-select v-model="item.containerType"><el-option v-for="type in ['20GP', '40GP', '40HQ']" :key="type" :label="type" :value="type" /></el-select></el-form-item><el-form-item label="柜量" :prop="`items.${index}.containerCount`" required :rules="positiveNumberRules('柜量必须为大于 0 的整数', true)"><el-input-number v-model="item.containerCount" :min="0" controls-position="right" / > < / el-form-item > < / template >
< el-form-item label = "货物备注" class = "form-span-full" > < el-input v-model = "item.remark" maxlength="300" placeholder="选填,例如:注意防潮" / > < / el-form-item >
< / div > < / el-collapse-item > < / el-collapse >
< / article >
< p class = "cargo-tip" > FBA 货物必须填写 SKU 、 UPC 、 货件编号和跟踪编号 ; 提交前请核对重量与体积 。 < / p >
< / section >
< / el-form >
< template # footer > < div class = "editor-actions" > < el-button :disabled = "saving" @click ="closeEditor" > 返回 < / el -button > < el-button v-if = "editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" / > { { companyApproved ? '提交订单' : '提交前需完成资料审核' } } < / el-button > < / div > < / template >
< / FirstLegOrderEditorShell >
< el-drawer v-model = "detailOpen" class="first-leg-detail" title="订单详情" size="680px" >
< div v-loading = "detailLoading" class="order-detail-content" >
< div v-if = "detailError" class="order-load-state is-compact" role="alert" >
< AlertTriangle :size = "24" / >
< div > < strong > 订单详情加载失败 < / strong > < span > { { detailError } } < / span > < / div >
< el-button :loading = "detailLoading" @click ="retryDetail" > 重新加载 < / el -button >
< / div >
< template v-else-if = "detailOrder" >
< div class = "order-detail-heading" >
< div > < span > { { getFirstLegTransportModeLabel ( detailOrder . transportMode ) } } < / span > < h2 > { { detailOrder . orderNo || '未提交草稿' } } < / h2 > < p > { { detailOrder . origin } } → { { detailOrder . destination } } < / p > < / div >
< el-tag :type = "getFirstLegStatusTone(detailOrder.status)" effect = "light" > { { getFirstLegStatusLabel ( detailOrder . status ) } } < / el-tag >
< / div >
< dl class = "order-detail-grid" >
< div > < dt > 客户参考号 < / dt > < dd > { { detailOrder . referenceNo || '-' } } < / dd > < / div > < div > < dt > 运输方式 < / dt > < dd > { { getFirstLegTransportModeLabel ( detailOrder . transportMode ) } } < / dd > < / div >
< div > < dt > 发货方 < / dt > < dd > { { detailOrder . shipperPartySnapshot ? . name || detailOrder . shipper } } < span v-if = "detailOrder.shipperPartySnapshot" class="detail-subline" > {{ detailOrder.shipperPartySnapshot.code }} · {{ detailOrder.shipperPartySnapshot.contactName | | ' 未填写联系人 ' }} · {{ detailOrder.shipperPartySnapshot.phone | | ' 未填写电话 ' }} < br / > { { detailOrder . shipperPartySnapshot . countryRegion } } { { detailOrder . shipperPartySnapshot . province } } { { detailOrder . shipperPartySnapshot . city } } { { detailOrder . shipperPartySnapshot . address } } < / span > < / dd > < / div >
< div > < dt > 收货方 < / dt > < dd > { { detailOrder . consigneePartySnapshot ? . name || detailOrder . consignee } } < span v-if = "detailOrder.consigneePartySnapshot" class="detail-subline" > {{ detailOrder.consigneePartySnapshot.code }} · {{ detailOrder.consigneePartySnapshot.contactName | | ' 未填写联系人 ' }} · {{ detailOrder.consigneePartySnapshot.phone | | ' 未填写电话 ' }} < br / > { { detailOrder . consigneePartySnapshot . countryRegion } } { { detailOrder . consigneePartySnapshot . province } } { { detailOrder . consigneePartySnapshot . city } } { { detailOrder . consigneePartySnapshot . address } } < / span > < / dd > < / div >
< div > < dt > 平台收货仓 < / dt > < dd > { { detailOrder . receivingWarehouseSnapshot ? . name || detailOrder . receivingWarehouseName } } < span v-if = "detailOrder.receivingWarehouseSnapshot" class="detail-subline" > {{ detailOrder.receivingWarehouseSnapshot.code }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }} < / span > < / dd > < / div > < div > < dt > 负责人 < / dt > < dd > { { detailOrder . operatorName || '未提供' } } < / dd > < / div >
< div > < dt > 期望出发 < / dt > < dd > { { detailOrder . expectedDepartureDate || '-' } } < / dd > < / div > < div > < dt > ETD / ETA < / dt > < dd > { { detailOrder . etd || '-' } } / { { detailOrder . eta || '-' } } < / dd > < / div >
< / dl >
< section class = "detail-section" >
< h3 > 履约快递信息 < / h3 >
< div v-if = "detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><PackageCheck :size="20" / > < div > < span > 快递公司 < / span > < strong > { { detailOrder . expressCompanyName || '待平台补充' } } < / strong > < / div > < div > < span > 快递单号 < / span > < strong > { { detailOrder . trackingNumber || '待平台补充' } } < / strong > < / div > < / div >
< div v-else class = "express-empty" > < PackageCheck :size = "20" / > < div > < strong > 平台尚未维护快递信息 < / strong > < span > 快递公司和快递单号由平台后台维护 , 客户端仅只读展示 。 < / span > < / div > < / div >
< / section >
< section class = "detail-section" > < h3 > 货物汇总 < / h3 > < div v-if = "detailSummary" class="cargo-summary"><div><Box :size="18" /><span>明细</span><strong>{{ detailOrder.items.length }} 项</strong></div><div><span>件数</span><strong>{{ detailSummary.pieces }}</strong></div><div><span>毛重</span><strong>{{ detailSummary.grossWeightKg.toLocaleString() }} kg</strong></div><div><span>体积</span><strong>{{ detailSummary.volumeCbm }} m³</strong></div></div><ul class="cargo-detail-list"><li v-for="item in detailOrder.items" :key="item.id" > < strong > { { item . cargoNameCn } } < / strong > < span > { { item . sku || '无 SKU' } } · { { item . pieces } } 件 · { { item . grossWeightKg } } kg < / span > < / li > < / ul > < / section >
< section class = "detail-section" > < h3 > 处理时间线 < / h3 > < el-timeline > < el-timeline-item v-for = "item in [...detailOrder.timeline].reverse()" :key="item.id" :timestamp="item.occurredAt" placement="top" > < strong > { { item . title } } < / strong > < p > { { item . description || '-' } } < / p > < span > { { item . operatorName || '未提供' } } < / span > < / el-timeline-item > < / el-timeline > < / section >
< / template >
< / div >
< template # footer > < div v-if = "detailOrder" class="editor-actions"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" :loading="openingEditor" @click="openEdit(detailOrder)"><Pencil :size="16" / > 编辑草稿 < / el-button > < / div > < / template >
< / el-drawer >
< / section >
< / template >
< style scoped >
. first - leg - page { display : flex ; flex - direction : column ; gap : 14 px ; }
: global ( . client - main : has ( > . first - leg - page : not ( . is - create - route ) ) > . client - page - heading ) { margin - bottom : 0 ; }
: global ( . client - main : has ( > . first - leg - page : not ( . is - create - route ) ) > . client - page - heading > div ) { position : absolute ; width : 1 px ; height : 1 px ; margin : - 1 px ; padding : 0 ; overflow : hidden ; border : 0 ; clip : rect ( 0 0 0 0 ) ; white - space : nowrap ; }
. first - leg - query - toolbar { flex - wrap : nowrap ; }
. first - leg - query - toolbar : deep ( . query - toolbar _ _filters ) { display : grid ; min - width : 0 ; flex : 1 1 auto ; grid - template - columns : minmax ( 160 px , 1.4 fr ) minmax ( 100 px , .72 fr ) minmax ( 100 px , .72 fr ) minmax ( 190 px , 1.15 fr ) ; }
. first - leg - query - toolbar : deep ( . query - toolbar _ _filters . el - input ) , . first - leg - query - toolbar : deep ( . query - toolbar _ _filters . el - select ) , . first - leg - query - toolbar : deep ( . query - toolbar _ _filters . el - date - editor ) { width : 100 % ; }
. first - leg - query - toolbar : deep ( . query - toolbar _ _actions ) { align - self : flex - start ; flex - wrap : nowrap ; margin - left : auto ; }
. order - eligibility { display : flex ; align - items : flex - start ; gap : 12 px ; padding : 14 px 16 px ; border : 1 px solid ; }
. order - eligibility svg { flex : 0 0 20 px ; margin - top : 1 px ; }
. order - eligibility div { display : flex ; min - width : 0 ; flex : 1 ; flex - direction : column ; gap : 3 px ; }
. order - eligibility strong { color : var ( -- navy ) ; font - size : 13 px ; }
. order - eligibility span { color : var ( -- muted ) ; font - size : 11 px ; line - height : 1.55 ; }
. order - eligibility . is - pending { border - color : # e5d2a5 ; background : # fff9eb ; color : # 9 a6a12 ; }
. supporting - data - error { display : grid ; grid - template - columns : auto minmax ( 0 , 1 fr ) auto ; align - items : center ; gap : 12 px ; padding : 14 px 16 px ; border : 1 px solid # e5d2a5 ; background : # fff9eb ; color : # 9 a6a12 ; }
. supporting - data - error > div { display : flex ; min - width : 0 ; flex - direction : column ; gap : 3 px ; }
. supporting - data - error strong { color : var ( -- navy ) ; font - size : 13 px ; }
. supporting - data - error span { overflow - wrap : anywhere ; color : var ( -- muted ) ; font - size : 11 px ; line - height : 1.55 ; }
. order - row - actions , . editor - actions { display : flex ; justify - content : flex - end ; gap : 8 px ; }
. order - table - card { padding : 0 ; overflow : hidden ; }
. order - table - toolbar { display : flex ; min - height : 58 px ; align - items : center ; justify - content : space - between ; gap : 16 px ; padding : 10 px 16 px ; border - bottom : 1 px solid var ( -- border ) ; }
. order - table - toolbar > div { display : flex ; min - width : 0 ; align - items : baseline ; gap : 8 px ; }
. order - table - toolbar strong { color : var ( -- navy ) ; font - size : 14 px ; }
. order - table - toolbar span { color : var ( -- muted ) ; font - size : 11 px ; }
. order - table - card > . order - eligibility , . order - table - card > . supporting - data - error { border - width : 0 0 1 px ; }
. order - load - state { display : grid ; min - height : 220 px ; grid - template - columns : auto minmax ( 0 , 1 fr ) auto ; align - items : center ; gap : 14 px ; padding : 28 px ; color : # 9 a5a09 ; }
. order - load - state > div { display : flex ; min - width : 0 ; flex - direction : column ; gap : 5 px ; }
. order - load - state strong { color : var ( -- navy ) ; font - size : 14 px ; }
. order - load - state span { overflow - wrap : anywhere ; color : var ( -- muted ) ; font - size : 12 px ; line - height : 1.55 ; }
. order - load - state . is - compact { min - height : 180 px ; }
. order - detail - content { min - height : 180 px ; }
. mobile - order - surface { display : none ; min - height : 220 px ; }
. mobile - order - list { display : none ; }
. order - number - link { display : block ; padding : 0 ; border : 0 ; background : none ; color : var ( -- primary ) ; cursor : pointer ; font - weight : 700 ; text - align : left ; }
. order - number - link : focus - visible { outline : 2 px solid var ( -- primary ) ; outline - offset : 3 px ; }
. order - reference { display : block ; margin - top : 4 px ; color : var ( -- muted ) ; font - size : 11 px ; font - weight : 400 ; }
: global ( . first - leg - editor . el - drawer _ _header ) { flex : 0 0 auto ; min - height : 58 px ; margin - bottom : 0 ; padding : 17 px 22 px ; border - bottom : 1 px solid var ( -- border ) ; }
: global ( . first - leg - editor . el - drawer _ _body ) { min - height : 0 ; padding : 18 px 22 px 32 px ; overscroll - behavior : contain ; }
: global ( . first - leg - editor . el - drawer _ _footer ) { flex : 0 0 auto ; padding : 12 px 22 px ; border - top : 1 px solid var ( -- border ) ; background : # fff ; }
. order - form { width : min ( 100 % , 1000 px ) ; margin : 0 auto ; padding : 0 0 12 px ; }
. order - form : deep ( . el - form - item _ _error ) { position : static ; padding - top : 6 px ; overflow - wrap : anywhere ; line - height : 1.45 ; }
. order - form - section + . order - form - section { margin - top : 24 px ; padding - top : 22 px ; border - top : 1 px solid var ( -- border ) ; }
. order - form - section h3 { margin : 0 0 16 px ; color : var ( -- navy ) ; font - size : 16 px ; }
. order - form - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; column - gap : 18 px ; }
. order - form - grid > * , . destination - fields > * , . party - contact - grid > * , . cargo - primary - grid > * , . cargo - secondary - grid > * { min - width : 0 ; }
. order - form : deep ( . el - select ) , . order - form : deep ( . el - date - editor ) , . order - form : deep ( . el - input - number ) { width : 100 % ; }
. form - span - full { grid - column : 1 / - 1 ; }
. prototype - section { padding : 2 px 2 px 0 ; }
. segmented - options { display : flex ; width : min ( 100 % , 620 px ) ; }
. segmented - options : deep ( . el - radio - button ) { flex : 1 ; }
. segmented - options : deep ( . el - radio - button _ _inner ) { width : 100 % ; min - height : 42 px ; padding : 11 px 16 px ; }
. warehouse - options { display : flex ; flex - wrap : wrap ; gap : 8 px ; }
. warehouse - options : deep ( . el - radio - button _ _inner ) { min - height : 40 px ; border : 1 px solid var ( -- border ) ; border - radius : 6 px ! important ; box - shadow : none ! important ; }
. warehouse - options : deep ( . el - radio - button . is - active . el - radio - button _ _inner ) { border - color : var ( -- primary ) ; }
. selected - warehouse { display : flex ; align - items : flex - start ; gap : 10 px ; margin - top : 0 ; padding : 11 px 13 px ; border : 1 px solid # d9def8 ; background : # f6f7fe ; color : var ( -- primary ) ; }
. selected - warehouse svg { flex : 0 0 17 px ; margin - top : 2 px ; }
. selected - warehouse div { display : flex ; min - width : 0 ; flex - direction : column ; gap : 2 px ; }
. selected - warehouse span , . selected - warehouse small { color : var ( -- muted ) ; font - size : 11 px ; line - height : 1.5 ; }
. order - section - heading { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 12 px ; margin - bottom : 12 px ; }
. order - section - heading h3 { margin - bottom : 5 px ; }
. order - section - heading p { margin : 0 ; color : var ( -- muted ) ; font - size : 11 px ; }
. party - select - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 14 px ; }
. party - panel { min - width : 0 ; padding : 16 px ; border : 1 px solid var ( -- border ) ; background : # fbfcfc ; }
. party - panel h4 { margin : 0 0 14 px ; color : var ( -- navy ) ; font - size : 14 px ; }
. party - panel : deep ( . el - alert ) { margin - bottom : 14 px ; }
. party - contact - grid , . destination - fields { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; column - gap : 12 px ; }
. destination - fields { margin - top : 16 px ; }
. cargo - entry { padding : 16 px ; border : 1 px solid var ( -- border ) ; background : # fbfcfc ; }
. cargo - entry + . cargo - entry { margin - top : 12 px ; }
. cargo - entry > header { display : flex ; align - items : center ; justify - content : space - between ; gap : 12 px ; margin - bottom : 12 px ; }
. cargo - primary - grid { display : grid ; grid - template - columns : repeat ( 5 , minmax ( 130 px , 1 fr ) ) ; column - gap : 12 px ; }
. cargo - secondary - grid { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 130 px , 1 fr ) ) ; column - gap : 12 px ; padding - top : 6 px ; }
. cargo - more { border - top : 1 px solid var ( -- border ) ; border - bottom : 0 ; }
. cargo - more : deep ( . el - collapse - item _ _header ) , . cargo - more : deep ( . el - collapse - item _ _wrap ) { background : transparent ; }
. cargo - more : deep ( . el - collapse - item _ _content ) { padding - bottom : 0 ; }
. cargo - tip { margin : 12 px 0 0 ; color : var ( -- muted ) ; font - size : 11 px ; line - height : 1.55 ; }
. order - detail - heading { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 16 px ; padding - bottom : 20 px ; border - bottom : 1 px solid var ( -- border ) ; }
. order - detail - heading span { color : var ( -- muted ) ; font - size : 11 px ; }
. order - detail - heading h2 { margin : 6 px 0 ; font - size : 21 px ; }
. order - detail - heading p { margin : 0 ; color : var ( -- muted ) ; }
. order - detail - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 0 ; margin : 18 px 0 ; border : 1 px solid var ( -- border ) ; }
. order - detail - grid div { padding : 13 px ; border - bottom : 1 px solid var ( -- border ) ; }
. order - detail - grid div : nth - child ( odd ) { border - right : 1 px solid var ( -- border ) ; }
. order - detail - grid dt { color : var ( -- muted ) ; font - size : 11 px ; }
. order - detail - grid dd { margin : 5 px 0 0 ; font - size : 13 px ; font - weight : 600 ; }
. detail - subline { display : block ; margin - top : 4 px ; color : var ( -- muted ) ; font - size : 11 px ; font - weight : 400 ; line - height : 1.5 ; }
. detail - section { margin - top : 22 px ; }
. detail - section h3 { margin : 0 0 12 px ; font - size : 15 px ; }
. express - information { display : grid ; grid - template - columns : auto repeat ( 2 , minmax ( 0 , 1 fr ) ) ; align - items : center ; gap : 16 px ; padding : 15 px 16 px ; background : # f2f7f6 ; color : var ( -- primary ) ; }
. express - information > div { display : flex ; min - width : 0 ; flex - direction : column ; gap : 4 px ; }
. express - information span { color : var ( -- muted ) ; font - size : 11 px ; }
. express - information strong { overflow - wrap : anywhere ; color : var ( -- navy ) ; font - size : 13 px ; }
. express - empty { display : flex ; align - items : flex - start ; gap : 12 px ; padding : 15 px 16 px ; border : 1 px dashed # cbd5da ; color : var ( -- muted ) ; }
. express - empty svg { flex : 0 0 20 px ; color : var ( -- primary ) ; }
. express - empty div { display : flex ; min - width : 0 ; flex - direction : column ; gap : 4 px ; }
. express - empty strong { color : var ( -- navy ) ; font - size : 13 px ; }
. express - empty span { font - size : 11 px ; line - height : 1.5 ; }
. cargo - summary { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ; border : 1 px solid var ( -- border ) ; }
. cargo - summary div { display : flex ; min - height : 74 px ; align - items : center ; justify - content : center ; flex - direction : column ; gap : 4 px ; border - right : 1 px solid var ( -- border ) ; }
. cargo - summary div : last - child { border - right : 0 ; }
. cargo - summary span { color : var ( -- muted ) ; font - size : 11 px ; }
. cargo - detail - list { margin : 10 px 0 0 ; padding : 0 ; list - style : none ; }
. cargo - detail - list li { display : flex ; justify - content : space - between ; gap : 12 px ; padding : 11 px 0 ; border - bottom : 1 px solid var ( -- border ) ; font - size : 12 px ; }
. cargo - detail - list span , : deep ( . el - timeline - item _ _content p ) , : deep ( . el - timeline - item _ _content > span ) { color : var ( -- muted ) ; }
: deep ( . el - timeline - item _ _content p ) { margin : 5 px 0 ; font - size : 12 px ; }
: deep ( . el - timeline - item _ _content > span ) { font - size : 11 px ; }
@ media ( max - width : 1280 px ) {
. desktop - order - table { display : none ; }
. mobile - order - surface { display : block ; }
. mobile - order - list { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 10 px ; padding : 10 px ; }
. mobile - order - card { min - width : 0 ; padding : 14 px ; border : 1 px solid var ( -- border ) ; background : # fff ; }
. mobile - order - card header , . mobile - order - card footer { display : flex ; align - items : center ; justify - content : space - between ; gap : 10 px ; }
. mobile - order - card header button { min - width : 0 ; overflow : hidden ; padding : 0 ; border : 0 ; background : none ; color : var ( -- primary ) ; font - weight : 750 ; text - overflow : ellipsis ; white - space : nowrap ; }
. mobile - order - card header button : focus - visible { outline : 2 px solid var ( -- primary ) ; outline - offset : 3 px ; }
. mobile - order - card dl { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 12 px ; margin : 14 px 0 ; }
. mobile - order - card dl div { min - width : 0 ; }
. mobile - order - card dt , . mobile - order - card footer > span { color : var ( -- muted ) ; font - size : 10 px ; }
. mobile - order - card dd { overflow : hidden ; margin : 4 px 0 0 ; font - size : 12 px ; font - weight : 600 ; text - overflow : ellipsis ; white - space : nowrap ; }
. mobile - order - card dd span { margin - left : 4 px ; color : var ( -- muted ) ; font - weight : 400 ; }
. mobile - order - card footer { align - items : flex - end ; padding - top : 10 px ; border - top : 1 px solid var ( -- border ) ; }
. mobile - order - card footer > div { display : flex ; }
}
@ media ( min - width : 721 px ) and ( max - width : 1050 px ) {
. first - leg - query - toolbar : deep ( . query - toolbar _ _filters ) { grid - template - columns : repeat ( 2 , minmax ( 140 px , 1 fr ) ) ; }
}
@ media ( max - width : 720 px ) {
. first - leg - query - toolbar { flex - wrap : wrap ; }
. first - leg - query - toolbar : deep ( . query - toolbar _ _filters ) { display : flex ; }
: global ( . client - main : has ( > . first - leg - page : not ( . is - create - route ) ) > . client - page - heading ) { margin - bottom : 10 px ; }
. order - table - toolbar { align - items : stretch ; flex - direction : column ; gap : 10 px ; }
. order - table - toolbar > div { justify - content : space - between ; }
. order - table - toolbar . el - button { width : 100 % ; min - height : 40 px ; }
. order - eligibility { flex - wrap : wrap ; }
. order - eligibility . el - button { width : 100 % ; min - height : 44 px ; }
. order - form - grid , . order - detail - grid , . party - select - grid , . party - contact - grid , . destination - fields , . cargo - primary - grid , . cargo - secondary - grid { grid - template - columns : 1 fr ; }
. segmented - options { width : 100 % ; align - items : stretch ; flex - direction : column ; }
. segmented - options : deep ( . el - radio - button _ _inner ) { border : 1 px solid var ( -- border ) ; border - radius : 6 px ! important ; box - shadow : none ! important ; }
. warehouse - options { display : grid ; width : 100 % ; grid - template - columns : 1 fr ; }
. warehouse - options : deep ( . el - radio - button ) , . warehouse - options : deep ( . el - radio - button _ _inner ) { width : 100 % ; }
: global ( . first - leg - editor . el - drawer _ _header ) { min - height : 56 px ; padding : 15 px 20 px ; }
: global ( . first - leg - editor . el - drawer _ _body ) { padding : 16 px 20 px 28 px ; }
: global ( . first - leg - editor . el - drawer _ _footer ) { padding : 10 px 20 px ; }
. form - span - full { grid - column : auto ; }
. editor - actions : deep ( . el - button ) { min - height : 44 px ; }
. desktop - order - table { display : none ; }
. order - load - state { min - height : 240 px ; grid - template - columns : 1 fr ; justify - items : start ; }
. supporting - data - error { grid - template - columns : auto minmax ( 0 , 1 fr ) ; }
. supporting - data - error . el - button { grid - column : 1 / - 1 ; width : 100 % ; min - height : 44 px ; }
. order - load - state . el - button { width : 100 % ; min - height : 44 px ; }
. mobile - order - list { grid - template - columns : 1 fr ; }
. mobile - order - card { padding : 14 px ; border : 1 px solid var ( -- border ) ; background : # fff ; }
. mobile - order - card header , . mobile - order - card footer { display : flex ; align - items : center ; justify - content : space - between ; gap : 10 px ; }
. mobile - order - card header button { padding : 0 ; border : 0 ; background : none ; color : var ( -- primary ) ; font - weight : 750 ; }
. mobile - order - card header button : focus - visible { outline : 2 px solid var ( -- primary ) ; outline - offset : 3 px ; }
. mobile - order - card dl { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 12 px ; margin : 14 px 0 ; }
. mobile - order - card dl div { min - width : 0 ; }
. mobile - order - card dt , . mobile - order - card footer > span { color : var ( -- muted ) ; font - size : 10 px ; }
. mobile - order - card dd { overflow : hidden ; margin : 4 px 0 0 ; font - size : 12 px ; font - weight : 600 ; text - overflow : ellipsis ; white - space : nowrap ; }
. mobile - order - card dd span { margin - left : 4 px ; color : var ( -- muted ) ; font - weight : 400 ; }
. mobile - order - card footer { align - items : flex - end ; padding - top : 10 px ; border - top : 1 px solid var ( -- border ) ; }
. mobile - order - card footer > div { display : flex ; }
. order - detail - grid div : nth - child ( odd ) { border - right : 0 ; }
. express - information { grid - template - columns : auto 1 fr ; }
. express - information > div : last - child { grid - column : 2 ; }
. cargo - summary { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; }
. cargo - summary div : nth - child ( 2 ) { border - right : 0 ; }
. cargo - summary div : nth - child ( - n + 2 ) { border - bottom : 1 px solid var ( -- border ) ; }
. cargo - detail - list li { flex - direction : column ; }
: global ( . first - leg - editor ) , : global ( . first - leg - detail ) { width : 100 % ! important ; }
}
@ media ( max - width : 480 px ) {
. editor - actions { display : grid ; width : 100 % ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; }
. editor - actions : deep ( . el - button ) { width : 100 % ; min - width : 0 ; margin - left : 0 ; }
. editor - actions : deep ( . el - button : nth - child ( 3 ) ) { grid - column : 1 / - 1 ; }
}
< / style >