@@ -1,13 +1,13 @@
< script setup lang = "ts" >
import { computed , onMounted , reactive , ref , watch } from 'vue'
import { useRouter } from 'vue-router'
import { Boxes , Check , Eye , FileText , FileUp , ListChecks , MoreHorizontal , Pencil , Sparkles , TriangleAlert } from '@lucide/vue'
import { Boxes , Check , Eye , FileText , FileUp , Link2 , ListChecks , MoreHorizontal , Pencil , Plus , Sparkles , TriangleAlert , Unlink } from '@lucide/vue'
import { ElMessage , type FormInstance , type FormRules , type UploadFile } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants , type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses , type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { listPlatformPartners , type PlatformPartnerResource } from '@/modules/master-data'
import { listOceanBookings , type OceanBookingResource } from '@/modules/ocean-bookings'
import { getOceanBooking , listOceanBookings , listWarehouseLogisticsChannels , type OceanBookingDetail , type OceanBookingResource , type OceanLogisticsChannel } from '@/modules/ocean-bookings'
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -19,8 +19,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import { cancelContainerException , completeContainerClearance , downloadContainerDocument , downloadContainerPdaPhoto , getContainer , getContainerClearance , getContainerOverview , listContainers , markContainerException , updateContainer , updateContainerTransport , uploadContainerClearance , uploadContainerDocument } from '../api/platformContainerLoading'
import type { ContainerBookingResource , ContainerDocumentType , ContainerOverview , ContainerOverviewDocument , ContainerPdaPhoto , ContainerPlanStatus , ContainerSaveRequest } from '../types'
import { cancelContainerException , completeContainerClearance , createContainerSimulation , downloadContainerDocument , downloadContainerPdaPhoto , getContainer , getContainerClearance , getContainerOverview , linkContainerOceanBooking , listContainers , markContainerException , unlinkContainerOceanBooking , updateContainer , updateContainerTransport , uploadContainerClearance , uploadContainerDocument } from '../api/platformContainerLoading'
import type { ContainerBookingResource , ContainerDocumentType , ContainerOverview , ContainerOverviewDocument , ContainerPdaPhoto , ContainerPlanStatus , ContainerSaveRequest , ContainerSimulationCreateRequest } from '../types'
type Filters = QueryModel & { customerId : string ; keyword : string ; containerType : string ; status : ContainerPlanStatus | '' ; sailingRange : string [ ] }
type ContainerForm = ContainerSaveRequest & { id : string ; version : number }
@@ -74,6 +74,22 @@ const editorOpen = ref(false)
const editorError = ref < ApiErrorDetails | null > ( null )
const saving = ref ( false )
const formRef = ref < FormInstance > ( )
const simulationOpen = ref ( false )
const simulationSaving = ref ( false )
const simulationError = ref < ApiErrorDetails | null > ( null )
const simulationChannels = ref < OceanLogisticsChannel [ ] > ( [ ] )
const simulationChannelsLoading = ref ( false )
const simulationFormRef = ref < FormInstance > ( )
const simulationForm = reactive < ContainerSimulationCreateRequest > ( { channelCode : '' , warehouseId : '' , containerType : '40HQ' , capacityCbm : 68 , maxWeightKg : 28000 } )
const linkOpen = ref ( false )
const linkLoading = ref ( false )
const linkSaving = ref ( false )
const linkError = ref < ApiErrorDetails | null > ( null )
const linkTarget = ref < ContainerBookingResource | null > ( null )
const linkCandidateKey = ref ( '' )
const linkCandidates = ref < Array < { key : string ; oceanBookingId : string ; requirementId : string ; label : string ; detail : OceanBookingDetail } > > ( [ ] )
const unlinkOpen = ref ( false )
const unlinkReason = ref ( '' )
let listSequence = 0
let detailSequence = 0
@@ -97,6 +113,7 @@ const rules: FormRules<ContainerForm> = {
const canView = computed ( ( ) => access . can ( 'platform:firstleg:container:list' ) )
const canEdit = computed ( ( ) => access . can ( 'platform:firstleg:container:edit' ) )
const canCreate = computed ( ( ) => access . can ( 'platform:firstleg:container:create' ) )
const canSmartLoading = computed ( ( ) => access . can ( 'platform:firstleg:smart-loading:list' ) )
const canCustomsManage = computed ( ( ) => access . can ( 'platform:firstleg:customs:manage' ) )
const canViewPreload = computed ( ( ) => access . can ( 'platform:firstleg:preload:list' ) )
@@ -108,6 +125,13 @@ const exceptionDialogTitle = computed(() => overview.value?.exception.active ? '
const transportOnly = computed ( ( ) => Boolean ( form . id ) && detail . value ? . planStatus === 'LOADING' )
const shippingCompanies = computed ( ( ) => partners . value . filter ( ( partner ) => partner . status === 'ACTIVE' && partner . types . includes ( 'CARRIER' ) ) )
const truckingCompanies = computed ( ( ) => partners . value . filter ( ( partner ) => partner . status === 'ACTIVE' && partner . types . some ( ( type ) => [ 'CARRIER' , 'SUPPLIER' ] . includes ( type ) ) ) )
const simulationRules : FormRules < ContainerSimulationCreateRequest > = {
warehouseId : [ { required : true , message : '请选择装柜仓库' , trigger : 'change' } ] ,
channelCode : [ { required : true , message : '请选择物流渠道' , trigger : 'change' } ] ,
containerType : [ { required : true , message : '请选择柜型' , trigger : 'change' } ] ,
capacityCbm : [ { required : true , message : '请输入容量' , trigger : 'change' } ] ,
maxWeightKg : [ { required : true , message : '请输入限重' , trigger : 'change' } ] ,
}
interface PortOption { value : string ; code : string | null ; name : string | null }
function portValue ( code : string | null | undefined , name : string | null | undefined ) { return code || name ? JSON . stringify ( [ code || '' , name || '' ] ) : '' }
@@ -144,6 +168,7 @@ const queryFields = computed<QueryField[]>(() => [
const columns : DataTableColumn [ ] = [
{ key : 'actions' , label : '操作' , width : 310 , fixed : 'left' , slotName : 'actions' } ,
{ key : 'cabinetNo' , label : '柜次' , minWidth : 180 , slotName : 'cabinet' } ,
{ key : 'oceanBooking' , label : '真实订舱' , minWidth : 170 , slotName : 'oceanBooking' } ,
{ key : 'containerNo' , label : '集装箱号' , minWidth : 150 , slotName : 'containerNo' } ,
{ key : 'planStatus' , label : '状态' , width : 110 , slotName : 'status' } ,
{ key : 'warehouseName' , prop : 'warehouseName' , label : '装柜仓库' , minWidth : 160 } ,
@@ -276,6 +301,103 @@ async function loadAllOceanBookings() {
const remaining = await Promise . all ( Array . from ( { length : firstPage . pages - 1 } , ( _ , index ) => listOceanBookings ( { page : index + 2 , size : 200 } ) ) )
return [ firstPage , ... remaining ] . flatMap ( ( pageResult ) => pageResult . records )
}
function openSimulation ( ) {
Object . assign ( simulationForm , { channelCode : '' , warehouseId : '' , containerType : '40HQ' , capacityCbm : 68 , maxWeightKg : 28000 } )
simulationChannels . value = [ ]
simulationError . value = null
simulationOpen . value = true
}
async function selectSimulationWarehouse ( value : unknown ) {
simulationForm . warehouseId = String ( value || '' )
simulationForm . channelCode = ''
simulationChannels . value = [ ]
simulationError . value = null
if ( ! simulationForm . warehouseId ) return
simulationChannelsLoading . value = true
try { simulationChannels . value = await listWarehouseLogisticsChannels ( simulationForm . warehouseId ) }
catch ( error ) { simulationError . value = getApiErrorDetails ( error , '物流渠道加载失败' ) }
finally { simulationChannelsLoading . value = false }
}
async function saveSimulation ( ) {
if ( ! ( await simulationFormRef . value ? . validate ( ) . catch ( ( ) => false ) ) ) return
simulationSaving . value = true
simulationError . value = null
try {
await createContainerSimulation ( { ... simulationForm } )
simulationOpen . value = false
ElMessage . success ( '模拟柜已创建,可先排货后关联真实订舱柜' )
await loadRows ( )
} catch ( error ) { simulationError . value = getApiErrorDetails ( error , '模拟柜创建失败' ) }
finally { simulationSaving . value = false }
}
async function loadBookingStatus ( status : 'CONFIRMED' | 'RELEASED' ) {
const first = await listOceanBookings ( { page : 1 , size : 200 , status } )
if ( first . pages <= 1 ) return first . records
const rest = await Promise . all ( Array . from ( { length : first . pages - 1 } , ( _ , index ) => listOceanBookings ( { page : index + 2 , size : 200 , status } ) ) )
return [ first , ... rest ] . flatMap ( ( result ) => result . records )
}
async function openLink ( source : object ) {
const row = source as ContainerBookingResource
linkTarget . value = row
linkCandidateKey . value = ''
linkCandidates . value = [ ]
linkError . value = null
linkOpen . value = true
linkLoading . value = true
try {
const bookings = ( await Promise . all ( [ loadBookingStatus ( 'CONFIRMED' ) , loadBookingStatus ( 'RELEASED' ) ] ) ) . flat ( )
. filter ( ( booking ) => booking . warehouseId === row . warehouseId && booking . channelCode === row . channelCode )
const details = await Promise . all ( bookings . map ( ( booking ) => getOceanBooking ( booking . id ) ) )
linkCandidates . value = details . flatMap ( ( booking ) => {
const allocated = new Set ( booking . containerAllocations . filter ( ( item ) => item . containerBookingId ) . map ( ( item ) => item . requirementId ) )
return booking . containerRequirements . filter ( ( requirement ) => requirement . id
&& ! allocated . has ( requirement . id )
&& requirement . containerType === row . containerType
&& Number ( requirement . capacityCbm ) === Number ( row . capacityCbm )
&& Number ( requirement . maxWeightKg ) === Number ( row . maxWeightKg ) ) . map ( ( requirement ) => ( {
key : ` ${ booking . id } : ${ requirement . id } ` ,
oceanBookingId : booking . id ,
requirementId : requirement . id ! ,
detail : booking ,
label : ` ${ booking . bookingNo || '订舱号待补' } · ${ requirement . containerType } · ${ booking . customerName || '未关联客户' } ` ,
} ) )
} )
} catch ( error ) { linkError . value = getApiErrorDetails ( error , '真实订舱柜候选加载失败' ) }
finally { linkLoading . value = false }
}
async function saveLink ( ) {
const target = linkTarget . value
const candidate = linkCandidates . value . find ( ( item ) => item . key === linkCandidateKey . value )
if ( ! target || ! candidate ) { linkError . value = { message : '请选择要关联的真实订舱柜' } ; return }
linkSaving . value = true
linkError . value = null
try {
await linkContainerOceanBooking ( target . id , { oceanBookingId : candidate . oceanBookingId , requirementId : candidate . requirementId , expectedVersion : target . version } )
linkOpen . value = false
ElMessage . success ( '已关联真实订舱柜,内部柜次与装柜明细保持不变' )
await loadRows ( )
} catch ( error ) { linkError . value = getApiErrorDetails ( error , '关联真实订舱柜失败' ) }
finally { linkSaving . value = false }
}
function openUnlink ( source : object ) {
linkTarget . value = source as ContainerBookingResource
unlinkReason . value = ''
linkError . value = null
unlinkOpen . value = true
}
async function saveUnlink ( ) {
const target = linkTarget . value
if ( ! target || ! unlinkReason . value . trim ( ) ) { linkError . value = { message : '请输入解除关联原因' } ; return }
linkSaving . value = true
linkError . value = null
try {
await unlinkContainerOceanBooking ( target . id , target . version , unlinkReason . value . trim ( ) )
unlinkOpen . value = false
ElMessage . success ( '真实订舱关联已解除,内部柜次保持不变' )
await loadRows ( )
} catch ( error ) { linkError . value = getApiErrorDetails ( error , '解除真实订舱关联失败' ) }
finally { linkSaving . value = false }
}
async function loadRows ( ) {
const sequence = ++ listSequence
loading . value = true ; loadError . value = null
@@ -306,6 +428,8 @@ async function openDetail(source: object) {
}
function handleMoreAction ( command : string , source : object ) {
const row = source as ContainerBookingResource
if ( command === 'link' ) { void openLink ( row ) ; return }
if ( command === 'unlink' ) { openUnlink ( row ) ; return }
if ( command === 'overview' ) { void openDetail ( row ) ; return }
detailTarget . value = row
void openDetail ( row ) . then ( ( ) => {
@@ -458,8 +582,10 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
< / template >
< section v-if = "!loadError" class="surface-panel table-panel" >
< DataTable v -model :page = "page" v -model :page-size = "pageSize" :data = "rows" :columns = "columns" :total = "total" :loading = "loading" stripe preference -key = " container -loading -table " empty -text = " 暂无货柜 " >
< template # actions = "{ row }" > < div class = "row-actions" > < el-button v-if = "canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? (row.containerNo ? '修改集装箱号' : '录入集装箱号') : '编辑运输信息' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15 " / > 异常处理 < / el-dropdown-item > < / el-dropdown-menu > < / template > < / el-dropdown > < / div > < / template >
< template # cabinet = "{ row }" > < strong > { { row . cabinetNo } } < / strong > < small > { { row . bookingNo } } < / small > < / template >
< template # toolbar -actions > < el-button v-if = "canCreate" type="primary" @click="openSimulation"><Plus :size="16 " / > 新增模拟柜 < / el-button > < / template >
< template # actions = "{ row }" > < div class = "row-actions" > < el-button v-if = "canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? (row.containerNo ? '修改集装箱号' : '录入集装箱号') : '编辑运输信息' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联真实订舱柜</el-dropdown-item><el-dropdown-item v-if="canEdit && row.oceanBookingId && row.planStatus === 'WAIT_LOADING'" command="unlink"><Unlink :size="15" />解除订舱关联</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" / > 异常处理 < / el-dropdown-item > < / el-dropdown-menu > < / template > < / el-dropdown > < / div > < / template >
< template # cabinet = "{ row }" > < strong > { { row . cabinetNo } } < / strong > < small > 内部柜次 < / small > < / template >
< template # oceanBooking = "{ row }" > < span v-if = "row.oceanBookingId" > < strong > { { row . billOfLadingNo || '已关联订舱' } } < / strong > < small > { { row . containerNo || '真实柜号待录入' } } < / small > < / span > < span v-else > 未关联 < / span > < / template >
< template # containerNo = "{ row }" > { { row . containerNo || '待录入' } } < / template >
< template # status = "{ row }" > < el-tag : type = "row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'" > { { statusText ( row . planStatus ) } } < / el-tag > < / template >
< template # route = "{ row }" > { { row . departurePortName || row . departurePortCode || '—' } } → { { row . destinationPortName || row . destinationPortCode || '—' } } < / template >
@@ -476,7 +602,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
< AppDrawer v-model = "detailOpen" title="货柜详情" size="min(760px, 94vw)" :loading="detailLoading" :show-confirm="false" cancel-text="关闭" >
< LoadErrorState v-if = "detailError" title="货柜详情加载失败" :message="detailError.message" @retry="detailTarget && openDetail(detailTarget)"><template #details><ApiErrorMeta :details="detailError" / > < / template > < / LoadErrorState >
< template v-else-if = "detail"><el-descriptions :column="2" border class="details"><el-descriptions-item label="柜次号 ">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '— ' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions><LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="overviewError" /></template></LoadErrorState><ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocumentDialog" @download-document="downloadDocument" @manage-exception="openExceptionDialog" @preview-photo="previewPhoto" / > < / template >
< template v-else-if = "detail"><el-descriptions :column="2" border class="details"><el-descriptions-item label="内部 柜次">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="真实订舱">{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</el-descriptions-item><el-descriptions-item label="真实 集装箱号">{{ detail.containerNo || '待录入 ' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions><LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="overviewError" /></template></LoadErrorState><ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocumentDialog" @download-document="downloadDocument" @manage-exception="openExceptionDialog" @preview-photo="previewPhoto" / > < / template >
< / AppDrawer >
< AppDialog v-model = "clearanceDialogOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :confirm-loading="clearanceActionLoading" :confirm-disabled="!clearanceFile" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`清关已完成:${clearanceDocument.fileName}`" /><el-form v-else label-position="top"><el-form-item label="清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ clearanceDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的目的港清关资料。' }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="clearanceError" compact title="操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" / > < / template > < / LoadErrorState > < / AppDialog >
@@ -485,6 +611,31 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
< AppDialog v-model = "exceptionDialogOpen" :title="exceptionDialogTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="exceptionError" / > < / template > < / LoadErrorState > < / AppDialog >
< AppDialog v-model = "simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation" >
< el -alert title = "模拟柜用于先排货,服务端会生成稳定的内部柜次;此处不填写真实柜号和船期。" type = "info" :closable = "false" / >
< el-form ref = "simulationFormRef" :model = "simulationForm" :rules = "simulationRules" label -position = " top " class = "simulation-form" >
< el-form-item label = "装柜仓库" prop = "warehouseId" > < el-select :model-value = "simulationForm.warehouseId" filterable placeholder = "请选择国内装柜仓库" :loading = "warehouseLoading" @ update :model-value = "selectSimulationWarehouse" > < el-option v-for = "warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" / > < / el-select > < / el-form-item >
< el-form-item label = "物流渠道" prop = "channelCode" > < el-select v-model = "simulationForm.channelCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="channel in simulationChannels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode" / > < / el-select > < / el-form-item >
< el-form-item label = "柜型" prop = "containerType" > < el-select v-model = "simulationForm.containerType"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" / > < / el-select > < / el-form-item >
< el-form-item label = "容量(m³)" prop = "capacityCbm" > < el-input-number v-model = "simulationForm.capacityCbm" :min="0.001" :precision="3" controls-position="right" / > < / el-form-item >
< el-form-item label = "限重(kg) " prop = "maxWeightKg" > < el-input-number v-model = "simulationForm.maxWeightKg" :min="0.001" :precision="3" controls-position="right" / > < / el-form-item >
< / el-form >
< LoadErrorState v-if = "simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" / > < / template > < / LoadErrorState >
< / AppDialog >
< AppDialog v-model = "linkOpen" title="关联真实订舱柜" width="min(620px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey" @confirm="saveLink" >
< el -alert title = "关联只补充真实订舱关系,不会重建内部柜次、装柜计划或已排货明细。" type = "info" :closable = "false" / >
< el-form label -position = " top " > < el-form-item label = "内部柜次" > < el-input : model -value = " linkTarget ? .cabinetNo | | ' ' " disabled / > < / el-form-item > < el-form-item label = "可关联的真实订舱柜" required > < el-select v-model = "linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择同仓、同渠道、同规格的已确认订舱柜" no-data-text="暂无可关联的真实订舱柜"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" / > < / el-select > < / el-form-item > < / el-form >
< p class = "dialog-hint" > 已预排货但尚未进入 PDA 、 取证或单证阶段的模拟柜仍可首次关联 ; 最终状态门禁以后端为准 。 < / p >
< LoadErrorState v-if = "linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" / > < / template > < / LoadErrorState >
< / AppDialog >
< AppDialog v-model = "unlinkOpen" title="解除真实订舱关联" width="min(520px, calc(100vw - 28px))" confirm-text="确认解除" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!unlinkReason.trim()" @confirm="saveUnlink" >
< el -alert title = "只有尚未排货的空柜可以解除;已有排货明细、PDA 或单证事实时服务端会拒绝。内部柜次不会被删除。" type = "warning" :closable = "false" / >
< el-form label -position = " top " > < el-form-item label = "解除原因" required > < el-input v-model = "unlinkReason" type="textarea" :rows="3" maxlength="500" show -word -limit / > < / el-form-item > < / el-form >
< LoadErrorState v-if = "linkError" compact title="解除关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" / > < / template > < / LoadErrorState >
< / AppDialog >
< AppDialog v-model = "editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError && !transportOnly)" confirm-text="确定" cancel-text="关闭" @confirm="save" >
< LoadErrorState v-if = "editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" / > < / template > < / LoadErrorState >
< el-alert v-if = "transportOnly" title="拣货中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" / >
@@ -515,7 +666,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
< / template >
< style scoped >
. container - page { display : grid ; gap : 16 px ; min - width : 0 ; } . container - page small { display : block ; margin - top : 4 px ; color : var ( -- cc - muted ) ; } . row - actions { display : flex ; align - items : center ; flex - wrap : wrap ; gap : 4 px ; } . row - actions : deep ( . el - button + . el - button ) { margin - left : 0 ; } . form - grid { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) ; gap : 0 ; } . form - grid : deep ( . el - select ) , . form - grid : deep ( . el - date - editor ) , . form - grid : deep ( . el - input - number ) { width : 100 % ; } . details : deep ( . el - descriptions _ _label ) { width : 120 px ; }
. container - page { display : grid ; gap : 16 px ; min - width : 0 ; } . container - page small { display : block ; margin - top : 4 px ; color : var ( -- cc - muted ) ; } . row - actions { display : flex ; align - items : center ; flex - wrap : wrap ; gap : 4 px ; } . row - actions : deep ( . el - button + . el - button ) { margin - left : 0 ; } . form - grid { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) ; gap : 0 ; } . form - grid : deep ( . el - select ) , . form - grid : deep ( . el - date - editor ) , . form - grid : deep ( . el - input - number ) , . simulation - form : deep ( . el - select ) , . simulation - form : deep ( . el - input - number ) { width : 100 % ; } . details : deep ( . el - descriptions _ _label ) { width : 120 px ; } . dialog - hint { margin : 4 px 0 0 ; color : var ( -- cc - muted ) ; font - size : 12 px ; line - height : 1.6 ; }
. document - panel { display : flex ; align - items : center ; justify - content : space - between ; gap : 16 px ; margin - top : 16 px ; padding : 14 px 16 px ; border : 1 px solid var ( -- cc - border ) ; background : # fff ; } . document - panel > div { display : grid ; gap : 4 px ; min - width : 0 ; } . document - panel span { overflow - wrap : anywhere ; color : var ( -- cc - muted ) ; font - size : 12 px ; }
: global ( . container - editor - dialog . el - dialog ) { display : flex ; width : min ( 820 px , calc ( 100 vw - 28 px ) ) ! important ; max - height : 90 vh ; margin - block : 5 vh ! important ; overflow : hidden ; flex - direction : column ; border - radius : 12 px ; }
: global ( . container - editor - dialog . el - dialog _ _header ) { flex : 0 0 auto ; margin : 0 ; padding : 20 px 28 px ; border - bottom : 1 px solid # e5eaf1 ; }