Files
admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue
T
2026-10-20 17:24:50 +08:00

1350 lines
96 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ChevronDown, ChevronUp, Download, Pencil, PackagePlus, SlidersHorizontal } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderPalletMerge, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders'
import { businessOrderLink } from '../businessOrderLink'
import { getReturnOrderStatistics, type ReturnOrderStatistics } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import { reviewCargoDiscard } from '../api/lastMileOrders'
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
import { confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { lastMileStatusLabel, packageDifferenceText, toLastMileOrderListItem } from '../lastMilePresentation'
import type { LastMileOrderListItem } from '../types'
import { reworkRelabelApplications } from '../relabelRework'
const reworkOpen = ref(false)
const reworkSaving = ref(false)
const reworkOptions = ref<Array<{ relabelNo: string; status: string }>>([])
const reworkForm = reactive({ relabelNo: '', remark: '' })
const rows = ref<LastMileOrderListItem[]>([])
const submitExport = useExportTask()
const access = useAccessPreviewStore()
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const router = useRouter()
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const containerTotal = ref(0)
const containerRows = ref<ContainerPlanProgressSummaryDto[]>([])
const containerDetailOpen = ref(false)
const containerDetailLoading = ref(false)
const containerDetailError = ref<ApiErrorDetails | null>(null)
const containerDetail = ref<ContainerPlanProgressDetailDto | null>(null)
const transferReviewOpen = ref(false)
const transferReviewTarget = ref<LastMileOrderListItem | null>(null)
const selectedRows = ref<LastMileOrderListItem[]>([])
const selectedReturnRows = computed(() => selectedRows.value.filter((row) => row.orderTypeCode === 'RETURN_RELABEL' && ['INBOUND_COMPLETED', 'COMPLETED', 'RECEIVED', 'IN_WAREHOUSE', '已完成入库'].includes(String(row.currentStatusCode || row.status || ''))))
const returnStatistics = ref<ReturnOrderStatistics>()
let orderLoadRequest = 0
function returnDimension(row: { id?: unknown; orderTypeCode?: unknown }) {
return row.orderTypeCode === 'RETURN_RELABEL' ? returnStatistics.value?.pageDimensions.find(item => String(item.lastlegOrderId) === String(row.id)) : undefined
}
function openReturnPackingPlan() {
const selected = selectedReturnRows.value
if (!selected.length) { ElMessage.warning('请勾选已入库的退货订单'); return }
if (new Set(selected.map(row => row.customerId)).size !== 1 || new Set(selected.map(row => row.overseasWarehouseId)).size !== 1) {
ElMessage.warning('合箱请选择同一客户、同一海外仓的退货订单'); return
}
void router.push({ name: 'overseas-return-inventory', query: {
packing: '1', customerId: selected[0]!.customerId, overseasWarehouseId: selected[0]!.overseasWarehouseId,
returnNos: selected.map(row => row.sourceOrderNo || row.orderNo).join(','),
} })
}
const exportLoading = ref(false)
const actionLoading = ref(false)
async function exportOrders() { exportLoading.value = true; try { await submitExport('LAST_MILE_ORDER_LIST', '尾程订单', orderQuery()) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } }
const palletMergeSelectionEditable = computed(() => selectedRows.value.length > 0
&& selectedRows.value.every(row => row.customerId && row.currentStatusCode !== 'INBOUND_COMPLETED' && row.palletMergeFlag !== 'MERGED')
&& new Set(selectedRows.value.map(row => row.customerId)).size === 1)
const canCancelPalletMerge = computed(() => palletMergeSelectionEditable.value
&& selectedRows.value.every(row => row.palletMergeFlag === 'REQUIRED'))
async function submitPalletMerge(required: boolean) {
if (actionLoading.value || !access.can(permission.lastMileOrderPalletMerge)
|| !(required ? palletMergeSelectionEditable.value : canCancelPalletMerge.value)) return
const selection = [...selectedRows.value]
const label = required ? '合托' : '取消合托'
try {
await ElMessageBox.confirm(required
? `将勾选的 ${selection.length} 票订单标记为同一合托组?所选订单须属于同一客户、同一目的仓,PDA 将提示现场合托。`
: `取消勾选的 ${selection.length} 票订单的合托标记和 PDA 提示?`, `${label} · ${selection.length} 票订单`,
{ customClass: 'admin-density-confirm', confirmButtonText: `确认${label}`, cancelButtonText: '返回', type: 'warning' })
} catch { return }
actionLoading.value = true
actionError.value = null
try {
await updateLastMileOrderPalletMerge(selection.map(row => row.id), selection[0]!.customerId, required)
ElMessage.success(required ? '已标记合托' : '已取消合托标记')
selectedRows.value = []
await load()
} catch (cause) {
actionError.value = getApiErrorDetails(cause, `${label}失败,请重试`)
} finally { actionLoading.value = false }
}
const actionError = ref<ApiErrorDetails | null>(null)
const actionTarget = ref<LastMileOrderListItem | null>(null)
const inboundPlanOpen = ref(false)
const inboundPlanWarehouseLabel = computed(() => [actionTarget.value?.overseasWarehouseCode, actionTarget.value?.warehouse]
.filter((value) => value && value !== '—')
.join(' · '))
const inboundPlanMinimumPalletCount = ref(1)
const inboundPlanSourcePalletCount = ref<number | null>(null)
const inboundPlanPreviewLoading = ref(false)
const inboundPlanPreviewError = ref('')
const inboundPlanIsDropship = computed(() => actionTarget.value?.orderTypeCode === 'ONE_CLICK_DROPSHIP')
const inboundPlanSubmitDisabled = computed(() => !actionTarget.value?.estimatedWarehouseAt
|| (inboundPlanIsDropship.value
&& (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null
|| inboundPlanSourcePalletCount.value <= 0)))
const inboundPlanForm = reactive({ inboundCode: '', palletCount: 1, palletType: 'WOOD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' })
const inboundPlanExpectedArrival = computed(() => actionTarget.value?.warehouseEta || '—')
const holdOpen = ref(false)
const holdForm = reactive({ held: true, chargeable: true, reason: '' })
const manualNodesOpen = ref(false)
const manualNodesLoading = ref(false)
const manualNodesVersion = ref(0)
const manualNodeDefinitions: Array<{ code: ManualNodeCode; label: string }> = [
{ code: 'DOMESTIC_CUSTOMS_COMPLETED', label: '国内报关完成' },
{ code: 'ESTIMATED_DEPARTURE', label: '预计开船' },
{ code: 'ACTUAL_DEPARTURE', label: '实际开船' },
{ code: 'ESTIMATED_ARRIVAL', label: '预计到港' },
{ code: 'OVERSEAS_CUSTOMS_COMPLETED', label: '国外清关完成' },
{ code: 'ACTUAL_ARRIVAL_WAITING_UNLOAD', label: '实际到港等待卸船' },
{ code: 'PICKUP_APPOINTED', label: '预约提柜' },
{ code: 'UNLOADED', label: '卸柜' },
]
const manualNodeValues = reactive<Record<ManualNodeCode, string | null>>(Object.fromEntries(manualNodeDefinitions.map(({ code }) => [code, null])) as Record<ManualNodeCode, string | null>)
const transferOpen = ref(false)
const transferWarehouseLoading = ref(false)
const transferWarehouseError = ref<ApiErrorDetails | null>(null)
const transferWarehouseOptions = ref<TransferWarehouseOption[]>([])
const transferForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
const containerConvertOpen = ref(false)
const containerOptions = ref<ContainerPlanProgressSummaryDto[]>([])
const containerOptionsLoading = ref(false)
const containerLoadError = ref<ApiErrorDetails | null>(null)
const selectedContainerBookingId = ref('')
const containerPreview = ref<ContainerInboundPlanPreviewDto | null>(null)
const containerPreviewLoading = ref(false)
const containerPreviewError = ref('')
type ContainerConversionPlan = Awaited<ReturnType<typeof convertLastMileContainerToInboundPlans>>[number]
interface ContainerConversionResult {
plans: ContainerConversionPlan[]
existingPlanNos: Set<string>
newPlanCount: number
existingPlanCount: number
resourceRecovered: boolean
}
const containerConversionResult = ref<ContainerConversionResult | null>(null)
let containerPreviewRequest = 0
let containerPreviewLoadingBookingId = ''
let containerConversionIdempotencyKey = ''
const containerIsResourceRecovery = computed(() => Boolean(containerPreview.value?.resourceRetry || containerConversionResult.value?.resourceRecovered))
const containerConfirmText = computed(() => containerConversionResult.value
? containerConversionResult.value.resourceRecovered ? '已确认' : '已生成'
: containerPreview.value?.resourceRetry ? '重试资源确认' : '确认生成')
const containerConfirmDisabled = computed(() => !access.can(permission.inboundOrderCreate)
|| !containerPreview.value?.ready
|| (!containerPreview.value.resourceRetry && containerPreview.value.pendingOrderCount <= 0)
|| containerOptionsLoading.value
|| containerPreviewLoading.value
|| actionLoading.value
|| Boolean(containerConversionResult.value))
const receivingWarehouseOpen = ref(false)
const receivingWarehouseLoading = ref(false)
const receivingWarehouseError = ref<ApiErrorDetails | null>(null)
const receivingWarehouseActionError = ref<ApiErrorDetails | null>(null)
const receivingWarehouseOptions = ref<TransferWarehouseOption[]>([])
const receivingWarehouseTarget = ref<ContainerPlanProgressSummaryDto | null>(null)
const receivingWarehouseForm = reactive({ warehouseId: '', remark: '' })
let receivingWarehouseIdempotencyKey = ''
let containerOptionRequest = 0
const statusTab = ref<'全部' | '开船前' | '开船后' | '柜子'>('全部')
const statusTabs = ['全部', '开船前', '开船后'] as const
const showAdvancedFilters = ref(false)
const filters = ref<QueryModel>({ businessType: '', businessOrderNo: '', externalBookingNo: '', orderNo: '', relabelNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const allFields: QueryField[] = [
{ key: 'businessType', type: 'select', label: '业务来源', width: 180, placeholder: '请选择业务来源', options: [
{ label: '本土散板(全部来源)', value: 'LOCAL_LOOSEBOARD' }, { label: '本土卡车', value: 'US_TRUCK' },
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '头程订单', value: 'FIRST_LEG' },
{ label: '换标下单', value: 'RELABEL_APPLICATION' }, { label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' },
] },
{ key: 'businessOrderNo', type: 'input', label: '关联商业单号', width: 210, placeholder: '请输入商业单号' },
{ key: 'externalBookingNo', type: 'input', label: '外接预订号', width: 210, placeholder: '请输入外接预订号' },
{ key: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
{ key: 'relabelNo', type: 'input', label: '换标单号', width: 190, placeholder: '请输入换标单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入LX代码' },
{ key: 'orderType', type: 'select', label: '履约类型', width: 170, placeholder: '请选择履约类型', options: [
{ label: '头程订单', value: 'FIRST_LEG' }, { label: '换标下单', value: 'RELABEL_APPLICATION' },
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '本土散板', value: 'LOCAL_LOOSEBOARD' },
{ label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' },
{ label: '货物废弃订单', value: 'CARGO_DISCARD' },
] },
{ key: 'warehouse', type: 'input', label: '海外仓', width: 210, placeholder: '请输入仓库名称或编码' },
{ key: 'sourceOrderNo', type: 'input', label: '来源订单号', width: 190, placeholder: '请输入来源订单号' },
{ key: 'containerNo', type: 'input', label: '集装箱号', width: 180, placeholder: '请输入集装箱号' },
{ key: 'transferWarehouseCode', type: 'input', label: '转仓代码', width: 170, placeholder: '请输入转仓代码' },
{ key: 'status', type: 'select', label: '当前状态', width: 160, placeholder: '请选择当前状态', options: [
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
{ label: '待换标', value: 'WAIT_RELABEL' },
{ label: '待排车', value: 'READY_FOR_DISPATCH' },
{ label: '待出库', value: 'WAIT_OUTBOUND' },
{ label: '待入库', value: 'PENDING_INBOUND' },
{ label: '入库完成', value: 'INBOUND_COMPLETED' },
{ label: '待派送', value: 'PENDING_DELIVERY' },
{ label: '派送中', value: 'DELIVERING' },
{ label: '派送完成', value: 'DELIVERED' },
] },
{ key: 'abnormal', type: 'select', label: '是否异常', width: 140, placeholder: '请选择是否异常', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'detained', type: 'select', label: '是否暂扣', width: 140, placeholder: '请选择是否暂扣', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'createdAt', type: 'daterange', label: '创建时间', width: 280 },
]
const availableFields = computed(() => allFields.filter((field) => statusTab.value === '全部' || field.key !== 'orderType'))
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
: showAdvancedFilters.value
? availableFields.value
: allFields.filter((field) => ['orderNo', 'inboundCode', 'warehouse'].includes(field.key)))
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
const selectedReceivingWarehouse = computed(() => receivingWarehouseOptions.value.find((row) => (
row.warehouseId === receivingWarehouseForm.warehouseId
&& row.warehouseId !== receivingWarehouseTarget.value?.overseasWarehouseId
)))
interface TransferWarehouseOption {
warehouseId: string
warehouseCode: string
warehouseName: string
region: string
label: string
}
function warehouseRegion(warehouse: PlatformWarehouseSummaryResource) {
const addressRegion = [warehouse.primaryAddress?.stateProvince, warehouse.primaryAddress?.city]
.map((value) => String(value || '').trim())
.filter(Boolean)
.join(' / ')
return addressRegion || String(warehouse.addressSummary || warehouse.countryCode || '').trim()
}
function toTransferWarehouseOption(warehouse: PlatformWarehouseSummaryResource): TransferWarehouseOption {
const warehouseId = String(warehouse.id)
const warehouseCode = warehouse.warehouseCode.trim()
const warehouseName = warehouse.warehouseName.trim()
const region = warehouseRegion(warehouse)
return {
warehouseId,
warehouseCode,
warehouseName,
region,
label: [warehouseCode, warehouseName, region].filter(Boolean).join(' · '),
}
}
function receivingWarehouseLabel(container: ContainerPlanProgressSummaryDto | null | undefined) {
if (!container) return '—'
const code = String(container.overseasWarehouseCode || '').trim()
const name = String(container.overseasWarehouseName || '').trim()
return [code, name].filter(Boolean).join(' · ') || '—'
}
function receivingWarehouseChangeDisabledReason(container: ContainerPlanProgressSummaryDto) {
if (!access.can(permission.lastMileOrderTransfer)) return '暂无修改接柜仓权限,请联系管理员开通尾程订单编辑权限'
if (!container.canChangeReceivingWarehouse) return container.receivingWarehouseLockReason || '当前柜已锁定,不能修改接柜仓'
return ''
}
function newReceivingWarehouseIdempotencyKey() {
return `container-receiving-warehouse-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 330, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
{ key: 'businessSource', prop: 'businessSource', label: '业务来源', minWidth: 220 },
{ key: 'businessOrderNo', prop: 'businessOrderNo', label: '关联商业单号', minWidth: 230, slotName: 'businessOrderNo' },
{ key: 'externalBookingNo', prop: 'externalBookingNo', label: '外接预订号', minWidth: 220 },
{ key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100, hideable: false },
{ key: 'palletMergeRelation', prop: 'palletMergeRelation', label: '合托关联入仓单', minWidth: 220, hideable: false },
{ key: 'warning', prop: 'warning', label: '预警', width: 90 },
{ key: 'orderType', prop: 'orderType', label: '履约类型', minWidth: 110 },
{ key: 'rejectedAt', prop: 'rejectedAt', label: '拒收时间', minWidth: 150 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
{ key: 'lxCode', prop: 'lxCode', label: 'LX代码', minWidth: 150 },
{ key: 'owner', prop: 'owner', label: '货主', minWidth: 150 },
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
{ key: 'transferWarehouse', prop: 'transferWarehouse', label: '转仓', minWidth: 140 },
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: 'FBA仓库', minWidth: 130 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 150 },
{ key: 'abnormal', prop: 'abnormal', label: '是否异常', width: 100 },
{ key: 'held', prop: 'held', label: '是否暂扣', width: 100 },
{ key: 'pallets', prop: 'pallets', label: '托数(退货实收)', width: 130, align: 'right', slotName: 'receiptPallets' },
{ key: 'cartons', prop: 'cartons', label: '箱数(退货实收)', width: 130, align: 'right', slotName: 'receiptBoxes' },
{ key: 'returnParcels', label: '退货实收包裹', width: 120, align: 'right', slotName: 'returnParcels' },
{ key: 'returnExpected', label: '退货预报(件)', width: 120, align: 'right', slotName: 'returnExpected' },
{ key: 'returnReceived', label: '退货实收(件)', width: 120, align: 'right', slotName: 'returnReceived' },
{ key: 'returnRemaining', label: '退货待收(件)', width: 120, align: 'right', slotName: 'returnRemaining' },
{ key: 'cartonDifference', prop: 'cartonDifference', label: '差异(箱)', width: 100, align: 'right', slotName: 'cartonDifference' },
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 100, align: 'right' },
{ key: 'quantity', prop: 'quantity', label: '数量(非退货)', width: 110, align: 'right', slotName: 'nonReturnQuantity' },
{ key: 'declaredPieces', prop: 'declaredPieces', label: '转运预报件数', width: 120, align: 'right' },
{ key: 'receivedPieces', prop: 'receivedPieces', label: '转运实收件数', width: 120, align: 'right' },
{ key: 'volumeWeight', prop: 'volumeWeight', label: '体积重量', width: 100, align: 'right' },
{ key: 'portEta', prop: 'portEta', label: '预计到港时间', minWidth: 150 },
{ key: 'portAta', prop: 'portAta', label: '实际到港时间', minWidth: 150 },
{ key: 'warehouseEta', prop: 'warehouseEta', label: '预计到仓', minWidth: 140 },
{ key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 150 },
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110 },
{ key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 150 },
{ key: 'dispatchOrderNo', prop: 'dispatchOrderNo', label: '派送单号', minWidth: 150 },
{ key: 'relabelOrderNo', slotName: 'relabelOrderNo', label: '换标单号', minWidth: 220 },
{ key: 'relabelStatus', slotName: 'relabelStatus', label: '换标子状态', minWidth: 180 },
{ key: 'status', prop: 'status', label: '当前状态', width: 110 },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(仅自己)', minWidth: 160 },
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160 },
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 },
]
const containerDetailColumns: DataTableColumn[] = [
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ key: 'orderStatus', prop: 'orderStatus', label: '订单状态', width: 130, slotName: 'orderStatus' },
{ key: 'planNo', prop: 'planNo', label: '独立入库计划', minWidth: 170 },
{ key: 'planStatus', prop: 'planStatus', label: '计划状态', width: 130, slotName: 'planStatus' },
{ key: 'plannedArrivalAt', prop: 'plannedArrivalAt', label: '计划到仓时间', minWidth: 170, slotName: 'plannedArrivalAt' },
]
function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 19) : '—'
}
function inboundPlanStatusLabel(value: unknown) {
const code = String(value ?? '').trim()
return ({
PENDING_ASSIGNMENT: '待入库',
ASSIGNED: '待入库',
PDA_PENDING: '待入库',
PENDING_RECEIVING: '待收货',
PICKING: '拣货中',
PARTIAL_INBOUND: '部分入库',
COMPLETED: '全部入库',
CANCELLED: '已取消',
} as Record<string, string>)[code] || (code ? '未知状态' : '—')
}
function canGenerateContainerPlans(container: ContainerPlanProgressSummaryDto) {
return Boolean(container.containerNo
&& container.overseasWarehouseId
&& container.overseasWarehouseCode
&& container.overseasWarehouseName
&& container.assignmentVersion >= 0
&& (container.pendingPlanCount > 0 || container.resourcePendingPlanCount > 0))
}
function containerSnapshotBlockReason(container: ContainerPlanProgressSummaryDto) {
if (!container.containerNo) return container.blockingReason || '来源柜真实集装箱号缺失、格式无效或存在漂移'
if (!container.overseasWarehouseId || !container.overseasWarehouseCode
|| !container.overseasWarehouseName || container.assignmentVersion < 0) {
return '柜内接柜仓业务快照不一致'
}
return ''
}
function pendingInboundCodeCount(container: ContainerPlanProgressSummaryDto) {
return Math.max(0, container.inboundCodeCount - container.plannedInboundCodeCount)
}
function changeContainerPage(value: number) {
page.value = value
void load()
}
function changeContainerPageSize(value: number) {
pageSize.value = value
page.value = 1
void load()
}
function orderQuery(): LastMileOrderQuery {
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined
return {
page: page.value,
size: pageSize.value,
businessType: String(filters.value.businessType || '').trim() || undefined,
businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined,
externalBookingNo: String(filters.value.externalBookingNo || '').trim() || undefined,
orderNo: String(filters.value.orderNo || '').trim() || undefined,
relabelNo: String(filters.value.relabelNo || '').trim() || undefined,
orderType: statusTab.value === '全部' ? String(filters.value.orderType || '').trim() || undefined : 'FIRST_LEG',
sailingStage: statusTab.value === '开船前' ? 'BEFORE_DEPARTURE' : statusTab.value === '开船后' ? 'AFTER_DEPARTURE' : undefined,
warehouse: String(filters.value.warehouse || '').trim() || undefined,
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
lxCode: String(filters.value.lxCode || '').trim() || undefined,
sourceOrderNo: String(filters.value.sourceOrderNo || '').trim() || undefined,
containerNo: String(filters.value.containerNo || '').trim() || undefined,
currentStatus: String(filters.value.status || '').trim() || undefined,
transferWarehouseCode: String(filters.value.transferWarehouseCode || '').trim() || undefined,
abnormal: booleanValue(filters.value.abnormal),
detained: booleanValue(filters.value.detained),
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
}
}
async function load(model: QueryModel = filters.value) {
const request = ++orderLoadRequest
returnStatistics.value = undefined
Object.assign(filters.value, model)
loading.value = true
loadError.value = null
if (statusTab.value === '柜子') {
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
try {
const result = await listContainerPlanProgress({
page: page.value,
size: pageSize.value,
containerNo: String(filters.value.containerNo || '').trim() || undefined,
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
})
containerRows.value = result.records
containerTotal.value = result.total
} catch (error) {
containerRows.value = []
containerTotal.value = 0
loadError.value = getApiErrorDetails(error, '柜级入库计划进度加载失败,请重试')
} finally {
loading.value = false
}
return
}
try {
const query = orderQuery()
const [result, summary] = await Promise.all([listLastMileOrders(query), getReturnOrderStatistics(query)])
if (request !== orderLoadRequest) return
rows.value = result.records.map(toLastMileOrderListItem)
total.value = result.total
returnStatistics.value = summary
} catch (error) {
if (request !== orderLoadRequest) return
rows.value = []
total.value = 0
loadError.value = error instanceof Error && error.message === '当前环境未签发海外访问令牌'
? { message: error.message }
: getApiErrorDetails(error, '尾程订单加载失败,请重试')
} finally {
if (request === orderLoadRequest) loading.value = false
}
}
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset() {
for (const field of allFields) filters.value[field.key] = field.type === 'daterange' ? [] : ''
page.value = 1
void load()
}
function changeStatusTab() {
selectedRows.value = []
page.value = 1
void load()
}
function selectStatusTab(value: typeof statusTabs[number]) {
if (statusTab.value === value) return
statusTab.value = value
changeStatusTab()
}
function handleStatusTabKeydown(event: KeyboardEvent, index: number) {
let nextIndex = index
if (event.key === 'ArrowRight') nextIndex = (index + 1) % statusTabs.length
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + statusTabs.length) % statusTabs.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = statusTabs.length - 1
else return
event.preventDefault()
const tab = statusTabs[nextIndex]
if (!tab) return
selectStatusTab(tab)
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
buttons?.[nextIndex]?.focus()
}
async function openContainerDetail(row: ContainerPlanProgressSummaryDto) {
containerDetailOpen.value = true
containerDetailLoading.value = true
containerDetailError.value = null
containerDetail.value = null
try {
containerDetail.value = await getContainerPlanProgress(row.sourceContainerBookingId)
} catch (error) {
containerDetailError.value = getApiErrorDetails(error, '柜级详情加载失败,请重试')
} finally {
containerDetailLoading.value = false
}
}
async function loadReceivingWarehouseOptions() {
receivingWarehouseLoading.value = true
receivingWarehouseError.value = null
receivingWarehouseOptions.value = []
try {
const warehouses = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' })
receivingWarehouseOptions.value = warehouses.map(toTransferWarehouseOption)
} catch (error) {
receivingWarehouseError.value = getApiErrorDetails(error, '海外接柜仓候选加载失败,请重试')
} finally {
receivingWarehouseLoading.value = false
}
}
function openReceivingWarehouseChange(container: ContainerPlanProgressSummaryDto) {
const disabledReason = receivingWarehouseChangeDisabledReason(container)
if (disabledReason) {
ElMessage.warning(disabledReason)
return
}
receivingWarehouseTarget.value = container
Object.assign(receivingWarehouseForm, { warehouseId: '', remark: '' })
receivingWarehouseIdempotencyKey = newReceivingWarehouseIdempotencyKey()
receivingWarehouseOptions.value = []
receivingWarehouseError.value = null
receivingWarehouseActionError.value = null
actionError.value = null
receivingWarehouseOpen.value = true
void loadReceivingWarehouseOptions()
}
async function submitReceivingWarehouseChange() {
const target = receivingWarehouseTarget.value
const selected = selectedReceivingWarehouse.value
if (!target || !selected) {
ElMessage.warning('请选择与当前仓不同的有效海外接柜仓')
return
}
actionLoading.value = true
actionError.value = null
receivingWarehouseActionError.value = null
try {
await changeContainerReceivingWarehouse(target.sourceContainerBookingId, {
overseasWarehouseId: selected.warehouseId,
expectedVersion: target.assignmentVersion,
remark: receivingWarehouseForm.remark.trim() || undefined,
}, receivingWarehouseIdempotencyKey)
receivingWarehouseOpen.value = false
receivingWarehouseTarget.value = null
ElMessage.success('海外接柜仓修改成功')
await load()
} catch (error) {
const details = getApiErrorDetails(error, '海外接柜仓修改失败,请重试')
receivingWarehouseActionError.value = details.code === 'A0409'
? { ...details, message: '接柜仓信息已被其他操作更新,请刷新后重试' }
: details
actionError.value = receivingWarehouseActionError.value
if (details.code === 'A0409') {
receivingWarehouseOpen.value = false
receivingWarehouseTarget.value = null
await load()
}
} finally {
actionLoading.value = false
}
}
function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] }
function isRejectedReturn(source: object) {
const row = source as LastMileOrderListItem
return row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'REJECTED'
}
function hasSelectedRejectedReturn() { return selectedRows.value.some(isRejectedReturn) }
function hasSelectedInboundPlan() { return selectedRows.value.some(hasInboundPlan) }
const inboundPlanExcludedOrderTypes = new Set(['WAREHOUSE_TRANSFER', 'RELABEL_APPLICATION', 'RETURN_RELABEL'])
function canGenerateInboundPlan(row: LastMileOrderListItem) {
return !inboundPlanExcludedOrderTypes.has(String(row.orderTypeCode || row.orderType || ''))
}
async function loadTransferWarehouseOptions() {
transferWarehouseLoading.value = true
transferWarehouseError.value = null
transferWarehouseOptions.value = []
try {
const warehouses = await listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' })
transferWarehouseOptions.value = warehouses.map(toTransferWarehouseOption)
} catch (error) {
transferWarehouseError.value = getApiErrorDetails(error, 'FBA 仓候选加载失败,请重试')
} finally {
transferWarehouseLoading.value = false
}
}
function selectTransferWarehouse(warehouseId: string) {
const selected = transferWarehouseOptions.value.find((warehouse) => warehouse.warehouseId === String(warehouseId || ''))
Object.assign(transferForm, selected
? { warehouseId: selected.warehouseId, warehouseCode: selected.warehouseCode, warehouseName: selected.warehouseName }
: { warehouseId: '', warehouseCode: '', warehouseName: '' })
}
function openTransfer() {
if (!selectedRows.value.length) { ElMessage.warning('请至少选择 1 条尾程订单'); return }
if (hasSelectedRejectedReturn()) { ElMessage.warning('已驳回的退货预报只能查看详情'); return }
Object.assign(transferForm, { warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
transferWarehouseOptions.value = []
transferWarehouseError.value = null
actionError.value = null
transferOpen.value = true
void loadTransferWarehouseOptions()
}
async function submitTransfer() {
const selectedWarehouse = transferWarehouseOptions.value.find((warehouse) => warehouse.warehouseId === transferForm.warehouseId)
if (!selectedWarehouse) {
ElMessage.warning('请选择目的 FBA 仓')
return
}
actionLoading.value = true; actionError.value = null
try {
await transferLastMileOrders({
orderIds: selectedRows.value.map((row) => String(row.id)),
warehouseId: selectedWarehouse.warehouseId,
warehouseCode: selectedWarehouse.warehouseCode,
warehouseName: selectedWarehouse.warehouseName,
remark: transferForm.remark.trim() || undefined,
})
transferOpen.value = false; selectedRows.value = []; ElMessage.success('转仓成功'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') }
finally { actionLoading.value = false }
}
function idempotencyKey(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function openInboundPlan(source?: object) {
const target = source ? source as LastMileOrderListItem : (selectedRows.value.length === 1 ? selectedRows.value[0] : null)
if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
if (isRejectedReturn(target)) { ElMessage.warning('已驳回的退货预报只能查看详情'); return }
if (hasInboundPlan(target)) { ElMessage.warning('当前订单已提交入库计划,不能重复生成'); return }
if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return }
actionTarget.value = target
const volumeMinimum = typeof target.volume === 'number' && target.volume > 0 ? Math.ceil(target.volume / 2) : 1
inboundPlanMinimumPalletCount.value = Math.max(1, volumeMinimum,
typeof target.pallets === 'number' ? target.pallets : 0)
inboundPlanSourcePalletCount.value = null
inboundPlanPreviewError.value = ''
Object.assign(inboundPlanForm, { inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: inboundPlanMinimumPalletCount.value, palletType: 'WOOD', assigned: false, mergeRequired: false, urgent: false, remark: '' })
actionError.value = null
inboundPlanOpen.value = true
void refreshManualPalletMinimum(target)
}
async function refreshManualPalletMinimum(target: LastMileOrderListItem) {
if (!target.sourceContainerBookingId) return
inboundPlanPreviewLoading.value = true
inboundPlanPreviewError.value = ''
try {
const preview = await getContainerInboundPlanPreview(target.sourceContainerBookingId)
if (actionTarget.value?.id !== target.id || !inboundPlanOpen.value) return
const order = preview.groups.flatMap((group) => group.orders).find((item) => item.orderId === target.id)
if (target.orderTypeCode === 'ONE_CLICK_DROPSHIP') {
const sourcePalletCount = Number(order?.sourcePalletCount || 0)
if (sourcePalletCount <= 0) throw new Error('一件代发订单缺少头程封托托盘快照')
inboundPlanSourcePalletCount.value = sourcePalletCount
inboundPlanMinimumPalletCount.value = sourcePalletCount
inboundPlanForm.palletCount = sourcePalletCount
return
}
const suggested = Number(order?.suggestedPalletCount || 0)
inboundPlanMinimumPalletCount.value = Math.max(inboundPlanMinimumPalletCount.value, suggested)
inboundPlanForm.palletCount = Math.max(inboundPlanForm.palletCount, inboundPlanMinimumPalletCount.value)
} catch (error) {
if (actionTarget.value?.id === target.id && inboundPlanOpen.value) {
inboundPlanPreviewError.value = getApiErrorDetails(error, '柜级托盘预览加载失败,请重试').message
}
} finally {
if (actionTarget.value?.id === target.id) inboundPlanPreviewLoading.value = false
}
}
async function submitInboundPlan() {
const row = actionTarget.value
const frozenInboundCode = String(row?.inboundCode || '').trim()
if (inboundPlanIsDropship.value && (inboundPlanSourcePalletCount.value == null
|| inboundPlanForm.palletCount !== inboundPlanSourcePalletCount.value)) {
ElMessage.warning('一件代发必须按头程已封托托盘数整体生成入库计划')
return
}
if (!row || !row.overseasWarehouseId || !row.estimatedWarehouseAt || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
ElMessage.warning('当前柜须已分配接柜仓、具备预计到仓时间,并完整填写托数')
return
}
actionLoading.value = true; actionError.value = null
try {
const result = await createManualInboundPlan({
customerId: row.customerId, orderId: row.id, expectedOrderVersion: row.version,
palletCount: inboundPlanForm.palletCount,
palletType: inboundPlanForm.palletType, assigned: inboundPlanForm.assigned,
mergeRequired: inboundPlanForm.mergeRequired, urgent: inboundPlanForm.urgent,
remark: inboundPlanForm.remark.trim() || undefined,
}, idempotencyKey('manual-inbound-plan'))
const palletCodesReady = result.pallets?.length === result.palletCount
&& result.pallets.every((pallet) => Boolean(pallet.warehousePalletId && pallet.palletNo))
inboundPlanOpen.value = false; selectedRows.value = []
ElMessage.success(palletCodesReady
? '入库计划和托盘码已自动生成'
: '入库计划已保存,整柜计划齐全后将自动生成托盘码')
await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试') }
finally { actionLoading.value = false }
}
function openHold(source: object) {
const row = source as LastMileOrderListItem
if (!row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED') {
ElMessage.warning('货物完成入库后才能暂扣;暂扣只限制后续排车和出库')
return
}
actionTarget.value = row
Object.assign(holdForm, { held: !row.detained, chargeable: row.detained ? false : true, reason: '' })
actionError.value = null
holdOpen.value = true
}
async function submitHold() {
const row = actionTarget.value
if (!row || !holdForm.reason.trim()) { ElMessage.warning('请输入变更原因'); return }
actionLoading.value = true; actionError.value = null
try {
await updateLastMileOrderHold(row.id, { held: holdForm.held, chargeable: holdForm.chargeable, reason: holdForm.reason.trim(), expectedVersion: row.version }, idempotencyKey('last-mile-hold'))
holdOpen.value = false; ElMessage.success(holdForm.held ? '订单已暂扣' : '订单已解除暂扣'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '暂扣状态保存失败,请重试') }
finally { actionLoading.value = false }
}
async function openManualNodes(source: object) {
const row = source as LastMileOrderListItem
actionTarget.value = row
manualNodesOpen.value = true
manualNodesLoading.value = true
actionError.value = null
manualNodeDefinitions.forEach(({ code }) => { manualNodeValues[code] = null })
try {
const result = await getLastMileOrderManualNodes(row.id)
manualNodesVersion.value = result.version
result.nodes.forEach((node) => { manualNodeValues[node.nodeCode] = node.nodeAt })
} catch (error) { actionError.value = getApiErrorDetails(error, '人工节点加载失败,请重试') }
finally { manualNodesLoading.value = false }
}
async function submitManualNodes() {
const row = actionTarget.value
if (!row || !manualNodeDefinitions.some(({ code }) => Boolean(manualNodeValues[code]))) { ElMessage.warning('请至少填写一个节点时间'); return }
actionLoading.value = true; actionError.value = null
try {
await updateLastMileOrderManualNodes(row.id, manualNodesVersion.value, manualNodeDefinitions.map(({ code }) => ({ nodeCode: code, nodeAt: manualNodeValues[code] })), idempotencyKey('last-mile-manual-nodes'))
manualNodesOpen.value = false; ElMessage.success('人工节点已保存,该订单不再继承柜级节点'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '人工节点保存失败,请重试') }
finally { actionLoading.value = false }
}
async function loadContainerOptions(containerNo = '') {
const request = ++containerOptionRequest
containerOptionsLoading.value = true
containerLoadError.value = null
try {
const result = await listContainerPlanProgress({
page: 1,
size: 50,
containerNo: containerNo.trim() || undefined,
})
if (request !== containerOptionRequest) return
containerOptions.value = result.records
} catch (error) {
if (request !== containerOptionRequest) return
containerOptions.value = []
containerLoadError.value = getApiErrorDetails(error, '待分配柜列表加载失败,请重试')
} finally {
if (request === containerOptionRequest) containerOptionsLoading.value = false
}
}
function openContainerConversion() {
selectedContainerBookingId.value = ''
containerOptions.value = []
containerLoadError.value = null
containerPreview.value = null
containerPreviewError.value = ''
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
actionError.value = null
containerConvertOpen.value = true
void loadContainerOptions()
}
function openContainerConversionFor(container: ContainerPlanProgressSummaryDto) {
selectedContainerBookingId.value = container.sourceContainerBookingId
containerOptions.value = [container]
containerLoadError.value = null
containerPreview.value = null
containerPreviewError.value = ''
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
actionError.value = null
containerConvertOpen.value = true
void loadContainerPreview(container.sourceContainerBookingId)
}
async function loadContainerPreview(sourceContainerBookingId = selectedContainerBookingId.value, preserveResult = false) {
if (containerPreviewLoading.value && containerPreviewLoadingBookingId === sourceContainerBookingId) return
if (containerPreview.value?.sourceContainerBookingId === sourceContainerBookingId) return
const request = ++containerPreviewRequest
containerPreview.value = null
containerPreviewError.value = ''
if (!preserveResult) {
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
}
if (!sourceContainerBookingId) {
containerPreviewLoading.value = false
containerPreviewLoadingBookingId = ''
return
}
containerPreviewLoading.value = true
containerPreviewLoadingBookingId = sourceContainerBookingId
try {
const preview = await getContainerInboundPlanPreview(sourceContainerBookingId)
if (request !== containerPreviewRequest || selectedContainerBookingId.value !== sourceContainerBookingId || !containerConvertOpen.value) return
containerPreview.value = preview
if (!preserveResult) containerConversionIdempotencyKey = preview.resourceRetry
? preview.resourceRetry.idempotencyKey : idempotencyKey('container-inbound-plan')
} catch (error) {
if (request !== containerPreviewRequest || selectedContainerBookingId.value !== sourceContainerBookingId || !containerConvertOpen.value) return
containerPreviewError.value = getApiErrorDetails(error, '加载整柜入库计划预览失败,请重试').message
} finally {
if (request === containerPreviewRequest) {
containerPreviewLoading.value = false
containerPreviewLoadingBookingId = ''
}
}
}
async function confirmContainerInboundPlans() {
const preview = containerPreview.value
if (!preview || containerConfirmDisabled.value) return
if (preview.resourceRetry) containerConversionIdempotencyKey = preview.resourceRetry.idempotencyKey
else if (!containerConversionIdempotencyKey) containerConversionIdempotencyKey = idempotencyKey('container-inbound-plan')
const resourceRecovery = Boolean(preview.resourceRetry)
const bookingId = preview.sourceContainerBookingId
const commandSnapshot = preview.resourceRetry ?? preview
const existingPlanNos = new Set(preview.groups.flatMap((group) => group.orders)
.filter((order) => order.readiness !== 'READY' && order.existingPlanNo)
.map((order) => String(order.existingPlanNo)))
actionLoading.value = true
actionError.value = null
try {
const plans = await convertLastMileContainerToInboundPlans({
sourceContainerBookingId: bookingId,
snapshotVersion: commandSnapshot.snapshotVersion,
snapshotHash: commandSnapshot.snapshotHash,
}, containerConversionIdempotencyKey)
if (selectedContainerBookingId.value !== bookingId || !containerConvertOpen.value) return
const existingPlanCount = plans.filter((plan) => existingPlanNos.has(plan.planNo)).length
containerConversionResult.value = {
plans,
existingPlanNos,
newPlanCount: plans.length - existingPlanCount,
existingPlanCount,
resourceRecovered: resourceRecovery,
}
ElMessage.success(resourceRecovery
? `已完成 ${plans.length} 个已有计划的资源确认`
: `已生成 ${plans.length - existingPlanCount} 个入库计划`)
containerPreview.value = null
await Promise.all([load(), loadContainerPreview(bookingId, true)])
} catch (error) {
actionError.value = getApiErrorDetails(error, '按柜生成入库计划失败,请使用原操作重试')
} finally {
actionLoading.value = false
}
}
function previewGroupTitle(group: ContainerInboundPlanPreviewGroupDto) {
if (group.destinationType === 'FBA') return `${group.destinationWarehouseName || group.destinationWarehouseCode || '未配置目的仓'} FBA仓`
return group.destinationType === 'ONE_CLICK_DROPSHIP' ? '一件代发' : '私人地址'
}
function destinationText(order: { destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null }) {
return [order.destinationWarehouseCode, order.destinationWarehouseName].filter(Boolean).join(' / ') || '—'
}
function orderTypeText(orderType: string) {
const labels: Record<string, string> = {
FIRST_LEG: '头程订单',
RELABEL_APPLICATION: '换标下单',
WAREHOUSE_TRANSFER: '仓储转运',
LOCAL_LOOSEBOARD: '本土散板',
RETURN_RELABEL: '退货换标',
ONE_CLICK_DROPSHIP: '一件代发',
CARGO_DISCARD: '货物废弃订单',
}
return labels[orderType] || orderType
}
function readinessText(readiness: ContainerInboundPlanPreviewOrderDto['readiness']) {
return readiness === 'READY' ? '可生成' : readiness === 'RESOURCE_PENDING' ? '将自动补齐' : readiness === 'PLANNED' ? '已有计划' : '已阻断'
}
function readinessType(readiness: ContainerInboundPlanPreviewOrderDto['readiness']) {
return readiness === 'READY' || readiness === 'RESOURCE_PENDING' ? 'success' : readiness === 'PLANNED' ? 'info' : 'danger'
}
function blockerText(blockers: string[]) {
const labels: Record<string, string> = {
MISSING_INBOUND_CODE: '缺少入仓码',
ORDER_STATUS_NOT_READY: '订单状态未就绪',
CONTAINER_NO_INCONSISTENT: '货柜号不一致',
RECEIVING_WAREHOUSE_INCONSISTENT: '收货仓不一致',
DESTINATION_NOT_READY: '目的地未就绪',
CARGO_FACT_INVALID: '货物数据无效',
PLAN_ALREADY_STARTED: '已有计划已开始作业,不能再补生成托盘',
PLAN_PALLET_BINDING_INCOMPLETE: '已有计划只绑定了部分真实托盘,请联系管理员处理',
DROPSHIP_PALLET_SNAPSHOT_MISSING: '缺少一件代发托盘快照',
DROPSHIP_PALLET_COUNT_MISMATCH: '一件代发计划托数与头程已封托托盘数不一致,请重新走单',
PALLET_COUNT_NOT_READY: '计划托数暂未就绪',
}
return [...new Set(blockers.map((code) => labels[code] || '计划数据暂未就绪,请刷新或联系管理员'))].join(';') || '—'
}
watch(containerConvertOpen, (open) => {
if (open) return
containerPreviewRequest += 1
containerPreviewLoadingBookingId = ''
containerPreviewLoading.value = false
containerPreview.value = null
containerPreviewError.value = ''
containerConversionResult.value = null
containerConversionIdempotencyKey = ''
})
const discardReviewBusy = ref(false)
function canReviewDiscard(row: LastMileOrderListItem) {
return access.can(permission.cargoDiscardApprove)
&& row.orderTypeCode === 'CARGO_DISCARD'
&& row.currentStatusCode === 'PENDING_REVIEW'
&& Boolean(row.orderNo && row.orderNo !== '—')
&& /^[1-9]\d*$/.test(row.customerId)
}
async function reviewDiscard(row: LastMileOrderListItem) {
if (discardReviewBusy.value || !canReviewDiscard(row)) return
discardReviewBusy.value = true
try {
let decision: 'APPROVE' | 'REJECT' = 'APPROVE'
let remark: string | undefined
try {
await ElMessageBox.confirm(`请核对废弃申请 ${row.orderNo},确认审核结论。`, `废弃申请审核 · ${row.orderNo || '—'}`, {
customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true,
})
} catch (action) {
if (action !== 'cancel') throw action
const { value } = await ElMessageBox.prompt('请填写驳回原因', `驳回废弃申请 · ${row.orderNo || '—'}`, {
customClass: 'admin-density-confirm', inputType: 'textarea',
inputValidator: (input) => Boolean(input?.trim()) && input.trim().length <= 1000 || '请填写 1 至 1000 字驳回原因',
confirmButtonText: '确认驳回', cancelButtonText: '取消',
})
decision = 'REJECT'
remark = value.trim()
}
await reviewCargoDiscard(row.orderNo, row.customerId, decision, remark)
ElMessage.success(decision === 'APPROVE' ? '废弃申请已审核通过' : '废弃申请已驳回')
await load()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '废弃申请审核失败').message)
} finally {
discardReviewBusy.value = false
}
}
async function reviewReturn(source: object) {
const row = source as LastMileOrderListItem
const returnNo = row.sourceOrderNo
if (!returnNo || !row.customerId) return
try {
await ElMessageBox.confirm('审核通过后,仓库才可以签收这张退货预报。', `退货预报审核 · ${returnNo}`, { customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await reviewReturnOrder(returnNo, row.customerId, 'APPROVE')
ElMessage.success('退货预报已审核通过')
await load()
} catch (error) {
if (error === 'cancel') {
try {
const { value } = await ElMessageBox.prompt('请填写驳回原因', `驳回退货预报 · ${returnNo}`, { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
await reviewReturnOrder(returnNo, row.customerId, 'REJECT', value)
ElMessage.success('退货预报已驳回')
await load()
} catch (rejectError) { if (rejectError !== 'cancel' && rejectError !== 'close') ElMessage.error(getApiErrorDetails(rejectError, '退货预报审核失败').message) }
} else if (error !== 'close') ElMessage.error(getApiErrorDetails(error, '退货预报审核失败').message)
}
}
async function reviewDirectRelabel(source: object) {
const row = source as LastMileOrderListItem
if (!row.id || !row.customerId) return
try {
await ElMessageBox.confirm('审核通过后,贴标单将进入海外仓换标作业。', `贴标单审核 · ${row.orderNo || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await reviewDirectRelabelOrder(row.id, row.customerId, 'APPROVE')
ElMessage.success('贴标单已审核通过')
await load()
} catch (error) {
if (error === 'cancel') {
try {
const { value } = await ElMessageBox.prompt('请填写驳回原因,客户可在贴标单详情查看并修改后重新提交。', `驳回贴标单 · ${row.orderNo || '—'}`, { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
await reviewDirectRelabelOrder(row.id, row.customerId, 'REJECT', value)
ElMessage.success('贴标单已驳回')
await load()
} catch (rejectError) { if (rejectError !== 'cancel' && rejectError !== 'close') ElMessage.error(getApiErrorDetails(rejectError, '贴标单审核失败').message) }
} else if (error !== 'close') ElMessage.error(getApiErrorDetails(error, '贴标单审核失败').message)
}
}
function canConfirmRelabel(row: { relabelStatusCode?: string }) { return row.relabelStatusCode === 'CUSTOMER_CONFIRM_PENDING' }
function canReworkRelabel(row: LastMileOrderListItem) { return reworkRelabelApplications(row).length > 0 }
function hasRelabelActions(row: LastMileOrderListItem) {
return access.can(permission.relabelOrderStatus)
&& Boolean(row.relabelOrderNo)
&& (canConfirmRelabel(row) || canReworkRelabel(row))
}
const inboundPlanCreatedStatuses = new Set([
'INBOUND_PLANNED',
'PENDING_RECEIVING',
'WAIT_PDA_RECEIVING',
'INBOUND_MEASURING',
'WAIT_CUSTOMER_CONFIRMATION',
'PARTIALLY_RECEIVED',
'INBOUND_COMPLETED',
])
function hasInboundPlan(row: LastMileOrderListItem) {
return inboundPlanCreatedStatuses.has(row.currentStatusCode)
|| Boolean(row.inboundOrderNo && row.inboundOrderNo !== '—')
}
function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
const inboundPlanCreated = hasInboundPlan(row)
return [
{ key: 'detail', label: '查看', priority: 500 },
{ key: 'review-discard', label: '审核', priority: 480, tone: 'warning', visible: canReviewDiscard(row), disabled: discardReviewBusy.value },
{ key: 'review-direct-relabel', label: '审核', priority: 470, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && row.orderTypeCode === 'RELABEL_APPLICATION' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'review-return', label: '审核', priority: 460, tone: 'warning', visible: access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'inbound-plan', label: '入库', priority: 400, visible: access.can(permission.inboundOrderCreate) && canGenerateInboundPlan(row) && !inboundPlanCreated },
{ key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && (row.detained || row.currentStatusCode === 'INBOUND_COMPLETED') },
{ key: 'manual-nodes', label: '修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) },
{ key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) },
{ key: 'rework-relabel', label: '换标退回重做', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && canReworkRelabel(row) },
]
}
function handleLastMileRowAction(command: string, row: LastMileOrderListItem) {
if (command === 'detail') openDetail(row)
else if (command === 'review-discard') void reviewDiscard(row)
else if (command === 'review-direct-relabel') void reviewDirectRelabel(row)
else if (command === 'review-return') void reviewReturn(row)
else if (command === 'review') openTransferReview(row)
else if (command === 'inbound-plan') openInboundPlan(row)
else if (command === 'hold') openHold(row)
else if (command === 'manual-nodes') void openManualNodes(row)
else if (command === 'confirm-relabel') void confirmRelabel(row)
else if (command === 'rework-relabel') void reworkRelabel(row)
}
async function confirmRelabel(row: { relabelOrderNo?: string }) {
const relabelNo = row.relabelOrderNo
if (!relabelNo) return
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续后续流程吗?', `换标完成确认 · ${relabelNo}`, { customClass: 'admin-density-confirm', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(relabelNo)
ElMessage.success('已确认换标完成')
await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '换标完成确认失败').message) }
}
function reworkRelabel(row: LastMileOrderListItem) {
reworkOptions.value = reworkRelabelApplications(row)
if (!reworkOptions.value.length) return
reworkForm.relabelNo = reworkOptions.value[0].relabelNo
reworkForm.remark = ''
reworkOpen.value = true
}
async function submitReworkRelabel() {
if (reworkSaving.value || !reworkForm.relabelNo || !reworkForm.remark.trim()) return
const relabelNo = reworkForm.relabelNo
reworkSaving.value = true
try {
const task = await getRelabelReviewTask(relabelNo)
if (task.taskStatus !== 'WAIT_REVIEW') { ElMessage.warning('仓库尚未提交完工复核,暂不能退回重做'); return }
await returnRelabelForRework(relabelNo, task.version, reworkForm.remark.trim(), crypto.randomUUID())
ElMessage.success('已退回重新换标')
reworkOpen.value = false
await load()
} catch (error) { ElMessage.error(getApiErrorDetails(error, '重新换标失败').message) }
finally { reworkSaving.value = false }
}
function openDetail(source: object) {
const row = source as LastMileOrderListItem
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
}
function openTransferReview(source: object) {
transferReviewTarget.value = source as LastMileOrderListItem
transferReviewOpen.value = true
}
onMounted(() => { void load() })
</script>
<template>
<AdminPage class="admin-density"><OperationErrorToast :details="actionError" /><ListPageLayout aria-label="尾程订单列表">
<DataTable appearance="integrated" :stripe="false" :results-visible="!loadError" :column-settings-mode="statusTab === '柜子' ? 'hide' : 'show'" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="statusTab === '柜子' ? undefined : total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>尾程订单</h1></template>
<template #filters>
<section class="last-mile-toolbar integrated-query-group" aria-label="尾程订单状态与筛选">
<div class="density-status-tabs" role="tablist" aria-label="尾程订单状态">
<button v-for="(tab, index) in statusTabs" :key="tab" type="button" role="tab"
:aria-selected="statusTab === tab" :tabindex="statusTab === tab ? 0 : -1"
:class="{ 'is-active': statusTab === tab }"
@click="selectStatusTab(tab)" @keydown="handleStatusTabKeydown($event, index)">{{ tab }}</button>
<!-- 柜子工作台入口暂时隐藏,保留现有实现便于后续恢复。 -->
<!-- <el-radio-button value="柜子">柜子工作台({{ containerTotal }})</el-radio-button> -->
</div>
<QueryToolbar id="last-mile-orders-advanced-filters" v-model="filters" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset">
<template v-if="statusTab !== '柜子'" #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="last-mile-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
</el-button>
</template>
</QueryToolbar>
</section>
</template>
<template #notices><template v-if="loadError">
<LoadErrorState title="尾程订单加载失败" :message="loadError.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template><LoadErrorState v-if="actionError" title="尾程订单操作失败" :message="actionError.message" compact>
<template #details><ApiErrorMeta :details="actionError" /></template>
</LoadErrorState></template>
<template v-if="statusTab === '柜子'" #results><section class="container-workbench" :aria-busy="loading">
<header class="container-workbench__header">
<div><strong>货柜工作台</strong></div>
<el-tag type="warning" effect="plain">{{ containerTotal }} 个货柜</el-tag>
</header>
<div v-if="containerRows.length" class="container-workbench__grid">
<article v-for="container in containerRows" :key="container.sourceContainerBookingId" class="container-workbench-card">
<span class="container-workbench-card__top"><strong :title="container.containerNo || '柜号待补充'">{{ container.containerNo || '柜号待补充' }}</strong></span>
<span class="container-workbench-card__warehouse">
<small>海外接柜仓</small><strong :title="receivingWarehouseLabel(container)">{{ receivingWarehouseLabel(container) }}</strong>
<small v-if="containerSnapshotBlockReason(container)" class="is-locked" :title="containerSnapshotBlockReason(container)">{{ containerSnapshotBlockReason(container) }}</small>
<small v-else-if="!container.canChangeReceivingWarehouse && container.receivingWarehouseLockReason" class="is-locked" :title="container.receivingWarehouseLockReason">{{ container.receivingWarehouseLockReason }}</small>
</span>
<span class="container-workbench-card__arrival"><small>计划到仓</small><strong>{{ formatDateTime(container.plannedArrivalAt) }}</strong></span>
<span class="container-workbench-card__pending">
<strong>{{ pendingInboundCodeCount(container) }}</strong><span>个入仓码待生成计划</span>
</span>
<span class="container-workbench-card__facts">
<span>共 {{ container.inboundCodeCount }} 个入仓码</span>
<span v-if="container.resourcePendingPlanCount">托盘码自动生成异常,可重试</span>
</span>
<span class="container-workbench-card__actions density-actions">
<el-button class="container-workbench-card__detail" plain @click="openContainerDetail(container)">查看详情</el-button>
<el-tooltip :content="receivingWarehouseChangeDisabledReason(container)" :disabled="!receivingWarehouseChangeDisabledReason(container)" placement="top">
<span><el-button plain :title="receivingWarehouseChangeDisabledReason(container) || '修改接柜仓'" :disabled="Boolean(receivingWarehouseChangeDisabledReason(container))" @click="openReceivingWarehouseChange(container)"><Pencil :size="15" />修改接柜仓</el-button></span>
</el-tooltip>
<el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading || !canGenerateContainerPlans(container)" @click="openContainerConversionFor(container)">{{ container.resourcePendingPlanCount && !container.pendingPlanCount ? '重试生成托盘码' : '入库' }}</el-button>
</span>
</article>
</div>
<el-empty v-else-if="!loading" description="暂无货柜数据" />
<div v-if="containerTotal" class="container-workbench__pagination">
<span>共 {{ containerTotal }} 个货柜</span>
<el-pagination :current-page="page" :page-size="pageSize" :page-sizes="[10, 20, 50]" :total="containerTotal" layout="sizes, prev, pager, next" background @current-change="changeContainerPage" @size-change="changeContainerPageSize" />
</div>
</section></template>
<template v-if="statusTab !== '柜子'" #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderPalletMerge)" plain :disabled="!palletMergeSelectionEditable || actionLoading" @click="submitPalletMerge(true)">合托</el-button><el-button v-if="access.can(permission.lastMileOrderPalletMerge)" plain :disabled="!canCancelPalletMerge || actionLoading" @click="submitPalletMerge(false)">取消合托</el-button><el-button v-if="selectedReturnRows.length" type="warning" @click="openReturnPackingPlan"><PackagePlus :size="16" />创建退货合箱计划({{ selectedReturnRows.length }})</el-button><el-button v-if="access.can(permission.lastMileOrderExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate) && selectedRows.length === 1 && canGenerateInboundPlan(selectedRows[0])" type="primary" plain :disabled="hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">入库</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #relabelOrderNo="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.relabelNo }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelOrderNo }}</span></template>
<template #relabelStatus="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.statusLabel }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelStatus }}</span></template>
<template #actions="{ row }"><RowActions text-actions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
<template #businessOrderNo="{ row }"><el-button v-if="businessOrderLink(row.businessType, row.businessOrderNo, access.can)" link type="primary" @click="router.push(businessOrderLink(row.businessType, row.businessOrderNo, access.can)!)">{{ row.businessOrderNo }}</el-button><span v-else>{{ row.businessOrderNo }}</span></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': row.orderTypeCode !== 'RETURN_RELABEL' && typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ row.orderTypeCode === 'RETURN_RELABEL' ? '—' : packageDifferenceText(row.cartonDifference) }}</span></template>
<template #receiptPallets="{ row }">{{ row.orderTypeCode === 'RETURN_RELABEL' ? returnDimension(row)?.receivedPalletCount ?? '—' : row.pallets }}</template>
<template #receiptBoxes="{ row }">{{ row.orderTypeCode === 'RETURN_RELABEL' ? returnDimension(row)?.receivedBoxCount ?? '—' : row.cartons }}</template>
<template #returnParcels="{ row }">{{ returnDimension(row)?.receivedParcelCount ?? '—' }}</template>
<template #returnExpected="{ row }">{{ returnDimension(row)?.expectedQuantity ?? '—' }}</template>
<template #returnReceived="{ row }">{{ returnDimension(row)?.receivedQuantity ?? '—' }}</template>
<template #returnRemaining="{ row }">{{ returnDimension(row)?.remainingQuantity ?? '—' }}</template>
<template #nonReturnQuantity="{ row }">{{ row.orderTypeCode === 'RETURN_RELABEL' ? '—' : row.quantity }}</template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable>
</ListPageLayout></AdminPage>
<WarehouseTransferReviewDialog v-if="transferReviewTarget" v-model="transferReviewOpen" :customer-id="transferReviewTarget.customerId" :customer-name="transferReviewTarget.owner" :transfer-no="transferReviewTarget.orderNo" @reviewed="load()" />
<AppDialog class="admin-density-dialog" v-model="transferOpen" :title="`转仓 · ${selectedRows.length} 票订单`" fullscreenable confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer">
<LoadErrorState v-if="transferWarehouseError" title="FBA 仓候选加载失败" :message="transferWarehouseError.message" compact @retry="loadTransferWarehouseOptions"><template #details><ApiErrorMeta :details="transferWarehouseError" /></template></LoadErrorState>
<section v-else class="density-dialog-section" data-tone="work" aria-label="转仓登记"><h3 class="density-dialog-section__title">转仓登记</h3>
<el-form class="density-form" label-position="left" label-width="110px" :disabled="actionLoading"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="目的 FBA 仓" required><el-select v-model="transferForm.warehouseId" filterable clearable placeholder="请选择目的 FBA 仓" @update:model-value="selectTransferWarehouse"><el-option v-for="warehouse in transferWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" /></el-form-item></td></tr>
</tbody></table></el-form>
<el-alert v-if="!transferWarehouseLoading && !transferWarehouseOptions.length" title="暂无可用 FBA 仓候选" type="info" :closable="false" show-icon />
</section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="containerConvertOpen" :title="`按柜生成入库计划预览${containerPreview?.containerNo ? ' · ' + containerPreview.containerNo : ''}`" width="min(1240px, calc(100vw - 80px))" fullscreenable :confirm-text="containerConfirmText" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="work" aria-label="选择待分配柜"><h3 class="density-dialog-section__title">选择待分配柜 <InlineHelp title="按柜生成说明">先读取服务端全柜预览,确认分组、托数和阻断原因;确认后整柜只提交一次生成命令。</InlineHelp></h3>
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()"><template #details><ApiErrorMeta :details="containerLoadError" /></template></LoadErrorState>
<el-form v-else class="density-form" label-position="left" label-width="110px" :disabled="actionLoading"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="待分配柜" required><el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" :disabled="actionLoading" placeholder="输入柜号搜索并选择" @change="loadContainerPreview"><el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票`" /></el-select></el-form-item></td></tr></tbody></table></el-form>
</section>
<el-alert v-if="containerPreviewError" type="error" :closable="false" show-icon :title="containerPreviewError" />
<div v-if="containerPreview" class="container-preview density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="全柜预览汇总"><h3 class="density-dialog-section__title">全柜预览</h3>
<table class="density-table last-mile-dialog-facts"><tbody>
<tr><th scope="row">柜号</th><td><strong class="container-preview__identity" :title="containerPreview.containerNo || '未录入货柜号'">{{ containerPreview.containerNo || '未录入货柜号' }}</strong></td><th scope="row">海外接柜仓</th><td>{{ [containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓' }}</td></tr>
<template v-if="!containerIsResourceRecovery"><tr><th scope="row">头程已有托盘码</th><td>{{ containerPreview.sourcePalletCount }}</td><th scope="row">海外仓补充托盘码</th><td>{{ containerPreview.supplementPalletCount }}</td></tr><tr><th scope="row">最终生成托盘</th><td>{{ containerPreview.pendingPalletCount }}</td><th scope="row">本次处理订单</th><td>{{ containerPreview.pendingOrderCount }}</td></tr></template>
<tr v-else><th scope="row">已保存计划托盘</th><td>{{ containerPreview.totalPlannedPalletCount }}</td><th scope="row">资源确认</th><td>{{ containerConversionResult?.resourceRecovered ? '已确认' : '待确认' }}</td></tr>
</tbody></table>
</section>
<el-alert v-if="containerPreview.blockedOrderCount > 0" type="error" :closable="false" show-icon title="存在阻断订单,暂不可确认生成;请按下方原因处理后重新预览。" />
<el-alert v-else-if="containerPreview.resourceRetry && !containerConversionResult" type="warning" :closable="false" show-icon title="本柜计划和托盘已保存,资源仍待确认。请重试原操作完成资源确认。" />
<el-alert v-else-if="!containerConversionResult" type="success" :closable="false" show-icon :title="`预览已就绪:头程已有 ${containerPreview.sourcePalletCount} 个托盘码,海外仓补充 ${containerPreview.supplementPalletCount} 个,最终生成 ${containerPreview.pendingPalletCount} 个托盘。`" />
<section v-if="containerConversionResult" class="density-dialog-section container-conversion-result" data-tone="summary" aria-label="生成结果">
<h3 class="density-dialog-section__title">{{ containerConversionResult.resourceRecovered ? '资源确认完成' : '生成完成' }}</h3>
<p class="density-dialog-note"><span v-if="containerConversionResult.resourceRecovered">已确认 {{ containerConversionResult.plans.length }} 个已有计划</span><span v-else>新建 {{ containerConversionResult.newPlanCount }} 个 / 已有 {{ containerConversionResult.existingPlanCount }} 个</span></p>
<table class="density-table"><thead><tr><th>计划号</th><th>状态</th><th>托盘码</th></tr></thead><tbody class="container-conversion-result__plans"><tr v-for="plan in containerConversionResult.plans" :key="plan.planNo" class="container-conversion-result__plan"><td>{{ plan.planNo }}</td><td><el-tag size="small" :type="containerConversionResult.existingPlanNos.has(plan.planNo) ? 'info' : 'success'">{{ containerConversionResult.existingPlanNos.has(plan.planNo) ? '已有计划' : '本次新建' }}</el-tag></td><td><template v-if="plan.pallets.length"><span v-for="(pallet, index) in plan.pallets" :key="pallet.id" class="container-conversion-result__pallet">{{ index ? '、' : '' }}{{ pallet.palletNo || '—' }}</span></template><span v-else>暂无托盘码</span></td></tr></tbody></table>
</section>
<section v-for="group in containerPreview.groups" :key="`${group.destinationType}-${group.destinationWarehouseId || group.destinationWarehouseCode || 'default'}`" class="density-dialog-section" data-tone="source" aria-label="目的地分组明细">
<h3 class="density-dialog-section__title">{{ previewGroupTitle(group) }}:{{ containerIsResourceRecovery ? `已有 ${group.plannedPalletCount} 托` : `最终 ${group.pendingPalletCount} 托` }}</h3>
<p class="density-dialog-note"><span v-if="containerIsResourceRecovery">已保存计划托数 {{ group.plannedPalletCount }}</span><span v-else>头程已有 {{ group.sourcePalletCount }} 托 / 海外仓补充 {{ group.supplementPalletCount }} 托 / 本次处理 {{ group.pendingOrderCount }} 票</span></p>
<el-table :data="group.orders" size="small" border>
<el-table-column label="入仓码" min-width="148" show-overflow-tooltip><template #default="{ row }">{{ row.inboundCode || '—' }}</template></el-table-column>
<el-table-column prop="businessOrderNo" label="订单号" min-width="150" show-overflow-tooltip />
<el-table-column label="客户" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ row.customerName || '—' }}</template></el-table-column>
<el-table-column label="业务类型" min-width="112" show-overflow-tooltip><template #default="{ row }">{{ orderTypeText(row.orderType) }}</template></el-table-column>
<el-table-column label="目的地" min-width="190" show-overflow-tooltip><template #default="{ row }">{{ destinationText(row) }}</template></el-table-column>
<el-table-column label="头程已有" width="94" align="right"><template #default="{ row }">{{ row.sourcePalletCount }}</template></el-table-column>
<el-table-column label="海外仓补充" width="102" align="right"><template #default="{ row }">{{ row.supplementPalletCount }}</template></el-table-column>
<el-table-column label="最终托数" width="94" align="right"><template #default="{ row }">{{ row.targetPalletCount }}</template></el-table-column>
<el-table-column label="已有计划托数" width="118" align="right"><template #default="{ row }">{{ row.plannedPalletCount ?? '—' }}</template></el-table-column>
<el-table-column label="可生成性" width="104"><template #default="{ row }"><el-tag :type="readinessType(row.readiness)" effect="light">{{ readinessText(row.readiness) }}</el-tag></template></el-table-column>
<el-table-column label="阻断原因" min-width="210" show-overflow-tooltip><template #default="{ row }">{{ blockerText(row.blockers) }}</template></el-table-column>
</el-table>
</section>
</div>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="containerDetailOpen" :title="`柜子详情 · ${containerDetail?.summary.containerNo || '加载中'}`" width="min(1120px, calc(100vw - 40px))" :loading="containerDetailLoading" :show-footer="false">
<LoadErrorState v-if="containerDetailError" title="柜级详情加载失败" :message="containerDetailError.message" compact>
<template #details><ApiErrorMeta :details="containerDetailError" /></template>
</LoadErrorState>
<template v-else-if="containerDetail">
<div class="container-detail-summary">
<span>柜号:{{ containerDetail.summary.containerNo || '—' }}</span>
<span :title="`海外接柜仓:${receivingWarehouseLabel(containerDetail.summary)}`">海外接柜仓:{{ receivingWarehouseLabel(containerDetail.summary) }}</span>
<span>计划到仓:{{ formatDateTime(containerDetail.summary.plannedArrivalAt) }}</span>
<span>订单:{{ containerDetail.summary.totalOrderCount }}</span>
<span>入仓码:{{ containerDetail.summary.inboundCodeCount }}</span>
<span>入库计划:{{ containerDetail.summary.planCount }},待生成 {{ pendingInboundCodeCount(containerDetail.summary) }}</span>
<span v-if="!containerDetail.summary.canChangeReceivingWarehouse">修改状态:{{ containerDetail.summary.receivingWarehouseLockReason || '当前已锁定' }}</span>
</div>
<DataTable :data="containerDetail.orders" :columns="containerDetailColumns" :loading="containerDetailLoading" preference-key="overseas-last-mile-container-detail" row-key="orderId" :total="undefined" empty-text="该柜暂无尾程订单">
<template #orderStatus="{ row }">{{ lastMileStatusLabel((row as ContainerPlanProgressDetailDto['orders'][number]).orderStatus) }}</template>
<template #planStatus="{ row }">{{ inboundPlanStatusLabel((row as ContainerPlanProgressDetailDto['orders'][number]).planStatus) }}</template>
<template #plannedArrivalAt="{ row }">{{ formatDateTime((row as ContainerPlanProgressDetailDto['orders'][number]).plannedArrivalAt) }}</template>
</DataTable>
</template>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="receivingWarehouseOpen" title="修改海外接柜仓" width="min(600px, calc(100vw - 28px))" confirm-text="确认修改" :loading="receivingWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!selectedReceivingWarehouse || Boolean(receivingWarehouseError)" @confirm="submitReceivingWarehouseChange">
<LoadErrorState v-if="receivingWarehouseError" title="海外接柜仓候选加载失败" :message="receivingWarehouseError.message" compact @retry="loadReceivingWarehouseOptions">
<template #details><ApiErrorMeta :details="receivingWarehouseError" /></template>
</LoadErrorState>
<el-form class="density-form" v-else label-position="top">
<el-form-item label="当前海外接柜仓"><el-input :model-value="receivingWarehouseLabel(receivingWarehouseTarget)" readonly /></el-form-item>
<el-form-item label="新的海外接柜仓" required>
<el-select v-model="receivingWarehouseForm.warehouseId" filterable clearable placeholder="请选择 ACTIVE 海外接柜仓">
<el-option v-for="warehouse in receivingWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" :disabled="warehouse.warehouseId === receivingWarehouseTarget?.overseasWarehouseId" />
</el-select>
</el-form-item>
<el-alert v-if="receivingWarehouseForm.warehouseId === receivingWarehouseTarget?.overseasWarehouseId" title="新的海外接柜仓必须与当前仓不同" type="warning" :closable="false" show-icon />
<el-alert v-if="!receivingWarehouseLoading && !receivingWarehouseOptions.length" title="暂无可用的 ACTIVE 海外接柜仓" type="info" :closable="false" show-icon />
<el-form-item label="备注"><el-input v-model="receivingWarehouseForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
<LoadErrorState v-if="receivingWarehouseActionError" title="海外接柜仓修改失败" :message="receivingWarehouseActionError.message" compact>
<template #details><ApiErrorMeta :details="receivingWarehouseActionError" /></template>
</LoadErrorState>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="inboundPlanOpen" :title="`入库计划 · ${actionTarget?.orderNo || '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
<el-alert v-if="inboundPlanPreviewError" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
<el-form class="command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">入库来源 <InlineHelp title="入库来源说明">取整柜预计到仓时间,同柜所有入库计划保持一致;已生成计划后锁定。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</InlineHelp></h3>
<table class="density-table last-mile-dialog-facts"><tbody>
<tr><th scope="row">预计到仓时间</th><td>{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></td></tr>
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td></tr><tr><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划登记</h3>
<table class="density-table density-table--editable last-mile-command-table"><tbody>
<tr><td><el-form-item label="托数" required><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
</tbody></table>
</section>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="holdOpen" :title="`${holdForm.held ? '暂扣订单' : '解除暂扣'} · ${actionTarget?.orderNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
<el-alert :title="holdForm.held ? '暂扣后不可创建出库计划;解除暂扣后恢复出库计划操作。' : '解除暂扣后恢复该订单的出库计划操作。'" type="warning" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work" aria-label="暂扣变更登记"><h3 class="density-dialog-section__title">变更登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="actionLoading"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="暂扣状态" required><el-radio-group v-model="holdForm.held"><el-radio :value="true">暂扣</el-radio><el-radio :value="false">解除暂扣</el-radio></el-radio-group></el-form-item></td></tr>
<tr><td><el-form-item label="是否计费" required><el-radio-group v-model="holdForm.chargeable"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item></td></tr>
<tr><td><el-form-item label="变更原因" required><el-input v-model="holdForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></td></tr>
</tbody></table></el-form></section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="manualNodesOpen" :title="`修改节点 · ${actionTarget?.orderNo || '—'}`" width="min(700px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :loading="manualNodesLoading" :confirm-loading="actionLoading" @confirm="submitManualNodes">
<el-alert title="保存后该订单只显示人工节点,不再跟随柜级节点;请至少填写一个节点时间。" type="warning" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work" aria-label="人工节点登记"><h3 class="density-dialog-section__title">人工节点</h3><el-form class="density-form" :disabled="actionLoading"><table class="density-table density-table--editable manual-nodes-editor"><thead><tr><th>节点</th><th>节点时间</th></tr></thead><tbody><tr v-for="node in manualNodeDefinitions" :key="node.code"><th scope="row">{{ node.label }}</th><td><el-date-picker v-model="manualNodeValues[node.code]" :aria-label="`${node.label}时间`" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="年/月/日 --:--" /></td></tr></tbody></table></el-form></section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="reworkOpen" :title="`换标退回重做 · ${reworkForm.relabelNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="确认退回重做" :confirm-loading="reworkSaving" :confirm-disabled="!reworkForm.relabelNo || !reworkForm.remark.trim()" @confirm="submitReworkRelabel">
<section class="density-dialog-section" data-tone="work" aria-label="换标重做登记"><h3 class="density-dialog-section__title">退回重做</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="reworkSaving"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="换标单" required><el-select v-model="reworkForm.relabelNo" :disabled="reworkSaving" placeholder="请选择需要退回的换标单"><el-option v-for="application in reworkOptions" :key="application.relabelNo" :label="application.relabelNo" :value="application.relabelNo" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="退回原因" required><el-input v-model="reworkForm.remark" :disabled="reworkSaving" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="填写退回原因,仓库将重新执行换标并上传照片" /></el-form-item></td></tr>
</tbody></table></el-form></section>
</AppDialog>
</template>
<style scoped>
.last-mile-dialog-facts, .last-mile-command-table, .manual-nodes-editor { table-layout: fixed; }
.last-mile-dialog-facts th { width: var(--admin-label-width); }
.last-mile-command-table td:not([colspan]) { width: 50%; }
.manual-nodes-editor th { width: 180px; }
.relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; }
.last-mile-toolbar { min-width: 0; }
.row-actions { white-space: nowrap; }
.text-ellipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin-bottom: var(--admin-space-5); padding: var(--admin-space-5) var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--cc-surface); font-size: var(--admin-font-body); }
.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.container-workbench { padding: var(--admin-space-5); }
.container-workbench__header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
.container-workbench__header > div { display: grid; gap: var(--admin-space-1); }
.container-workbench__header strong { color: var(--cc-text); font-size: var(--admin-font-section); }
.container-workbench__header span { color: var(--cc-muted); font-size: var(--admin-font-body); }
.container-workbench__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-5); }
.container-workbench-card { display: grid; min-width: 0; gap: var(--admin-space-5); padding: var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: #fff; color: inherit; font: inherit; text-align: left; transition: border-color .16s ease, box-shadow .16s ease; }
.container-workbench-card:hover { border-color: var(--cc-primary); box-shadow: none; }
.container-workbench-card__top { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); }
.container-workbench-card__top strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: var(--admin-font-section); text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__top > span { flex: 0 0 auto; color: var(--cc-primary); font-size: var(--admin-font-body); }
.container-workbench-card__arrival { display: grid; gap: var(--admin-space-1); }
.container-workbench-card__arrival small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.container-workbench-card__arrival strong { color: var(--cc-text); font-size: var(--admin-font-section); }
.container-workbench-card__warehouse { display: grid; gap: var(--admin-space-1); }
.container-workbench-card__warehouse small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: var(--admin-font-section); text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__warehouse .is-locked { min-width: 0; overflow: hidden; color: #c2410c; text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__pending { display: flex; align-items: baseline; gap: var(--admin-space-3); padding: var(--admin-space-5) var(--admin-space-5); border-left: 3px solid #f59e0b; background: #fffaf0; }
.container-workbench-card__pending strong { color: #b45309; font-size: var(--admin-font-title); line-height: 1; }
.container-workbench-card__pending span { color: #92400e; font-size: var(--admin-font-body); }
.container-workbench-card__facts { display: flex; flex-wrap: wrap; gap: var(--admin-space-2) var(--admin-space-5); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.container-workbench-card__facts .is-warning { color: #c2410c; }
.container-workbench-card__actions { display: flex; justify-content: flex-end; padding-top: 2px; white-space: nowrap; }
.container-workbench__pagination { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: var(--admin-font-body); }
@media (max-width: 1380px) { .container-workbench__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.package-difference--nonzero { color: var(--el-color-danger); font-weight: 600; }
</style>