Files
client-web/src/modules/first-leg/views/FirstLegOrdersView.vue
T
2026-08-13 17:12:05 +08:00

2219 lines
149 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, Box, ChevronDown, ChevronUp, Download, Eye, FilePlus2, 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 { ApiError } from '@/api/http'
import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue'
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue'
import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling'
import { getMerchantCustomsByOrder, isMerchantCustomsUnavailable, type MerchantCustomsCaseDetail } from '@/modules/first-leg/api/customs'
import {
estimateMerchantFirstLegPrice,
downloadMerchantOrderCartonLabelsBatch,
downloadMerchantProductImage,
getMerchantPricingChannels,
uploadMerchantProductImage,
type MerchantPriceEstimate,
type MerchantPriceEstimateRequest,
type MerchantPricingChannel,
} 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 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'
const store = useFirstLegOrdersStore()
const tenantStore = useTenantAdministrationStore()
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const props = withDefaults(defineProps<{ createPage?: boolean }>(), { createPage: false })
const isCreatePage = computed(() => props.createPage)
const routeDraftId = computed(() => typeof route.params.draftId === 'string' ? route.params.draftId : '')
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 availableWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC'))
const availableDestinationWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === form.destinationType))
const filters = ref<QueryModel>({ orderNo: '', receivingWarehouseId: '', channelName: '', cargoKeyword: '', createdRange: [], status: '', destinationType: '', trackingNumber: '' })
const appliedFilters = ref<QueryModel>({ ...filters.value })
const queryFields = computed<QueryField[]>(() => [
{ key: 'orderNo', label: '订单号', type: 'input', placeholder: '请输入订单号', props: { 'aria-label': '订单号' } },
{ 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: 'cargoKeyword', label: '入仓码 / SKU', type: 'input', placeholder: '请输入入仓码或 SKU', props: { 'aria-label': '入仓码或 SKU' } },
{ 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 channelLoading = ref(false)
const channelError = ref('')
const priceEstimate = ref<MerchantPriceEstimate | null>(null)
const priceEstimateLoading = ref(false)
const priceEstimateError = ref('')
const selectedCargoIds = ref<string[]>([])
const cargoImportInput = ref<HTMLInputElement>()
const cargoTemplateColumns = [
['SKU NO.', 'sku'], ['中文品名', 'cargoNameCn'], ['英文品名', 'cargoNameEn'], ['报关 HS CODE', 'hsCode'], ['清关 HS CODE', 'customsHsCode'],
['包装类型', 'packageType'], ['箱规长 cm', 'lengthCm'], ['箱规宽 cm', 'widthCm'], ['箱规高 cm', 'heightCm'], ['每箱数量', 'unitsPerPackage'],
['箱数', 'packageCount'], ['总毛重', 'grossWeightKg'], ['总体积', 'volumeCbm'], ['总数量', 'pieces'], ['总净重', 'netWeightKg'],
['清关申报单价 USD', 'unitDeclaredValue'], ['清关申报金额 USD', 'declaredValue'], ['材质', 'material'], ['用途', 'usage'], ['型号', 'model'],
['品牌', 'brand'], ['产品长 cm', 'productLengthCm'], ['产品宽 cm', 'productWidthCm'], ['产品高 cm', 'productHeightCm'], ['危险品', 'dangerousGoods'],
['货件编号', 'shipmentId'], ['跟踪编号', 'referenceId'], ['UPC NO.', 'upc'], ['备注', 'remark'],
] as const
const cargoNumberKeys = new Set<string>(['lengthCm', 'widthCm', 'heightCm', 'unitsPerPackage', 'packageCount', 'grossWeightKg', 'volumeCbm', 'pieces', 'netWeightKg', 'unitDeclaredValue', 'declaredValue', 'productLengthCm', 'productWidthCm', 'productHeightCm'])
const fixedOrderColumns: DataTableColumn[] = [
{ key: 'selection', type: 'selection', width: 48, fixed: 'left', reserveSelection: true },
{ key: 'actions', label: '操作', width: 128, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' },
{ key: 'order', label: '订单号', width: 190, fixed: 'left', align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'order' },
]
const selectableOrderColumns: DataTableColumn[] = [
{ key: 'inboundCode', label: '入仓码', width: 176, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'inboundCode' },
{ key: 'channel', label: '渠道', width: 150, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'channel' },
{ key: 'destinationWarehouse', label: '目的仓', width: 120, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'destinationWarehouse' },
{ key: 'status', label: '当前订单状态', width: 126, align: 'center', headerAlign: 'center', slotName: 'status' },
{ 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: 'businessDate', label: '业务日期', width: 148, align: 'center', headerAlign: 'center', slotName: 'businessDate' },
{ key: 'createdAt', label: '创建时间', width: 164, align: 'center', headerAlign: 'center', slotName: 'createdAt' },
]
const allSelectableColumnKeys = selectableOrderColumns.map((column) => String(column.key))
const visibleColumnKeys = ref<string[]>([...allSelectableColumnKeys])
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 = [...allSelectableColumnKeys]
}
const tablePage = ref(1)
const tablePageSize = ref(10)
const downloadingMarks = ref(false)
const exportingOrders = ref(false)
const selectedOrders = ref<FirstLegOrder[]>([])
const selectedOrderCount = computed(() => selectedOrders.value.length)
const orderTableVersion = ref(0)
const editorOpen = ref(false)
const orderFormRef = ref<FormInstance>()
const detailOpen = ref(false)
const editingId = ref<string>()
const openingEditor = ref(false)
const detailId = ref<string>()
const saving = ref(false)
const detailLoading = ref(false)
const detailError = ref('')
const 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 customsDetail = ref<MerchantCustomsCaseDetail | null>(null)
const customsLoading = ref(false)
const customsUnavailable = ref(false)
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 inboundBillingRequestSequence = 0
let channelRequestSequence = 0
let estimateRequestSequence = 0
let estimateTimer: ReturnType<typeof setTimeout> | undefined
interface CachedPrivateAddress {
id: string
label: string
consigneePartyId: string
consigneePartySnapshot: NonNullable<FirstLegOrderDraft['consigneePartySnapshot']>
consignee: string
destination: string
destinationCountryCode: string
destinationWarehouseName: string
}
const privateAddresses = ref<CachedPrivateAddress[]>([])
const selectedPrivateAddressId = ref('')
function privateAddressStorageKey() {
return `cc:first-leg:private-addresses:${auth.user?.id ?? 'anonymous'}`
}
function loadPrivateAddresses() {
try {
const value = JSON.parse(localStorage.getItem(privateAddressStorageKey()) ?? '[]') as unknown
privateAddresses.value = Array.isArray(value) ? value.filter((item): item is CachedPrivateAddress => Boolean(item && typeof item === 'object' && 'id' in item && 'consigneePartySnapshot' in item)) : []
} catch {
privateAddresses.value = []
}
}
function persistPrivateAddresses() {
localStorage.setItem(privateAddressStorageKey(), JSON.stringify(privateAddresses.value))
}
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 {
referenceNo: '',
transportMode: 'FCL',
businessType: 'EXPORT',
origin: '',
destination: '',
receivingWarehouseId: '',
expectedDepartureDate: '',
shipperPartyId: '',
shipperPartySnapshot: null,
shipper: '',
consigneePartyId: '',
consigneePartySnapshot: null,
consignee: '',
priority: 'NORMAL',
customerRemark: '',
tradeTerm: 'FOB',
destinationCountryCode: 'US',
destinationWarehouseId: '',
destinationWarehouseName: '',
pickupMethod: 'SELF_EXPRESS',
channelCode: '',
channelName: '',
pricingTaxMode: 'TAX_INCLUDED',
taxMode: 'BUY_DECLARATION',
customsInspection: false,
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 unsavedRouteKey = props.createPage ? 'first-leg-order-create' : 'first-leg-orders'
useUnsavedWorkRegistration({
key: `first-leg-order-editor:${unsavedRouteKey}`,
routeKey: unsavedRouteKey,
label: props.createPage ? '创建订单' : '订单编辑',
dirty: editorDirty,
})
const filteredOrders = computed(() => store.orders.filter((order) => {
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 orderNo = textValue('orderNo')
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 matchesOrderNo = !orderNo || String(order.orderNo ?? '').toLowerCase().includes(orderNo)
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 matchesOrderNo && matchesWarehouse && matchesChannel && matchesCargo && matchesDestinationType && matchesTrackingNumber && matchesStatus && matchesDate
}))
const pagedOrders = computed(() => {
const start = (tablePage.value - 1) * tablePageSize.value
return filteredOrders.value.slice(start, start + tablePageSize.value)
})
const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined)
const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null)
const 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: customsDetail.value?.receipts.length ? `${customsDetail.value.receipts.length} 份回执` : customsUnavailable.value ? '暂无回执' : '查询中' },
{ 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
? store.loadWarehouseType('DOMESTIC', true)
: store.load()
const [ordersResult, companyResult, destinationResult] = await Promise.allSettled([
orderPageRequest,
companyRequest,
destinationRequest,
])
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)}`)
await autofillWarehouseSelections()
if (isCreatePage.value && routeDraftId.value) await loadRouteDraft(routeDraftId.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 getOrderCargoSummary(order: FirstLegOrder) {
if (order.items.length) return `${order.items[0]?.cargoNameCn || '货物'}${order.items.length > 1 ? `等 ${order.items.length} 项` : ''}`
if (order.businessSummary.totalPackageCount != null) return `${order.businessSummary.totalPackageCount} 箱`
return '待更新'
}
onMounted(() => {
loadPrivateAddresses()
void loadPageData()
})
watch(isCreatePage, (isCreate, wasCreate) => {
if (!isCreate || wasCreate) return
if (routeDraftId.value) return
editingId.value = undefined
replaceForm(quickOrderDraft())
resetEditorValidation()
}, { immediate: true })
watch(detailOpen, (open) => {
if (!open) {
resetInboundBillingState()
customsDetail.value = null
customsLoading.value = false
customsUnavailable.value = false
if (detailOrder.value) releaseProductImagePreviews(detailOrder.value.items)
}
})
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 draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
return {
referenceNo: order.referenceNo,
transportMode: order.transportMode ?? 'FCL',
businessType: order.businessType,
origin: order.origin,
destination: order.destination,
receivingWarehouseId: order.receivingWarehouseId,
expectedDepartureDate: order.expectedDepartureDate,
shipperPartyId: order.shipperPartyId,
shipperPartySnapshot: order.shipperPartySnapshot ? { ...order.shipperPartySnapshot } : null,
shipper: order.shipper,
consigneePartyId: order.consigneePartyId,
consigneePartySnapshot: order.consigneePartySnapshot ? { ...order.consigneePartySnapshot } : null,
consignee: order.consignee,
priority: order.priority,
customerRemark: order.customerRemark,
tradeTerm: order.tradeTerm,
destinationCountryCode: order.destinationCountryCode,
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 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() {
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 = ''
selectedPrivateAddressId.value = ''
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.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) {
const address = privateAddresses.value.find((item) => item.id === id)
if (!address) return
form.consigneePartyId = address.consigneePartyId
form.consigneePartySnapshot = { ...address.consigneePartySnapshot }
form.consignee = address.consignee
form.destination = address.destination
form.destinationCountryCode = address.destinationCountryCode
form.destinationWarehouseId = ''
form.destinationWarehouseName = address.destinationWarehouseName
}
function savePrivateAddress() {
if (!form.consigneePartySnapshot || !form.destinationWarehouseName.trim() || !form.destinationCountryCode.trim()
|| !form.consigneePartySnapshot.contactName.trim() || !form.consigneePartySnapshot.phone.trim()
|| !form.consigneePartySnapshot.city.trim() || !form.consigneePartySnapshot.address.trim()) {
ElMessage.warning('请先完整填写私人地址和收件资料')
return
}
const snapshot = { ...form.consigneePartySnapshot }
snapshot.name = form.destinationWarehouseName.trim()
snapshot.countryRegion = form.destinationCountryCode.trim().toUpperCase()
const existing = privateAddresses.value.find((item) => item.consigneePartySnapshot.address === snapshot.address)
const partyId = existing?.consigneePartyId || snapshot.id || `private-party-${Date.now()}`
snapshot.id = partyId
const saved: CachedPrivateAddress = {
id: existing?.id ?? globalThis.crypto?.randomUUID?.() ?? `private-${Date.now()}`,
label: `${form.destinationWarehouseName.trim()} · ${snapshot.address}`,
consigneePartyId: partyId,
consigneePartySnapshot: snapshot,
consignee: snapshot.name,
destination: form.destination,
destinationCountryCode: form.destinationCountryCode.trim().toUpperCase(),
destinationWarehouseName: form.destinationWarehouseName.trim(),
}
privateAddresses.value = existing
? privateAddresses.value.map((item) => item.id === existing.id ? saved : item)
: [saved, ...privateAddresses.value]
selectedPrivateAddressId.value = saved.id
persistPrivateAddresses()
ElMessage.success(existing ? '私人地址已更新' : '私人地址已保存到当前账号')
}
function removePrivateAddress() {
if (!selectedPrivateAddressId.value) return
privateAddresses.value = privateAddresses.value.filter((item) => item.id !== selectedPrivateAddressId.value)
selectedPrivateAddressId.value = ''
persistPrivateAddresses()
ElMessage.success('已从当前账号的私人地址中移除')
}
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()
}
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 store.loadDetail(order.id)
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)
detailOpen.value = false
editorOpen.value = true
resetEditorValidation()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '订单详情加载失败,暂时无法编辑')
} finally {
openingEditor.value = false
}
}
async function openDetail(order: FirstLegOrder) {
detailId.value = order.id
detailCargoPage.value = 1
detailOpen.value = true
detailLoading.value = true
detailError.value = ''
void loadInboundBilling(order)
void loadCustoms(order)
try {
const detailedOrder = await store.loadDetail(order.id)
await Promise.all(detailedOrder.items.map(ensureProductImagePreview))
} catch (error) {
detailError.value = error instanceof Error ? error.message : '订单详情加载失败'
ElMessage.error(detailError.value)
} finally {
detailLoading.value = false
}
}
async function retryDetail() {
const order = detailId.value ? store.getById(detailId.value) : undefined
if (order) await openDetail(order)
}
function resetInboundBillingState() {
inboundBillingRequestSequence += 1
inboundBilling.value = null
inboundBillingLoading.value = false
inboundBillingUnavailable.value = false
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布'
inboundBillingError.value = ''
}
function isCurrentInboundBillingRequest(sequence: number, orderId: string) {
return sequence === inboundBillingRequestSequence
&& detailOpen.value
&& detailId.value === orderId
}
async function loadInboundBilling(order: FirstLegOrder) {
const sequence = ++inboundBillingRequestSequence
inboundBilling.value = null
inboundBillingLoading.value = false
inboundBillingUnavailable.value = false
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布'
inboundBillingError.value = ''
if (order.status === 'DRAFT') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.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 = '入库及费用结果尚未发布'
} else {
inboundBillingError.value = error instanceof Error ? error.message : '入库与费用加载失败'
}
} finally {
if (isCurrentInboundBillingRequest(sequence, order.id)) inboundBillingLoading.value = false
}
}
async function loadCustoms(order: FirstLegOrder) {
customsDetail.value = null
customsUnavailable.value = false
if (order.status === 'DRAFT') return
customsLoading.value = true
try {
customsDetail.value = await getMerchantCustomsByOrder(order.id)
} catch (error) {
if (isMerchantCustomsUnavailable(error)) customsUnavailable.value = true
else ElMessage.warning(error instanceof Error ? error.message : '报关回执加载失败')
} finally {
customsLoading.value = false
}
}
async function retryInboundBilling() {
const order = detailId.value ? store.getById(detailId.value) : undefined
if (order) await loadInboundBilling(order)
}
async function confirmDiscardChanges() {
try {
await ElMessageBox.confirm('当前订单还有未保存的修改。离开后这些内容将丢失。', '放弃未保存的修改?', {
type: 'warning',
confirmButtonText: '放弃修改',
cancelButtonText: '继续编辑',
})
return true
} catch (error) {
if (error === 'cancel' || error === 'close') return false
throw error
}
}
async function requestCloseEditor(done: () => void) {
if (saving.value) return
if (!editorDirty.value || await confirmDiscardChanges()) done()
}
function closeEditor() {
void requestCloseEditor(() => {
editorOpen.value = false
if (isCreatePage.value) {
markEditorClean()
void router.push({ name: 'first-leg-orders' })
}
})
}
function addCargoItem() {
form.items.push(createEmptyCargoItem(`new-cargo-${++cargoSequence}`))
resetEditorValidation()
}
function removeCargoItem(index: number) {
if (form.items.length === 1) {
ElMessage.warning('订单至少需要保留一条货物明细')
return
}
const [removed] = form.items.splice(index, 1)
if (removed) releaseProductImagePreviews([removed])
resetEditorValidation()
}
function displayListValue(value: string | number | null | undefined) {
return value === '' || value == null ? '-' : String(value)
}
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 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.orderNo),
displayListValue(order.businessSummary.inboundCode),
displayListValue(order.channelName),
displayListValue(order.destinationWarehouseName),
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')),
'-',
'-',
'-',
displayListValue(order.customerRemark),
displayListValue(order.shipmentId || uniqueItemValues(order, 'shipmentId')),
displayListValue(order.referenceNo || uniqueItemValues(order, 'referenceId')),
formatExportDateTime(order.inboundedAt),
formatExportDateTime(order.businessDate),
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)))
releaseProductImagePreviews(removedItems)
selectedCargoIds.value = []
resetEditorValidation()
}
function downloadCargoTemplate() {
const headers = cargoTemplateColumns.map(([label]) => `<th>${label}</th>`).join('')
const example = cargoTemplateColumns.map(([label]) => {
if (label === '包装类型') return '<td>CARTON</td>'
if (label === '危险品') return '<td>否</td>'
if (['每箱数量', '箱数', '总数量'].includes(label)) return '<td>1</td>'
return '<td></td>'
}).join('')
const html = `<!doctype html><html><head><meta charset="UTF-8"></head><body><table><thead><tr>${headers}</tr></thead><tbody><tr>${example}</tr></tbody></table></body></html>`
const url = URL.createObjectURL(new Blob(['\ufeff', html], { type: 'application/vnd.ms-excel;charset=utf-8' }))
const anchor = document.createElement('a')
anchor.href = url
anchor.download = '货物明细导入模板.xls'
anchor.click()
URL.revokeObjectURL(url)
}
function parseCargoTemplateRows(text: string) {
const documentNode = new DOMParser().parseFromString(text, 'text/html')
const tableRows = [...documentNode.querySelectorAll('table tr')]
if (tableRows.length < 2) return []
const headers = [...tableRows[0].querySelectorAll('th,td')].map((cell) => cell.textContent?.trim() || '')
return tableRows.slice(1).map((row) => {
const values = [...row.querySelectorAll('td')].map((cell) => cell.textContent?.trim() || '')
return { headers, values }
}).filter(({ values }) => values.some(Boolean))
}
async function importCargoTemplate(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
if (!file.name.toLowerCase().endsWith('.xls')) {
ElMessage.error('请上传由“下载模板”生成的 .xls 文件')
return
}
try {
const parsedRows = parseCargoTemplateRows(await file.text())
if (!parsedRows.length) throw new Error('模板中没有可导入的货物数据')
const imported = parsedRows.map(({ headers, values }) => {
const item = createEmptyCargoItem(`imported-cargo-${++cargoSequence}`)
for (const [columnLabel, itemKey] of cargoTemplateColumns) {
const value = values[headers.indexOf(columnLabel)] ?? ''
if (itemKey === 'dangerousGoods') item.dangerousGoods = ['是', 'true', '1', 'yes'].includes(value.toLowerCase())
else if (cargoNumberKeys.has(itemKey)) (item as unknown as Record<string, number>)[itemKey] = Number(value) || 0
else (item as unknown as Record<string, string>)[itemKey] = value
}
item.packageType = item.packageType || 'CARTON'
item.unitsPerPackage = item.unitsPerPackage || 1
item.packageCount = item.packageCount || 1
item.pieces = item.pieces || 1
return item
})
const shouldReplaceDefaultRow = form.items.length === 1 && !form.items[0].cargoNameCn && !form.items[0].sku
if (shouldReplaceDefaultRow) form.items.splice(0, 1, ...imported)
else form.items.push(...imported)
selectedCargoIds.value = []
resetEditorValidation()
ElMessage.success(`已导入 ${imported.length} 行货物明细`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '货物明细导入失败')
}
}
async function validateEditorForm() {
let fieldsValid = false
try {
fieldsValid = await orderFormRef.value?.validate() ?? false
} catch {
fieldsValid = false
}
if (!fieldsValid) {
ElMessage.warning('请检查并完善表单中标红的必填信息')
return false
}
const errors = validateFirstLegOrderDraft(form)
if (errors.length > 0) {
ElMessage.warning(errors[0])
return false
}
return true
}
async function saveDraft() {
if (saving.value) return
if (!await validateEditorForm()) return
saving.value = true
try {
const order = await store.saveDraft(cloneDraft(form), tenantStore.company.companyName, editingId.value)
editingId.value = order.id
markEditorClean()
editorOpen.value = false
if (isCreatePage.value) await router.push({ name: 'first-leg-orders' })
ElMessage.success('订单草稿已保存')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '草稿保存失败')
} finally {
saving.value = false
}
}
async function submitOrder() {
if (saving.value) return
if (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 order = await store.submit(cloneDraft(form), tenantStore.company.companyName, editingId.value, knownCompanyAuditStatus)
markEditorClean()
editorOpen.value = false
if (isCreatePage.value) await router.push({ name: 'first-leg-orders' })
detailId.value = order.id
detailOpen.value = true
ElMessage.success('订单已提交,等待平台审核')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '订单提交失败')
} finally {
saving.value = false
}
}
async function searchOrders(model: QueryModel) {
const createdRange = model.createdRange
appliedFilters.value = {
...model,
createdRange: Array.isArray(createdRange) ? createdRange.filter((value): value is string => typeof value === 'string') : [],
}
tablePage.value = 1
try {
const statusValue = typeof model.status === 'string' ? model.status as FirstLegOrderStatus | '' : ''
const orderNo = typeof model.orderNo === 'string' ? model.orderNo.trim() : ''
const cargoKeyword = typeof model.cargoKeyword === 'string' ? model.cargoKeyword.trim() : ''
const range = Array.isArray(createdRange) ? createdRange.filter((value): value is string => typeof value === 'string') : []
await store.load({
keyword: cargoKeyword || undefined,
orderNo: orderNo || 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,
})
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 }">
<template v-if="!isCreatePage">
<QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders">
<template #actions>
<el-button class="query-export-button" type="primary" :loading="downloadingMarks" :disabled="selectedOrderCount === 0" @click="downloadShippingMarks"><Download :size="16" />下载唛头</el-button>
<el-button class="query-export-button" type="primary" :loading="exportingOrders" :disabled="selectedOrderCount === 0" @click="exportOrderInformation"><Download :size="16" />导出订单信息</el-button>
</template>
</QueryToolbar>
<div class="client-card order-table-card">
<div class="order-table-toolbar">
<div><strong>订单列表</strong><span>共 {{ filteredOrders.length }} 条</span></div>
<div class="order-table-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 v-if="auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>
</div>
</div>
<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>
<div v-if="store.loadError" class="order-load-state" role="alert">
<AlertTriangle :size="24" />
<div><strong>订单数据加载失败</strong><span>{{ store.loadError }}</span></div>
<el-button :loading="store.loading" @click="loadPageData">重新加载</el-button>
</div>
<template v-else>
<div class="desktop-order-table">
<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="visibleOrderColumns" :key="orderTableVersion" :loading="store.loading" :total="filteredOrders.length" :page-sizes="[10, 20, 50]" row-key="id" empty-text="暂无订单" class="prototype-order-table" @selection-change="handleOrderSelectionChange">
<template #actions="{ row }">
<div class="order-row-actions">
<el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button>
<el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button>
</div>
</template>
<template #order="{ row }">
<button class="order-number-link table-code" type="button" :title="(row as FirstLegOrder).orderNo || '未提交草稿'" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '未提交草稿' }}</button>
</template>
<template #inboundCode="{ row }"><span class="table-ellipsis table-code" :title="displayListValue((row as FirstLegOrder).businessSummary.inboundCode)">{{ displayListValue((row as FirstLegOrder).businessSummary.inboundCode) }}</span></template>
<template #channel="{ row }"><span class="table-ellipsis" :title="displayListValue((row as FirstLegOrder).channelName)">{{ displayListValue((row as FirstLegOrder).channelName) }}</span></template>
<template #destinationWarehouse="{ row }"><span class="table-ellipsis" :title="displayListValue((row as FirstLegOrder).destinationWarehouseName)">{{ displayListValue((row as FirstLegOrder).destinationWarehouseName) }}</span></template>
<template #status="{ row }"><el-tag :type="getFirstLegStatusTone((row as FirstLegOrder).status)" effect="light">{{ getFirstLegStatusLabel((row as FirstLegOrder).status) }}</el-tag></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>-</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 #businessDate>-</template>
<template #createdAt="{ row }">{{ formatDateTime((row as FirstLegOrder).createdAt) }}</template>
<template #empty>
<el-empty :description="emptyDescription">
<el-button v-if="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>
<div v-loading="store.loading" class="mobile-order-surface">
<div v-if="filteredOrders.length" class="mobile-order-list">
<article v-for="order in filteredOrders" :key="order.id" class="mobile-order-card">
<header><button type="button" @click="openDetail(order)">{{ order.orderNo || '未提交草稿' }}</button><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></header>
<span v-if="order.businessSummary.inboundCode" class="order-reference">入仓码 {{ order.businessSummary.inboundCode }}</span>
<span class="order-reference">{{ order.referenceNo || '无 Reference ID' }}</span>
<dl><div><dt>目的地</dt><dd>{{ order.destination || '待更新' }}</dd></div><div><dt>收货仓</dt><dd>{{ order.receivingWarehouseName || '待更新' }}</dd></div><div><dt>货物摘要</dt><dd>{{ getOrderCargoSummary(order) }}</dd></div><div><dt>运输渠道</dt><dd>{{ order.transportMode ? getFirstLegTransportModeLabel(order.transportMode) : order.channelName || '待更新' }}</dd></div></dl>
<footer><span>更新于 {{ formatDateTime(order.updatedAt) }}</span><div><el-button link type="primary" @click="openDetail(order)"><Eye :size="15" />详情</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" :loading="openingEditor" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button></div></footer>
</article>
</div>
<el-empty v-else :description="emptyDescription">
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ pageDataLoading ? '加载收货仓中' : (availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓') }}</el-button>
</el-empty>
</div>
</template>
</div>
</template>
<div v-if="isCreatePage && supportingDataErrors.length" class="supporting-data-error" role="alert">
<AlertTriangle :size="20" />
<div><strong>下单配套数据加载失败</strong><span v-for="message in supportingDataErrors" :key="message">{{ message }}</span></div>
<el-button :loading="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="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="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 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>
<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" required><el-radio-group v-model="form.customsInspection"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item>
<el-form-item label="期望出货日期"><el-date-picker v-model="form.expectedDepartureDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择期望出货日期" /></el-form-item>
<el-form-item label="订单备注"><el-input v-model="form.customerRemark" 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 value="FBA">FBA 仓</el-radio-button><el-radio-button value="OVERSEAS">海外仓</el-radio-button><el-radio-button value="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item>
<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="selectedPrivateAddressId" clearable filterable placeholder="选择当前账号保存的私人地址" @change="selectPrivateAddress"><el-option v-for="address in privateAddresses" :key="address.id" :label="address.label" :value="address.id" /></el-select><el-button @click="savePrivateAddress">保存当前地址</el-button><el-button v-if="selectedPrivateAddressId" link type="danger" @click="removePrivateAddress">移除</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 class="cargo-import-actions"><input ref="cargoImportInput" class="cargo-import-input" type="file" accept=".xls,application/vnd.ms-excel" @change="importCargoTemplate" /><el-button class="cargo-upload-button" @click="cargoImportInput?.click()"><Upload :size="16" />上传 Excel</el-button><el-button class="cargo-download-button" @click="downloadCargoTemplate"><Download :size="16" />下载模板</el-button></div>
<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">
<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>FBA必填</small></th><th>中文品名</th><th class="cargo-image-column">图片</th><th>英文品名</th><th>报关 HS CODE</th><th>清关 HS CODE</th><th>包装类型</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">总毛重</th><th class="cargo-number-column">总体积</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>品牌</th><th class="cargo-dimensions-column">产品规格 长×宽×高</th><th>危险品</th><th>货件编号<small>FBA必填</small></th><th>跟踪编号<small>FBA必填</small></th><th>UPC NO.<small>FBA必填</small></th><th>备注</th><th class="cargo-operation-column">操作</th>
</tr></thead>
<tbody><tr v-for="(item, index) in form.items" :key="item.id">
<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'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入 SKU NO.') : undefined"><el-input v-model="item.sku" maxlength="64" placeholder="SKU编号" /></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><el-form-item :prop="`items.${index}.packageType`" required :rules="requiredSelectRules('请选择包装类型')"><el-select v-model="item.packageType"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item></td>
<td><el-input-number v-model="item.lengthCm" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.widthCm" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.heightCm" :min="0" :precision="2" :controls="false" /></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" /></el-form-item></td><td><el-form-item :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须大于 0', true)"><el-input-number v-model="item.packageCount" :min="1" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须大于 0', true)"><el-input-number v-model="item.pieces" :min="1" :controls="false" /></el-form-item></td><td><el-input-number v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" /></td>
<td><el-input-number v-model="item.unitDeclaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.declaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input v-model="item.material" maxlength="128" 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" /><span>×</span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" /><span>×</span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" /></div></td><td class="cargo-danger-cell"><el-switch v-model="item.dangerousGoods" /></td>
<td><el-form-item :prop="`items.${index}.shipmentId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('亚马逊货物请输入 Shipment ID') : undefined"><el-input v-model="item.shipmentId" maxlength="64" placeholder="SHIPMENT ID" /></el-form-item></td><td><el-form-item :prop="`items.${index}.referenceId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('亚马逊货物请输入 Reference ID') : undefined"><el-input v-model="item.referenceId" maxlength="64" placeholder="REFERENCE ID" /></el-form-item></td><td><el-form-item :prop="`items.${index}.upc`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入 UPC NO.') : undefined"><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 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>FBA 货物的 SKU NO.、UPC NO. 为必填项</span><i></i><span>亚马逊货物的货件编号和跟踪编号为必填项</span></div>
</section>
<section class="order-form-section prototype-section cost-estimate-section" aria-label="费用预估">
<div class="order-section-heading"><div><h3>费用预估</h3></div></div>
<div class="cost-estimate-cards">
<article class="cost-estimate-card">
<span class="cost-estimate-badge is-fastest">最快</span>
<strong class="cost-estimate-days">10天</strong>
<div class="cost-estimate-price"><small>¥</small><strong>3,310</strong></div>
<p>优先配送,时效保障</p>
</article>
<article class="cost-estimate-card is-recommended">
<span class="cost-estimate-badge is-recommend">推荐</span>
<strong class="cost-estimate-days">13天</strong>
<div class="cost-estimate-price"><small>¥</small><strong>2,990</strong></div>
<p>性价比之选</p>
</article>
<article class="cost-estimate-card">
<span class="cost-estimate-badge is-economy">经济</span>
<strong class="cost-estimate-days">16天</strong>
<div class="cost-estimate-price"><small>¥</small><strong>2,500</strong></div>
<p>经济实惠</p>
</article>
</div>
<p class="cost-estimate-note">以上费用为静态预估展示,实际费用以最终结算结果为准。</p>
</section>
</el-form>
<template #footer><div class="editor-actions"><el-button :disabled="saving" @click="closeEditor">返回</el-button><el-button v-if="editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" />{{ companySubmissionBlocked ? '提交前需完成资料审核' : '提交订单' }}</el-button></div></template>
</FirstLegOrderEditorShell>
<el-dialog v-model="detailOpen" class="first-leg-detail-dialog" title="订单详情" width="94vw" top="3vh" destroy-on-close append-to-body>
<div v-loading="detailLoading" class="order-detail-content">
<div v-if="detailError" class="order-load-state is-compact" role="alert">
<AlertTriangle :size="24" />
<div><strong>订单详情加载失败</strong><span>{{ detailError }}</span></div>
<el-button :loading="detailLoading" @click="retryDetail">重新加载</el-button>
</div>
<template v-else-if="detailOrder">
<div class="order-detail-heading">
<div><span>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</span><h2>{{ detailOrder.orderNo || '未提交草稿' }}</h2><p>{{ detailOrder.origin || '起运地待更新' }} → {{ detailOrder.destination || '目的地待更新' }}</p></div>
<el-tag :type="getFirstLegStatusTone(detailOrder.status)" effect="light">{{ getFirstLegStatusLabel(detailOrder.status) }}</el-tag>
</div>
<section class="detail-section">
<h3>订单与运输</h3>
<dl class="order-detail-grid">
<div v-if="detailOrder.businessSummary.inboundCode"><dt>入仓码</dt><dd>{{ detailOrder.businessSummary.inboundCode }}</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>{{ 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 ? '需要' : '不需要' }}</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>
<div class="party-detail-grid">
<article><span>发货方</span><strong>{{ detailOrder.shipperPartySnapshot?.name || detailOrder.shipper || '未提供' }}</strong><p v-if="detailOrder.shipperPartySnapshot">{{ detailOrder.shipperPartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.shipperPartySnapshot.phone || '未填写电话' }}<br />{{ detailOrder.shipperPartySnapshot.email || '' }}<br />{{ detailOrder.shipperPartySnapshot.countryRegion }} {{ detailOrder.shipperPartySnapshot.province }} {{ detailOrder.shipperPartySnapshot.city }} {{ detailOrder.shipperPartySnapshot.address }} {{ detailOrder.shipperPartySnapshot.postalCode }}</p></article>
<article><span>收货方</span><strong>{{ detailOrder.consigneePartySnapshot?.name || detailOrder.consignee || detailOrder.destinationWarehouseName || '未提供' }}</strong><p v-if="detailOrder.consigneePartySnapshot">{{ detailOrder.consigneePartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.consigneePartySnapshot.phone || '未填写电话' }}<br />{{ detailOrder.consigneePartySnapshot.email || '' }}<br />{{ detailOrder.consigneePartySnapshot.countryRegion }} {{ detailOrder.consigneePartySnapshot.province }} {{ detailOrder.consigneePartySnapshot.city }} {{ detailOrder.consigneePartySnapshot.address }} {{ detailOrder.consigneePartySnapshot.postalCode }}</p><p v-else>平台目的仓地址以最终履约安排为准</p></article>
</div>
<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>
<section v-if="detailOrder.status !== 'DRAFT'" class="detail-section">
<h3>柜次进度</h3>
<div v-if="cabinetProgressItems.length" class="cabinet-progress-list">
<article v-for="cabinet in cabinetProgressItems" :key="cabinet.cabinetId" class="cabinet-progress-card">
<header><strong>{{ cabinet.cabinetNo || '柜次待分配' }}</strong><el-tag size="small" effect="plain">{{ cabinet.status || '待更新' }}</el-tag></header>
<dl>
<div><dt>柜号</dt><dd>{{ cabinet.cabinetNo || '待分配' }}</dd></div>
<div><dt>船名 / 航次</dt><dd>{{ [cabinet.vesselName, cabinet.voyageNo].filter(Boolean).join(' · ') || '待更新' }}</dd></div>
<div><dt>预计船期</dt><dd>{{ cabinet.sailingAt ? formatDateTime(cabinet.sailingAt) : '待更新' }}</dd></div>
</dl>
</article>
</div>
<el-empty v-else description="暂无柜次分配信息" :image-size="50" />
</section>
<section v-if="detailOrder.status !== 'DRAFT'" class="detail-section">
<h3>报关回执</h3>
<el-skeleton v-if="customsLoading" :rows="2" animated />
<el-empty v-else-if="customsUnavailable || !customsDetail?.receipts.length" description="暂无客户可见报关回执" :image-size="50" />
<div v-else class="customs-receipt-list">
<article v-for="receipt in customsDetail.receipts" :key="receipt.id">
<strong>{{ receipt.receiptType }}</strong><span>版本 {{ receipt.receiptVersion ?? '-' }}</span><span>{{ receipt.uploadedAt ? formatDateTime(receipt.uploadedAt) : '时间待更新' }}</span>
</article>
</div>
</section>
<section class="detail-section">
<h3>履约快递信息</h3>
<div v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><PackageCheck :size="20" /><div><span>快递公司</span><strong>{{ detailOrder.expressCompanyName || '待平台补充' }}</strong></div><div><span>快递单号</span><strong>{{ detailOrder.trackingNumber || '待平台补充' }}</strong></div></div>
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>平台尚未维护快递信息</strong><span>快递公司和快递单号由平台后台维护,客户端仅只读展示。</span></div></div>
</section>
<section class="detail-section">
<h3>货物明细</h3>
<div v-if="detailSummary" class="cargo-summary"><div><Box :size="18" /><span>明细</span><strong>{{ detailOrder.items.length }} 项</strong></div><div><span>件数</span><strong>{{ detailSummary.pieces }}</strong></div><div><span>毛重</span><strong>{{ detailSummary.grossWeightKg.toLocaleString() }} kg</strong></div><div><span>体积</span><strong>{{ detailSummary.volumeCbm }} m³</strong></div></div>
<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 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="Shipment ID" 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="Reference ID" 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="备注" 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>
<OrderProgressPanel :order="detailOrder" />
<InboundBillingPanel
:data="inboundBilling"
:loading="inboundBillingLoading"
:unavailable="inboundBillingUnavailable"
:unavailable-message="inboundBillingUnavailableMessage"
:error="inboundBillingError"
@retry="retryInboundBilling"
/>
</template>
</div>
<template #footer><div v-if="detailOrder" class="editor-actions"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" :loading="openingEditor" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button></div></template>
</el-dialog>
</section>
</template>
<style scoped>
.first-leg-page { display: flex; flex-direction: column; gap: 14px; }
: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; }
.first-leg-query-toolbar { display: block; padding: 16px 18px 14px; border: 0; border-radius: 10px; background: #fff; box-shadow: 0 3px 14px rgba(31, 48, 78, .05); }
.first-leg-query-toolbar :deep(.query-toolbar__filters) { display: grid; min-width: 0; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 18px; }
.first-leg-query-toolbar :deep(.query-toolbar__field) { min-width: 0; }
.first-leg-query-toolbar :deep(.query-toolbar__field .el-input), .first-leg-query-toolbar :deep(.query-toolbar__field .el-select), .first-leg-query-toolbar :deep(.query-toolbar__field .el-date-editor) { flex: 1 1 auto; width: 100%; }
.first-leg-query-toolbar :deep(.query-toolbar__filters .el-input), .first-leg-query-toolbar :deep(.query-toolbar__filters .el-select), .first-leg-query-toolbar :deep(.query-toolbar__filters .el-date-editor) { width: 100%; }
.first-leg-query-toolbar :deep(.query-toolbar__actions) { justify-content: flex-start; margin-top: 14px; padding-top: 12px; border-top: 1px solid #edf0f5; }
.first-leg-query-toolbar :deep(.query-toolbar__actions .el-button) { min-height: 36px; margin-left: 0; }
.first-leg-query-toolbar :deep(.query-toolbar__actions .query-export-button) { order: -1; }
.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; }
.order-table-toolbar { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 16px; border-bottom: 1px solid var(--border); }
.order-table-toolbar > div { display: flex; min-width: 0; align-items: baseline; gap: 8px; }
.order-table-toolbar strong { color: var(--navy); font-size: 14px; }
.order-table-toolbar span { color: var(--muted); font-size: 11px; }
.order-table-toolbar > .order-table-toolbar-actions { align-items: center; gap: 8px; }
.order-table-toolbar-actions :deep(.el-button + .el-button) { margin-left: 0; }
.column-settings-trigger { width: 40px; min-width: 40px; height: 36px; padding: 0; 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; }
.prototype-order-table { font-size: 11px; }
.prototype-order-table :deep(.el-table) { color: #626b78; font-size: 11px; }
.prototype-order-table :deep(.el-table__header-wrapper th.el-table__cell) { height: 44px; padding: 0; background: #fafbfc; color: #687386; font-size: 11px; font-weight: 650; vertical-align: middle; }
.prototype-order-table :deep(.el-table__body-wrapper td.el-table__cell) { height: 50px; padding: 0; vertical-align: middle; }
.prototype-order-table :deep(.el-table .cell) { min-width: 0; padding: 0 12px; line-height: 18px; white-space: nowrap; }
.prototype-order-table :deep(.el-table__header-wrapper .cell) { overflow: visible; line-height: 16px; text-overflow: clip; }
.prototype-order-table :deep(.el-table__body-wrapper .cell) { overflow: hidden; }
.prototype-order-table :deep(.el-tag) { height: 24px; padding: 0 9px; line-height: 22px; white-space: nowrap; }
.prototype-order-table :deep(.el-table__row:nth-child(even) td.el-table__cell) { background: #fcfcfd; }
.prototype-order-table :deep(.el-table__row:hover > td.el-table__cell) { background: #f6f8fc; }
.prototype-order-table :deep(.data-table__pagination) { min-height: 54px; padding: 10px 16px; }
.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; }
.order-detail-content { min-height: 180px; }
:global(.first-leg-detail-dialog.el-dialog) { display: flex; max-width: 1500px; height: 94vh; margin-bottom: 0; flex-direction: column; overflow: hidden; border-radius: 6px; }
:global(.first-leg-detail-dialog .el-dialog__header) { flex: 0 0 auto; margin-right: 0; padding: 17px 22px; border-bottom: 1px solid var(--border); }
:global(.first-leg-detail-dialog .el-dialog__title) { color: var(--navy); font-size: 16px; font-weight: 700; }
:global(.first-leg-detail-dialog .el-dialog__headerbtn) { top: 8px; right: 10px; width: 44px; height: 44px; }
:global(.first-leg-detail-dialog .el-dialog__body) { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 22px 24px 28px; overscroll-behavior: contain; }
:global(.first-leg-detail-dialog .el-dialog__footer) { flex: 0 0 auto; padding: 12px 22px; border-top: 1px solid var(--border); background: #fff; }
.mobile-order-surface { display: none; min-height: 220px; }
.mobile-order-list { display: none; }
.order-number-link { display: block; width: 100%; max-width: 100%; overflow: hidden; padding: 0; border: 0; background: none; color: var(--primary); cursor: pointer; font-size: 11px; font-weight: 700; line-height: 18px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.order-number-link: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; }
.cost-estimate-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.cost-estimate-card { position: relative; display: flex; min-height: 158px; box-sizing: border-box; align-items: center; justify-content: center; flex-direction: column; padding: 24px 18px 18px; border: 1px solid #dde4f2; border-radius: 8px; background: #fff; box-shadow: 0 2px 8px rgba(15, 31, 58, .025); text-align: center; }
.cost-estimate-card.is-recommended { border: 2px solid #637fec; background: #f8f8ff; box-shadow: 0 8px 22px rgba(99, 127, 236, .13); }
.cost-estimate-badge { position: absolute; top: -9px; right: 14px; padding: 3px 11px; border-radius: 12px; color: #fff; font-size: 10px; font-weight: 650; line-height: 1.4; }
.cost-estimate-badge.is-fastest, .cost-estimate-badge.is-recommend { background: #5368c6; }
.cost-estimate-badge.is-economy { background: #71858e; }
.cost-estimate-days { color: #30343b; font-size: 21px; }
.cost-estimate-price { display: flex; align-items: baseline; gap: 5px; margin-top: 13px; color: #637fec; }
.cost-estimate-price small { font-size: 15px; font-weight: 700; }
.cost-estimate-price strong { font-size: 29px; line-height: 1; }
.cost-estimate-card p { margin: 12px 0 0; color: #98a0ad; font-size: 11px; }
.cost-estimate-note { margin: 10px 0 0; 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(.el-form-item:nth-child(2)) { 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; }
.cargo-dimensions-column { width: 240px !important; }
.cargo-operation-column { width: 76px !important; text-align: center !important; }
.cargo-danger-cell { text-align: center; vertical-align: middle !important; }
.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; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
.order-detail-heading span { color: var(--muted); font-size: 11px; }
.order-detail-heading h2 { margin: 6px 0; font-size: 21px; }
.order-detail-heading p { margin: 0; color: var(--muted); }
.order-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; margin: 18px 0; border: 1px solid var(--border); }
.order-detail-grid div { padding: 13px; border-bottom: 1px solid var(--border); }
.order-detail-grid div:nth-child(odd) { border-right: 1px solid var(--border); }
.order-detail-grid dt { color: var(--muted); font-size: 11px; }
.order-detail-grid dd { margin: 5px 0 0; font-size: 13px; font-weight: 600; }
.detail-subline { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; font-weight: 400; line-height: 1.5; }
.detail-section { margin-top: 22px; }
.detail-section h3 { margin: 0 0 12px; font-size: 15px; }
.party-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.party-detail-grid article { min-width: 0; padding: 15px; border: 1px solid var(--border); background: #fbfcfc; }
.party-detail-grid span { display: block; color: var(--muted); font-size: 10px; }
.party-detail-grid strong { display: block; margin-top: 5px; overflow-wrap: anywhere; color: var(--navy); font-size: 13px; }
.party-detail-grid p, .customer-remark p { margin: 7px 0 0; overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.65; }
.customer-remark { margin-top: 10px; padding: 13px 15px; background: #f2f7f6; }
.customer-remark strong { color: var(--navy); font-size: 11px; }
.fulfillment-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border: 1px solid var(--border); }
.fulfillment-summary > div { min-width: 0; padding: 13px; border-bottom: 1px solid var(--border); }
.fulfillment-summary > div:not(:nth-child(3n)) { border-right: 1px solid var(--border); }
.fulfillment-summary > div:nth-last-child(-n + 3) { border-bottom: 0; }
.fulfillment-summary dt { color: var(--muted); font-size: 10px; }
.fulfillment-summary dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 650; }
.cabinet-progress-list { display: grid; gap: 10px; }
.cabinet-progress-card { padding: 14px 15px; border: 1px solid var(--border); background: #fbfcfc; }
.cabinet-progress-card header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.cabinet-progress-card header strong { color: var(--navy); font-size: 13px; }
.cabinet-progress-card dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 13px 0 0; }
.cabinet-progress-card dt { color: var(--muted); font-size: 10px; }
.cabinet-progress-card dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 11px; font-weight: 600; }
.customs-receipt-list { display: grid; gap: 8px; }
.customs-receipt-list article { display: flex; flex-wrap: wrap; gap: 10px; padding: 11px 13px; border: 1px solid var(--border); color: var(--muted); font-size: 11px; }
.customs-receipt-list strong { color: var(--navy); }
.express-information { display: grid; grid-template-columns: auto repeat(2, minmax(0, 1fr)); align-items: center; gap: 16px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); }
.express-information > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.express-information span { color: var(--muted); font-size: 11px; }
.express-information strong { overflow-wrap: anywhere; color: var(--navy); font-size: 13px; }
.express-empty { display: flex; align-items: flex-start; gap: 12px; padding: 15px 16px; 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)); border: 1px solid var(--border); }
.cargo-summary div { display: flex; min-height: 74px; align-items: center; justify-content: center; flex-direction: column; gap: 4px; border-right: 1px solid var(--border); }
.cargo-summary div:last-child { border-right: 0; }
.cargo-summary span { color: var(--muted); font-size: 11px; }
.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)); }
.desktop-order-table { display: none; }
.mobile-order-surface { display: block; }
.mobile-order-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 10px; }
.mobile-order-card { min-width: 0; padding: 14px; border: 1px solid var(--border); background: #fff; }
.mobile-order-card header, .mobile-order-card footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.mobile-order-card header button { min-width: 0; overflow: hidden; padding: 0; border: 0; background: none; color: var(--primary); font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
.mobile-order-card header button:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.mobile-order-card dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
.mobile-order-card dl div { min-width: 0; }
.mobile-order-card dt, .mobile-order-card footer > span { color: var(--muted); font-size: 10px; }
.mobile-order-card dd { overflow: hidden; margin: 4px 0 0; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.mobile-order-card dd span { margin-left: 4px; color: var(--muted); font-weight: 400; }
.mobile-order-card footer { align-items: flex-end; padding-top: 10px; border-top: 1px solid var(--border); }
.mobile-order-card footer > div { display: flex; }
}
@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-table-toolbar { align-items: stretch; flex-direction: column; gap: 10px; }
.order-table-toolbar > div { justify-content: space-between; }
.order-table-toolbar .el-button { width: 100%; min-height: 40px; }
.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, .fulfillment-summary > div:not(:nth-child(3n)) { border-right: 0; 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(.el-form-item:nth-child(2)) { 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; }
.cost-estimate-cards { grid-template-columns: 1fr; }
.cost-estimate-card { min-height: 140px; }
.form-span-full { grid-column: auto; }
.editor-actions :deep(.el-button) { min-height: 44px; }
.desktop-order-table { display: none; }
.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; }
.mobile-order-list { grid-template-columns: 1fr; }
.mobile-order-card { padding: 14px; border: 1px solid var(--border); background: #fff; }
.mobile-order-card header, .mobile-order-card footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.mobile-order-card header button { padding: 0; border: 0; background: none; color: var(--primary); font-weight: 750; }
.mobile-order-card header button:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.mobile-order-card dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
.mobile-order-card dl div { min-width: 0; }
.mobile-order-card dt, .mobile-order-card footer > span { color: var(--muted); font-size: 10px; }
.mobile-order-card dd { overflow: hidden; margin: 4px 0 0; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.mobile-order-card dd span { margin-left: 4px; color: var(--muted); font-weight: 400; }
.mobile-order-card footer { align-items: flex-end; padding-top: 10px; border-top: 1px solid var(--border); }
.mobile-order-card footer > div { display: flex; }
.order-detail-grid div:nth-child(odd) { border-right: 0; }
.express-information { grid-template-columns: auto 1fr; }
.express-information > div:last-child { grid-column: 2; }
.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); }
.cabinet-progress-card dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
:global(.first-leg-detail-dialog.el-dialog) { width: calc(100vw - 24px) !important; height: 96vh; margin-top: 2vh; }
}
@media (max-width: 360px) {
.cargo-detail-pagination { justify-content: flex-start; overflow-x: auto; }
}
@media (max-width: 480px) {
:global(.first-leg-detail-dialog.el-dialog) { width: 100vw !important; height: 100vh; margin: 0; border-radius: 0; }
:global(.first-leg-detail-dialog .el-dialog__body) { padding: 16px 12px 22px; }
.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>