Release/test 合并上生产 #1

Merged
admin merged 236 commits from release/test into main 2026-09-17 11:51:50 +08:00
9 changed files with 297 additions and 124 deletions
Showing only changes of commit aa6c349fce - Show all commits
+31 -1
View File
@@ -1,4 +1,4 @@
import { apiRequest } from '@/api/http'
import { apiBlobRequest, apiRequest } from '@/api/http'
export type ApiFirstLegStatus = 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED' | 'PROCESSING' | 'COMPLETED'
export type ApiTransportMode = 'SEA' | 'AIR' | 'RAIL' | 'TRUCK'
@@ -95,8 +95,28 @@ export interface FirstLegOrderCargoPayload {
skuNo?: string
upcNo?: string
cargoNameEn?: string
customsHsCode?: string
unitsPerPackage?: number
totalQuantity?: number
netWeightKg?: number
unitDeclaredValue?: number
material?: string
usage?: string
model?: string
brand?: string
productLengthCm?: number
productWidthCm?: number
productHeightCm?: number
shipmentId?: string
referenceId?: string
productImageFileId?: number | string
}
export interface ProductImageUploadResult {
fileId: number | string
fileName: string
contentType: string
sizeBytes: number
}
export interface FirstLegOrderSaveRequest {
@@ -196,6 +216,16 @@ export interface MerchantFirstLegOrderDetail {
statusHistory: MerchantOrderStatusHistory[]
}
export function uploadMerchantProductImage(file: File) {
const data = new FormData()
data.append('file', file)
return apiRequest<ProductImageUploadResult>({ method: 'POST', url: '/merchant/v1/files/product-images', data })
}
export function downloadMerchantProductImage(fileId: number | string) {
return apiBlobRequest({ method: 'GET', url: `/merchant/v1/files/product-images/${fileId}` })
}
export interface FirstLegOrderQuery {
keyword?: string
status?: ApiFirstLegStatus
@@ -88,8 +88,6 @@ test('sends backend-required channel, pricing, customs, destination and cargo fi
assert.match(storeSource, /destinationWarehouseId: draft\.destinationType === 'PRIVATE' \? undefined : draft\.destinationWarehouseId/)
assert.match(storeSource, /draft\.pickupMethod === 'PICKUP'.*draft\.shipperPartySnapshot/s)
assert.match(storeSource, /draft\.destinationType === 'PRIVATE'.*draft\.consigneePartySnapshot/s)
for (const dimension of ['lengthCm', 'widthCm', 'heightCm']) assert.match(storeSource, new RegExp(`${dimension}: item\\.${dimension}`))
assert.match(storeSource, /inferCargoBoxSizeType\(item\.lengthCm, item\.widthCm, item\.heightCm\)/)
})
test('separates domestic receiving warehouses from public destinations', () => {
+27 -7
View File
@@ -27,7 +27,7 @@ import type { MerchantTenantStatus } from '@/modules/tenant-management/public'
import type { PlatformReceivingWarehouse, PlatformReceivingWarehouseSnapshot } from '@chongchong/shared'
import type { FirstLegPartySnapshot } from '@/modules/first-leg/data/firstLegParties'
import type { FirstLegOrder, FirstLegOrderDraft, FirstLegOrderTimelineItem } from '@/modules/first-leg/types/firstLegOrder'
import { canClientEditFirstLegOrder, getFirstLegStatusLabel, inferCargoBoxSizeType } from '@/modules/first-leg/utils/firstLegOrder'
import { canClientEditFirstLegOrder, getFirstLegStatusLabel } from '@/modules/first-leg/utils/firstLegOrder'
import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination'
export type ClientWarehouseType = 'DOMESTIC' | 'FBA' | 'OVERSEAS'
@@ -107,6 +107,7 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust
cargoName: item.cargoNameCn,
cargoNameEn: item.cargoNameEn || undefined,
hsCode: item.hsCode || undefined,
customsHsCode: item.customsHsCode || undefined,
packageType: item.packageType,
packageCount: item.packageCount,
grossWeightKg: item.grossWeightKg,
@@ -121,6 +122,19 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust
skuNo: item.sku || undefined,
upcNo: item.upc || undefined,
totalQuantity: item.pieces,
unitsPerPackage: item.unitsPerPackage || undefined,
netWeightKg: item.netWeightKg || undefined,
unitDeclaredValue: item.unitDeclaredValue || undefined,
material: item.material || undefined,
usage: item.usage || undefined,
model: item.model || undefined,
brand: item.brand || undefined,
productLengthCm: item.productLengthCm || undefined,
productWidthCm: item.productWidthCm || undefined,
productHeightCm: item.productHeightCm || undefined,
shipmentId: item.shipmentId || undefined,
referenceId: item.referenceId || undefined,
productImageFileId: item.productImageFileId || undefined,
})),
pickupMethod: draft.pickupMethod,
receivingWarehouseId: warehouse.id,
@@ -133,8 +147,8 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust
customsInspection: draft.customsInspection,
destinationType: draft.destinationType,
amazonWarehouseCode: draft.destinationType === 'FBA' ? draft.amazonWarehouseCode || undefined : undefined,
shipmentId: draft.destinationType === 'FBA' ? draft.shipmentId || undefined : undefined,
referenceId: draft.referenceNo || undefined,
shipmentId: draft.destinationType === 'FBA' ? draft.items.find((item) => item.shipmentId)?.shipmentId || undefined : undefined,
referenceId: draft.items.find((item) => item.referenceId)?.referenceId || draft.referenceNo || undefined,
}
}
@@ -275,13 +289,19 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
status: clientStatus(order.status), version: order.version ?? 0,
items: (detail?.cargoes ?? []).map((item) => ({
id: String(item.id), sku: item.skuNo ?? '', upc: item.upcNo ?? '', cargoNameCn: item.cargoName, cargoNameEn: item.cargoNameEn ?? '',
productImageFileId: item.productImageFileId == null ? '' : String(item.productImageFileId),
productImageName: '', productImagePreviewUrl: '', productImageUploading: false, productImageError: '',
customsHsCode: item.customsHsCode ?? '', lengthCm: item.lengthCm ?? 0, widthCm: item.widthCm ?? 0,
heightCm: item.heightCm ?? 0, unitsPerPackage: item.unitsPerPackage ?? 1,
pieces: item.totalQuantity ?? item.packageCount, packageCount: item.packageCount, grossWeightKg: item.grossWeightKg,
volumeCbm: item.volumeCbm,
boxSizeType: inferCargoBoxSizeType(item.lengthCm, item.widthCm, item.heightCm),
lengthCm: item.lengthCm ?? 0, widthCm: item.widthCm ?? 0, heightCm: item.heightCm ?? 0,
containerType: '', containerCount: 0, remark: item.cargoRemark ?? '',
volumeCbm: item.volumeCbm, containerType: '', containerCount: 0, remark: item.cargoRemark ?? '',
packageType: item.packageType, hsCode: item.hsCode ?? '', declaredValue: item.declaredValue ?? 0,
currency: item.currency ?? 'USD', dangerousGoods: item.dangerousGoods ?? false,
netWeightKg: item.netWeightKg ?? 0, unitDeclaredValue: item.unitDeclaredValue ?? 0,
material: item.material ?? '', usage: item.usage ?? '', model: item.model ?? '', brand: item.brand ?? '',
productLengthCm: item.productLengthCm ?? 0, productWidthCm: item.productWidthCm ?? 0,
productHeightCm: item.productHeightCm ?? 0, shipmentId: item.shipmentId ?? order.shipmentId ?? '',
referenceId: item.referenceId ?? order.referenceId ?? '',
})),
timeline: detail ? timeline(detail) : [], businessSummary: businessSummary(), createdAt: order.createdAt, updatedAt: order.updatedAt,
tradeTerm: order.tradeTerm ?? '', destinationCountryCode: order.destinationCountryCode,
+21 -5
View File
@@ -7,7 +7,6 @@ import type { FirstLegPartySnapshot } from '../data/firstLegParties.ts'
export type FirstLegBusinessType = 'EXPORT' | 'IMPORT'
export type FirstLegPriority = 'NORMAL' | 'URGENT' | 'EXPEDITED'
export type CargoBoxSizeType = '' | 'SMALL' | 'MEDIUM' | 'LARGE' | 'EXTRA_LARGE'
export interface FirstLegCargoItem {
id: string
@@ -15,14 +14,31 @@ export interface FirstLegCargoItem {
upc: string
cargoNameCn: string
cargoNameEn: string
productImageFileId: string
productImageName: string
productImagePreviewUrl: string
productImageUploading: boolean
productImageError: string
customsHsCode: string
lengthCm: number
widthCm: number
heightCm: number
unitsPerPackage: number
pieces: number
packageCount: number
grossWeightKg: number
volumeCbm: number
boxSizeType: CargoBoxSizeType
lengthCm: number
widthCm: number
heightCm: number
netWeightKg: number
unitDeclaredValue: number
material: string
usage: string
model: string
brand: string
productLengthCm: number
productWidthCm: number
productHeightCm: number
shipmentId: string
referenceId: string
containerType: string
containerCount: number
remark: string
@@ -2,14 +2,12 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import { createFirstLegPartySnapshot } from '../data/firstLegParties.ts'
import {
applyCargoBoxSize,
canClientCancelFirstLegOrder,
canClientEditFirstLegOrder,
createEmptyCargoItem,
getFirstLegStatusLabel,
getFirstLegTransportModeLabel,
summarizeFirstLegCargo,
updateCargoBoxVolume,
validateFirstLegOrderDraft,
} from './firstLegOrder.ts'
@@ -54,24 +52,10 @@ function validDraft() {
destinationType: 'FBA',
amazonWarehouseCode: 'ONT8',
shipmentId: 'FBA-001',
items: [{ ...createEmptyCargoItem('cargo-1'), sku: 'SKU-001', upc: '012345678905', cargoNameCn: '家居用品', pieces: 100, grossWeightKg: 1200, volumeCbm: 3.5, boxSizeType: 'SMALL', lengthCm: 34, widthCm: 24, heightCm: 24, containerType: '40HQ', containerCount: 1 }],
items: [{ ...createEmptyCargoItem('cargo-1'), sku: 'SKU-001', upc: '012345678905', cargoNameCn: '家居用品', pieces: 100, grossWeightKg: 1200, volumeCbm: 3.5, containerType: '40HQ', containerCount: 1 }],
}
}
test('applies the four fixed box sizes and recalculates total CBM from package count', () => {
const item = createEmptyCargoItem('cargo-box')
item.packageCount = 2
applyCargoBoxSize(item, 'SMALL')
assert.deepEqual([item.lengthCm, item.widthCm, item.heightCm, item.volumeCbm], [34, 24, 24, 0.039])
item.packageCount = 3
updateCargoBoxVolume(item)
assert.equal(item.volumeCbm, 0.059)
applyCargoBoxSize(item, '')
assert.deepEqual([item.lengthCm, item.widthCm, item.heightCm, item.volumeCbm], [0, 0, 0, 0])
})
test('uses the confirmed status and transport labels', () => {
assert.equal(getFirstLegStatusLabel('WAIT_MEASURE'), '待测量数据')
assert.equal(getFirstLegStatusLabel('INBOUNDED'), '已入库')
+26 -47
View File
@@ -4,46 +4,7 @@ import {
type FirstLegOrderStatus,
type FirstLegTransportMode,
} from '@chongchong/shared'
import type { CargoBoxSizeType, FirstLegCargoItem, FirstLegOrderDraft } from '../types/firstLegOrder.ts'
export const CARGO_BOX_SIZES = [
{ code: 'SMALL', label: '小箱', inner: '30 × 20 × 20', lengthCm: 34, widthCm: 24, heightCm: 24 },
{ code: 'MEDIUM', label: '中箱', inner: '40 × 30 × 30', lengthCm: 44, widthCm: 34, heightCm: 34 },
{ code: 'LARGE', label: '大箱', inner: '50 × 40 × 40', lengthCm: 54, widthCm: 44, heightCm: 44 },
{ code: 'EXTRA_LARGE', label: '特大箱', inner: '60 × 50 × 50', lengthCm: 64, widthCm: 54, heightCm: 54 },
] as const satisfies ReadonlyArray<{
code: Exclude<CargoBoxSizeType, ''>
label: string
inner: string
lengthCm: number
widthCm: number
heightCm: number
}>
export function inferCargoBoxSizeType(lengthCm?: number, widthCm?: number, heightCm?: number): CargoBoxSizeType {
return CARGO_BOX_SIZES.find((box) => box.lengthCm === lengthCm && box.widthCm === widthCm && box.heightCm === heightCm)?.code ?? ''
}
export function applyCargoBoxSize(item: FirstLegCargoItem, type: CargoBoxSizeType) {
item.boxSizeType = type
const box = CARGO_BOX_SIZES.find((option) => option.code === type)
if (!box) {
item.lengthCm = 0
item.widthCm = 0
item.heightCm = 0
item.volumeCbm = 0
return
}
item.lengthCm = box.lengthCm
item.widthCm = box.widthCm
item.heightCm = box.heightCm
updateCargoBoxVolume(item)
}
export function updateCargoBoxVolume(item: FirstLegCargoItem) {
if (!item.boxSizeType || item.packageCount <= 0) return
item.volumeCbm = Number(((item.lengthCm * item.widthCm * item.heightCm * item.packageCount) / 1_000_000).toFixed(3))
}
import type { FirstLegCargoItem, FirstLegOrderDraft } from '../types/firstLegOrder.ts'
const statusLabels = new Map<FirstLegOrderStatus, string>(
FIRST_LEG_ORDER_STATUSES.map((item) => [item.code, item.label]),
@@ -92,14 +53,31 @@ export function createEmptyCargoItem(id = `cargo-${Date.now()}`): FirstLegCargoI
upc: '',
cargoNameCn: '',
cargoNameEn: '',
productImageFileId: '',
productImageName: '',
productImagePreviewUrl: '',
productImageUploading: false,
productImageError: '',
customsHsCode: '',
lengthCm: 0,
widthCm: 0,
heightCm: 0,
unitsPerPackage: 1,
pieces: 1,
packageCount: 1,
grossWeightKg: 0,
volumeCbm: 0,
boxSizeType: '',
lengthCm: 0,
widthCm: 0,
heightCm: 0,
netWeightKg: 0,
unitDeclaredValue: 0,
material: '',
usage: '',
model: '',
brand: '',
productLengthCm: 0,
productWidthCm: 0,
productHeightCm: 0,
shipmentId: '',
referenceId: '',
containerType: '',
containerCount: 0,
remark: '',
@@ -138,19 +116,20 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
if (draft.items.length === 0) errors.push('请至少添加一条货物明细')
if (draft.destinationType === 'FBA') {
if (!draft.referenceNo.trim()) errors.push('FBA 订单请填写客户参考号')
if (!draft.amazonWarehouseCode.trim()) errors.push('FBA 订单请填写 Amazon 仓库编码')
if (!draft.shipmentId.trim()) errors.push('FBA 订单请填写 Shipment ID')
}
draft.items.forEach((item, index) => {
const line = `第 ${index + 1} 条货物`
if (!item.cargoNameCn.trim()) errors.push(`${line}缺少中文品名`)
if (!item.packageType.trim()) errors.push(`${line}缺少包装类型`)
if (!item.boxSizeType) errors.push(`${line}请选择箱规`)
if (draft.destinationType === 'FBA' && !item.sku.trim()) errors.push(`${line}缺少客户 SKU`)
if (draft.destinationType === 'FBA' && !item.upc.trim()) errors.push(`${line}缺少 UPC`)
if (draft.destinationType === 'FBA' && !item.shipmentId.trim()) errors.push(`${line}缺少 Shipment ID`)
if (draft.destinationType === 'FBA' && !item.referenceId.trim()) errors.push(`${line}缺少 Reference ID`)
if (!Number.isInteger(item.pieces) || item.pieces <= 0) errors.push(`${line}件数必须为正整数`)
if (!Number.isInteger(item.packageCount) || item.packageCount <= 0) errors.push(`${line}箱数必须为正整数`)
if (!Number.isInteger(item.unitsPerPackage) || item.unitsPerPackage <= 0) errors.push(`${line}每箱数量必须为正整数`)
if (item.grossWeightKg <= 0) errors.push(`${line}毛重必须大于 0`)
if (item.volumeCbm <= 0) errors.push(`${line}体积必须大于 0`)
if (draft.transportMode === 'FCL' && (!item.containerType || item.containerCount <= 0)) {
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { PRODUCT_IMAGE_MAX_SIZE, validateProductImageFile } from './productImage.ts'
test('accepts JPG and PNG product images up to 5MB', () => {
assert.equal(validateProductImageFile({ name: 'product.JPG', size: PRODUCT_IMAGE_MAX_SIZE, type: 'image/jpeg' }), '')
assert.equal(validateProductImageFile({ name: 'product.png', size: 1024, type: 'image/png' }), '')
})
test('rejects empty, oversized, mismatched, and unsupported product image files', () => {
assert.match(validateProductImageFile({ name: 'product.png', size: 0, type: 'image/png' }), /内容为空/)
assert.match(validateProductImageFile({ name: 'product.png', size: PRODUCT_IMAGE_MAX_SIZE + 1, type: 'image/png' }), /超过 5MB/)
assert.match(validateProductImageFile({ name: 'product.jpg', size: 1024, type: 'image/png' }), /仅支持/)
assert.match(validateProductImageFile({ name: 'product.webp', size: 1024, type: 'image/webp' }), /仅支持/)
})
@@ -0,0 +1,14 @@
const allowedTypes = new Set(['image/jpeg', 'image/png'])
const allowedExtensions = new Set(['jpg', 'jpeg', 'png'])
export const PRODUCT_IMAGE_MAX_SIZE = 5 * 1024 * 1024
export function validateProductImageFile(file: Pick<File, 'name' | 'size' | 'type'>) {
const extension = file.name.split('.').pop()?.toLowerCase() ?? ''
if (!allowedTypes.has(file.type.toLowerCase()) || !allowedExtensions.has(extension)) {
return '产品图片仅支持 JPG、JPEG、PNG 格式'
}
if (file.size <= 0) return '产品图片内容为空'
if (file.size > PRODUCT_IMAGE_MAX_SIZE) return '产品图片不能超过 5MB'
return ''
}
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance, type FormItemRule, type FormRules } from 'element-plus'
import { AlertTriangle, Box, Eye, FilePlus2, MapPin, PackageCheck, Pencil, Plus, Send, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormItemRule, type FormRules, type UploadRequestOptions } from 'element-plus'
import { AlertTriangle, Box, Eye, FilePlus2, ImagePlus, MapPin, PackageCheck, Pencil, Plus, Send, Trash2 } from '@lucide/vue'
import { FIRST_LEG_ORDER_STATUSES, FIRST_LEG_TRANSPORT_MODES, type FirstLegOrderStatus } from '@chongchong/shared'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
@@ -13,7 +13,9 @@ import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPane
import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling'
import {
estimateMerchantFirstLegPrice,
downloadMerchantProductImage,
getMerchantPricingChannels,
uploadMerchantProductImage,
type MerchantPriceEstimate,
type MerchantPriceEstimateRequest,
type MerchantPricingChannel,
@@ -23,20 +25,18 @@ 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 { CargoBoxSizeType, FirstLegCargoItem, FirstLegOrder, FirstLegOrderDraft } from '@/modules/first-leg/types/firstLegOrder'
import type { FirstLegCargoItem, FirstLegOrder, FirstLegOrderDraft } from '@/modules/first-leg/types/firstLegOrder'
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
import {
canClientEditFirstLegOrder,
applyCargoBoxSize,
CARGO_BOX_SIZES,
createEmptyCargoItem,
getFirstLegStatusLabel,
getFirstLegStatusTone,
getFirstLegTransportModeLabel,
summarizeFirstLegCargo,
updateCargoBoxVolume,
validateFirstLegOrderDraft,
} from '@/modules/first-leg/utils/firstLegOrder'
import { validateProductImageFile } from '@/modules/first-leg/utils/productImage'
const store = useFirstLegOrdersStore()
const tenantStore = useTenantAdministrationStore()
@@ -402,6 +402,8 @@ onBeforeUnmount(() => {
channelRequestSequence += 1
estimateRequestSequence += 1
if (estimateTimer) clearTimeout(estimateTimer)
releaseProductImagePreviews(form.items)
if (detailOrder.value) releaseProductImagePreviews(detailOrder.value.items)
})
function markEditorClean() {
@@ -422,10 +424,65 @@ function cloneDraft(draft: FirstLegOrderDraft): FirstLegOrderDraft {
}
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,
@@ -456,7 +513,12 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
destinationType: order.destinationType,
amazonWarehouseCode: order.amazonWarehouseCode,
shipmentId: order.shipmentId,
items: order.items.map((item) => ({ ...item })),
items: order.items.map((item) => ({
...item,
productImagePreviewUrl: '',
productImageUploading: false,
productImageError: '',
})),
}
}
@@ -755,6 +817,7 @@ async function openEdit(order: FirstLegOrder) {
}
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
@@ -773,7 +836,8 @@ async function openDetail(order: FirstLegOrder) {
detailError.value = ''
void loadInboundBilling(order)
try {
await store.loadDetail(order.id)
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)
@@ -873,10 +937,6 @@ function addCargoItem() {
resetEditorValidation()
}
function changeCargoBoxSize(item: FirstLegCargoItem, type: CargoBoxSizeType) {
applyCargoBoxSize(item, type)
}
function removeCargoItem(index: number) {
if (form.items.length === 1) {
ElMessage.warning('订单至少需要保留一条货物明细')
@@ -1116,37 +1176,81 @@ async function searchOrders(model: QueryModel) {
<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>
<el-form-item v-if="form.destinationType === 'FBA'" label="货件编号" prop="shipmentId" required :rules="requiredTextRules('请输入 Shipment ID')" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.shipmentId" maxlength="64" placeholder="Shipment ID" /></el-form-item>
<el-form-item label="跟踪编号" prop="referenceNo" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 订单请输入 Reference ID') : undefined" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.referenceNo" maxlength="100" :placeholder="form.destinationType === 'FBA' ? 'Reference ID' : '选填,贵司内部跟踪编号'" /></el-form-item>
</div>
</section>
<section class="order-form-section prototype-section cargo-section">
<div class="order-section-heading"><div><h3>货物明细</h3><p>先填写常用字段,申报和整柜信息可按需展开。</p></div><el-button @click="addCargoItem"><Plus :size="16" />添加货物</el-button></div>
<div class="order-section-heading"><div><h3>货物明细</h3></div><el-button @click="addCargoItem"><Plus :size="16" />添加货物</el-button></div>
<article v-for="(item, index) in form.items" :key="item.id" class="cargo-entry">
<header><strong>货物 {{ index + 1 }}</strong><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="16" />删除</el-button></header>
<div class="cargo-primary-grid">
<el-form-item label="SKU" :prop="`items.${index}.sku`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入客户 SKU') : undefined"><el-input v-model="item.sku" placeholder="SKU NO." /></el-form-item>
<el-form-item label="中文品名" :prop="`items.${index}.cargoNameCn`" required :rules="requiredTextRules('请输入中文品名')"><el-input v-model="item.cargoNameCn" placeholder="例如:蓝牙音箱" /></el-form-item>
<el-form-item label="英文品名"><el-input v-model="item.cargoNameEn" placeholder="Bluetooth speakers" /></el-form-item>
<el-form-item v-if="form.destinationType === 'FBA'" label="UPC" :prop="`items.${index}.upc`" required :rules="requiredTextRules('FBA 货物请输入 UPC')"><el-input v-model="item.upc" maxlength="32" placeholder="UPC NO." /></el-form-item>
<el-form-item label="包装类型" :prop="`items.${index}.packageType`" required :rules="requiredSelectRules('请选择包装类型')"><el-select v-model="item.packageType"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item>
<el-form-item label="总数量" :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('件数必须为大于 0 的整数', true)"><el-input-number v-model="item.pieces" :min="1" controls-position="right" /></el-form-item>
<el-form-item label="箱规" :prop="`items.${index}.boxSizeType`" required :rules="requiredSelectRules('请选择箱规')"><el-select v-model="item.boxSizeType" clearable placeholder="请选择固定箱规" @change="changeCargoBoxSize(item, $event as CargoBoxSizeType)"><el-option v-for="box in CARGO_BOX_SIZES" :key="box.code" :value="box.code" :label="`${box.label} · ${box.lengthCm} × ${box.widthCm} × ${box.heightCm} cm`" /></el-select></el-form-item>
<el-form-item label="箱数"><el-input-number v-model="item.packageCount" :min="1" controls-position="right" @change="updateCargoBoxVolume(item)" /></el-form-item>
<el-form-item label="总毛重 kg" :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item label="总体积 m³" :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="3" controls-position="right" disabled /></el-form-item>
</div>
<el-collapse class="cargo-more"><el-collapse-item title="更多申报信息"><div class="cargo-secondary-grid">
<el-form-item label="HS Code"><el-input v-model="item.hsCode" maxlength="20" placeholder="例如:8518210000" /></el-form-item>
<el-form-item label="申报货值"><el-input-number v-model="item.declaredValue" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item label="币种"><el-input v-model="item.currency" maxlength="3" placeholder="例如:USD" /></el-form-item>
<el-form-item label="危险品"><el-switch v-model="item.dangerousGoods" /></el-form-item>
<template v-if="form.transportMode === 'FCL'"><el-form-item label="柜型" :prop="`items.${index}.containerType`" required :rules="requiredSelectRules('请选择柜型')"><el-select v-model="item.containerType"><el-option v-for="type in ['20GP', '40GP', '40HQ', '45HQ']" :key="type" :label="type" :value="type" /></el-select></el-form-item><el-form-item label="柜量" :prop="`items.${index}.containerCount`" required :rules="positiveNumberRules('柜量必须为大于 0 的整数', true)"><el-input-number v-model="item.containerCount" :min="0" controls-position="right" /></el-form-item></template>
<el-form-item label="货物备注" class="form-span-full"><el-input v-model="item.remark" maxlength="300" placeholder="选填,例如:注意防潮" /></el-form-item>
</div></el-collapse-item></el-collapse>
<section class="cargo-field-section">
<h4>商品信息</h4>
<div class="cargo-form-grid cargo-form-grid--basic">
<el-form-item label="SKU NO." :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 NO." /></el-form-item>
<el-form-item label="中文品名" :prop="`items.${index}.cargoNameCn`" required :rules="requiredTextRules('请输入中文品名')"><el-input v-model="item.cargoNameCn" maxlength="128" placeholder="例如:蓝牙音箱" /></el-form-item>
<el-form-item label="英文品名 DESCRIPTION OF GOODS"><el-input v-model="item.cargoNameEn" maxlength="128" placeholder="Bluetooth speakers" /></el-form-item>
<el-form-item label="图片 PRODUCT PICTURE" class="product-image-field">
<div class="product-image-control">
<div v-if="item.productImagePreviewUrl" class="product-image-preview"><img :src="item.productImagePreviewUrl" :alt="`${item.cargoNameCn || `货物 ${index + 1}`}产品图片`" /><el-button circle type="danger" size="small" :aria-label="`移除货物 ${index + 1} 产品图片`" @click="removeCargoProductImage(item)"><Trash2 :size="14" /></el-button></div>
<div v-else class="product-image-placeholder"><ImagePlus :size="22" /><span>{{ item.productImageUploading ? '上传中...' : '暂无图片' }}</span></div>
<el-upload :show-file-list="false" accept=".jpg,.jpeg,.png,image/jpeg,image/png" :disabled="item.productImageUploading" :http-request="(options: UploadRequestOptions) => uploadCargoProductImage(item, options)"><el-button :loading="item.productImageUploading"><ImagePlus :size="15" />{{ item.productImageFileId ? '替换图片' : '上传图片' }}</el-button></el-upload>
<small>JPG、PNG,最大 5MB</small>
<span v-if="item.productImageError" class="product-image-error" role="alert">{{ item.productImageError }}</span>
</div>
</el-form-item>
<el-form-item label="UPC NO." :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>
</div>
</section>
<section class="cargo-field-section">
<h4>箱规、数量与重量</h4>
<div class="cargo-form-grid">
<el-form-item label="包装类型" :prop="`items.${index}.packageType`" required :rules="requiredSelectRules('请选择包装类型')"><el-select v-model="item.packageType"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item>
<el-form-item label="箱规长 cm"><el-input-number v-model="item.lengthCm" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item label="箱规宽 cm"><el-input-number v-model="item.widthCm" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item label="箱规高 cm"><el-input-number v-model="item.heightCm" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item label="每箱数量 QTY/CTN" :prop="`items.${index}.unitsPerPackage`" required :rules="positiveNumberRules('每箱数量必须为大于 0 的整数', true)"><el-input-number v-model="item.unitsPerPackage" :min="1" controls-position="right" /></el-form-item>
<el-form-item label="箱数 CARTONS" :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须为大于 0 的整数', true)"><el-input-number v-model="item.packageCount" :min="1" controls-position="right" /></el-form-item>
<el-form-item label="总毛重 T.K.GW" :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="总体积 T.CBM" :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" controls-position="right" /></el-form-item>
<el-form-item label="总数量 T.QTY" :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须为大于 0 的整数', true)"><el-input-number v-model="item.pieces" :min="1" controls-position="right" /></el-form-item>
<el-form-item label="总净重 T.QNW"><el-input-number v-model="item.netWeightKg" :min="0" :precision="3" controls-position="right" /></el-form-item>
</div>
</section>
<section class="cargo-field-section">
<h4>报关与产品资料</h4>
<div class="cargo-form-grid">
<el-form-item label="报关 HS CODE"><el-input v-model="item.hsCode" maxlength="20" placeholder="报关 HS CODE" /></el-form-item>
<el-form-item label="清关 HS CODE"><el-input v-model="item.customsHsCode" maxlength="20" placeholder="清关 HS CODE" /></el-form-item>
<el-form-item label="清关申报单价 USD"><el-input-number v-model="item.unitDeclaredValue" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item label="清关申报金额 USD"><el-input-number v-model="item.declaredValue" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item label="中英文材质 MATERIAL"><el-input v-model="item.material" maxlength="128" placeholder="材质 / Material" /></el-form-item>
<el-form-item label="中英文用途 USE FOR"><el-input v-model="item.usage" maxlength="128" placeholder="用途 / Use for" /></el-form-item>
<el-form-item label="型号 MODEL"><el-input v-model="item.model" maxlength="64" placeholder="型号" /></el-form-item>
<el-form-item label="品牌 LOGO"><el-input v-model="item.brand" maxlength="64" placeholder="品牌" /></el-form-item>
<el-form-item label="产品规格 长×宽×高" class="product-dimensions-field"><div class="dimension-inputs"><el-input-number v-model="item.productLengthCm" :min="0" :precision="2" placeholder="长 cm" controls-position="right" /><span>×</span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" placeholder="宽 cm" controls-position="right" /><span>×</span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" placeholder="高 cm" controls-position="right" /></div></el-form-item>
<el-form-item label="危险品"><el-switch v-model="item.dangerousGoods" /></el-form-item>
</div>
</section>
<section class="cargo-field-section">
<h4>货件与备注</h4>
<div class="cargo-form-grid">
<el-form-item label="货件编号 SHIPMENT ID" :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>
<el-form-item label="跟踪编号 REFERENCE ID" :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>
<el-form-item label="备注" class="cargo-remark-field"><el-input v-model="item.remark" maxlength="500" placeholder="选填,例如:注意防潮" /></el-form-item>
</div>
</section>
<section v-if="form.transportMode === 'FCL'" class="cargo-field-section">
<h4>整柜信息</h4>
<div class="cargo-form-grid">
<el-form-item label="箱型" :prop="`items.${index}.containerType`" required :rules="requiredSelectRules('请选择箱型')"><el-select v-model="item.containerType"><el-option v-for="type in ['20GP', '40GP', '40HQ']" :key="type" :label="type" :value="type" /></el-select></el-form-item>
<el-form-item label="柜量" :prop="`items.${index}.containerCount`" required :rules="positiveNumberRules('柜量必须为大于 0 的整数', true)"><el-input-number v-model="item.containerCount" :min="1" controls-position="right" /></el-form-item>
</div>
</section>
</article>
<p class="cargo-tip">FBA 货物必须填写 SKU、UPC、货件编号和跟踪编号;提交前请核对重量与体积。</p>
<aside class="price-estimate" aria-live="polite">
<div class="price-estimate__heading"><div><strong>参考运费</strong><span>仅供参考,最终以实际结算为准</span></div><el-button v-if="priceEstimateError" link type="primary" @click="loadPriceEstimate">重新估价</el-button></div>
<div v-if="priceEstimateLoading" class="price-estimate__state">正在匹配当前生效价格...</div>
@@ -1213,7 +1317,7 @@ async function searchOrders(model: QueryModel) {
<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-list"><article v-for="(item, index) in detailOrder.items" :key="item.id"><header><span>货物 {{ index + 1 }}</span><strong>{{ item.cargoNameCn || '未命名货物' }}</strong><small>{{ item.cargoNameEn || '' }}</small></header><dl><div><dt>SKU / UPC</dt><dd>{{ item.sku || '-' }} / {{ item.upc || '-' }}</dd></div><div><dt>HS 编码</dt><dd>{{ item.hsCode || '-' }}</dd></div><div><dt>包装 / 箱数</dt><dd>{{ packageTypeLabels[item.packageType] || item.packageType || '-' }} · {{ item.packageCount }} 箱</dd></div><div><dt>数量</dt><dd>{{ item.pieces }} 件</dd></div><div><dt>毛重</dt><dd>{{ item.grossWeightKg }} kg</dd></div><div><dt>体积</dt><dd>{{ item.volumeCbm }} m³</dd></div><div><dt>申报价值</dt><dd>{{ item.declaredValue ? `${item.currency} ${item.declaredValue.toLocaleString()}` : '-' }}</dd></div><div><dt>危险品</dt><dd>{{ item.dangerousGoods ? '是' : '否' }}</dd></div></dl><p v-if="item.remark">{{ item.remark }}</p></article></div><el-empty v-else description="暂无货物明细" :image-size="56" /></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-list"><article v-for="(item, index) in detailOrder.items" :key="item.id"><header><span>货物 {{ index + 1 }}</span><strong>{{ item.cargoNameCn || '未命名货物' }}</strong><small>{{ item.cargoNameEn || '' }}</small></header><div v-if="item.productImageFileId" class="cargo-detail-image"><img v-if="item.productImagePreviewUrl" :src="item.productImagePreviewUrl" :alt="`${item.cargoNameCn || `货物 ${index + 1}`}产品图片`" /><span v-else-if="item.productImageUploading">图片加载中...</span><span v-else>{{ item.productImageError || '产品图片暂不可用' }}</span></div><dl><div><dt>SKU / UPC</dt><dd>{{ item.sku || '-' }} / {{ item.upc || '-' }}</dd></div><div><dt>HS 编码</dt><dd>{{ item.hsCode || '-' }}</dd></div><div><dt>包装 / 箱数</dt><dd>{{ packageTypeLabels[item.packageType] || item.packageType || '-' }} · {{ item.packageCount }} 箱</dd></div><div><dt>数量</dt><dd>{{ item.pieces }} 件</dd></div><div><dt>毛重</dt><dd>{{ item.grossWeightKg }} kg</dd></div><div><dt>体积</dt><dd>{{ item.volumeCbm }} m³</dd></div><div><dt>申报价值</dt><dd>{{ item.declaredValue ? `${item.currency} ${item.declaredValue.toLocaleString()}` : '-' }}</dd></div><div><dt>危险品</dt><dd>{{ item.dangerousGoods ? '是' : '否' }}</dd></div></dl><p v-if="item.remark">{{ item.remark }}</p></article></div><el-empty v-else description="暂无货物明细" :image-size="56" /></section>
<OrderProgressPanel :order="detailOrder" />
<InboundBillingPanel
:data="inboundBilling"
@@ -1274,7 +1378,7 @@ async function searchOrders(model: QueryModel) {
.order-form-section + .order-form-section { margin-top: 24px; padding-top: 22px; border-top: 1px solid var(--border); }
.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-primary-grid > *, .cargo-secondary-grid > * { min-width: 0; }
.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: 2px 2px 0; }
@@ -1324,12 +1428,23 @@ async function searchOrders(model: QueryModel) {
.cargo-entry { padding: 16px; border: 1px solid var(--border); background: #fbfcfc; }
.cargo-entry + .cargo-entry { margin-top: 12px; }
.cargo-entry > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.cargo-primary-grid { display: grid; grid-template-columns: repeat(5, minmax(130px, 1fr)); column-gap: 12px; }
.cargo-secondary-grid { display: grid; grid-template-columns: repeat(4, minmax(130px, 1fr)); column-gap: 12px; padding-top: 6px; }
.cargo-more { border-top: 1px solid var(--border); border-bottom: 0; }
.cargo-more :deep(.el-collapse-item__header), .cargo-more :deep(.el-collapse-item__wrap) { background: transparent; }
.cargo-more :deep(.el-collapse-item__content) { padding-bottom: 0; }
.cargo-tip { margin: 12px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
.cargo-field-section + .cargo-field-section { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border); }
.cargo-field-section h4 { margin: 0 0 12px; color: var(--navy); 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; }
@@ -1409,7 +1524,9 @@ async function searchOrders(model: QueryModel) {
.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-primary-grid, .cargo-secondary-grid, .party-detail-grid { grid-template-columns: 1fr; }
.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; }