Files
client-web/src/modules/first-leg/views/FirstLegOrdersView.vue
T
2026-09-19 17:02:11 +08:00

2732 lines
178 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 { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance, type FormItemRule, type FormRules, type UploadRequestOptions } from 'element-plus'
import { AlertTriangle, ChevronDown, ChevronUp, Download, FilePlus2, FileText, GripVertical, ImagePlus, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Send, SlidersHorizontal, Trash2, Upload } from '@lucide/vue'
import { FIRST_LEG_ORDER_STATUSES, type FirstLegOrderStatus } from '@chongchong/shared'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import type { TableAction } from '@/components/common/tableActions'
import { ApiError } from '@/api/http'
import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue'
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
import InboundCodeAssociationDialog from '@/modules/first-leg/components/InboundCodeAssociationDialog.vue'
import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue'
import CustomsDocumentsDialog from '@/modules/first-leg/components/CustomsDocumentsDialog.vue'
import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling'
import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '@/modules/first-leg/api/customs'
import {
estimateMerchantFirstLegPrice,
createIdempotencyKey,
createMerchantOrderAddress,
disableMerchantOrderAddress,
downloadMerchantFirstLegBatchImportErrorReport,
downloadMerchantFirstLegBatchImportTemplate,
downloadMerchantOrderCartonLabelsBatch,
downloadMerchantProductImage,
getMerchantInboundCodePolicy,
getMerchantOrderAddresses,
getMerchantPricingChannels,
importMerchantFirstLegOrders,
submitMerchantFirstLegOrder,
updateMerchantOrderAddress,
uploadMerchantProductImage,
type CustomerOrderAddress,
type CustomerOrderAddressSaveRequest,
type MerchantBatchImportMode,
type MerchantBatchImportResponse,
type MerchantPriceEstimate,
type MerchantPriceEstimateRequest,
type MerchantPricingChannel,
type InboundCodePolicy,
} from '@/modules/first-leg/api/firstLeg'
import { useFirstLegOrdersStore } from '@/modules/first-leg/store/firstLegOrders'
import { useTenantAdministrationStore } from '@/modules/tenant-management/public'
import { useAuthStore } from '@/modules/auth/public'
import { useUnsavedWorkRegistration } from '@/navigation/workspace-tabs/unsavedWork'
import { useWorkspaceTabsStore } from '@/navigation/workspace-tabs/workspaceTabs'
import type { FirstLegPartyRole, FirstLegPartySnapshot } from '@/modules/first-leg/data/firstLegParties'
import type { FirstLegCargoItem, FirstLegOrder, FirstLegOrderDraft } from '@/modules/first-leg/types/firstLegOrder'
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
import {
canClientEditFirstLegOrder,
createEmptyCargoItem,
getFirstLegStatusLabel,
getFirstLegStatusTone,
getFirstLegTransportModeLabel,
summarizeFirstLegCargo,
validateFirstLegOrderDraft,
} from '@/modules/first-leg/utils/firstLegOrder'
import { validateProductImageFile } from '@/modules/first-leg/utils/productImage'
import { createPriceEstimateBreakdown } from '@/modules/first-leg/utils/priceEstimate'
const store = useFirstLegOrdersStore()
const tenantStore = useTenantAdministrationStore()
const workspace = useWorkspaceTabsStore()
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const props = withDefaults(defineProps<{ createPage?: boolean; detailPage?: boolean; showCreateAction?: boolean }>(), { createPage: false, detailPage: false, showCreateAction: true })
const isCreatePage = computed(() => props.createPage)
const isDetailPage = computed(() => props.detailPage)
const routeDraftId = computed(() => typeof route.params.draftId === 'string' ? route.params.draftId : '')
const routeDetailId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const canViewCompanyProfile = computed(() => auth.hasPermission('company:profile:view'))
const companyAuditLoaded = ref(false)
const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE')
const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create'))
const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit'))
const canSubmitOrder = computed(() => auth.hasPermission('order:first-leg:submit'))
const canConfirmInboundFee = computed(() => auth.hasPermission('order:first-leg:edit'))
const availableWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC'))
const availableDestinationWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === form.destinationType))
const filters = ref<QueryModel>({ receivingWarehouseId: '', channelName: '', cargoKeyword: '', createdRange: [], status: '', destinationType: '', trackingNumber: '' })
const appliedFilters = ref<QueryModel>({ ...filters.value })
const queryFields = computed<QueryField[]>(() => [
{ key: 'cargoKeyword', label: '入仓码 / SKU', type: 'input', placeholder: '请输入入仓码或 SKU', props: { 'aria-label': '入仓码或 SKU' } },
{ key: 'receivingWarehouseId', label: '收货仓', type: 'select', placeholder: '全部', options: availableWarehouses.value.map((item) => ({ label: item.name, value: item.id })), props: { 'aria-label': '收货仓' } },
{ key: 'channelName', label: '渠道名称', type: 'select', placeholder: '全部', options: [...new Set(store.orders.map((item) => item.channelName).filter(Boolean))].map((name) => ({ label: name, value: name })), props: { 'aria-label': '渠道名称' } },
{ key: 'createdRange', label: '创建时间', type: 'daterange', placeholder: '请选择创建时间', props: { 'aria-label': '创建时间' } },
{ key: 'status', label: '当前状态', type: 'select', placeholder: '全部', options: FIRST_LEG_ORDER_STATUSES.map((item) => ({ label: item.label, value: item.code })), props: { 'aria-label': '当前状态' } },
{ key: 'destinationType', label: '目的仓类型', type: 'select', placeholder: '全部', options: [{ label: 'FBA 仓', value: 'FBA' }, { label: '海外仓', value: 'OVERSEAS' }, { label: '私人地址', value: 'PRIVATE' }], props: { 'aria-label': '目的仓类型' } },
{ key: 'trackingNumber', label: '快递单号', type: 'input', placeholder: '请输入快递单号', props: { 'aria-label': '快递单号' } },
])
const channelOptions = ref<MerchantPricingChannel[]>([])
const inboundCodePolicy = ref<InboundCodePolicy | null>(null)
const manualInboundCodeRequired = computed(() => inboundCodePolicy.value?.mode === 'LEGACY_MANUAL')
const channelLoading = ref(false)
const channelError = ref('')
const priceEstimate = ref<MerchantPriceEstimate | null>(null)
const priceEstimateLoading = ref(false)
const priceEstimateError = ref('')
const selectedCargoIds = ref<string[]>([])
const batchImportInput = ref<HTMLInputElement>()
const batchImportOpen = ref(false)
const batchImportMode = ref<MerchantBatchImportMode>('PARTIAL')
const batchImportFile = ref<File | null>(null)
const batchImportIdempotencyKey = ref('')
const batchImportSubmitting = ref(false)
const batchTemplateDownloading = ref(false)
const batchErrorReportDownloading = ref(false)
const batchImportResult = ref<MerchantBatchImportResponse | null>(null)
const batchImportError = ref('')
const fixedOrderColumns: DataTableColumn[] = [
{ key: 'selection', type: 'selection', width: 48, fixed: 'left', reserveSelection: true },
{ key: 'actions', label: '操作', width: 284, fixed: 'left', align: 'left', headerAlign: 'left', slotName: 'actions' },
{ key: 'inboundCode', label: '入仓码', width: 230, fixed: 'left', align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'inboundCode' },
]
const selectableOrderColumns: DataTableColumn[] = [
{ key: 'status', label: '当前订单状态', width: 126, align: 'center', headerAlign: 'center', slotName: 'status' },
{ key: 'channel', label: '渠道', width: 150, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'channel' },
{ key: 'transportMode', label: '运输方式', width: 104, align: 'center', headerAlign: 'center', slotName: 'transportMode' },
{ key: 'pickupMethod', label: '交货方式', width: 112, align: 'center', headerAlign: 'center', slotName: 'pickupMethod' },
{ key: 'requestedPickupAt', label: '预约提货时间', width: 164, align: 'center', headerAlign: 'center', slotName: 'requestedPickupAt' },
{ key: 'receivingWarehouse', label: '国内收货仓', width: 150, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'receivingWarehouse' },
{ key: 'destinationWarehouse', label: '目的仓', width: 120, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'destinationWarehouse' },
{ key: 'destinationType', label: '目的仓类型', width: 112, align: 'center', headerAlign: 'center', slotName: 'destinationType' },
{ key: 'packageCount', label: '箱数', width: 82, align: 'center', headerAlign: 'center', slotName: 'packageCount' },
{ key: 'expressCompany', label: '快递公司', width: 128, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'expressCompany' },
{ key: 'trackingNumber', label: '快递单号', width: 158, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'trackingNumber' },
{ key: 'skus', label: 'SKUS', width: 160, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'skus' },
{ key: 'grossWeight', label: '总毛重', width: 108, align: 'right', headerAlign: 'right', slotName: 'grossWeight' },
{ key: 'netWeight', label: '总净重', width: 108, align: 'right', headerAlign: 'right', slotName: 'netWeight' },
{ key: 'volume', label: '总体积', width: 106, align: 'right', headerAlign: 'right', slotName: 'volume' },
{ key: 'pieces', label: '总数量', width: 92, align: 'right', headerAlign: 'right', slotName: 'pieces' },
{ key: 'billingUnitPrice', label: '计费单价', width: 108, align: 'right', headerAlign: 'right', slotName: 'billingUnitPrice' },
{ key: 'chargeableVolume', label: '计费体积', width: 108, align: 'right', headerAlign: 'right', slotName: 'chargeableVolume' },
{ key: 'chargeableWeight', label: '计费重量', width: 108, align: 'right', headerAlign: 'right', slotName: 'chargeableWeight' },
{ key: 'remark', label: '备注', width: 160, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'remark' },
{ key: 'shipmentId', label: '货件编号', width: 150, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'shipmentId' },
{ key: 'referenceId', label: '跟踪编号', width: 150, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'referenceId' },
{ key: 'receivingTime', label: '国内仓入库时间', width: 164, align: 'center', headerAlign: 'center', slotName: 'receivingTime' },
{ key: 'expectedShipDate', label: '期望出运', width: 132, align: 'center', headerAlign: 'center', slotName: 'expectedShipDate' },
{ key: 'cabinetNo', label: '柜号 / 柜次', width: 150, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'cabinetNo' },
{ key: 'vesselVoyage', label: '船名 / 航次', width: 160, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'vesselVoyage' },
{ key: 'sailingAt', label: '预计船期', width: 164, align: 'center', headerAlign: 'center', slotName: 'sailingAt' },
{ key: 'createdAt', label: '创建时间', width: 164, align: 'center', headerAlign: 'center', slotName: 'createdAt' },
{ key: 'updatedAt', label: '更新时间', width: 164, align: 'center', headerAlign: 'center', slotName: 'updatedAt' },
]
const allSelectableColumnKeys = selectableOrderColumns.map((column) => String(column.key))
const defaultVisibleColumnKeys = ['status', 'channel', 'transportMode', 'pickupMethod', 'receivingWarehouse', 'createdAt']
const visibleColumnKeys = ref<string[]>([...defaultVisibleColumnKeys])
const orderedColumnKeys = ref<string[]>([...allSelectableColumnKeys])
const draggedColumnKey = ref('')
const orderedSelectableColumns = computed(() => orderedColumnKeys.value
.map((key) => selectableOrderColumns.find((column) => String(column.key) === key))
.filter((column): column is DataTableColumn => Boolean(column)))
const visibleOrderColumns = computed(() => [
...fixedOrderColumns,
...orderedSelectableColumns.value.filter((column) => visibleColumnKeys.value.includes(String(column.key))),
])
function moveOrderColumn(key: string, offset: -1 | 1) {
const index = orderedColumnKeys.value.indexOf(key)
const targetIndex = index + offset
if (index < 0 || targetIndex < 0 || targetIndex >= orderedColumnKeys.value.length) return
const nextKeys = [...orderedColumnKeys.value]
;[nextKeys[index], nextKeys[targetIndex]] = [nextKeys[targetIndex], nextKeys[index]]
orderedColumnKeys.value = nextKeys
}
function dropOrderColumn(targetKey: string) {
const sourceKey = draggedColumnKey.value
draggedColumnKey.value = ''
if (!sourceKey || sourceKey === targetKey) return
const nextKeys = orderedColumnKeys.value.filter((key) => key !== sourceKey)
const targetIndex = nextKeys.indexOf(targetKey)
nextKeys.splice(targetIndex, 0, sourceKey)
orderedColumnKeys.value = nextKeys
}
function resetOrderColumns() {
orderedColumnKeys.value = [...allSelectableColumnKeys]
visibleColumnKeys.value = [...defaultVisibleColumnKeys]
}
const tablePage = ref(1)
const tablePageSize = ref(10)
const downloadingMarks = ref(false)
const exportingOrders = ref(false)
const batchSubmitting = ref(false)
const selectedOrders = ref<FirstLegOrder[]>([])
const selectedOrderCount = computed(() => selectedOrders.value.length)
const selectedDraftOrders = computed(() => selectedOrders.value.filter((order) => order.status === 'DRAFT'))
const orderTableVersion = ref(0)
type OrderQueue = 'all' | 'fee' | 'customs' | 'draft'
const activeOrderQueue = ref<OrderQueue>('all')
const editorOpen = ref(false)
const orderFormRef = ref<FormInstance>()
const editingId = ref<string>()
const openingEditor = ref(false)
const detailId = ref<string>()
const saving = ref(false)
const detailLoading = ref(false)
const detailError = ref('')
const detailCargoPage = ref(1)
const detailCargoPageSize = ref(5)
const inboundBilling = ref<MerchantInboundBilling | null>(null)
const inboundBillingLoading = ref(false)
const inboundBillingUnavailable = ref(false)
const inboundBillingUnavailableMessage = ref('还未测量推送')
const inboundBillingError = ref('')
const inboundBillingOrderId = ref('')
const feeConfirmationOpen = ref(false)
const customsByOrder = reactive<Record<string, MerchantCustomsContainer | null | undefined>>({})
const customsEligibilityPending = new Set<string>()
const customsDialogOpen = ref(false)
const customsDialogOrderId = ref('')
const inboundAssociationOpen = ref(false)
const inboundAssociationCode = ref('')
const supportingDataLoading = ref(false)
const supportingDataErrors = ref<string[]>([])
const destinationWarehousesLoading = ref(false)
const pageDataLoading = computed(() => supportingDataLoading.value || (!isCreatePage.value && store.loading))
const editorBaseline = ref('')
let cargoSequence = 0
let packingGroupSequence = 0
let inboundBillingRequestSequence = 0
let channelRequestSequence = 0
let estimateRequestSequence = 0
let estimateTimer: ReturnType<typeof setTimeout> | undefined
const orderAddresses = ref<CustomerOrderAddress[]>([])
const orderAddressesLoading = ref(false)
const pickupAddresses = computed(() => orderAddresses.value.filter((item) => item.addressType === 'PICKUP'))
const privateAddresses = computed(() => orderAddresses.value.filter((item) => item.addressType === 'PRIVATE_DESTINATION'))
function addressLabel(address: CustomerOrderAddress) {
return `${address.addressName} · ${address.contactName} · ${address.countryCode} ${address.city} ${address.addressLine}`
}
const orderFormRules: FormRules = {
transportMode: [{ required: true, message: '请选择运输方式', trigger: 'change' }],
businessType: [{ required: true, message: '请选择业务类型', trigger: 'change' }],
receivingWarehouseId: [{ required: true, message: '请选择平台收货仓', trigger: 'change' }],
destinationCountryCode: [
{ required: true, message: '请输入目的国代码', trigger: 'blur' },
{ pattern: /^[A-Za-z]{2}$/, message: '请输入两位英文字母,例如 US', trigger: 'blur' },
],
destinationWarehouseName: [{ required: true, message: '请输入目的仓名称', trigger: 'blur' }],
channelCode: [{ required: true, message: '请选择运输渠道', trigger: 'change' }],
channelName: [{ required: true, message: '请输入渠道名称', trigger: 'blur' }],
pricingTaxMode: [{ required: true, message: '请选择报价包税方式', trigger: 'change' }],
pickupMethod: [{ required: true, message: '请选择提货方式', trigger: 'change' }],
taxMode: [{ required: true, message: '请选择报关模式', trigger: 'change' }],
destinationType: [{ required: true, message: '请选择目的仓类型', trigger: 'change' }],
}
function createEmptyPartySnapshot(role: FirstLegPartyRole): FirstLegPartySnapshot {
return {
id: '',
code: '',
name: '',
role,
contactName: '',
phone: '',
email: '',
countryRegion: role === 'SHIPPER' ? 'CN' : 'US',
province: '',
city: '',
postalCode: '',
address: '',
}
}
function createPickupPartySnapshot(): FirstLegPartySnapshot {
return {
...createEmptyPartySnapshot('SHIPPER'),
name: tenantStore.company.companyName,
contactName: tenantStore.company.contactName,
phone: tenantStore.company.contactPhone,
email: tenantStore.company.contactEmail,
}
}
function requiredTextRules(message: string): FormItemRule[] {
return [{ required: true, message, trigger: 'blur' }]
}
function requiredSelectRules(message: string): FormItemRule[] {
return [{ required: true, message, trigger: 'change' }]
}
function positiveNumberRules(message: string, integer = false): FormItemRule[] {
return [{ type: integer ? 'integer' : 'number', min: integer ? 1 : 0.001, message, trigger: 'change' }]
}
function formatDateTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value || '-'
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
function formatExportDateTime(value: string | null | undefined) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const pad = (part: number) => String(part).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
const pickupMethodLabels: Record<FirstLegOrder['pickupMethod'], string> = {
SELF_EXPRESS: '自行快递到仓',
PICKUP: '平台上门提货',
SELF_TRUCK: '自行送货到仓',
}
const destinationTypeLabels: Record<FirstLegOrder['destinationType'], string> = {
FBA: 'FBA 仓',
OVERSEAS: '海外仓',
PRIVATE: '私人地址',
}
const taxModeLabels: Record<FirstLegOrder['taxMode'], string> = {
BUY_DECLARATION: '买单报关',
TAX_REFUND: '退税报关',
}
const packageTypeLabels: Readonly<Record<string, string>> = {
CARTON: '纸箱',
PALLET: '托盘',
WOODEN_CASE: '木箱',
BAG: '袋装',
OTHER: '其他',
}
function displayValue(value: string | null | undefined) {
return value?.trim() || '—'
}
function emptyDraft(): FirstLegOrderDraft {
return {
inboundCode: '',
referenceNo: '',
transportMode: 'FCL',
businessType: 'EXPORT',
fulfillmentType: 'STANDARD',
lxCode: '',
origin: '',
destination: '',
receivingWarehouseId: '',
expectedDepartureDate: '',
shipperPartyId: '',
senderAddressId: '',
shipperPartySnapshot: null,
shipper: '',
consigneePartyId: '',
privateDestinationAddressId: '',
consigneePartySnapshot: null,
consignee: '',
priority: 'NORMAL',
customerRemark: '',
tradeTerm: 'FOB',
destinationCountryCode: 'US',
destinationWarehouseId: '',
destinationWarehouseName: '',
pickupMethod: 'SELF_EXPRESS',
channelCode: '',
channelName: '',
pricingTaxMode: 'TAX_INCLUDED',
taxMode: 'BUY_DECLARATION',
customsInspection: null,
destinationType: 'FBA',
amazonWarehouseCode: '',
shipmentId: '',
items: [createEmptyCargoItem(`new-cargo-${++cargoSequence}`)],
}
}
function quickOrderDraft() {
const draft = emptyDraft()
if (!isCreatePage.value || route.query.quick !== '1') return draft
const destinationType = String(route.query.destinationType ?? '')
if (destinationType === 'FBA' || destinationType === 'OVERSEAS' || destinationType === 'PRIVATE') draft.destinationType = destinationType
draft.receivingWarehouseId = String(route.query.receivingWarehouseId ?? '')
draft.channelCode = String(route.query.channelCode ?? '')
draft.channelName = String(route.query.channelName ?? '')
draft.destinationWarehouseId = String(route.query.destinationWarehouseId ?? '')
draft.destinationWarehouseName = String(route.query.destinationWarehouseName ?? '')
draft.destinationCountryCode = String(route.query.destinationCountryCode ?? draft.destinationCountryCode).toUpperCase()
draft.amazonWarehouseCode = String(route.query.amazonWarehouseCode ?? '')
if (draft.destinationType === 'PRIVATE') {
try {
const consignee = JSON.parse(String(route.query.consignee ?? '{}')) as Record<string, unknown>
draft.consigneePartySnapshot = {
...createEmptyPartySnapshot('CONSIGNEE'),
name: draft.destinationWarehouseName,
countryRegion: draft.destinationCountryCode,
contactName: typeof consignee.contactName === 'string' ? consignee.contactName : '',
phone: typeof consignee.phone === 'string' ? consignee.phone : '',
city: typeof consignee.city === 'string' ? consignee.city : '',
address: typeof consignee.address === 'string' ? consignee.address : '',
postalCode: typeof consignee.postalCode === 'string' ? consignee.postalCode : '',
}
draft.consignee = draft.destinationWarehouseName
draft.destination = draft.destinationWarehouseName
} catch {
draft.consigneePartySnapshot = createEmptyPartySnapshot('CONSIGNEE')
}
} else {
draft.destination = draft.destinationWarehouseName
}
try {
const cargo = JSON.parse(String(route.query.cargo ?? '[]')) as Array<{ sku?: unknown; packageCount?: unknown }>
if (Array.isArray(cargo) && cargo.length) {
draft.items = cargo.map((item) => ({
...createEmptyCargoItem(`quick-cargo-${++cargoSequence}`),
sku: typeof item.sku === 'string' ? item.sku.slice(0, 64) : '',
packageCount: Number.isInteger(item.packageCount) && Number(item.packageCount) > 0 ? Number(item.packageCount) : 1,
}))
}
} catch {
// Keep the standard empty row when quick-order data is malformed.
}
return draft
}
const form = reactive<FirstLegOrderDraft>(quickOrderDraft())
const serializedDraft = computed(() => JSON.stringify(form))
const editorDirty = computed(() => (editorOpen.value || isCreatePage.value) && serializedDraft.value !== editorBaseline.value)
const woodenCargoLines = computed(() => form.items
.map((item, index) => item.woodenProduct ? index + 1 : null)
.filter((index): index is number => index != null))
const unsavedRouteKey = props.createPage ? 'first-leg-order-create' : 'first-leg-orders'
useUnsavedWorkRegistration({
key: `first-leg-order-editor:${unsavedRouteKey}`,
routeKey: unsavedRouteKey,
label: props.createPage ? '创建订单' : '订单编辑',
dirty: editorDirty,
})
function matchesAppliedFilters(order: FirstLegOrder) {
const statusValue = appliedFilters.value.status
const createdRangeValue = appliedFilters.value.createdRange
const textValue = (key: string) => typeof appliedFilters.value[key] === 'string' ? String(appliedFilters.value[key]).trim().toLowerCase() : ''
const receivingWarehouseId = textValue('receivingWarehouseId')
const channelName = textValue('channelName')
const cargoKeyword = textValue('cargoKeyword')
const destinationType = textValue('destinationType')
const trackingNumber = textValue('trackingNumber')
const status = typeof statusValue === 'string' ? statusValue as FirstLegOrderStatus | '' : ''
const createdRange = Array.isArray(createdRangeValue) ? createdRangeValue.filter((value): value is string => typeof value === 'string') : []
const matchesWarehouse = !receivingWarehouseId || order.receivingWarehouseId.toLowerCase() === receivingWarehouseId
const matchesChannel = !channelName || order.channelName.toLowerCase() === channelName
const matchesCargo = !cargoKeyword || [order.businessSummary.inboundCode, ...order.skuNos, ...order.items.map((item) => item.sku)]
.filter(Boolean).some((value) => String(value).toLowerCase().includes(cargoKeyword))
const matchesDestinationType = !destinationType || order.destinationType.toLowerCase() === destinationType
const matchesTrackingNumber = !trackingNumber || order.trackingNumber.toLowerCase().includes(trackingNumber)
const matchesStatus = !status || order.status === status
const matchesDate = createdRange.length !== 2 || (order.createdAt.slice(0, 10) >= createdRange[0] && order.createdAt.slice(0, 10) <= createdRange[1])
return matchesWarehouse && matchesChannel && matchesCargo && matchesDestinationType && matchesTrackingNumber && matchesStatus && matchesDate
}
const queryMatchedOrders = computed(() => store.orders.filter(matchesAppliedFilters))
const orderQueueCounts = computed(() => ({
all: queryMatchedOrders.value.length,
fee: queryMatchedOrders.value.filter((order) => order.businessSummary.customerFeeConfirmationRequired).length,
customs: queryMatchedOrders.value.filter((order) => Boolean(eligibleCustomsDetail(order))).length,
draft: queryMatchedOrders.value.filter((order) => order.status === 'DRAFT').length,
}))
const filteredOrders = computed(() => queryMatchedOrders.value.filter((order) => {
if (activeOrderQueue.value === 'fee') return order.businessSummary.customerFeeConfirmationRequired
if (activeOrderQueue.value === 'customs') return Boolean(eligibleCustomsDetail(order))
if (activeOrderQueue.value === 'draft') return order.status === 'DRAFT'
return true
}))
const pagedOrders = computed(() => {
const start = (tablePage.value - 1) * tablePageSize.value
return filteredOrders.value.slice(start, start + tablePageSize.value)
})
function selectOrderQueue(queue: OrderQueue) {
activeOrderQueue.value = queue
tablePage.value = 1
selectedOrders.value = []
orderTableVersion.value += 1
}
function eligibleCustomsDetail(order: FirstLegOrder) {
const value = customsByOrder[order.id]
if (order.status !== 'LOADED' || !value?.merchantStatus) return null
return value.inbounds.some((item) => item.orderId === order.id && item.taxMode === 'TAX_REFUND') ? value : null
}
async function loadCustomsEligibility(order: FirstLegOrder) {
if (order.status !== 'LOADED' || customsByOrder[order.id] !== undefined || customsEligibilityPending.has(order.id)) return
customsEligibilityPending.add(order.id)
try {
customsByOrder[order.id] = await getMerchantCustomsForOrder(order.id)
} catch (error) {
customsByOrder[order.id] = null
if (!isMerchantCustomsUnavailable(error)) {
// Keep the operation hidden when eligibility cannot be proven by the customer-scoped server response.
}
} finally {
customsEligibilityPending.delete(order.id)
}
}
watch(() => pagedOrders.value.map((order) => `${order.id}:${order.status}`).join('|'), () => {
pagedOrders.value.forEach((order) => void loadCustomsEligibility(order))
}, { immediate: true })
function openCustomsDocuments(order: FirstLegOrder) {
if (!eligibleCustomsDetail(order)) return
customsDialogOrderId.value = order.id
customsDialogOpen.value = true
}
function openInboundCodeAssociation(inboundCode: string) {
const code = inboundCode.trim()
if (!code) return
inboundAssociationCode.value = code
inboundAssociationOpen.value = true
}
function updateCustomsDetail(value: MerchantCustomsContainer) {
if (customsDialogOrderId.value) customsByOrder[customsDialogOrderId.value] = value
}
const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined)
const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null)
const pagedDetailCargoItems = computed(() => {
const items = detailOrder.value?.items ?? []
const start = (detailCargoPage.value - 1) * detailCargoPageSize.value
return items.slice(start, start + detailCargoPageSize.value)
})
const progressSummaryItems = computed(() => {
const order = detailOrder.value
if (!order) return []
const inbound = inboundBilling.value?.inbound.summary
const summary = order.businessSummary
const formatMetric = (value: number | null | undefined, unit = '') => value == null ? '待更新' : `${value.toLocaleString()}${unit ? ` ${unit}` : ''}`
const preloadStatus = ({ PENDING: '待确认', CONFIRMING: '确认中', CONFIRMED: '已确认' } as Record<string, string>)[summary.preloadStatus] ?? summary.preloadStatus
const transportReference = [summary.vesselName, summary.voyageNo].filter(Boolean).join(' · ')
const pickingProgress = summary.expectedPickCount == null
? '待更新'
: `${summary.pickedCount ?? 0} / ${summary.expectedPickCount}`
return [
{ label: '实际箱数', value: formatMetric(summary.totalPackageCount ?? inbound?.totalPackageCount, '箱') },
{ label: '实测毛重', value: formatMetric(summary.actualGrossWeightKg ?? inbound?.actualGrossWeightKg, 'kg') },
{ label: '实测体积', value: formatMetric(summary.actualVolumeCbm ?? inbound?.actualVolumeCbm, 'm³') },
{ label: '预装清单', value: preloadStatus ? `${preloadStatus}${summary.preloadItemCount == null ? '' : ` · ${summary.preloadItemCount} 项`}` : '待更新' },
{ label: '拣货进度', value: pickingProgress },
{ label: '关联柜次', value: summary.cabinetNo || summary.containerNo || '—' },
{ label: '船期摘要', value: transportReference || '待更新' },
{ label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') },
{ label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') },
]
})
const cabinetProgressItems = computed(() => {
const summary = detailOrder.value?.businessSummary
if (!summary || (!summary.cabinetNo && !summary.containerNo)) return []
return [{ cabinetId: summary.cabinetNo || summary.containerNo, cabinetNo: summary.cabinetNo || summary.containerNo, status: summary.loadingStatus || '待更新', vesselName: summary.vesselName, voyageNo: summary.voyageNo, sailingAt: summary.sailingAt }]
})
const selectedWarehouse = computed(() => availableWarehouses.value.find((item) => item.id === form.receivingWarehouseId))
const selectedDestinationWarehouse = computed(() => store.warehouses.find((item) => item.id === form.destinationWarehouseId && item.warehouseType === form.destinationType))
const selectedPricingChannel = computed(() => channelOptions.value.find((item) => item.channelCode === form.channelCode))
const availablePricingTaxModes = computed(() => selectedPricingChannel.value?.pricingTaxModes ?? [])
const hasActiveFilters = computed(() => {
const values = appliedFilters.value
return Object.values(values).some((value) => Array.isArray(value) ? value.length > 0 : Boolean(typeof value === 'string' ? value.trim() : value))
})
const emptyDescription = computed(() => hasActiveFilters.value ? '没有符合筛选条件的订单' : '还没有订单')
async function loadPageData() {
supportingDataLoading.value = true
supportingDataErrors.value = []
companyAuditLoaded.value = false
const companyRequest = canViewCompanyProfile.value
? tenantStore.loadCompanyContext()
: Promise.resolve()
const destinationRequest = isCreatePage.value && form.destinationType !== 'PRIVATE'
? loadDestinationWarehouses(form.destinationType)
: Promise.resolve()
const orderPageRequest = isCreatePage.value || isDetailPage.value
? store.loadWarehouseType('DOMESTIC', true)
: store.load({ includeDrafts: true })
const addressRequest = isCreatePage.value ? loadOrderAddresses() : Promise.resolve()
const [ordersResult, companyResult, destinationResult, policyResult, addressResult] = await Promise.allSettled([
orderPageRequest,
companyRequest,
destinationRequest,
getMerchantInboundCodePolicy(),
addressRequest,
])
if (policyResult.status === 'fulfilled') inboundCodePolicy.value = policyResult.value
else {
inboundCodePolicy.value = null
supportingDataErrors.value.push(`入仓码配置加载失败:${errorMessage(policyResult.reason)}`)
}
if (canViewCompanyProfile.value && companyResult.status === 'fulfilled') {
companyAuditLoaded.value = true
} else if (companyResult.status === 'rejected') {
supportingDataErrors.value.push(`企业资料加载失败:${errorMessage(companyResult.reason)}`)
}
if (!isCreatePage.value && ordersResult.status === 'rejected' && !store.loadError) supportingDataErrors.value.push(`订单加载失败:${errorMessage(ordersResult.reason)}`)
if (store.warehouseLoadError) supportingDataErrors.value.push(`公共仓库加载失败:${store.warehouseLoadError}`)
if (!isCreatePage.value && store.draftLoadError) supportingDataErrors.value.push(`订单草稿加载失败:${store.draftLoadError}`)
if (destinationResult.status === 'rejected') supportingDataErrors.value.push(`目的仓加载失败:${errorMessage(destinationResult.reason)}`)
if (addressResult.status === 'rejected') supportingDataErrors.value.push(`企业地址簿加载失败:${errorMessage(addressResult.reason)}`)
await autofillWarehouseSelections()
if (isCreatePage.value && routeDraftId.value) await loadRouteDraft(routeDraftId.value)
if (isDetailPage.value && routeDetailId.value) await loadDetailPage(routeDetailId.value)
supportingDataLoading.value = false
}
async function loadRouteDraft(id: string) {
const detailedOrder = await store.loadDraftDetail(id)
if (!canClientEditFirstLegOrder(detailedOrder.status)) throw new Error('当前订单已不是草稿状态,无法继续编辑')
const draft = draftFromOrder(detailedOrder)
if (draft.destinationType !== 'PRIVATE') await loadDestinationWarehouses(draft.destinationType)
editingId.value = detailedOrder.id
replaceForm(draft)
await Promise.all(form.items.map(ensureProductImagePreview))
if (form.receivingWarehouseId) await loadSelectedWarehouse(form.receivingWarehouseId)
resetEditorValidation()
markEditorClean()
}
async function loadDestinationWarehouses(type: 'FBA' | 'OVERSEAS' | 'PRIVATE') {
if (type === 'PRIVATE') return
destinationWarehousesLoading.value = true
try {
await store.loadWarehouseType(type)
} finally {
destinationWarehousesLoading.value = false
}
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : '服务暂时不可用'
}
function batchImportErrorMessage(error: unknown) {
if (!(error instanceof ApiError)) return errorMessage(error)
return [
error.message,
error.code ? `错误码 ${error.code}` : '',
error.requestId ? `requestId ${error.requestId}` : '',
error.traceId ? `traceId ${error.traceId}` : '',
].filter(Boolean).join(' · ')
}
onMounted(() => {
void loadPageData()
})
watch(() => auth.isAuthenticated, (authenticated, wasAuthenticated) => {
if (authenticated && !wasAuthenticated && !isCreatePage.value) void loadPageData()
})
watch(isCreatePage, (isCreate, wasCreate) => {
if (!isCreate || wasCreate) return
if (routeDraftId.value) return
editingId.value = undefined
replaceForm(quickOrderDraft())
resetEditorValidation()
// The list and create routes reuse this component, so onMounted does not run again.
if (wasCreate === false) void loadPageData()
}, { immediate: true })
watch(routeDetailId, (orderId, previousOrderId) => {
if (!isDetailPage.value || !orderId || orderId === previousOrderId) return
void loadDetailPage(orderId)
})
watch(feeConfirmationOpen, (open) => {
if (!open && !isDetailPage.value) resetInboundBillingState()
})
onBeforeUnmount(() => {
resetInboundBillingState()
channelRequestSequence += 1
estimateRequestSequence += 1
if (estimateTimer) clearTimeout(estimateTimer)
releaseProductImagePreviews(form.items)
if (detailOrder.value) releaseProductImagePreviews(detailOrder.value.items)
})
function markEditorClean() {
editorBaseline.value = serializedDraft.value
}
function resetEditorValidation() {
void nextTick(() => orderFormRef.value?.clearValidate())
}
function cloneDraft(draft: FirstLegOrderDraft): FirstLegOrderDraft {
return {
...draft,
shipperPartySnapshot: draft.shipperPartySnapshot ? { ...draft.shipperPartySnapshot } : null,
consigneePartySnapshot: draft.consigneePartySnapshot ? { ...draft.consigneePartySnapshot } : null,
items: draft.items.map((item) => ({ ...item })),
}
}
function replaceForm(draft: FirstLegOrderDraft) {
releaseProductImagePreviews(form.items)
Object.assign(form, cloneDraft(draft))
markEditorClean()
}
function releaseProductImagePreviews(items: FirstLegCargoItem[]) {
items.forEach((item) => {
if (item.productImagePreviewUrl.startsWith('blob:')) URL.revokeObjectURL(item.productImagePreviewUrl)
item.productImagePreviewUrl = ''
})
}
async function ensureProductImagePreview(item: FirstLegCargoItem) {
if (!item.productImageFileId || item.productImagePreviewUrl || item.productImageUploading) return
item.productImageUploading = true
item.productImageError = ''
try {
const blob = await downloadMerchantProductImage(item.productImageFileId)
item.productImagePreviewUrl = URL.createObjectURL(blob)
} catch (error) {
item.productImageError = error instanceof Error ? error.message : '产品图片加载失败'
} finally {
item.productImageUploading = false
}
}
async function uploadCargoProductImage(item: FirstLegCargoItem, options: UploadRequestOptions) {
const file = options.file
const validationError = validateProductImageFile(file)
if (validationError) {
item.productImageError = validationError
ElMessage.warning(validationError)
throw new Error(validationError)
}
item.productImageUploading = true
item.productImageError = ''
try {
const result = await uploadMerchantProductImage(file)
if (item.productImagePreviewUrl.startsWith('blob:')) URL.revokeObjectURL(item.productImagePreviewUrl)
item.productImageFileId = String(result.fileId)
item.productImageName = result.fileName
item.productImagePreviewUrl = URL.createObjectURL(file)
} catch (error) {
item.productImageError = error instanceof Error ? error.message : '产品图片上传失败'
ElMessage.error(item.productImageError)
throw error
} finally {
item.productImageUploading = false
}
}
function removeCargoProductImage(item: FirstLegCargoItem) {
if (item.productImagePreviewUrl.startsWith('blob:')) URL.revokeObjectURL(item.productImagePreviewUrl)
item.productImageFileId = ''
item.productImageName = ''
item.productImagePreviewUrl = ''
item.productImageError = ''
}
function normalizedAddressValue(value: string | null | undefined) {
return value?.trim().toLocaleLowerCase() ?? ''
}
function findAddressBookEntryId(addressType: CustomerOrderAddress['addressType'], snapshot: FirstLegPartySnapshot | null) {
if (!snapshot) return ''
const address = orderAddresses.value.find((item) => item.addressType === addressType
&& normalizedAddressValue(item.companyName || item.addressName) === normalizedAddressValue(snapshot.name)
&& normalizedAddressValue(item.contactName) === normalizedAddressValue(snapshot.contactName)
&& normalizedAddressValue(item.phone) === normalizedAddressValue(snapshot.phone)
&& normalizedAddressValue(item.email) === normalizedAddressValue(snapshot.email)
&& normalizedAddressValue(item.countryCode) === normalizedAddressValue(snapshot.countryRegion)
&& normalizedAddressValue(item.province) === normalizedAddressValue(snapshot.province)
&& normalizedAddressValue(item.city) === normalizedAddressValue(snapshot.city)
&& normalizedAddressValue(item.postalCode) === normalizedAddressValue(snapshot.postalCode)
&& normalizedAddressValue(item.addressLine) === normalizedAddressValue(snapshot.address))
return address == null ? '' : String(address.id)
}
function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
return {
inboundCode: order.businessSummary.inboundCode,
referenceNo: order.referenceNo,
transportMode: order.transportMode ?? 'FCL',
businessType: order.businessType,
fulfillmentType: order.fulfillmentType ?? 'STANDARD',
lxCode: order.lxCode ?? '',
origin: order.origin,
destination: order.destination,
receivingWarehouseId: order.receivingWarehouseId,
expectedDepartureDate: order.expectedDepartureDate,
shipperPartyId: order.shipperPartyId,
senderAddressId: findAddressBookEntryId('PICKUP', order.shipperPartySnapshot),
shipperPartySnapshot: order.shipperPartySnapshot ? { ...order.shipperPartySnapshot } : null,
shipper: order.shipper,
consigneePartyId: order.consigneePartyId,
privateDestinationAddressId: findAddressBookEntryId('PRIVATE_DESTINATION', order.consigneePartySnapshot),
consigneePartySnapshot: order.consigneePartySnapshot ? { ...order.consigneePartySnapshot } : null,
consignee: order.consignee,
priority: order.priority,
customerRemark: order.customerRemark,
tradeTerm: order.tradeTerm,
destinationCountryCode: order.destinationCountryCode,
destinationWarehouseId: order.destinationWarehouseId,
destinationWarehouseName: order.destinationWarehouseName,
pickupMethod: order.pickupMethod,
channelCode: order.channelCode,
channelName: order.channelName,
pricingTaxMode: order.pricingTaxMode,
taxMode: order.taxMode,
customsInspection: order.customsInspection,
destinationType: order.destinationType,
amazonWarehouseCode: order.amazonWarehouseCode,
shipmentId: order.shipmentId,
items: order.items.map((item) => ({
...item,
productImagePreviewUrl: '',
productImageUploading: false,
productImageError: '',
})),
}
}
function selectChannel(code: string) {
const channel = channelOptions.value.find((item) => item.channelCode === code)
form.channelName = channel?.channelName ?? ''
if (channel && !channel.pricingTaxModes.includes(form.pricingTaxMode)) {
form.pricingTaxMode = channel.pricingTaxModes[0] ?? 'TAX_INCLUDED'
}
}
function pricingTaxModeLabel(mode: 'TAX_INCLUDED' | 'TAX_EXCLUDED') {
return mode === 'TAX_INCLUDED' ? '包税' : '不包税'
}
function formatEstimateMoney(value: number, currency: string) {
try {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value)
} catch {
return `${currency} ${value.toFixed(2)}`
}
}
function formatEstimateQuantity(value: number, unit: string) {
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 4 })} ${unit}`
}
const priceEstimateDisclaimer = computed(() => priceEstimate.value?.disclaimer || '仅供参考,最终以实际结算为准')
const priceEstimateBreakdown = computed(() => {
if (!priceEstimate.value) return null
const summary = summarizeFirstLegCargo(form.items)
return createPriceEstimateBreakdown(summary.grossWeightKg, summary.volumeCbm, priceEstimate.value.chargeUnit)
})
function resetPriceEstimate() {
estimateRequestSequence += 1
priceEstimate.value = null
priceEstimateLoading.value = false
priceEstimateError.value = ''
if (estimateTimer) clearTimeout(estimateTimer)
estimateTimer = undefined
}
async function loadChannelsForWarehouse(warehouseId: string) {
const sequence = ++channelRequestSequence
const selectedCode = form.channelCode
channelOptions.value = []
channelError.value = ''
channelLoading.value = true
form.channelCode = ''
form.channelName = ''
resetPriceEstimate()
try {
const channels = await getMerchantPricingChannels(warehouseId)
if (sequence !== channelRequestSequence || form.receivingWarehouseId !== warehouseId) return
channelOptions.value = channels
const selected = channels.find((item) => item.channelCode === selectedCode)
if (selected) {
form.channelCode = selected.channelCode
form.channelName = selected.channelName
if (!selected.pricingTaxModes.includes(form.pricingTaxMode)) {
form.pricingTaxMode = selected.pricingTaxModes[0] ?? 'TAX_INCLUDED'
}
}
} catch (error) {
if (sequence !== channelRequestSequence || form.receivingWarehouseId !== warehouseId) return
channelError.value = errorMessage(error)
} finally {
if (sequence === channelRequestSequence) channelLoading.value = false
}
}
function createEstimateRequest(): MerchantPriceEstimateRequest | null {
if (!form.receivingWarehouseId || !form.channelCode || form.destinationType === 'PRIVATE') return null
if (!availablePricingTaxModes.value.includes(form.pricingTaxMode)) return null
const summary = summarizeFirstLegCargo(form.items)
if (summary.grossWeightKg <= 0 || summary.volumeCbm <= 0) return null
if (form.destinationType === 'FBA' && !form.amazonWarehouseCode) return null
if (form.destinationType === 'OVERSEAS' && !form.destinationWarehouseId) return null
return {
originWarehouseId: form.receivingWarehouseId,
channelCode: form.channelCode,
destinationType: form.destinationType,
destinationCode: form.destinationType === 'FBA' ? form.amazonWarehouseCode : undefined,
destinationWarehouseId: form.destinationType === 'OVERSEAS' ? form.destinationWarehouseId : undefined,
destinationName: form.destinationWarehouseName || undefined,
pricingTaxMode: form.pricingTaxMode,
actualWeightKg: summary.grossWeightKg,
actualVolumeCbm: summary.volumeCbm,
}
}
async function loadPriceEstimate() {
const request = createEstimateRequest()
const sequence = ++estimateRequestSequence
priceEstimate.value = null
priceEstimateError.value = ''
if (!request) {
priceEstimateLoading.value = false
return
}
priceEstimateLoading.value = true
try {
const estimate = await estimateMerchantFirstLegPrice(request)
if (sequence === estimateRequestSequence) priceEstimate.value = estimate
} catch (error) {
if (sequence === estimateRequestSequence) priceEstimateError.value = errorMessage(error)
} finally {
if (sequence === estimateRequestSequence) priceEstimateLoading.value = false
}
}
function schedulePriceEstimate() {
estimateRequestSequence += 1
priceEstimate.value = null
priceEstimateError.value = ''
priceEstimateLoading.value = false
if (estimateTimer) clearTimeout(estimateTimer)
estimateTimer = setTimeout(() => void loadPriceEstimate(), 300)
}
function retryPricingChannels() {
if (form.receivingWarehouseId) void loadChannelsForWarehouse(form.receivingWarehouseId)
}
watch(
() => JSON.stringify({
receivingWarehouseId: form.receivingWarehouseId,
channelCode: form.channelCode,
pricingTaxMode: form.pricingTaxMode,
destinationType: form.destinationType,
destinationWarehouseId: form.destinationWarehouseId,
amazonWarehouseCode: form.amazonWarehouseCode,
destinationWarehouseName: form.destinationWarehouseName,
items: form.items.map((item) => [item.grossWeightKg, item.volumeCbm]),
}),
schedulePriceEstimate,
)
async function selectDestinationWarehouse(id: string) {
const warehouse = availableDestinationWarehouses.value.find((item) => item.id === id)
if (!warehouse) return
form.destinationWarehouseId = warehouse.id
form.destinationWarehouseName = warehouse.name
form.destinationCountryCode = warehouse.countryCode.toUpperCase()
form.destination = warehouse.name
form.amazonWarehouseCode = form.destinationType === 'FBA' ? warehouse.code : ''
try {
const detail = await store.loadWarehouseDetail(warehouse.id)
form.destinationWarehouseName = detail.name
form.destinationCountryCode = detail.countryCode.toUpperCase()
form.destination = detail.name
form.amazonWarehouseCode = form.destinationType === 'FBA' ? detail.code : ''
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '目的仓详情加载失败')
}
}
async function changeDestinationType() {
form.destinationWarehouseId = ''
form.destinationWarehouseName = ''
form.destination = ''
form.amazonWarehouseCode = ''
form.privateDestinationAddressId = ''
form.consigneePartyId = ''
form.consigneePartySnapshot = form.destinationType === 'PRIVATE' ? createEmptyPartySnapshot('CONSIGNEE') : null
form.consignee = ''
try {
await loadDestinationWarehouses(form.destinationType)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '目的仓数据加载失败')
}
if (form.destinationType !== 'PRIVATE' && availableDestinationWarehouses.value.length === 1) {
await selectDestinationWarehouse(availableDestinationWarehouses.value[0].id)
}
resetEditorValidation()
}
function changePickupMethod() {
if (form.pickupMethod === 'PICKUP') {
form.shipperPartySnapshot ??= createPickupPartySnapshot()
} else {
form.senderAddressId = ''
form.shipperPartyId = ''
form.shipperPartySnapshot = null
form.shipper = ''
form.origin = ''
}
resetEditorValidation()
}
function syncPrivateDestinationName(value: string) {
if (form.destinationType !== 'PRIVATE') return
form.destinationWarehouseName = value
form.destination = value.trim()
if (form.consigneePartySnapshot) form.consigneePartySnapshot.name = value
}
function selectPrivateAddress(id: string | null | undefined) {
const address = privateAddresses.value.find((item) => String(item.id) === id)
if (!address) {
form.privateDestinationAddressId = ''
form.consigneePartyId = ''
form.consigneePartySnapshot = createEmptyPartySnapshot('CONSIGNEE')
form.consignee = ''
form.destination = ''
form.destinationCountryCode = ''
form.destinationWarehouseId = ''
form.destinationWarehouseName = ''
return
}
form.privateDestinationAddressId = String(address.id)
form.consigneePartyId = ''
form.consigneePartySnapshot = addressSnapshot(address, 'CONSIGNEE')
form.consignee = address.companyName || address.addressName
form.destination = address.addressName
form.destinationCountryCode = address.countryCode
form.destinationWarehouseId = ''
form.destinationWarehouseName = address.addressName
}
function addressSnapshot(address: CustomerOrderAddress, role: FirstLegPartyRole): FirstLegPartySnapshot {
return {
id: '',
code: '',
name: address.companyName || address.addressName,
role,
contactName: address.contactName,
phone: address.phone,
email: address.email ?? '',
countryRegion: address.countryCode,
province: address.province ?? '',
city: address.city,
postalCode: address.postalCode ?? '',
address: address.addressLine,
}
}
async function loadOrderAddresses() {
orderAddressesLoading.value = true
try {
orderAddresses.value = await getMerchantOrderAddresses()
} finally {
orderAddressesLoading.value = false
}
}
function selectPickupAddress(id: string | null | undefined) {
const address = pickupAddresses.value.find((item) => String(item.id) === id)
if (!address) {
form.senderAddressId = ''
form.shipperPartyId = ''
form.shipperPartySnapshot = createEmptyPartySnapshot('SHIPPER')
form.shipper = ''
form.origin = ''
return
}
form.senderAddressId = String(address.id)
form.shipperPartyId = ''
form.shipperPartySnapshot = addressSnapshot(address, 'SHIPPER')
form.shipper = address.companyName || address.addressName
form.origin = address.city
}
function addressPayload(addressType: CustomerOrderAddress['addressType'], snapshot: FirstLegPartySnapshot, addressName: string): CustomerOrderAddressSaveRequest {
const payload: CustomerOrderAddressSaveRequest = {
addressType,
addressName: addressName.trim(),
companyName: snapshot.name.trim() || addressName.trim(),
contactName: snapshot.contactName.trim(),
phone: snapshot.phone.trim(),
countryCode: snapshot.countryRegion.trim().toUpperCase(),
city: snapshot.city.trim(),
addressLine: snapshot.address.trim(),
defaultAddress: false,
}
if (snapshot.email.trim()) payload.email = snapshot.email.trim()
if (snapshot.province.trim()) payload.province = snapshot.province.trim()
if (snapshot.postalCode.trim()) payload.postalCode = snapshot.postalCode.trim()
return payload
}
function addressComplete(snapshot: FirstLegPartySnapshot | null, addressName: string) {
return Boolean(snapshot && addressName.trim() && snapshot.contactName.trim() && snapshot.phone.trim()
&& /^[A-Za-z]{2}$/.test(snapshot.countryRegion.trim()) && snapshot.city.trim() && snapshot.address.trim())
}
async function savePickupAddress() {
const snapshot = form.shipperPartySnapshot
const addressName = snapshot?.name.trim() || `${snapshot?.city.trim() || '国内'}提货地址`
if (!addressComplete(snapshot, addressName)) {
ElMessage.warning('请先完整填写提货地址和联系人资料')
return
}
const existingAddressId = findAddressBookEntryId('PICKUP', snapshot)
if (existingAddressId && existingAddressId !== form.senderAddressId) {
selectPickupAddress(existingAddressId)
ElMessage.info('相同的企业提货地址已存在,已为您选中')
return
}
try {
const payload = addressPayload('PICKUP', snapshot!, addressName)
if (form.senderAddressId) await updateMerchantOrderAddress(form.senderAddressId, payload)
else form.senderAddressId = String(await createMerchantOrderAddress(payload))
await loadOrderAddresses()
selectPickupAddress(form.senderAddressId)
ElMessage.success('企业提货地址已保存')
} catch (error) {
ElMessage.error(errorMessage(error))
}
}
async function savePrivateAddress() {
const snapshot = form.consigneePartySnapshot
const addressName = form.destinationWarehouseName.trim()
if (snapshot) snapshot.countryRegion = form.destinationCountryCode.trim().toUpperCase()
if (!addressComplete(snapshot, addressName)) {
ElMessage.warning('请先完整填写私人仓地址和收件资料')
return
}
snapshot!.name = addressName
const existingAddressId = findAddressBookEntryId('PRIVATE_DESTINATION', snapshot)
if (existingAddressId && existingAddressId !== form.privateDestinationAddressId) {
selectPrivateAddress(existingAddressId)
ElMessage.info('相同的企业私人仓地址已存在,已为您选中')
return
}
try {
const payload = addressPayload('PRIVATE_DESTINATION', snapshot!, addressName)
if (form.privateDestinationAddressId) await updateMerchantOrderAddress(form.privateDestinationAddressId, payload)
else form.privateDestinationAddressId = String(await createMerchantOrderAddress(payload))
await loadOrderAddresses()
selectPrivateAddress(form.privateDestinationAddressId)
ElMessage.success('企业私人仓地址已保存')
} catch (error) {
ElMessage.error(errorMessage(error))
}
}
async function removeOrderAddress(type: CustomerOrderAddress['addressType']) {
const id = type === 'PICKUP' ? form.senderAddressId : form.privateDestinationAddressId
if (!id) return
try {
await disableMerchantOrderAddress(id)
if (type === 'PICKUP') selectPickupAddress(null)
else selectPrivateAddress(null)
await loadOrderAddresses()
ElMessage.success('地址已从企业地址簿停用')
} catch (error) {
ElMessage.error(errorMessage(error))
}
}
async function loadSelectedWarehouse(id: string | number | boolean | undefined) {
const warehouseId = typeof id === 'string' ? id : typeof id === 'number' ? String(id) : ''
if (!warehouseId) {
channelRequestSequence += 1
channelOptions.value = []
channelError.value = ''
form.channelCode = ''
form.channelName = ''
resetPriceEstimate()
return
}
const requests = [loadChannelsForWarehouse(warehouseId)]
try {
requests.push(store.loadWarehouseDetail(warehouseId).then(() => undefined))
await Promise.all(requests)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '仓库地址与收货时间加载失败')
}
}
async function autofillWarehouseSelections() {
if (!isCreatePage.value || editingId.value) return
if (!form.receivingWarehouseId && availableWarehouses.value.length === 1) {
form.receivingWarehouseId = availableWarehouses.value[0].id
await loadSelectedWarehouse(form.receivingWarehouseId)
}
if (form.destinationType !== 'PRIVATE' && !form.destinationWarehouseId && availableDestinationWarehouses.value.length === 1) {
await selectDestinationWarehouse(availableDestinationWarehouses.value[0].id)
}
markEditorClean()
}
function getOrderRowActions(row: object): TableAction[] {
const order = row as FirstLegOrder
const actions: TableAction[] = [
{ key: 'detail', label: '详情', run: () => openDetail(order) },
]
if (canConfirmInboundFee.value && order.businessSummary.customerFeeConfirmationRequired) {
actions.push({ key: 'fee', label: '确认计费重', run: () => openFeeConfirmation(order) })
}
if (canEditOrder.value && canClientEditFirstLegOrder(order.status)) {
actions.push({ key: 'edit', label: '编辑', loading: openingEditor.value, run: () => openEdit(order) })
}
if (eligibleCustomsDetail(order)) {
actions.push({ key: 'customs', label: '报关资料', run: () => openCustomsDocuments(order) })
}
return actions
}
async function openCreate() {
if (availableWarehouses.value.length === 0) {
ElMessage.warning(supportingDataLoading.value
? '平台收货仓正在加载,请稍候再试'
: '当前没有可用的平台收货仓,请联系平台维护仓库后再创建订单')
return
}
editingId.value = undefined
replaceForm(emptyDraft())
resetEditorValidation()
await router.push({ name: 'first-leg-order-create' })
}
async function openEdit(order: FirstLegOrder) {
if (openingEditor.value || !canEditOrder.value || !canClientEditFirstLegOrder(order.status)) return
openingEditor.value = true
try {
const [detailedOrder] = await Promise.all([store.loadDetail(order.id), loadOrderAddresses()])
const draft = draftFromOrder(detailedOrder)
if (draft.destinationType !== 'PRIVATE') await loadDestinationWarehouses(draft.destinationType)
const warehouseAvailable = availableWarehouses.value.some((item) => item.id === detailedOrder.receivingWarehouseId)
if (!warehouseAvailable) {
draft.receivingWarehouseId = ''
draft.channelCode = ''
draft.channelName = ''
channelOptions.value = []
channelError.value = ''
resetPriceEstimate()
ElMessage.warning('原平台收货仓当前不可用,请重新选择可用收货仓')
}
if (draft.destinationType !== 'PRIVATE') {
const destinationAvailable = store.warehouses.some((item) => item.id === draft.destinationWarehouseId && item.warehouseType === draft.destinationType)
if (!destinationAvailable) {
draft.destinationWarehouseId = ''
draft.destinationWarehouseName = ''
draft.amazonWarehouseCode = ''
ElMessage.warning('原目的仓当前不可用,请重新选择平台维护的目的仓')
}
}
editingId.value = detailedOrder.id
replaceForm(draft)
await Promise.all(form.items.map(ensureProductImagePreview))
if (warehouseAvailable) void loadSelectedWarehouse(detailedOrder.receivingWarehouseId)
editorOpen.value = true
resetEditorValidation()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '订单详情加载失败,暂时无法编辑')
} finally {
openingEditor.value = false
}
}
async function openDetail(order: FirstLegOrder) {
await router.push({ name: 'first-leg-order-detail', params: { orderId: order.id } })
}
async function loadDetailPage(orderId: string) {
feeConfirmationOpen.value = false
detailId.value = orderId
detailCargoPage.value = 1
detailLoading.value = true
detailError.value = ''
try {
const detailedOrder = await store.loadDetail(orderId)
void loadInboundBilling(detailedOrder)
// Load previews from the persisted file IDs after the store has replaced the detail row.
await Promise.all((detailOrder.value?.items ?? detailedOrder.items).map(ensureProductImagePreview))
} catch (error) {
detailError.value = error instanceof Error ? error.message : '订单详情加载失败'
ElMessage.error(detailError.value)
} finally {
detailLoading.value = false
}
}
function openFeeConfirmation(order: FirstLegOrder) {
resetInboundBillingState()
inboundBillingUnavailableMessage.value = '未推送计费重'
feeConfirmationOpen.value = true
void loadInboundBilling(order, '未推送计费重')
}
async function retryDetail() {
if (detailId.value) await loadDetailPage(detailId.value)
}
function resetInboundBillingState() {
inboundBillingRequestSequence += 1
inboundBillingOrderId.value = ''
inboundBilling.value = null
inboundBillingLoading.value = false
inboundBillingUnavailable.value = false
inboundBillingUnavailableMessage.value = '还未测量推送'
inboundBillingError.value = ''
}
function isCurrentInboundBillingRequest(sequence: number, orderId: string) {
return sequence === inboundBillingRequestSequence
&& inboundBillingOrderId.value === orderId
&& (isDetailPage.value || feeConfirmationOpen.value)
}
async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '还未测量推送') {
const sequence = ++inboundBillingRequestSequence
inboundBillingOrderId.value = order.id
inboundBilling.value = null
inboundBillingLoading.value = false
inboundBillingUnavailable.value = false
inboundBillingUnavailableMessage.value = unavailableMessage
inboundBillingError.value = ''
if (order.status === 'DRAFT') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
? '未推送计费重'
: '订单提交后可查看入库结果'
return
}
inboundBillingLoading.value = true
try {
const result = await getMerchantInboundBilling(order.id)
if (!isCurrentInboundBillingRequest(sequence, order.id)) return
inboundBilling.value = result
} catch (error) {
if (!isCurrentInboundBillingRequest(sequence, order.id)) return
if (error instanceof ApiError && error.code === 'A0404') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
? '未推送计费重'
: error.message || unavailableMessage
} else {
inboundBillingError.value = error instanceof Error ? error.message : '入库与费用加载失败'
}
} finally {
if (isCurrentInboundBillingRequest(sequence, order.id)) inboundBillingLoading.value = false
}
}
async function retryInboundBilling() {
const order = inboundBillingOrderId.value ? store.getById(inboundBillingOrderId.value) : undefined
if (order) await loadInboundBilling(order, feeConfirmationOpen.value ? '未推送计费重' : '还未测量推送')
}
function handleInboundFeeConfirmed() {
if (!feeConfirmationOpen.value) void retryInboundBilling()
}
async function confirmDiscardChanges() {
try {
await ElMessageBox.confirm('当前订单还有未保存的修改。离开后这些内容将丢失。', '放弃未保存的修改?', {
type: 'warning',
confirmButtonText: '放弃修改',
cancelButtonText: '继续编辑',
})
return true
} catch (error) {
if (error === 'cancel' || error === 'close') return false
throw error
}
}
async function requestCloseEditor(done: () => void) {
if (saving.value) return
if (!editorDirty.value || await confirmDiscardChanges()) done()
}
function closeEditor() {
void requestCloseEditor(() => {
editorOpen.value = false
if (isCreatePage.value) {
markEditorClean()
void router.push({ name: 'first-leg-orders' })
}
})
}
function addCargoItem() {
form.items.push(createEmptyCargoItem(`new-cargo-${++cargoSequence}`))
resetEditorValidation()
}
async function changeFulfillmentType() {
if (form.fulfillmentType !== 'ONE_CLICK_DROPSHIP' || form.destinationType === 'OVERSEAS') return
form.destinationType = 'OVERSEAS'
await changeDestinationType()
}
const sharedPackingFields = [
'packageType', 'packageCount', 'lengthCm', 'widthCm', 'heightCm',
'grossWeightKg', 'netWeightKg', 'volumeCbm',
] as const
function packingGroupFor(item: FirstLegCargoItem) {
return item.packingGroupNo.trim()
}
function mixedSkuCount(item: FirstLegCargoItem) {
const group = packingGroupFor(item)
return group ? form.items.filter((candidate) => packingGroupFor(candidate) === group).length : 1
}
function isAdditionalMixedSku(item: FirstLegCargoItem, index: number) {
const group = packingGroupFor(item)
return Boolean(group && form.items.slice(0, index).some((candidate) => packingGroupFor(candidate) === group))
}
function syncSkuQuantity(item: FirstLegCargoItem) {
if (Number.isInteger(item.unitsPerPackage) && item.unitsPerPackage > 0
&& Number.isInteger(item.packageCount) && item.packageCount > 0) {
item.pieces = item.unitsPerPackage * item.packageCount
}
}
function syncPackingGroupFields(source: FirstLegCargoItem) {
if (form.fulfillmentType !== 'ONE_CLICK_DROPSHIP') return
syncSkuQuantity(source)
const group = packingGroupFor(source)
if (!group) return
form.items.forEach((item) => {
if (item === source || packingGroupFor(item) !== group) return
sharedPackingFields.forEach((field) => { item[field] = source[field] as never })
syncSkuQuantity(item)
})
}
function normalizeSingleSkuGroups() {
const counts = new Map<string, number>()
form.items.forEach((item) => {
const group = packingGroupFor(item)
if (group) counts.set(group, (counts.get(group) ?? 0) + 1)
})
form.items.forEach((item) => {
const group = packingGroupFor(item)
if (group && counts.get(group) === 1) item.packingGroupNo = ''
})
}
function addSkuToPackingGroup(index: number) {
const source = form.items[index]
if (!source) return
const group = packingGroupFor(source) || `PG-${Date.now().toString(36)}-${++packingGroupSequence}`
source.packingGroupNo = group
const added = createEmptyCargoItem(`new-cargo-${++cargoSequence}`)
added.packingGroupNo = group
sharedPackingFields.forEach((field) => { added[field] = source[field] as never })
syncSkuQuantity(added)
let insertAt = index + 1
while (insertAt < form.items.length && packingGroupFor(form.items[insertAt]!) === group) insertAt++
form.items.splice(insertAt, 0, added)
resetEditorValidation()
}
function removeCargoItem(index: number) {
if (form.items.length === 1) {
ElMessage.warning('订单至少需要保留一条货物明细')
return
}
const [removed] = form.items.splice(index, 1)
if (removed) releaseProductImagePreviews([removed])
normalizeSingleSkuGroups()
resetEditorValidation()
}
function displayListValue(value: string | number | null | undefined) {
return value === '' || value == null ? '—' : String(value)
}
function orderMatchesCargoKeyword(order: FirstLegOrder, keyword: string) {
const normalized = keyword.toLowerCase()
return [
order.businessSummary.inboundCode,
...order.skuNos,
...order.items.map((item) => item.sku),
].some((value) => String(value ?? '').toLowerCase().includes(normalized))
}
function uniqueItemValues(order: FirstLegOrder, key: 'sku' | 'shipmentId' | 'referenceId') {
if (key === 'sku' && order.skuNos.length) return [...new Set(order.skuNos)].join('、')
const values = order.items.map((item) => item[key]?.trim()).filter((value): value is string => Boolean(value))
return [...new Set(values)].join('、') || '-'
}
function orderPackageCount(order: FirstLegOrder) {
return order.businessSummary.totalPackageCount ?? (order.items.length ? summarizeFirstLegCargo(order.items).packageCount : null)
}
function orderGrossWeight(order: FirstLegOrder) {
return order.businessSummary.actualGrossWeightKg ?? (order.items.length ? summarizeFirstLegCargo(order.items).grossWeightKg : null)
}
function orderVolume(order: FirstLegOrder) {
return order.businessSummary.actualVolumeCbm ?? (order.items.length ? summarizeFirstLegCargo(order.items).volumeCbm : null)
}
function orderItemTotal(order: FirstLegOrder, key: 'netWeightKg' | 'pieces') {
if (key === 'netWeightKg' && order.totalNetWeightKg != null) return order.totalNetWeightKg
if (key === 'pieces' && order.totalQuantity != null) return order.totalQuantity
if (!order.items.length) return null
return order.items.reduce((total, item) => total + (Number(item[key]) || 0), 0)
}
function formatListMetric(value: number | null | undefined, unit = '') {
if (value == null) return '-'
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })}${unit ? ` ${unit}` : ''}`
}
function csvCell(value: unknown) {
return `"${String(value ?? '').replaceAll('"', '""')}"`
}
function downloadCsv(fileName: string, rows: unknown[][]) {
const content = `\uFEFF${rows.map((row) => row.map(csvCell).join(',')).join('\r\n')}`
const url = URL.createObjectURL(new Blob([content], { type: 'text/csv;charset=utf-8' }))
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
function saveBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
function handleOrderSelectionChange(rows: FirstLegOrder[]) {
selectedOrders.value = rows
}
async function submitSelectedDraftOrders() {
const drafts = selectedDraftOrders.value
if (batchSubmitting.value || !drafts.length) return
if (companySubmissionBlocked.value) {
ElMessage.warning('企业资料审核通过后才能提交订单')
return
}
try {
await ElMessageBox.confirm(`确认提交选中的 ${drafts.length} 条草稿订单吗?`, '批量提交订单', {
type: 'warning',
confirmButtonText: '确认提交',
cancelButtonText: '取消',
})
} catch (error) {
if (error === 'cancel' || error === 'close') return
throw error
}
batchSubmitting.value = true
try {
const results = await Promise.allSettled(drafts.map((order) => submitMerchantFirstLegOrder(
order.id,
createIdempotencyKey(`first-leg-batch-submit-${order.id}`),
)))
const successCount = results.filter((result) => result.status === 'fulfilled').length
const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
await searchOrders(appliedFilters.value)
if (!failures.length) {
ElMessage.success(`已提交 ${successCount} 条草稿订单`)
return
}
const firstReason = failures[0]?.reason
const reason = firstReason instanceof Error ? firstReason.message : '提交失败'
if (successCount > 0) ElMessage.warning(`${successCount} 条提交成功,${failures.length} 条失败:${reason}`)
else ElMessage.error(`${failures.length} 条草稿订单提交失败:${reason}`)
} finally {
batchSubmitting.value = false
}
}
async function downloadShippingMarks() {
if (downloadingMarks.value) return
const orders = selectedOrders.value
if (!orders.length) return
downloadingMarks.value = true
try {
const result = await downloadMerchantOrderCartonLabelsBatch(orders.map((order) => order.id))
saveBlob(result.blob, `批量箱唛-${orders.length}单.pdf`)
if (result.failureCount > 0) {
ElMessage.warning(`箱唛已下载,${result.failureCount} 份订单生成失败:${result.failures}`)
} else {
ElMessage.success(`已下载 ${orders.length} 份订单箱唛`)
}
} catch (error) {
const reason = error instanceof ApiError
? `${error.message}${error.code ? ` [${error.code}]` : ''}${error.traceId ? `,追踪号 ${error.traceId}` : ''}`
: (error instanceof Error ? error.message : '箱唛生成失败')
ElMessage.error(reason)
} finally {
downloadingMarks.value = false
}
}
function exportOrderInformation() {
exportingOrders.value = true
try {
const rows = selectedOrders.value.map((order) => [
displayListValue(order.businessSummary.inboundCode),
displayListValue(order.channelName),
order.transportMode ? getFirstLegTransportModeLabel(order.transportMode) : '-',
pickupMethodLabels[order.pickupMethod],
order.pickupMethod === 'PICKUP' ? formatExportDateTime(order.requestedPickupAt) : '-',
displayListValue(order.destinationWarehouseName),
destinationTypeLabels[order.destinationType],
getFirstLegStatusLabel(order.status),
formatListMetric(orderPackageCount(order)),
displayListValue(order.expressCompanyName),
displayListValue(order.trackingNumber),
uniqueItemValues(order, 'sku'),
formatListMetric(orderGrossWeight(order), 'kg'),
formatListMetric(orderItemTotal(order, 'netWeightKg'), 'kg'),
formatListMetric(orderVolume(order), 'm³'),
formatListMetric(orderItemTotal(order, 'pieces')),
'-',
formatListMetric(order.chargeableVolumeCbm, 'm³'),
'-',
displayListValue(order.customerRemark),
displayListValue(order.shipmentId || uniqueItemValues(order, 'shipmentId')),
displayListValue(order.referenceNo || uniqueItemValues(order, 'referenceId')),
formatExportDateTime(order.inboundedAt),
formatExportDateTime(order.createdAt),
])
downloadCsv('订单信息.csv', [[
'入仓码', '渠道', '运输方式', '交货方式', '预约提货时间', '目的仓', '目的仓类型', '当前订单状态', '箱数', '快递公司', '快递单号', 'SKUS',
'总毛重', '总净重', '总体积', '总数量', '计费单价', '计费体积', '计费重量', '备注',
'货件编号', '跟踪编号', '国内仓入库时间', '创建时间',
], ...rows])
ElMessage.success(`已导出 ${rows.length} 条订单信息`)
} finally {
exportingOrders.value = false
}
}
const allCargoSelected = computed(() => form.items.length > 0 && selectedCargoIds.value.length === form.items.length)
const someCargoSelected = computed(() => selectedCargoIds.value.length > 0 && !allCargoSelected.value)
function toggleAllCargoItems(checked: boolean | string | number) {
selectedCargoIds.value = checked ? form.items.map((item) => item.id) : []
}
function toggleCargoItem(itemId: string, checked: boolean | string | number) {
if (checked) {
if (!selectedCargoIds.value.includes(itemId)) selectedCargoIds.value.push(itemId)
return
}
selectedCargoIds.value = selectedCargoIds.value.filter((id) => id !== itemId)
}
function removeSelectedCargoItems() {
if (!selectedCargoIds.value.length) {
ElMessage.warning('请先勾选要删除的货物')
return
}
if (selectedCargoIds.value.length >= form.items.length) {
ElMessage.warning('订单至少需要保留一条货物明细')
return
}
const selectedIds = new Set(selectedCargoIds.value)
const removedItems = form.items.filter((item) => selectedIds.has(item.id))
form.items.splice(0, form.items.length, ...form.items.filter((item) => !selectedIds.has(item.id)))
normalizeSingleSkuGroups()
releaseProductImagePreviews(removedItems)
selectedCargoIds.value = []
resetEditorValidation()
}
function openBatchImport() {
batchImportMode.value = 'PARTIAL'
batchImportFile.value = null
batchImportIdempotencyKey.value = ''
batchImportResult.value = null
batchImportError.value = ''
batchImportOpen.value = true
}
function batchImportExcelRow(item: { index: number; clientRowId: string }) {
const matched = /^excel-row-(\d+)$/.exec(item.clientRowId)
return matched ? Number(matched[1]) : item.index + 3
}
function selectBatchImportFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
if (!file.name.toLowerCase().endsWith('.xlsx')) {
batchImportError.value = '仅支持 .xlsx 格式的订单导入文件'
ElMessage.error(batchImportError.value)
return
}
if (file.size > 5 * 1024 * 1024) {
batchImportError.value = '订单导入文件不能超过 5MB'
ElMessage.error(batchImportError.value)
return
}
batchImportFile.value = file
batchImportIdempotencyKey.value = createIdempotencyKey('first-leg-batch-import')
batchImportResult.value = null
batchImportError.value = ''
}
async function downloadBatchTemplate() {
try {
batchTemplateDownloading.value = true
batchImportError.value = ''
saveBlob(await downloadMerchantFirstLegBatchImportTemplate(), '头程订单批量导入模板.xlsx')
} catch (error) {
batchImportError.value = batchImportErrorMessage(error)
ElMessage.error(batchImportError.value)
} finally {
batchTemplateDownloading.value = false
}
}
async function downloadBatchErrorReport() {
const result = batchImportResult.value
if (!result?.errorReportFileId) return
try {
batchErrorReportDownloading.value = true
batchImportError.value = ''
saveBlob(
await downloadMerchantFirstLegBatchImportErrorReport(result.batchNo, result.errorReportFileId),
`${result.batchNo}-错误报告.xlsx`,
)
} catch (error) {
batchImportError.value = batchImportErrorMessage(error)
ElMessage.error(batchImportError.value)
} finally {
batchErrorReportDownloading.value = false
}
}
async function submitBatchImport() {
if (!batchImportFile.value) {
batchImportError.value = '请先选择 .xlsx 订单导入文件'
ElMessage.warning(batchImportError.value)
return
}
try {
batchImportSubmitting.value = true
batchImportError.value = ''
batchImportResult.value = await importMerchantFirstLegOrders(
batchImportFile.value,
batchImportMode.value,
batchImportIdempotencyKey.value,
)
const result = batchImportResult.value.result
if (result.succeeded && auth.hasPermission('order:first-leg:list')) await store.load({ includeDrafts: true })
if (result.failed) ElMessage.warning(`导入完成:成功 ${result.succeeded} 条,失败 ${result.failed} 条`)
else ElMessage.success(`已成功导入 ${result.succeeded} 条订单草稿`)
} catch (error) {
batchImportError.value = batchImportErrorMessage(error)
ElMessage.error(batchImportError.value)
} finally {
batchImportSubmitting.value = false
}
}
function viewImportedDrafts() {
batchImportOpen.value = false
void router.push({ name: 'first-leg-orders' })
}
async function validateEditorForm() {
try {
const policy = await getMerchantInboundCodePolicy()
inboundCodePolicy.value = policy
if (policy.mode === 'AUTO' && !editingId.value) form.inboundCode = ''
await nextTick()
} catch (error) {
inboundCodePolicy.value = null
ElMessage.warning(`入仓码配置加载失败:${errorMessage(error)}`)
return false
}
let fieldsValid = false
try {
fieldsValid = await orderFormRef.value?.validate() ?? false
} catch {
fieldsValid = false
}
if (!fieldsValid) {
ElMessage.warning('请检查并完善表单中标红的必填信息')
return false
}
if (manualInboundCodeRequired.value && !form.inboundCode?.trim()) {
ElMessage.warning('当前为旧系统并跑模式,请填写旧系统入仓码')
return false
}
const errors = validateFirstLegOrderDraft(form)
if (errors.length > 0) {
ElMessage.warning(errors[0])
return false
}
return true
}
async function saveDraft() {
if (saving.value) return
if (!await validateEditorForm()) return
saving.value = true
try {
const order = await store.saveDraft(cloneDraft(form), tenantStore.company.companyName, editingId.value)
editingId.value = order.id
markEditorClean()
editorOpen.value = false
if (isCreatePage.value) await router.push({ name: 'first-leg-orders' })
ElMessage.success('订单草稿已保存')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '草稿保存失败')
} finally {
saving.value = false
}
}
async function submitOrder() {
if (saving.value) return
if (companySubmissionBlocked.value) {
try {
await ElMessageBox.confirm('企业资料审核通过后才能提交订单。您可以继续保存草稿,或前往企业资料核对并补充信息。', '暂时无法提交订单', {
type: 'warning',
confirmButtonText: '前往企业资料',
cancelButtonText: '继续编辑草稿',
})
markEditorClean()
editorOpen.value = false
await router.push('/company/profile')
} catch (error) {
if (error === 'cancel' || error === 'close') return
throw error
}
return
}
if (!await validateEditorForm()) return
try {
saving.value = true
const knownCompanyAuditStatus = companyAuditLoaded.value ? tenantStore.company.auditStatus : undefined
const savedOrder = await store.saveDraft(cloneDraft(form), tenantStore.company.companyName, editingId.value)
editingId.value = savedOrder.id
const order = await store.submitSaved(savedOrder.id, knownCompanyAuditStatus)
markEditorClean()
editorOpen.value = false
if (isCreatePage.value) {
workspace.close(['first-leg-order-create', 'first-leg-order-edit'])
await router.push({ name: 'first-leg-orders' })
ElMessage.success('订单已提交,等待平台审核')
return
}
await router.push({ name: 'first-leg-order-detail', params: { orderId: order.id } })
ElMessage.success('订单已提交,等待平台审核')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '订单提交失败')
} finally {
saving.value = false
}
}
async function searchOrders(model: QueryModel) {
const createdRange = model.createdRange
appliedFilters.value = {
...model,
createdRange: Array.isArray(createdRange) ? createdRange.filter((value): value is string => typeof value === 'string') : [],
}
tablePage.value = 1
try {
const statusValue = typeof model.status === 'string' ? model.status as FirstLegOrderStatus | '' : ''
const cargoKeyword = typeof model.cargoKeyword === 'string' ? model.cargoKeyword.trim() : ''
const range = Array.isArray(createdRange) ? createdRange.filter((value): value is string => typeof value === 'string') : []
const query = {
keyword: undefined,
inboundCode: cargoKeyword || undefined,
receivingWarehouseId: typeof model.receivingWarehouseId === 'string' && model.receivingWarehouseId ? model.receivingWarehouseId : undefined,
mainStatus: statusValue && statusValue !== 'DRAFT' ? statusValue : undefined,
draftOnly: statusValue === 'DRAFT',
includeDrafts: !statusValue,
createdFrom: range[0] ? `${range[0]}T00:00:00` : undefined,
createdTo: range[1] ? `${range[1]}T23:59:59` : undefined,
}
await store.load(query)
const cargoMatched = cargoKeyword && store.orders.some((order) => order.status !== 'DRAFT' && orderMatchesCargoKeyword(order, cargoKeyword))
if (cargoKeyword && !cargoMatched && statusValue !== 'DRAFT') {
await store.load({ ...query, keyword: undefined, inboundCode: undefined, fbaCode: cargoKeyword })
}
selectedOrders.value = []
orderTableVersion.value += 1
} catch {
// Store keeps the recoverable error state rendered below the query toolbar.
}
}
</script>
<template>
<section class="first-leg-page" :class="{ 'is-create-route': isCreatePage, 'is-detail-route': isDetailPage, 'client-ui': !isCreatePage }">
<template v-if="!isCreatePage && !isDetailPage">
<QueryToolbar v-model="filters" class="first-leg-query-toolbar" layout="grid" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders" />
<div class="client-card order-table-card">
<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="visibleOrderColumns" :key="orderTableVersion" :loading="store.loading" :total="filteredOrders.length" :page-sizes="[10, 20, 50]" row-key="id" empty-text="暂无订单" class="prototype-order-table" :row-actions="getOrderRowActions" @selection-change="handleOrderSelectionChange">
<template #toolbar>
<nav class="order-queue-tabs" aria-label="订单事项分类">
<button type="button" :class="{ active: activeOrderQueue === 'all' }" @click="selectOrderQueue('all')">全部</button>
<button type="button" :class="{ active: activeOrderQueue === 'fee' }" @click="selectOrderQueue('fee')">确认计费重<span v-if="orderQueueCounts.fee" class="queue-count">{{ orderQueueCounts.fee }}</span></button>
<button type="button" :class="{ active: activeOrderQueue === 'customs' }" @click="selectOrderQueue('customs')">上传资料<span v-if="orderQueueCounts.customs" class="queue-count">{{ orderQueueCounts.customs }}</span></button>
<button type="button" :class="{ active: activeOrderQueue === 'draft' }" @click="selectOrderQueue('draft')">草稿<span v-if="orderQueueCounts.draft" class="queue-count">{{ orderQueueCounts.draft }}</span></button>
</nav>
<span v-if="selectedOrderCount">已选 {{ selectedOrderCount }} 条</span>
</template>
<template #toolbar-actions>
<el-popover placement="bottom-end" :width="320" trigger="click" popper-class="order-column-popper">
<template #reference>
<el-button class="column-settings-trigger" aria-label="设置订单列表显示列" title="设置显示列"><SlidersHorizontal :size="18" />显示字段筛选</el-button>
</template>
<div class="column-settings-panel">
<p>拖动调整顺序,也可使用上下移动按钮。</p>
<el-checkbox-group v-model="visibleColumnKeys" aria-label="订单列表显示列">
<div
v-for="(column, index) in orderedSelectableColumns"
:key="String(column.key)"
class="column-settings-item"
draggable="true"
@dragstart="draggedColumnKey = String(column.key)"
@dragend="draggedColumnKey = ''"
@dragover.prevent
@drop.prevent="dropOrderColumn(String(column.key))"
>
<GripVertical :size="17" aria-hidden="true" />
<el-checkbox :value="String(column.key)">{{ column.label }}</el-checkbox>
<el-button link :disabled="index === 0" :aria-label="`上移${column.label}`" :title="`上移${column.label}`" @click="moveOrderColumn(String(column.key), -1)"><ChevronUp :size="17" /></el-button>
<el-button link :disabled="index === orderedSelectableColumns.length - 1" :aria-label="`下移${column.label}`" :title="`下移${column.label}`" @click="moveOrderColumn(String(column.key), 1)"><ChevronDown :size="17" /></el-button>
</div>
</el-checkbox-group>
<el-button link class="column-settings-reset" @click="resetOrderColumns"><RotateCcw :size="17" />恢复默认</el-button>
</div>
</el-popover>
<el-button :loading="downloadingMarks" :disabled="selectedOrderCount === 0" @click="downloadShippingMarks"><Download :size="16" />下载唛头</el-button>
<el-button :loading="exportingOrders" :disabled="selectedOrderCount === 0" @click="exportOrderInformation"><Download :size="16" />导出订单信息</el-button>
<el-button v-if="canSubmitOrder" :loading="batchSubmitting" :disabled="selectedDraftOrders.length === 0 || companySubmissionBlocked" @click="submitSelectedDraftOrders"><Send :size="17" />批量提交草稿</el-button>
<el-button v-if="auth.hasPermission('order:first-leg:create')" @click="openBatchImport"><Upload :size="17" />批量导入订单</el-button>
<el-button v-if="props.showCreateAction && auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>
</template>
<template #before-table>
<div v-if="companySubmissionBlocked && !supportingDataErrors.some((message) => message.startsWith('企业资料'))" class="order-eligibility is-pending" role="status">
<AlertTriangle :size="20" />
<div><strong>{{ canCreateOrder ? '企业资料待审核,当前仅可保存订单草稿' : '企业资料待审核,当前订单为只读' }}</strong><span>{{ canCreateOrder ? '平台审核通过后即可提交;您仍可创建、编辑和保存草稿。' : '您当前拥有只读权限,可以继续查看订单进展。' }}</span></div>
<el-button v-if="canViewCompanyProfile" link type="primary" @click="router.push('/company/profile')">查看企业资料</el-button>
</div>
<div v-if="supportingDataErrors.length" class="supporting-data-error" role="alert">
<AlertTriangle :size="20" />
<div><strong>部分订单配套数据加载失败</strong><span v-for="message in supportingDataErrors" :key="message">{{ message }}</span></div>
<el-button :loading="supportingDataLoading || store.loading" @click="loadPageData">重新加载</el-button>
</div>
</template>
<template v-if="store.loadError" #error>
<div class="order-load-state" role="alert">
<AlertTriangle :size="24" />
<div><strong>订单数据加载失败</strong><span>{{ store.loadError }}</span></div>
<el-button :loading="store.loading" @click="loadPageData">重新加载</el-button>
</div>
</template>
<template #inboundCode="{ row }">
<button
v-if="(row as FirstLegOrder).businessSummary.inboundCode"
class="inbound-code-primary table-ellipsis table-code"
type="button"
:title="`查看入仓码 ${(row as FirstLegOrder).businessSummary.inboundCode} 的订单详情`"
@click="openDetail(row as FirstLegOrder)"
>{{ (row as FirstLegOrder).businessSummary.inboundCode }}</button>
<span v-else class="pending-inbound-code">{{ (row as FirstLegOrder).status === 'DRAFT' ? '提交后生成' : '待生成' }}</span>
</template>
<template #channel="{ row }"><span class="table-ellipsis" :title="displayListValue((row as FirstLegOrder).channelName)">{{ displayListValue((row as FirstLegOrder).channelName) }}</span></template>
<template #transportMode="{ row }">{{ (row as FirstLegOrder).transportMode ? getFirstLegTransportModeLabel((row as FirstLegOrder).transportMode) : '-' }}</template>
<template #pickupMethod="{ row }">{{ pickupMethodLabels[(row as FirstLegOrder).pickupMethod] }}</template>
<template #requestedPickupAt="{ row }">{{ (row as FirstLegOrder).pickupMethod === 'PICKUP' && (row as FirstLegOrder).requestedPickupAt ? formatDateTime((row as FirstLegOrder).requestedPickupAt) : '-' }}</template>
<template #receivingWarehouse="{ row }"><span class="table-ellipsis" :title="displayListValue((row as FirstLegOrder).receivingWarehouseName)">{{ displayListValue((row as FirstLegOrder).receivingWarehouseName) }}</span></template>
<template #destinationWarehouse="{ row }"><span class="table-ellipsis" :title="displayListValue((row as FirstLegOrder).destinationWarehouseName)">{{ displayListValue((row as FirstLegOrder).destinationWarehouseName) }}</span></template>
<template #destinationType="{ row }">{{ destinationTypeLabels[(row as FirstLegOrder).destinationType] }}</template>
<template #status="{ row }"><span class="client-status" :data-tone="getFirstLegStatusTone((row as FirstLegOrder).status)">{{ getFirstLegStatusLabel((row as FirstLegOrder).status) }}</span></template>
<template #packageCount="{ row }">{{ formatListMetric(orderPackageCount(row as FirstLegOrder)) }}</template>
<template #expressCompany="{ row }"><span class="table-ellipsis" :title="displayListValue((row as FirstLegOrder).expressCompanyName)">{{ displayListValue((row as FirstLegOrder).expressCompanyName) }}</span></template>
<template #trackingNumber="{ row }"><span class="table-ellipsis table-code" :title="displayListValue((row as FirstLegOrder).trackingNumber)">{{ displayListValue((row as FirstLegOrder).trackingNumber) }}</span></template>
<template #skus="{ row }"><span class="table-ellipsis table-code" :title="uniqueItemValues(row as FirstLegOrder, 'sku')">{{ uniqueItemValues(row as FirstLegOrder, 'sku') }}</span></template>
<template #grossWeight="{ row }">{{ formatListMetric(orderGrossWeight(row as FirstLegOrder), 'kg') }}</template>
<template #netWeight="{ row }">{{ formatListMetric(orderItemTotal(row as FirstLegOrder, 'netWeightKg'), 'kg') }}</template>
<template #volume="{ row }">{{ formatListMetric(orderVolume(row as FirstLegOrder), 'm³') }}</template>
<template #pieces="{ row }">{{ formatListMetric(orderItemTotal(row as FirstLegOrder, 'pieces')) }}</template>
<template #billingUnitPrice>-</template>
<template #chargeableVolume="{ row }">{{ formatListMetric((row as FirstLegOrder).chargeableVolumeCbm, 'm³') }}</template>
<template #chargeableWeight>-</template>
<template #remark="{ row }"><span class="table-ellipsis" :title="(row as FirstLegOrder).customerRemark">{{ displayListValue((row as FirstLegOrder).customerRemark) }}</span></template>
<template #shipmentId="{ row }"><span class="table-ellipsis table-code" :title="displayListValue((row as FirstLegOrder).shipmentId || uniqueItemValues(row as FirstLegOrder, 'shipmentId'))">{{ displayListValue((row as FirstLegOrder).shipmentId || uniqueItemValues(row as FirstLegOrder, 'shipmentId')) }}</span></template>
<template #referenceId="{ row }"><span class="table-ellipsis table-code" :title="displayListValue((row as FirstLegOrder).referenceNo || uniqueItemValues(row as FirstLegOrder, 'referenceId'))">{{ displayListValue((row as FirstLegOrder).referenceNo || uniqueItemValues(row as FirstLegOrder, 'referenceId')) }}</span></template>
<template #receivingTime="{ row }">{{ (row as FirstLegOrder).inboundedAt ? formatDateTime((row as FirstLegOrder).inboundedAt) : '-' }}</template>
<template #expectedShipDate="{ row }">{{ (row as FirstLegOrder).expectedDepartureDate ? formatDateTime((row as FirstLegOrder).expectedDepartureDate) : '-' }}</template>
<template #cabinetNo="{ row }"><span class="table-ellipsis table-code" :title="displayListValue((row as FirstLegOrder).businessSummary.cabinetNo || (row as FirstLegOrder).businessSummary.containerNo)">{{ displayListValue((row as FirstLegOrder).businessSummary.cabinetNo || (row as FirstLegOrder).businessSummary.containerNo) }}</span></template>
<template #vesselVoyage="{ row }"><span class="table-ellipsis" :title="displayListValue([(row as FirstLegOrder).businessSummary.vesselName, (row as FirstLegOrder).businessSummary.voyageNo].filter(Boolean).join(' · '))">{{ displayListValue([(row as FirstLegOrder).businessSummary.vesselName, (row as FirstLegOrder).businessSummary.voyageNo].filter(Boolean).join(' · ')) }}</span></template>
<template #sailingAt="{ row }">{{ (row as FirstLegOrder).businessSummary.sailingAt ? formatDateTime((row as FirstLegOrder).businessSummary.sailingAt) : '-' }}</template>
<template #createdAt="{ row }">{{ formatDateTime((row as FirstLegOrder).createdAt) }}</template>
<template #updatedAt="{ row }">{{ formatDateTime((row as FirstLegOrder).updatedAt) }}</template>
<template #empty>
<el-empty :description="emptyDescription">
<el-button v-if="props.showCreateAction && auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ pageDataLoading ? '加载收货仓中' : (availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓') }}</el-button>
</el-empty>
</template>
</DataTable>
</div>
</template>
<div v-if="isCreatePage && supportingDataErrors.length" class="supporting-data-error" role="alert">
<AlertTriangle :size="20" />
<div><strong>下单配套数据加载失败</strong><span v-for="message in supportingDataErrors" :key="message">{{ message }}</span></div>
<el-button :loading="pageDataLoading" @click="loadPageData">重新加载</el-button>
</div>
<el-alert v-if="isCreatePage && !supportingDataLoading && availableWarehouses.length === 0" title="当前没有可用的平台收货仓" description="请联系平台维护并启用收货仓后再创建订单。" type="warning" :closable="false" show-icon />
<FirstLegOrderEditorShell v-model="editorOpen" :page="isCreatePage" :title="editingId ? '编辑订单草稿' : '创建订单'" :before-close="requestCloseEditor" @back="closeEditor">
<el-form ref="orderFormRef" v-loading="isCreatePage && supportingDataLoading" :model="form" :rules="orderFormRules" label-position="top" class="order-form prototype-order-form" :class="{ 'is-dialog-order-form': !isCreatePage }" scroll-to-error>
<section class="order-form-section prototype-section">
<div class="order-section-heading"><div><h3>客户下单</h3><p>先选择货物如何送到国内收货仓,并填写本次运输安排。</p></div></div>
<div class="prototype-form-flow shipping-form-flow">
<el-form-item label="履约类型" prop="fulfillmentType" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.fulfillmentType" class="segmented-options" @change="changeFulfillmentType"><el-radio-button value="STANDARD">普通头程</el-radio-button><el-radio-button value="ONE_CLICK_DROPSHIP">一件代发</el-radio-button></el-radio-group><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" class="field-guidance">测量完成后不办理国内库存入库,将直接推送 PDA 合托。</small></el-form-item>
<el-form-item v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX入库单号" prop="lxCode" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.lxCode" maxlength="128" placeholder="请输入LX入库单号" /><small class="field-guidance">用于绑定领星入库单,提交后可在尾程订单、入库单和出库单中查询。</small></el-form-item>
<el-form-item label="取货方式" prop="pickupMethod" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.pickupMethod" class="segmented-options" @change="changePickupMethod"><el-radio-button value="SELF_EXPRESS">快递自送</el-radio-button><el-radio-button value="PICKUP">我司提货</el-radio-button><el-radio-button value="SELF_TRUCK">卡车自送</el-radio-button></el-radio-group></el-form-item>
<template v-if="form.pickupMethod === 'PICKUP' && form.shipperPartySnapshot">
<el-form-item label="企业提货地址簿" prop="senderAddressId" required :rules="requiredSelectRules('请选择企业提货地址')" class="prototype-form-row prototype-form-row--wide address-book-row">
<div class="address-book-control"><el-select v-model="form.senderAddressId" clearable filterable :loading="orderAddressesLoading" placeholder="选择企业保存的提货地址" @change="selectPickupAddress"><el-option v-for="address in pickupAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" /></el-select><el-button @click="savePickupAddress">保存当前地址</el-button><el-button v-if="form.senderAddressId" link type="danger" @click="removeOrderAddress('PICKUP')">停用</el-button></div>
</el-form-item>
<el-form-item label="提货联系人" prop="shipperPartySnapshot.contactName" required :rules="requiredTextRules('请输入提货联系人')" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.shipperPartySnapshot.contactName" maxlength="50" placeholder="请输入联系人姓名" /></el-form-item>
<el-form-item label="联系电话" prop="shipperPartySnapshot.phone" required :rules="requiredTextRules('请输入提货联系电话')" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.shipperPartySnapshot.phone" maxlength="30" placeholder="请输入联系电话" /></el-form-item>
<el-form-item label="州 / 省" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.shipperPartySnapshot.province" maxlength="100" placeholder="请输入州或省" /></el-form-item>
<el-form-item label="城市" prop="shipperPartySnapshot.city" required :rules="requiredTextRules('请输入提货城市')" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.shipperPartySnapshot.city" maxlength="100" placeholder="请输入城市" /></el-form-item>
<el-form-item label="提货地址" prop="shipperPartySnapshot.address" required :rules="requiredTextRules('请输入提货地址')" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.shipperPartySnapshot.address" maxlength="300" placeholder="请输入详细提货地址" /></el-form-item>
</template>
<el-form-item label="国内收货仓" prop="receivingWarehouseId" required class="prototype-form-row prototype-form-row--wide"><el-radio-group v-model="form.receivingWarehouseId" class="warehouse-options" @change="loadSelectedWarehouse"><el-radio v-for="warehouse in availableWarehouses" :key="warehouse.id" :value="warehouse.id">{{ warehouse.name }}</el-radio></el-radio-group></el-form-item>
</div>
<div class="order-form-grid compact-transport-grid">
<el-form-item v-if="manualInboundCodeRequired" label="旧系统入仓码" prop="inboundCode" required :rules="requiredTextRules('请输入旧系统生成的入仓码')">
<el-input v-model="form.inboundCode" maxlength="40" placeholder="例如:NYEXX2608140001" @input="form.inboundCode = String($event).trim().toUpperCase()" />
<small class="field-guidance">当前全系统处于旧系统并跑模式,请粘贴旧系统生成的入仓码;订单提交后不可修改。</small>
</el-form-item>
<el-form-item label="渠道" prop="channelCode" required>
<el-select v-model="form.channelCode" :loading="channelLoading" :disabled="!form.receivingWarehouseId || channelLoading" :placeholder="form.receivingWarehouseId ? '请选择运输渠道' : '请先选择国内收货仓'" @change="selectChannel"><el-option v-for="channel in channelOptions" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode" /></el-select>
<small v-if="channelError" class="field-guidance field-guidance--error">渠道加载失败:{{ channelError }} <el-button link type="primary" @click="retryPricingChannels">重试</el-button></small>
<small v-else-if="form.receivingWarehouseId && !channelLoading && channelOptions.length === 0" class="field-guidance">当前收货仓暂无可用渠道,请联系平台管理员。</small>
</el-form-item>
<el-form-item label="报价包税方式" prop="pricingTaxMode" required class="pricing-tax-mode-field">
<el-radio-group v-model="form.pricingTaxMode"><el-radio v-for="mode in availablePricingTaxModes" :key="mode" :value="mode">{{ pricingTaxModeLabel(mode) }}</el-radio></el-radio-group>
<small v-if="form.channelCode && availablePricingTaxModes.length === 0" class="field-guidance field-guidance--error">当前渠道没有可用报价方式,请重新选择渠道。</small>
</el-form-item>
<el-form-item label="买单 / 退税" prop="taxMode" required><el-radio-group v-model="form.taxMode"><el-radio value="BUY_DECLARATION">买单</el-radio><el-radio value="TAX_REFUND">退税</el-radio></el-radio-group></el-form-item>
<el-form-item label="是否整柜" prop="transportMode" required><el-radio-group v-model="form.transportMode"><el-radio value="FCL">整柜</el-radio><el-radio value="LCL">散货</el-radio></el-radio-group></el-form-item>
<el-form-item label="是否商检" prop="customsInspection"><el-select v-model="form.customsInspection" clearable placeholder="暂不选择"><el-option label="是" :value="true" /><el-option label="否" :value="false" /></el-select><small v-if="woodenCargoLines.length && form.customsInspection !== true" class="field-guidance field-guidance--error">第 {{ woodenCargoLines.join('、') }} 条货物为木制品,请将“是否商检”选择为“是”。</small></el-form-item>
<el-form-item label="期望出货日期"><el-date-picker v-model="form.expectedDepartureDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择期望出货日期" /></el-form-item>
<el-form-item label="订单备注"><el-input v-model="form.customerRemark" type="textarea" :rows="3" resize="vertical" maxlength="300" show-word-limit placeholder="选填,例如:入仓前请电话联系" /></el-form-item>
</div>
</section>
<section class="order-form-section prototype-section destination-section">
<div class="order-section-heading destination-section-heading"><div><h3>目的地信息</h3></div></div>
<div class="prototype-form-flow destination-form-flow">
<el-form-item label="目的仓类型" prop="destinationType" required class="prototype-form-row prototype-form-row--tabs destination-type-row"><el-radio-group v-model="form.destinationType" class="segmented-options destination-type-options" @change="changeDestinationType"><el-radio-button v-if="form.fulfillmentType !== 'ONE_CLICK_DROPSHIP'" value="FBA">FBA 仓</el-radio-button><el-radio-button value="OVERSEAS">海外仓</el-radio-button><el-radio-button v-if="form.fulfillmentType !== 'ONE_CLICK_DROPSHIP'" value="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="form.destinationType === 'PRIVATE'" label="私人地址簿" class="prototype-form-row prototype-form-row--wide address-book-row">
<div class="address-book-control"><el-select v-model="form.privateDestinationAddressId" clearable filterable :loading="orderAddressesLoading" placeholder="选择企业保存的私人仓地址" @change="selectPrivateAddress"><el-option v-for="address in privateAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" /></el-select><el-button @click="savePrivateAddress">保存当前地址</el-button><el-button v-if="form.privateDestinationAddressId" link type="danger" @click="removeOrderAddress('PRIVATE_DESTINATION')">停用</el-button></div>
</el-form-item>
<el-form-item v-if="form.destinationType !== 'PRIVATE'" :label="form.destinationType === 'FBA' ? 'FBA 仓' : '海外仓'" prop="destinationWarehouseId" required :rules="requiredSelectRules('请选择平台维护的目的仓')" class="prototype-form-row"><el-select v-model="form.destinationWarehouseId" filterable :loading="destinationWarehousesLoading" placeholder="请选择当前可送达的目的仓" @change="selectDestinationWarehouse"><el-option v-for="warehouse in availableDestinationWarehouses" :key="warehouse.id" :label="`${warehouse.name} · ${warehouse.code}`" :value="warehouse.id" /></el-select><small v-if="!destinationWarehousesLoading && availableDestinationWarehouses.length === 0" class="field-guidance">平台暂未维护可用的{{ form.destinationType === 'FBA' ? ' FBA 仓' : '海外仓' }},请联系平台管理员。</small></el-form-item>
<div v-if="form.destinationType !== 'PRIVATE' && selectedDestinationWarehouse" class="selected-destination"><MapPin :size="17" /><div><span>{{ selectedDestinationWarehouse.name }} · {{ selectedDestinationWarehouse.code }} · {{ selectedDestinationWarehouse.countryCode }}</span><small v-if="selectedDestinationWarehouse.address">仓库地址:{{ selectedDestinationWarehouse.province }}{{ selectedDestinationWarehouse.city }} {{ selectedDestinationWarehouse.address }}</small><small v-if="selectedDestinationWarehouse.contactName || selectedDestinationWarehouse.contactPhone">联系人:{{ selectedDestinationWarehouse.contactName || '-' }} {{ selectedDestinationWarehouse.contactPhone }}</small></div></div>
<template v-if="form.destinationType === 'PRIVATE' && form.consigneePartySnapshot">
<el-form-item label="私人地址名称" prop="destinationWarehouseName" required class="prototype-form-row"><el-input v-model="form.destinationWarehouseName" maxlength="128" placeholder="例如:洛杉矶客户自有仓" @input="syncPrivateDestinationName" /></el-form-item>
<el-form-item label="收件人姓名" prop="consigneePartySnapshot.contactName" required :rules="requiredTextRules('请输入收货联系人姓名')" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.consigneePartySnapshot.contactName" maxlength="50" placeholder="请输入收件人姓名" /></el-form-item>
<el-form-item label="收件人电话" prop="consigneePartySnapshot.phone" required :rules="requiredTextRules('请输入收货联系电话')" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.consigneePartySnapshot.phone" maxlength="30" placeholder="请输入收件人电话" /></el-form-item>
<el-form-item label="收件国家代码" prop="destinationCountryCode" required class="prototype-form-row prototype-form-row--short"><el-input v-model="form.destinationCountryCode" maxlength="2" placeholder="例如:US" /></el-form-item>
<el-form-item label="州 / 省" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.consigneePartySnapshot.province" maxlength="100" placeholder="请输入州或省" /></el-form-item>
<el-form-item label="城市" prop="consigneePartySnapshot.city" required :rules="requiredTextRules('请输入收件城市')" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.consigneePartySnapshot.city" maxlength="100" placeholder="请输入城市" /></el-form-item>
<el-form-item label="收件地址" prop="consigneePartySnapshot.address" required :rules="requiredTextRules('请输入本单实际收货地址')" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.consigneePartySnapshot.address" maxlength="300" placeholder="请输入收件详细地址" /></el-form-item>
<el-form-item label="邮编" class="prototype-form-row prototype-form-row--short"><el-input v-model="form.consigneePartySnapshot.postalCode" maxlength="20" placeholder="请输入邮编" /></el-form-item>
</template>
</div>
</section>
<section class="order-form-section prototype-section cargo-section">
<div class="order-section-heading cargo-table-heading"><div><h3>货物明细</h3></div></div>
<div class="cargo-table-toolbar">
<div v-if="isCreatePage && !routeDraftId && canCreateOrder" class="cargo-import-actions">
<el-button class="cargo-download-button" :loading="batchTemplateDownloading" @click="downloadBatchTemplate"><Download :size="16" />下载 Excel 模板</el-button>
<el-button class="cargo-upload-button" type="primary" plain @click="openBatchImport"><Upload :size="16" />Excel 批量导入</el-button>
<span class="cargo-table-hint">Excel 导入用于一次创建多条订单草稿。</span>
</div>
<span v-else class="cargo-table-hint">可逐行维护货物明细。</span>
<div class="cargo-table-actions"><el-button @click="addCargoItem"><Plus :size="16" />添加行</el-button><el-button type="danger" plain :disabled="!selectedCargoIds.length" @click="removeSelectedCargoItems"><Trash2 :size="15" />删除所选行</el-button></div>
</div>
<div class="cargo-table-wrap" :class="{ 'is-dropship': form.fulfillmentType === 'ONE_CLICK_DROPSHIP' }">
<table class="cargo-edit-table">
<thead><tr>
<th class="cargo-check-cell"><el-checkbox :model-value="allCargoSelected" :indeterminate="someCargoSelected" aria-label="全选货物" @change="toggleAllCargoItems" /></th><th class="cargo-index-cell">#</th>
<th>SKU NO.<small>SKU必填</small></th><th>中文品名</th><th class="cargo-image-column">图片</th><th>英文品名</th><th>报关 HS CODE</th><th>清关 HS CODE</th><th>包装类型</th><th>材质</th><th class="cargo-switch-column">危险品</th><th class="cargo-switch-column">木制品<small>勾选后须商检</small></th>
<th class="cargo-number-column">箱规长 cm</th><th class="cargo-number-column">箱规宽 cm</th><th class="cargo-number-column">箱规高 cm</th><th class="cargo-number-column">每箱数量</th><th class="cargo-number-column">箱数</th><th class="cargo-number-column"><span class="required-field-mark" aria-hidden="true">*</span>总毛重</th><th class="cargo-number-column"><span class="required-field-mark" aria-hidden="true">*</span>总体积</th><th class="cargo-number-column">总数量</th><th class="cargo-number-column">总净重</th>
<th>清关申报单价 USD</th><th>清关申报金额 USD</th><th>用途</th><th>型号</th><th>品牌</th><th class="cargo-dimensions-column">产品规格 长×宽×高</th><th>货件编号<small>货件编号必填</small></th><th>跟踪编号<small>跟踪编号必填</small></th><th>UPC NO.</th><th>备注</th><th class="cargo-operation-column">操作</th>
</tr></thead>
<tbody><tr v-for="(item, index) in form.items" :key="item.id" :class="{ 'mixed-sku-detail-row': isAdditionalMixedSku(item, index) }">
<td class="cargo-check-cell"><el-checkbox :model-value="selectedCargoIds.includes(item.id)" :aria-label="`选择货物 ${index + 1}`" @change="toggleCargoItem(item.id, $event)" /></td><td class="cargo-index-cell">{{ index + 1 }}</td>
<td><el-form-item :prop="`items.${index}.sku`" :required="form.destinationType === 'FBA' || form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" :rules="form.destinationType === 'FBA' || form.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? requiredTextRules('请输入 SKU NO.') : undefined"><div class="cargo-sku-input"><el-input v-model="item.sku" maxlength="64" placeholder="SKU编号" /><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP' && mixedSkuCount(item) > 1" class="mixed-sku-badge">同箱</small></div></el-form-item></td>
<td><el-form-item :prop="`items.${index}.cargoNameCn`" required :rules="requiredTextRules('请输入中文品名')"><el-input v-model="item.cargoNameCn" maxlength="128" placeholder="中文品名" /></el-form-item></td>
<td class="cargo-image-column"><div class="cargo-table-image"><img v-if="item.productImagePreviewUrl" :src="item.productImagePreviewUrl" :alt="`${item.cargoNameCn || `货物 ${index + 1}`}产品图片`" /><el-upload v-else :show-file-list="false" accept=".jpg,.jpeg,.png,image/jpeg,image/png" :disabled="item.productImageUploading" :http-request="(options: UploadRequestOptions) => uploadCargoProductImage(item, options)"><el-button text :loading="item.productImageUploading" aria-label="上传图片"><ImagePlus :size="17" /></el-button></el-upload><el-button v-if="item.productImagePreviewUrl" class="cargo-image-remove" text type="danger" aria-label="移除图片" @click="removeCargoProductImage(item)"><Trash2 :size="12" /></el-button></div></td>
<td><el-input v-model="item.cargoNameEn" maxlength="128" placeholder="English Name" /></td><td><el-input v-model="item.hsCode" maxlength="20" placeholder="HS Code" /></td><td><el-input v-model="item.customsHsCode" maxlength="20" placeholder="HS Code" /></td>
<td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.packageType`" required :rules="requiredSelectRules('请选择包装类型')"><el-select v-model="item.packageType" @change="syncPackingGroupFields(item)"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item></td><td><el-input v-model="item.material" maxlength="128" placeholder="材质" /></td>
<td class="cargo-switch-column"><el-switch v-model="item.dangerousGoods" :aria-label="`第 ${index + 1} 条货物是否危险品`" /></td><td class="cargo-switch-column"><div class="cargo-switch-cell"><el-switch v-model="item.woodenProduct" :aria-label="`第 ${index + 1} 条货物是否木制品`" /><small v-if="item.woodenProduct && form.customsInspection !== true">第 {{ index + 1 }} 条须商检</small></div></td>
<td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同上</span><el-input-number v-else v-model="item.lengthCm" :min="0" :precision="2" :controls="false" placeholder="长" @change="syncPackingGroupFields(item)" /></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同上</span><el-input-number v-else v-model="item.widthCm" :min="0" :precision="2" :controls="false" placeholder="宽" @change="syncPackingGroupFields(item)" /></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同上</span><el-input-number v-else v-model="item.heightCm" :min="0" :precision="2" :controls="false" placeholder="高" @change="syncPackingGroupFields(item)" /></td>
<td><el-form-item :prop="`items.${index}.unitsPerPackage`" required :rules="positiveNumberRules('每箱数量必须大于 0', true)"><el-input-number v-model="item.unitsPerPackage" :min="1" :controls="false" placeholder="每箱数量" @change="syncSkuQuantity(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须大于 0', true)"><el-input-number v-model="item.packageCount" :min="1" :controls="false" placeholder="箱数" @change="syncPackingGroupFields(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" :controls="false" placeholder="总毛重" @change="syncPackingGroupFields(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" :controls="false" placeholder="总体积" @change="syncPackingGroupFields(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="derived-sku-value">{{ item.pieces }}</span><el-form-item v-else :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须大于 0', true)"><el-input-number v-model="item.pieces" :min="1" :controls="false" placeholder="总数量" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-input-number v-else v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" placeholder="净重" @change="syncPackingGroupFields(item)" /></td>
<td><el-input-number v-model="item.unitDeclaredValue" :min="0" :precision="2" :controls="false" placeholder="申报单价" /></td><td><el-input-number v-model="item.declaredValue" :min="0" :precision="2" :controls="false" placeholder="申报总价" /></td><td><el-input v-model="item.usage" maxlength="128" placeholder="用途" /></td><td><el-input v-model="item.model" maxlength="64" placeholder="型号" /></td><td><el-input v-model="item.brand" maxlength="64" placeholder="品牌" /></td>
<td class="cargo-dimensions-column"><div class="cargo-table-dimensions"><el-input-number v-model="item.productLengthCm" :min="0" :precision="2" :controls="false" placeholder="长" /><span>×</span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" placeholder="宽" /><span>×</span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" placeholder="高" /></div></td>
<td><el-form-item :prop="`items.${index}.shipmentId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入货件编号') : undefined"><el-input v-model="item.shipmentId" maxlength="64" placeholder="货件编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.referenceId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入跟踪编号') : undefined"><el-input v-model="item.referenceId" maxlength="64" placeholder="跟踪编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.upc`"><el-input v-model="item.upc" maxlength="64" placeholder="UPC NO.(选填)" /></el-form-item></td><td><el-input v-model="item.remark" maxlength="500" placeholder="备注" /></td><td class="cargo-operation-column"><el-button v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" link type="primary" :aria-label="`为货物 ${index + 1} 添加同箱 SKU`" @click="addSkuToPackingGroup(index)"><Plus :size="15" />添加 SKU</el-button><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="15" />删除</el-button></td>
</tr></tbody>
</table>
</div>
<div class="cargo-table-hint"><span v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'">每个箱组默认装一个 SKU;同一箱内有多个 SKU 时点击“添加 SKU”,箱数、箱规、重量和体积只填写一次。</span><span v-else>SKU NO.、货件编号、跟踪编号为 FBA 货物必填项,UPC NO. 选填</span></div>
</section>
<section class="order-form-section prototype-section cost-estimate-section" aria-label="费用预估">
<div class="order-section-heading"><div><h3>参考运费</h3><p>根据当前价格表、渠道、目的仓以及货物重量和体积实时试算。</p></div></div>
<div class="price-estimate" :class="{ 'is-loading': priceEstimateLoading }" aria-live="polite">
<div v-if="priceEstimateLoading" class="price-estimate__state" v-loading="true">正在获取实时参考报价</div>
<div v-else-if="priceEstimateError" class="price-estimate__state is-error" role="alert">
<AlertTriangle :size="20" />
<div><strong>参考报价暂时不可用</strong><span>{{ priceEstimateError }},您仍可正常保存订单草稿。</span></div>
<el-button @click="loadPriceEstimate">重新试算</el-button>
</div>
<div v-else-if="priceEstimate" class="price-estimate__result">
<div class="price-estimate__channel">
<span>当前渠道</span>
<strong>{{ selectedPricingChannel?.channelName || priceEstimate.channelCode }}</strong>
<small>{{ pricingTaxModeLabel(priceEstimate.pricingTaxMode) }}<template v-if="selectedPricingChannel?.referenceTransit"> · 参考时效 {{ selectedPricingChannel.referenceTransit }}</template></small>
</div>
<div v-if="priceEstimateBreakdown" class="price-estimate__comparison">
<span>计费对比</span>
<strong>{{ priceEstimateBreakdown.actualLabel }} {{ formatEstimateQuantity(priceEstimateBreakdown.actualQuantity, priceEstimate.chargeUnit) }}</strong>
<strong>{{ priceEstimateBreakdown.convertedLabel }} {{ formatEstimateQuantity(priceEstimateBreakdown.convertedQuantity, priceEstimate.chargeUnit) }}</strong>
<small>{{ priceEstimateBreakdown.convertedFormula }}</small>
</div>
<div class="price-estimate__chargeable">
<span>{{ priceEstimateBreakdown?.chargeableLabel || '计费量' }}</span>
<strong>{{ formatEstimateQuantity(priceEstimate.chargeableQuantity, priceEstimate.chargeUnit) }}</strong>
<small v-if="priceEstimateBreakdown">{{ priceEstimateBreakdown.basisLabel }}(两者取大)</small>
</div>
<div><span>基础单价</span><strong>{{ formatEstimateMoney(priceEstimate.unitPrice, priceEstimate.currency) }} / {{ priceEstimate.chargeUnit }}</strong></div>
<div class="price-estimate__amount"><span>参考运费</span><strong>{{ formatEstimateMoney(priceEstimate.estimatedAmount, priceEstimate.currency) }}</strong></div>
<small>{{ priceEstimateDisclaimer }}</small>
</div>
<div v-else-if="form.destinationType === 'PRIVATE'" class="price-estimate__state">私人仓暂不提供在线参考报价,您仍可正常保存订单草稿。</div>
<div v-else class="price-estimate__state">请先选择国内收货仓、渠道、目的仓,并填写大于 0 的总毛重和总体积。</div>
</div>
</section>
</el-form>
<template #footer><div class="editor-actions"><el-button :disabled="saving" @click="closeEditor">返回</el-button><el-button v-if="editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" />{{ companySubmissionBlocked ? '提交前需完成资料审核' : '提交订单' }}</el-button></div></template>
</FirstLegOrderEditorShell>
<el-dialog v-model="batchImportOpen" title="Excel 批量创建订单草稿" width="680px" destroy-on-close append-to-body>
<div class="batch-import-dialog">
<el-alert title="使用中文模板创建订单草稿" description="仅支持 .xlsx,文件不超过 5MB、最多 50 行;请直接选择模板内的中文下拉项,不需要填写英文代码或内部编号。导入成功的数据只创建草稿,不会直接提交审核;选择木制品时,模板会要求同步选择需要商检。" type="info" :closable="false" show-icon />
<el-alert v-if="batchImportError" title="批量导入处理失败" :description="batchImportError" type="error" :closable="false" show-icon />
<div class="batch-import-actions">
<input ref="batchImportInput" class="cargo-import-input" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @change="selectBatchImportFile" />
<el-button :loading="batchTemplateDownloading" @click="downloadBatchTemplate"><Download :size="16" />下载正式模板</el-button>
<el-button type="primary" plain @click="batchImportInput?.click()"><Upload :size="16" />选择 Excel</el-button>
<span>{{ batchImportFile?.name || '尚未选择文件' }}</span>
</div>
<el-radio-group v-model="batchImportMode" aria-label="导入模式">
<el-radio value="PARTIAL">部分成功:有效行创建草稿,失败行返回原因</el-radio>
<el-radio value="ATOMIC">原子导入:任一行失败则整批不创建</el-radio>
</el-radio-group>
<section v-if="batchImportResult" class="batch-import-result" aria-live="polite">
<strong>批次 {{ batchImportResult.batchNo }}</strong>
<span>共 {{ batchImportResult.result.total }} 条,已创建草稿 {{ batchImportResult.result.succeeded }} 条,失败 {{ batchImportResult.result.failed }} 条</span>
<div v-if="batchImportResult.errorReportFileId" class="batch-import-result-actions">
<el-button type="danger" plain :loading="batchErrorReportDownloading" @click="downloadBatchErrorReport"><Download :size="16" />下载 Excel 错误报告</el-button>
</div>
<ul v-if="batchImportResult.result.failed">
<li v-for="item in batchImportResult.result.items.filter((row) => !row.success)" :key="item.index">
Excel 第 {{ batchImportExcelRow(item) }} 行:{{ item.errorMessage || item.errorCode || '导入失败' }}
</li>
</ul>
</section>
</div>
<template #footer><div class="editor-actions"><el-button v-if="batchImportResult?.result.succeeded && auth.hasPermission('order:first-leg:list')" :disabled="batchImportSubmitting" @click="viewImportedDrafts">查看订单草稿</el-button><el-button :disabled="batchImportSubmitting" @click="batchImportOpen = false">关闭</el-button><el-button type="primary" :loading="batchImportSubmitting" :disabled="!batchImportFile" @click="submitBatchImport">开始导入并创建草稿</el-button></div></template>
</el-dialog>
<section v-if="isDetailPage" class="first-leg-detail-page" aria-label="订单详情">
<div v-loading="detailLoading" class="order-detail-content">
<div v-if="detailError" class="order-load-state is-compact" role="alert">
<AlertTriangle :size="24" />
<div><strong>订单详情加载失败</strong><span>{{ detailError }}</span></div>
<el-button :loading="detailLoading" @click="retryDetail">重新加载</el-button>
</div>
<template v-else-if="detailOrder">
<div class="order-detail-heading">
<div class="detail-heading-main">
<el-button link type="primary" class="detail-back-action" @click="router.push({ name: 'first-leg-orders' })">← 返回列表</el-button>
<div><h2>{{ detailOrder.businessSummary.inboundCode || (detailOrder.status === 'DRAFT' ? '入仓码提交后生成' : '入仓码待生成') }}</h2><p>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }} · {{ detailOrder.origin || '起运地待更新' }} → {{ detailOrder.destination || '目的地待更新' }}</p></div>
</div>
<div class="detail-heading-actions"><el-tag :type="getFirstLegStatusTone(detailOrder.status)" effect="light">{{ getFirstLegStatusLabel(detailOrder.status) }}</el-tag><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" type="primary" plain :loading="openingEditor" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button></div>
</div>
<section class="detail-section">
<h3>订单与运输</h3>
<dl class="order-detail-grid">
<div><dt>入仓码</dt><dd><button v-if="detailOrder.businessSummary.inboundCode" class="inbound-code-link table-code" type="button" @click="openInboundCodeAssociation(detailOrder.businessSummary.inboundCode)">{{ detailOrder.businessSummary.inboundCode }}</button><span v-else>{{ detailOrder.status === 'DRAFT' ? '提交后生成' : '待生成' }}</span></dd></div>
<div><dt>创建时间</dt><dd>{{ formatDateTime(detailOrder.createdAt) }}</dd></div>
<div><dt>运输方式</dt><dd>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</dd></div>
<div><dt>贸易条款</dt><dd>{{ displayValue(detailOrder.tradeTerm) }}</dd></div>
<div><dt>发货方式</dt><dd>{{ pickupMethodLabels[detailOrder.pickupMethod] }}</dd></div>
<div><dt>预约提货时间</dt><dd>{{ detailOrder.pickupMethod === 'PICKUP' && detailOrder.requestedPickupAt ? formatDateTime(detailOrder.requestedPickupAt) : '-' }}</dd></div>
<div><dt>报关方式</dt><dd>{{ taxModeLabels[detailOrder.taxMode] }}</dd></div>
<div><dt>运输渠道</dt><dd>{{ detailOrder.channelName || detailOrder.channelCode || '-' }}<span v-if="detailOrder.channelName && detailOrder.channelCode" class="detail-subline">{{ detailOrder.channelCode }}</span></dd></div>
<div><dt>国内收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName || '—' }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code || '—' }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div>
<div><dt>收货要求</dt><dd>{{ displayValue(detailOrder.receivingTime) }}</dd></div>
<div><dt>目的仓类型</dt><dd>{{ destinationTypeLabels[detailOrder.destinationType] }}</dd></div>
<div><dt>目的国家 / 仓库</dt><dd>{{ detailOrder.destinationCountryCode || '-' }} · {{ detailOrder.destinationWarehouseName || '-' }}</dd></div>
<div v-if="detailOrder.destinationType === 'FBA'"><dt>Amazon 仓库代码</dt><dd>{{ displayValue(detailOrder.amazonWarehouseCode) }}</dd></div>
<div><dt>期望出运</dt><dd>{{ displayValue(detailOrder.expectedDepartureDate) }}</dd></div>
<div><dt>海关查验</dt><dd>{{ detailOrder.customsInspection == null ? '未选择' : (detailOrder.customsInspection ? '需要' : '不需要') }}</dd></div>
<div><dt>提交时间</dt><dd>{{ detailOrder.submittedAt ? formatDateTime(detailOrder.submittedAt) : '-' }}</dd></div>
<div><dt>完成时间</dt><dd>{{ detailOrder.completedAt ? formatDateTime(detailOrder.completedAt) : '-' }}</dd></div>
</dl>
</section>
<section class="detail-section">
<h3>收发货信息</h3>
<dl class="party-detail-grid">
<div><dt>发货方</dt><dd><strong>{{ detailOrder.shipperPartySnapshot?.name || detailOrder.shipper || '未提供' }}</strong><span v-if="detailOrder.shipperPartySnapshot">{{ detailOrder.shipperPartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.shipperPartySnapshot.phone || '未填写电话' }} · {{ detailOrder.shipperPartySnapshot.email || '未填写邮箱' }}<br />{{ detailOrder.shipperPartySnapshot.countryRegion }} {{ detailOrder.shipperPartySnapshot.province }} {{ detailOrder.shipperPartySnapshot.city }} {{ detailOrder.shipperPartySnapshot.address }} {{ detailOrder.shipperPartySnapshot.postalCode }}</span></dd></div>
<div><dt>收货方</dt><dd><strong>{{ detailOrder.consigneePartySnapshot?.name || detailOrder.consignee || detailOrder.destinationWarehouseName || '未提供' }}</strong><span v-if="detailOrder.consigneePartySnapshot">{{ detailOrder.consigneePartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.consigneePartySnapshot.phone || '未填写电话' }} · {{ detailOrder.consigneePartySnapshot.email || '未填写邮箱' }}<br />{{ detailOrder.consigneePartySnapshot.countryRegion }} {{ detailOrder.consigneePartySnapshot.province }} {{ detailOrder.consigneePartySnapshot.city }} {{ detailOrder.consigneePartySnapshot.address }} {{ detailOrder.consigneePartySnapshot.postalCode }}</span><span v-else>平台目的仓地址以最终履约安排为准</span></dd></div>
</dl>
<div v-if="detailOrder.customerRemark" class="customer-remark"><strong>客户备注</strong><p>{{ detailOrder.customerRemark }}</p></div>
</section>
<section v-if="detailOrder.status !== 'DRAFT'" class="detail-section">
<h3>关键业务摘要</h3>
<dl class="fulfillment-summary">
<div v-for="item in progressSummaryItems" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div>
</dl>
</section>
<OrderProgressPanel :order="detailOrder">
<template #cabinet>
<div class="cabinet-progress-detail">
<strong>柜次明细</strong>
<el-table v-if="cabinetProgressItems.length" :data="cabinetProgressItems" border size="small" class="cabinet-progress-table" row-key="cabinetId">
<el-table-column prop="cabinetNo" label="柜号 / 柜次" min-width="150"><template #default="{ row }"><span class="table-code">{{ row.cabinetNo || '—' }}</span></template></el-table-column>
<el-table-column label="船名 / 航次" min-width="180"><template #default="{ row }">{{ [row.vesselName, row.voyageNo].filter(Boolean).join(' · ') || '待更新' }}</template></el-table-column>
<el-table-column label="预计船期" width="170"><template #default="{ row }">{{ row.sailingAt ? formatDateTime(row.sailingAt) : '待更新' }}</template></el-table-column>
<el-table-column prop="status" label="当前节点" width="120"><template #default="{ row }"><el-tag size="small" effect="plain">{{ row.status || '待更新' }}</el-tag></template></el-table-column>
</el-table>
<p v-else class="detail-inline-empty">尚未分配柜次,后续柜次节点将在这里同步。</p>
</div>
</template>
</OrderProgressPanel>
<section class="detail-section">
<h3>履约快递信息</h3>
<dl v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><div><dt>快递公司</dt><dd>{{ detailOrder.expressCompanyName || '待平台补充' }}</dd></div><div><dt>快递单号</dt><dd class="table-code">{{ detailOrder.trackingNumber || '待平台补充' }}</dd></div></dl>
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>平台尚未维护快递信息</strong><span>快递公司和快递单号由平台后台维护,客户端仅只读展示。</span></div></div>
</section>
<section class="detail-section">
<h3>货物明细</h3>
<dl v-if="detailSummary" class="cargo-summary"><div><dt>明细</dt><dd>{{ detailOrder.items.length }} 项</dd></div><div><dt>件数</dt><dd>{{ detailSummary.pieces }}</dd></div><div><dt>毛重</dt><dd>{{ detailSummary.grossWeightKg.toLocaleString() }} kg</dd></div><div><dt>体积</dt><dd>{{ detailSummary.volumeCbm }} m³</dd></div></dl>
<div v-if="detailOrder.items.length" class="cargo-detail-table-wrap">
<el-table :data="pagedDetailCargoItems" border stripe class="cargo-detail-table" row-key="id">
<el-table-column type="index" label="#" width="54" align="center" :index="(index: number) => (detailCargoPage - 1) * detailCargoPageSize + index + 1" />
<el-table-column label="SKU NO." width="150" show-overflow-tooltip><template #default="{ row }"><span class="table-code table-ellipsis">{{ row.sku || '-' }}</span></template></el-table-column>
<el-table-column v-if="detailOrder.fulfillmentType === 'ONE_CLICK_DROPSHIP'" prop="packingGroupNo" label="混箱组合" width="100" align="center"><template #default="{ row }">{{ row.packingGroupNo || '-' }}</template></el-table-column>
<el-table-column label="中文品名" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.cargoNameCn || '-' }}</template></el-table-column>
<el-table-column label="图片" width="66" align="center"><template #default="{ row }"><div class="cargo-detail-thumbnail"><img v-if="row.productImagePreviewUrl" :src="row.productImagePreviewUrl" :alt="`${row.cargoNameCn || '货物'}产品图片`" /><span v-else>-</span></div></template></el-table-column>
<el-table-column label="英文品名" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.cargoNameEn || '-' }}</template></el-table-column>
<el-table-column label="包装类型" width="100" align="center"><template #default="{ row }">{{ packageTypeLabels[row.packageType] || row.packageType || '-' }}</template></el-table-column>
<el-table-column label="箱规 长×宽×高(cm)" width="170" align="center"><template #default="{ row }">{{ row.lengthCm || '-' }} × {{ row.widthCm || '-' }} × {{ row.heightCm || '-' }}</template></el-table-column>
<el-table-column prop="unitsPerPackage" label="每箱数量" width="88" align="right" />
<el-table-column prop="packageCount" label="箱数" width="72" align="right" />
<el-table-column prop="pieces" label="总数量" width="82" align="right" />
<el-table-column label="总毛重" width="94" align="right"><template #default="{ row }">{{ row.grossWeightKg }} kg</template></el-table-column>
<el-table-column label="总净重" width="94" align="right"><template #default="{ row }">{{ row.netWeightKg ? `${row.netWeightKg} kg` : '-' }}</template></el-table-column>
<el-table-column label="总体积" width="98" align="right"><template #default="{ row }">{{ row.volumeCbm }} m³</template></el-table-column>
<el-table-column label="报关 HS CODE" width="128" show-overflow-tooltip><template #default="{ row }"><span class="table-code">{{ row.hsCode || '-' }}</span></template></el-table-column>
<el-table-column label="清关 HS CODE" width="128" show-overflow-tooltip><template #default="{ row }"><span class="table-code">{{ row.customsHsCode || '-' }}</span></template></el-table-column>
<el-table-column label="申报单价" width="104" align="right"><template #default="{ row }">{{ row.unitDeclaredValue ? `USD ${row.unitDeclaredValue.toLocaleString()}` : '-' }}</template></el-table-column>
<el-table-column label="申报金额" width="104" align="right"><template #default="{ row }">{{ row.declaredValue ? `USD ${row.declaredValue.toLocaleString()}` : '-' }}</template></el-table-column>
<el-table-column label="货件编号" width="150" show-overflow-tooltip><template #default="{ row }"><span class="table-code table-ellipsis">{{ row.shipmentId || '-' }}</span></template></el-table-column>
<el-table-column label="跟踪编号" width="150" show-overflow-tooltip><template #default="{ row }"><span class="table-code table-ellipsis">{{ row.referenceId || '-' }}</span></template></el-table-column>
<el-table-column label="UPC NO." width="130" show-overflow-tooltip><template #default="{ row }"><span class="table-code table-ellipsis">{{ row.upc || '-' }}</span></template></el-table-column>
<el-table-column label="中英文材质" width="120" show-overflow-tooltip><template #default="{ row }">{{ row.material || '-' }}</template></el-table-column>
<el-table-column label="中英文用途" width="120" show-overflow-tooltip><template #default="{ row }">{{ row.usage || '-' }}</template></el-table-column>
<el-table-column label="型号" width="110" show-overflow-tooltip><template #default="{ row }">{{ row.model || '-' }}</template></el-table-column>
<el-table-column label="品牌" width="110" show-overflow-tooltip><template #default="{ row }">{{ row.brand || '-' }}</template></el-table-column>
<el-table-column label="产品规格 长×宽×高(cm)" width="180" align="center"><template #default="{ row }">{{ row.productLengthCm || '-' }} × {{ row.productWidthCm || '-' }} × {{ row.productHeightCm || '-' }}</template></el-table-column>
<el-table-column label="危险品" width="82" align="center"><template #default="{ row }">{{ row.dangerousGoods ? '是' : '否' }}</template></el-table-column>
<el-table-column label="木制品" width="82" align="center"><template #default="{ row }">{{ row.woodenProduct ? '是' : '否' }}</template></el-table-column>
<el-table-column label="备注" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.remark || '-' }}</template></el-table-column>
</el-table>
<el-pagination
v-model:current-page="detailCargoPage"
v-model:page-size="detailCargoPageSize"
class="cargo-detail-pagination"
:page-sizes="[5, 10, 20]"
:total="detailOrder.items.length"
layout="total, sizes, prev, pager, next"
/>
</div>
<el-empty v-else description="暂无货物明细" :image-size="56" />
</section>
<InboundBillingPanel
:data="inboundBilling"
:loading="inboundBillingLoading"
:unavailable="inboundBillingUnavailable"
:unavailable-message="inboundBillingUnavailableMessage"
:error="inboundBillingError"
:can-confirm="canConfirmInboundFee"
@retry="retryInboundBilling"
@confirmed="handleInboundFeeConfirmed"
/>
</template>
</div>
</section>
<InboundBillingPanel
v-if="feeConfirmationOpen"
dialog-only
auto-open
:data="inboundBilling"
:loading="inboundBillingLoading"
:unavailable="inboundBillingUnavailable"
unavailable-message="未推送计费重"
:error="inboundBillingError"
:can-confirm="canConfirmInboundFee"
@retry="retryInboundBilling"
@confirmed="handleInboundFeeConfirmed"
@closed="feeConfirmationOpen = false"
/>
<CustomsDocumentsDialog
v-model="customsDialogOpen"
:order-id="customsDialogOrderId"
:can-edit="auth.hasPermission('order:first-leg:edit')"
:initial-detail="customsByOrder[customsDialogOrderId]"
@updated="updateCustomsDetail"
/>
<InboundCodeAssociationDialog
v-model="inboundAssociationOpen"
:inbound-code="inboundAssociationCode"
/>
</section>
</template>
<style scoped>
.first-leg-page { display: flex; min-width: 0; flex-direction: column; gap: var(--client-section-gap); }
:global(.client-main:has(> .first-leg-page:not(.is-create-route)) > .client-page-heading) { margin-bottom: 0; }
:global(.client-main:has(> .first-leg-page:not(.is-create-route)) > .client-page-heading > div) { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; border: 0; clip: rect(0 0 0 0); white-space: nowrap; }
:global(.client-main:has(> .first-leg-page.is-detail-route) > .client-page-heading) { display: none; }
.order-eligibility { display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px; border: 1px solid; }
.order-eligibility svg { flex: 0 0 20px; margin-top: 1px; }
.order-eligibility div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
.order-eligibility strong { color: var(--navy); font-size: 13px; }
.order-eligibility span { color: var(--muted); font-size: 11px; line-height: 1.55; }
.order-eligibility.is-pending { border-color: #e5d2a5; background: #fff9eb; color: #9a6a12; }
.supporting-data-error { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 14px 16px; border: 1px solid #e5d2a5; background: #fff9eb; color: #9a6a12; }
.supporting-data-error > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.supporting-data-error strong { color: var(--navy); font-size: 13px; }
.supporting-data-error span { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.55; }
.order-row-actions, .editor-actions { display: flex; justify-content: flex-end; gap: 8px; }
.order-table-card { padding: 0; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); box-shadow: none; }
.order-queue-tabs { display: flex; align-items: center; gap: 4px; }
.order-queue-tabs button { position: relative; min-height: 34px; padding: 0 12px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: #667085; cursor: pointer; font: inherit; font-weight: 600; }
.order-queue-tabs button:hover { color: var(--client-action); }
.order-queue-tabs button.active { border-color: #cfe0ff; background: #f3f7ff; color: var(--client-action); }
.order-queue-tabs button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.queue-count { position: absolute; display: grid; min-width: 18px; height: 18px; place-items: center; padding: 0 4px; border: 2px solid #fff; border-radius: 999px; background: #ef5350; color: #fff; font-size: 10px; line-height: 1; right: -4px; top: -8px; }
.batch-import-dialog { display: flex; flex-direction: column; gap: 18px; }
.batch-import-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 10px; }
.batch-import-actions > span { min-width: 0; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.batch-import-dialog :deep(.el-radio-group) { display: flex; align-items: flex-start; flex-direction: column; gap: 8px; }
.batch-import-result { display: flex; flex-direction: column; gap: 6px; padding: 14px; border: 1px solid var(--border); background: #f8fafc; }
.batch-import-result > span, .batch-import-result li { color: var(--muted); font-size: 12px; line-height: 1.55; }
.batch-import-result-actions { display: flex; margin-top: 4px; }
.batch-import-result ul { max-height: 180px; margin: 4px 0 0; padding-left: 20px; overflow-y: auto; }
.column-settings-trigger { min-width: 116px; height: 36px; padding: 0 12px; color: #53627b; }
:global(.order-column-popper.el-popover) { padding: 0; border-color: #e3e8f0; border-radius: 4px; box-shadow: 0 8px 24px rgba(29, 45, 74, .12); }
:global(.order-column-popper .column-settings-panel) { padding: 14px 10px 10px; }
:global(.order-column-popper .column-settings-panel > p) { margin: 0 8px 10px; color: #778398; font-size: 12px; line-height: 1.5; }
:global(.order-column-popper .el-checkbox-group) { max-height: 430px; overflow-y: auto; padding: 0 2px; scrollbar-width: thin; }
:global(.order-column-popper .column-settings-item) { display: grid; min-height: 42px; grid-template-columns: 24px minmax(0, 1fr) 28px 28px; align-items: center; padding: 0 4px; color: #596982; }
:global(.order-column-popper .column-settings-item:hover) { background: #f7f8fb; }
:global(.order-column-popper .column-settings-item > svg) { cursor: grab; color: #71809a; }
:global(.order-column-popper .column-settings-item > svg:active) { cursor: grabbing; }
:global(.order-column-popper .column-settings-item .el-checkbox) { height: 42px; margin-right: 0; overflow: hidden; }
:global(.order-column-popper .column-settings-item .el-checkbox__label) { overflow: hidden; color: #4f5f78; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
:global(.order-column-popper .column-settings-item .el-checkbox.is-checked .el-checkbox__label) { color: var(--primary); }
:global(.order-column-popper .column-settings-item .el-button) { width: 28px; height: 28px; padding: 0; margin: 0; color: #45556e; }
:global(.order-column-popper .column-settings-reset) { min-height: 36px; margin: 8px 6px 0; color: #40516b; }
.table-ellipsis, .table-multivalue { display: block; width: 100%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.table-code { font-variant-numeric: tabular-nums; letter-spacing: 0; }
.order-table-card > .order-eligibility, .order-table-card > .supporting-data-error { border-width: 0 0 1px; }
.order-load-state { display: grid; min-height: 220px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 28px; color: #9a5a09; }
.order-load-state > div { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
.order-load-state strong { color: var(--navy); font-size: 14px; }
.order-load-state span { overflow-wrap: anywhere; color: var(--muted); font-size: 12px; line-height: 1.55; }
.order-load-state.is-compact { min-height: 180px; }
.first-leg-detail-page { min-width: 0; padding: 10px 14px 20px; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
.order-detail-content { min-height: 360px; }
.inbound-code-primary { display: block; width: 100%; max-width: 100%; overflow: hidden; padding: 0; border: 0; background: none; color: var(--client-action); cursor: pointer; font-size: var(--client-font-size); font-weight: 700; line-height: 18px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.pending-inbound-code { color: var(--muted); font-size: 12px; }
.inbound-code-link { max-width: 100%; overflow: hidden; padding: 0; border: 0; background: transparent; color: var(--client-action); cursor: pointer; text-align: left; text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 3px; text-overflow: ellipsis; white-space: nowrap; }
.inbound-code-link:hover, .inbound-code-link:focus-visible { text-decoration-color: currentColor; }
.inbound-code-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.inbound-code-primary:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.order-reference { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; font-weight: 400; }
.order-row-actions { align-items: center; justify-content: center; gap: 6px; white-space: nowrap; }
.order-row-actions :deep(.el-button) { margin-left: 0; padding: 3px 1px; font-size: 11px; }
.is-create-route { gap: 0; }
.is-create-route .first-leg-editor-page { width: 100%; }
.is-create-route .supporting-data-error, .is-create-route > .el-alert { margin-bottom: 14px; }
.is-create-route .prototype-order-form { width: 100%; margin: 0; padding: 0 0 16px; }
.is-create-route .prototype-section:not(.cargo-section):not(.cost-estimate-section):not(.destination-section) { width: min(100%, 900px); }
.order-form { width: min(100%, 1000px); margin: 0; padding: 0 0 12px; }
.order-form.is-dialog-order-form { width: 100%; max-width: none; }
.is-dialog-order-form > .cargo-section, .is-dialog-order-form > .cost-estimate-section { width: 100%; max-width: none; }
.order-form :deep(.el-form-item__error) { position: static; padding-top: 6px; overflow-wrap: anywhere; line-height: 1.45; }
.order-form-section + .order-form-section { margin-top: 30px; padding-top: 28px; border-top: 1px solid #edf0f5; }
.order-form-section h3 { margin: 0 0 16px; color: var(--navy); font-size: 16px; }
.order-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 18px; }
.order-form-grid > *, .cargo-form-grid > * { min-width: 0; }
.order-form :deep(.el-select), .order-form :deep(.el-date-editor), .order-form :deep(.el-input-number) { width: 100%; }
.form-span-full { grid-column: 1 / -1; }
.prototype-section { padding: 4px 0 0; }
.prototype-section:first-child > .order-section-heading { margin-top: -10px; }
.prototype-section:first-child > .order-section-heading h3 { margin-bottom: 6px; font-size: 20px; font-weight: 700; line-height: 1.35; }
.segmented-options { display: flex; width: min(100%, 560px); overflow: hidden; border: 1px solid #e3e8f0; border-radius: 5px; background: #fff; }
.segmented-options :deep(.el-radio-button) { flex: 1; }
.segmented-options :deep(.el-radio-button__inner) { display: flex; width: 100%; min-height: 42px; box-sizing: border-box; align-items: center; justify-content: center; padding: 10px 16px; border: 0; border-right: 1px solid #e3e8f0; border-radius: 0; box-shadow: none; color: #526070; font-size: 13px; }
.segmented-options :deep(.el-radio-button:last-child .el-radio-button__inner) { border-right: 0; }
.segmented-options :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { border-color: var(--primary); background: var(--primary); box-shadow: none; color: #fff; }
.warehouse-options { display: flex; width: 100%; align-items: center; flex-wrap: wrap; gap: 10px 22px; padding-top: 9px; }
.warehouse-options :deep(.el-radio) { height: auto; margin-right: 0; color: #4d5a6c; }
.warehouse-options :deep(.el-radio__label) { padding-left: 7px; font-size: 13px; }
.order-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
.order-section-heading h3 { margin-bottom: 5px; }
.order-section-heading p { margin: 0; color: #8b96a6; font-size: 12px; }
.prototype-form-flow { display: flex; max-width: 860px; flex-direction: column; padding-top: 2px; }
.prototype-form-row { display: grid; width: 100%; grid-template-columns: 114px minmax(0, 560px); align-items: start; margin-bottom: 16px; }
.prototype-form-row--wide { grid-template-columns: 114px minmax(0, 760px); }
.prototype-form-row--compact { grid-template-columns: 114px minmax(0, 420px); }
.prototype-form-row--short { grid-template-columns: 114px minmax(0, 280px); }
.prototype-form-row--tabs { grid-template-columns: 114px minmax(0, 560px); margin-bottom: 18px; }
.prototype-form-row :deep(.el-form-item__label) { width: auto !important; min-height: 40px; justify-content: flex-end; padding: 0 18px 0 0; line-height: 40px; color: #4d5a6c; font-size: 13px; }
.prototype-form-row :deep(.el-form-item__content) { display: block; min-width: 0; line-height: normal; }
.prototype-form-row :deep(.el-input__wrapper), .prototype-form-row :deep(.el-select__wrapper) { min-height: 40px; box-shadow: 0 0 0 1px #dfe5ed inset; }
.address-book-control { display: grid; grid-template-columns: minmax(260px, 1fr) auto auto; gap: 10px; align-items: center; }
.address-book-control :deep(.el-button) { min-height: 40px; margin-left: 0; }
.field-guidance { display: block; margin-top: 5px; color: var(--muted); line-height: 1.45; }
.field-guidance--error { color: #b54708; }
.cost-estimate-section { width: 100%; }
.cost-estimate-section .order-section-heading { margin-bottom: 12px; }
.price-estimate { min-height: 126px; overflow: hidden; border: 1px solid #dde4f2; border-radius: 8px; background: #fff; box-shadow: 0 2px 8px rgba(15, 31, 58, .025); }
.price-estimate__state { display: flex; min-height: 126px; align-items: center; justify-content: center; gap: 10px; padding: 24px; color: #7b8798; font-size: 12px; text-align: center; }
.price-estimate__state.is-error { justify-content: flex-start; background: #fffaf0; color: #b54708; text-align: left; }
.price-estimate__state.is-error > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 4px; }
.price-estimate__state.is-error strong { color: #7a2e0e; font-size: 13px; }
.price-estimate__state.is-error span { overflow-wrap: anywhere; }
.price-estimate__result { display: grid; grid-template-columns: minmax(180px, 1.15fr) minmax(220px, 1.25fr) minmax(170px, .9fr) minmax(140px, .8fr) minmax(190px, 1fr); align-items: stretch; }
.price-estimate__result > div { display: flex; min-width: 0; min-height: 96px; justify-content: center; flex-direction: column; gap: 7px; padding: 18px 20px; border-right: 1px solid #edf0f5; }
.price-estimate__result > div > span { color: #8b96a6; font-size: 11px; }
.price-estimate__result > div > strong { overflow-wrap: anywhere; color: #303b50; font-size: 15px; }
.price-estimate__comparison strong { font-size: 13px !important; font-weight: 600; }
.price-estimate__comparison small, .price-estimate__chargeable small { color: #7b8798; font-size: 10px; line-height: 1.4; }
.price-estimate__chargeable strong { color: #526fd8 !important; font-size: 18px !important; }
.price-estimate__channel small { color: #7b8798; font-size: 10px; line-height: 1.5; }
.price-estimate__result .price-estimate__amount { border-right: 0; background: #f8f8ff; }
.price-estimate__result .price-estimate__amount strong { color: #637fec; font-size: 25px; }
.price-estimate__result > small { grid-column: 1 / -1; padding: 9px 14px; border-top: 1px solid #edf0f5; color: #98a0ad; font-size: 10px; text-align: right; }
.selected-destination { display: flex; width: min(100%, 720px); align-items: flex-start; gap: 9px; margin: -4px 0 18px 140px; padding: 10px 12px; background: #f2f7f6; color: var(--primary); }
.selected-destination svg { flex: 0 0 17px; margin-top: 2px; }
.selected-destination div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
.selected-destination span { color: var(--navy); font-size: 12px; font-weight: 600; }
.selected-destination small { color: var(--muted); font-size: 11px; line-height: 1.5; }
.destination-section { width: 100%; }
.destination-section-heading { margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #edf0f5; }
.destination-section-heading h3 { margin-bottom: 0; }
.destination-form-flow { width: min(100%, 900px); padding-left: 0; }
.destination-type-row { grid-template-columns: 114px minmax(0, 300px); margin-bottom: 16px; }
.destination-type-options { width: 300px; }
.destination-type-options :deep(.el-radio-button__inner) { min-height: 36px; padding: 8px 14px; font-size: 12px; }
.destination-form-flow > .prototype-form-row:not(.destination-type-row):not(.prototype-form-row--wide):not(.prototype-form-row--compact):not(.prototype-form-row--short) { grid-template-columns: 114px minmax(0, 360px); }
.flow-divider { width: min(100%, 860px); margin: 4px 0 22px; border-top: 1px solid var(--border); }
.compact-transport-grid { width: min(100%, 820px); grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 28px; margin: 20px 0 0 114px; }
.compact-transport-grid :deep(.el-form-item__label) { color: #4d5a6c; font-size: 13px; }
.shipping-form-flow { max-width: 900px; }
.prototype-order-form > .prototype-section:first-child { width: 100%; }
.shipping-form-flow .prototype-form-row { margin-bottom: 10px; }
.shipping-form-flow .prototype-form-row--tabs { margin-bottom: 12px; }
.compact-transport-grid { display: grid; width: 100%; grid-template-columns: 1fr; gap: 0; margin: 6px 0 0; }
.compact-transport-grid > :deep(.el-form-item) { display: grid; width: 100%; grid-template-columns: 114px minmax(0, 560px); align-items: start; margin-bottom: 10px; }
.compact-transport-grid > :deep(.pricing-tax-mode-field) { display: none; }
.compact-transport-grid > :deep(.el-form-item:nth-child(1)) { grid-template-columns: 114px minmax(0, 360px); }
.compact-transport-grid > :deep(.el-form-item:nth-child(6)) { grid-template-columns: 114px minmax(0, 360px); }
.compact-transport-grid > :deep(.el-form-item:nth-child(7)) { grid-template-columns: 114px minmax(0, 620px); margin-top: 2px; margin-bottom: 0; }
.compact-transport-grid > :deep(.el-form-item__label) { width: auto !important; min-height: 40px; justify-content: flex-end; padding: 0 18px 0 0; line-height: 40px; }
.compact-transport-grid > :deep(.el-form-item__content) { display: block; min-width: 0; line-height: normal; }
.compact-transport-grid > :deep(.el-form-item .el-input__wrapper), .compact-transport-grid > :deep(.el-form-item .el-select__wrapper) { min-height: 40px; }
.cargo-section { min-width: 0; padding-bottom: 4px; }
.cargo-table-heading { margin-bottom: 8px; }
.cargo-table-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 10px; }
.cargo-import-actions { display: flex; align-items: center; gap: 10px; }
.cargo-import-input { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; pointer-events: none; }
.cargo-import-actions :deep(.el-button) { min-height: 38px; margin-left: 0; padding: 8px 17px; }
.cargo-import-actions .cargo-upload-button { border-style: dashed; border-width: 2px; border-color: #b8c5ff; color: #637fec; }
.cargo-import-actions .cargo-upload-button:hover, .cargo-import-actions .cargo-upload-button:focus-visible { border-color: var(--primary); background: #f5f7ff; color: var(--primary); }
.cargo-import-actions .cargo-download-button { color: #4d5a6c; }
.cargo-import-actions .cargo-download-button :deep(svg) { color: #09a8d8; }
.cargo-table-actions { display: flex; align-items: center; gap: 8px; }
.cargo-table-actions :deep(.el-button) { margin-left: 0; }
.cargo-table-wrap { width: 100%; overflow-x: auto; border: 1px solid #e6eaf0; border-radius: 6px; background: #fff; scrollbar-color: #c8ced8 #f1f3f6; scrollbar-width: thin; }
.cargo-edit-table { width: max-content; min-width: 100%; border-collapse: collapse; table-layout: fixed; color: #4d5a6c; font-size: 12px; }
.cargo-edit-table th { height: 44px; padding: 7px 8px; border-bottom: 1px solid #e6eaf0; background: #f4f5fb; color: #637fec; font-size: 11px; font-weight: 650; text-align: left; white-space: nowrap; }
.cargo-edit-table th small { margin-left: 3px; color: #e8a20c; font-size: 9px; font-weight: 600; }
.cargo-edit-table td { width: 130px; height: 58px; padding: 7px 6px; border-bottom: 1px solid #edf0f5; background: #fff; vertical-align: top; }
.cargo-edit-table tbody tr:last-child td { border-bottom: 0; }
.cargo-edit-table :deep(.el-form-item) { margin-bottom: 0; }
.cargo-edit-table :deep(.el-form-item__content) { display: block; line-height: normal; }
.cargo-edit-table :deep(.el-form-item__error) { width: 100%; padding: 3px 2px 0; font-size: 9px; white-space: normal; }
.cargo-edit-table :deep(.el-input), .cargo-edit-table :deep(.el-select), .cargo-edit-table :deep(.el-input-number) { width: 100%; }
.cargo-edit-table :deep(.el-input__wrapper), .cargo-edit-table :deep(.el-select__wrapper) { min-height: 34px; padding: 1px 8px; box-shadow: none; }
.cargo-edit-table :deep(.el-input__wrapper:hover), .cargo-edit-table :deep(.el-select__wrapper:hover) { box-shadow: 0 0 0 1px #b9c8f5 inset; }
.cargo-edit-table :deep(.el-input__inner) { font-size: 11px; text-align: left; }
.cargo-check-cell, .cargo-index-cell { width: 38px !important; text-align: center !important; vertical-align: middle !important; }
.cargo-image-column { width: 62px !important; text-align: center !important; }
.cargo-number-column { width: 92px !important; }
.required-field-mark { margin-right: 2px; color: var(--el-color-danger); }
.cargo-dimensions-column { width: 240px !important; }
.cargo-operation-column { width: 76px !important; text-align: center !important; }
.cargo-table-wrap.is-dropship .cargo-operation-column { width: 148px !important; }
.cargo-sku-input { position: relative; width: 100%; }
.mixed-sku-badge { position: absolute; top: -10px; right: 2px; padding: 1px 5px; border-radius: 999px; background: #eaf6f2; color: var(--primary); font-size: 9px; font-weight: 650; line-height: 16px; white-space: nowrap; }
.mixed-sku-detail-row { background: #f8fbff; }
.shared-carton-value { display: inline-flex; min-height: 30px; align-items: center; color: #8a97a8; font-size: 12px; white-space: nowrap; }
.derived-sku-value { display: inline-flex; min-height: 30px; align-items: center; color: #172b4d; font-weight: 650; }
.cargo-switch-column { width: 112px !important; text-align: center !important; vertical-align: middle !important; }
.cargo-switch-cell { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 4px; }
.cargo-switch-cell small { color: #b54708; font-size: 9px; line-height: 1.25; white-space: normal; }
.cargo-table-image { position: relative; display: flex; width: 34px; height: 34px; align-items: center; justify-content: center; margin: 0 auto; border: 1px dashed #d6dce5; border-radius: 4px; color: #788493; }
.cargo-table-image img { width: 100%; height: 100%; border-radius: 3px; object-fit: contain; }
.cargo-table-image :deep(.el-button) { width: 30px; height: 30px; padding: 0; }
.cargo-table-image .cargo-image-remove { position: absolute; top: -9px; right: -9px; width: 19px; height: 19px; border: 1px solid #ffd7d7; border-radius: 50%; background: #fff; }
.cargo-table-dimensions { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr); gap: 3px; align-items: center; }
.cargo-table-dimensions > span { color: #9aa4b2; }
.cargo-table-hint { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; padding: 10px 2px 0; color: #8b96a6; font-size: 11px; }
.cargo-table-hint::before { color: #e5a512; content: '●'; font-size: 8px; }
.cargo-table-hint i { width: 3px; height: 3px; border-radius: 50%; background: #aab2bd; }
.cargo-entry { padding: 18px 20px; border: 1px solid #e2e7ee; background: #fff; box-shadow: 0 2px 8px rgba(15, 31, 58, .025); }
.cargo-entry + .cargo-entry { margin-top: 12px; }
.cargo-entry > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.cargo-field-section + .cargo-field-section { margin-top: 18px; padding-top: 16px; border-top: 1px solid #edf0f5; }
.cargo-field-section h4 { margin: 0 0 14px; color: #516071; font-size: 12px; }
.cargo-form-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); column-gap: 12px; }
.cargo-form-grid--basic { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.product-image-field { grid-row: span 2; }
.product-image-control { display: grid; grid-template-columns: 78px minmax(0, 1fr); align-items: center; gap: 7px 10px; width: 100%; }
.product-image-preview, .product-image-placeholder { position: relative; display: flex; width: 78px; height: 78px; grid-row: 1 / span 3; align-items: center; justify-content: center; overflow: hidden; border: 1px dashed #cbd5da; background: #fff; color: var(--muted); }
.product-image-preview img { width: 100%; height: 100%; object-fit: contain; }
.product-image-preview :deep(.el-button) { position: absolute; top: 4px; right: 4px; width: 26px; height: 26px; }
.product-image-placeholder { flex-direction: column; gap: 5px; font-size: 10px; }
.product-image-control small { color: var(--muted); font-size: 10px; line-height: 1.4; }
.product-image-error { grid-column: 2; overflow-wrap: anywhere; color: #b42318; font-size: 10px; line-height: 1.4; }
.product-dimensions-field, .cargo-remark-field { grid-column: span 2; }
.dimension-inputs { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 6px; }
.dimension-inputs > span { color: var(--muted); }
.cargo-detail-image { display: flex; width: 112px; height: 112px; align-items: center; justify-content: center; margin-top: 12px; overflow: hidden; border: 1px solid var(--border); background: #fff; color: var(--muted); font-size: 10px; text-align: center; }
.cargo-detail-image img { width: 100%; height: 100%; object-fit: contain; }
.order-detail-heading { display: flex; min-height: 50px; align-items: center; justify-content: space-between; gap: 16px; padding: 5px 0; border-bottom: 1px solid var(--border); }
.detail-heading-main, .detail-heading-actions { display: flex; min-width: 0; align-items: center; gap: 12px; }
.detail-heading-main > div { min-width: 0; }
.detail-back-action, .detail-heading-actions { flex: 0 0 auto; }
.order-detail-heading h2 { margin: 0; overflow: hidden; color: var(--navy); font-size: 18px; text-overflow: ellipsis; white-space: nowrap; }
.order-detail-heading p { margin: 3px 0 0; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.order-detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; margin: 0; border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
.order-detail-grid div { display: grid; min-width: 0; min-height: 34px; grid-template-columns: 78px minmax(0, 1fr); align-items: center; gap: 7px; padding: 3px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.order-detail-grid dt { color: var(--muted); font-size: 10px; white-space: nowrap; }
.order-detail-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-size: 12px; font-weight: 600; }
.detail-subline { display: block; margin-top: 2px; color: var(--muted); font-size: 9px; font-weight: 400; line-height: 1.35; }
.detail-section { margin-top: 10px; }
.detail-section h3 { margin: 0; padding: 0 0 5px; border-bottom: 1px solid #eaf0f7; color: var(--navy); font-size: 13px; }
.party-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-left: 1px solid var(--border); }
.party-detail-grid > div { display: grid; min-width: 0; grid-template-columns: 62px minmax(0, 1fr); gap: 7px; padding: 5px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.party-detail-grid dt { color: var(--muted); font-size: 10px; }
.party-detail-grid dd { min-width: 0; margin: 0; }
.party-detail-grid strong { display: block; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; }
.party-detail-grid span { display: block; margin-top: 3px; overflow-wrap: anywhere; color: var(--muted); font-size: 10px; line-height: 1.5; }
.customer-remark p { margin: 7px 0 0; overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.65; }
.customer-remark { margin-top: 7px; padding: 8px 10px; background: #f2f7f6; }
.customer-remark strong { color: var(--navy); font-size: 11px; }
.fulfillment-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; border-left: 1px solid var(--border); }
.fulfillment-summary > div { display: grid; min-width: 0; min-height: 34px; grid-template-columns: 70px minmax(0, 1fr); align-items: center; gap: 7px; padding: 3px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.fulfillment-summary dt { color: var(--muted); font-size: 10px; }
.fulfillment-summary dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 11px; font-weight: 650; }
.cabinet-progress-detail { border-top: 1px solid var(--border); }
.cabinet-progress-detail > strong { display: block; padding: 7px 0 5px; color: var(--navy); font-size: 11px; }
.cabinet-progress-table { margin-top: 0; }
.detail-inline-empty { margin: 0; padding: 7px 9px; background: #f7f9fb; color: var(--muted); font-size: 10px; }
.express-information { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-left: 1px solid var(--border); }
.express-information > div { display: grid; min-width: 0; min-height: 40px; grid-template-columns: 70px minmax(0, 1fr); align-items: center; gap: 8px; padding: 6px 10px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.express-information dt { color: var(--muted); font-size: 10px; }
.express-information dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 11px; font-weight: 650; }
.express-empty { display: flex; align-items: center; gap: 9px; padding: 7px 9px; border: 1px dashed #cbd5da; color: var(--muted); }
.express-empty svg { flex: 0 0 20px; color: var(--primary); }
.express-empty div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.express-empty strong { color: var(--navy); font-size: 13px; }
.express-empty span { font-size: 11px; line-height: 1.5; }
.cargo-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; border-left: 1px solid var(--border); }
.cargo-summary div { display: grid; min-width: 0; min-height: 34px; grid-template-columns: 54px minmax(0, 1fr); align-items: center; gap: 7px; padding: 3px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.cargo-summary dt { color: var(--muted); font-size: 10px; }
.cargo-summary dd { margin: 0; color: var(--navy); font-size: 11px; font-weight: 700; }
.cargo-detail-table-wrap { overflow: hidden; margin-top: 10px; border: 1px solid var(--border); border-radius: 5px; background: #fff; }
.cargo-detail-table { width: 100%; font-size: 11px; }
.cargo-detail-table :deep(.el-table__header th.el-table__cell) { height: 42px; padding: 0; background: #f7f8fb; color: #657187; font-size: 11px; font-weight: 650; white-space: nowrap; }
.cargo-detail-table :deep(.el-table__body td.el-table__cell) { height: 48px; padding: 0; color: #4f5b6d; vertical-align: middle; }
.cargo-detail-table :deep(.cell) { min-width: 0; padding: 0 10px; line-height: 18px; white-space: nowrap; }
.cargo-detail-thumbnail { display: flex; width: 34px; height: 34px; align-items: center; justify-content: center; margin: 0 auto; overflow: hidden; border: 1px solid #e1e6ee; border-radius: 3px; color: var(--muted); }
.cargo-detail-thumbnail img { width: 100%; height: 100%; object-fit: contain; }
.cargo-detail-pagination { min-height: 52px; justify-content: flex-end; box-sizing: border-box; padding: 9px 14px; border-top: 1px solid var(--border); }
:deep(.el-timeline-item__content p), :deep(.el-timeline-item__content > span) { color: var(--muted); }
:deep(.el-timeline-item__content p) { margin: 5px 0; font-size: 12px; }
:deep(.el-timeline-item__content > span) { font-size: 11px; }
@media (max-width: 1280px) {
.first-leg-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.order-detail-grid, .fulfillment-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 721px) and (max-width: 1050px) {
.cargo-form-grid, .cargo-form-grid--basic { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.product-image-field { grid-row: auto; }
}
@media (max-width: 720px) {
.first-leg-query-toolbar { padding: 12px; }
.first-leg-query-toolbar :deep(.query-toolbar__filters) { display: flex; }
:global(.client-main:has(> .first-leg-page:not(.is-create-route)) > .client-page-heading) { margin-bottom: 10px; }
.order-eligibility { flex-wrap: wrap; }
.order-eligibility .el-button { width: 100%; min-height: 44px; }
.order-form-grid, .order-detail-grid, .cargo-form-grid, .cargo-form-grid--basic, .party-detail-grid { grid-template-columns: 1fr; }
.product-image-field { grid-row: auto; }
.product-dimensions-field, .cargo-remark-field { grid-column: auto; }
.fulfillment-summary { grid-template-columns: 1fr; }
.fulfillment-summary > div { border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.fulfillment-summary > div:last-child { border-bottom: 0; }
.prototype-form-row, .prototype-form-row--wide, .prototype-form-row--compact, .prototype-form-row--short, .prototype-form-row--tabs { display: block; margin-bottom: 18px; }
.prototype-form-row :deep(.el-form-item__label) { min-height: auto; justify-content: flex-start; padding: 0 0 8px; line-height: 1.4; }
.selected-destination { width: 100%; margin-left: 0; }
.address-book-control { grid-template-columns: 1fr auto; }
.address-book-control :deep(.el-select) { grid-column: 1 / -1; }
.segmented-options { width: 100%; align-items: stretch; flex-direction: column; }
.segmented-options :deep(.el-radio-button__inner) { border: 1px solid var(--border); border-radius: 6px !important; box-shadow: none !important; }
.warehouse-options { width: 100%; gap: 12px 18px; }
.compact-transport-grid > :deep(.el-form-item), .compact-transport-grid > :deep(.el-form-item:nth-child(7)) { display: block; margin-bottom: 14px; }
.compact-transport-grid > :deep(.pricing-tax-mode-field) { display: none; }
.compact-transport-grid > :deep(.el-form-item__label) { min-height: auto; justify-content: flex-start; padding: 0 0 8px; line-height: 1.4; }
.cargo-table-toolbar { align-items: stretch; flex-direction: column; }
.cargo-import-actions, .cargo-table-actions { flex-wrap: wrap; }
.price-estimate__result { grid-template-columns: 1fr; }
.price-estimate__result > div { min-height: auto; border-right: 0; border-bottom: 1px solid #edf0f5; }
.price-estimate__result > small { grid-column: auto; }
.price-estimate__result > small { text-align: left; }
.price-estimate__state.is-error { align-items: flex-start; flex-direction: column; }
.form-span-full { grid-column: auto; }
.editor-actions :deep(.el-button) { min-height: 44px; }
.order-load-state { min-height: 240px; grid-template-columns: 1fr; justify-items: start; }
.supporting-data-error { grid-template-columns: auto minmax(0, 1fr); }
.supporting-data-error .el-button { grid-column: 1 / -1; width: 100%; min-height: 44px; }
.order-load-state .el-button { width: 100%; min-height: 44px; }
.express-information { grid-template-columns: 1fr; }
.cargo-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cargo-summary div:nth-child(2) { border-right: 0; }
.cargo-summary div:nth-child(-n+2) { border-bottom: 1px solid var(--border); }
}
@media (max-width: 360px) {
.cargo-detail-pagination { justify-content: flex-start; overflow-x: auto; }
}
@media (max-width: 480px) {
.first-leg-detail-page { padding-bottom: 22px; }
.order-detail-heading { align-items: flex-start; flex-direction: column; }
.detail-heading-main { width: 100%; align-items: flex-start; }
.detail-heading-actions { width: 100%; justify-content: flex-end; }
.editor-actions { display: grid; width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.editor-actions :deep(.el-button) { width: 100%; min-width: 0; margin-left: 0; }
.editor-actions :deep(.el-button:nth-child(3)) { grid-column: 1 / -1; }
}
</style>