This commit is contained in:
2026-08-13 17:12:05 +08:00
parent 5aa060def2
commit fb6465104d
13 changed files with 776 additions and 220 deletions
+9
View File
@@ -136,4 +136,13 @@ export async function apiBlobRequest(config: AxiosRequestConfig) {
}
}
export async function apiBlobResponseRequest(config: AxiosRequestConfig) {
try {
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
return { blob: response.data, headers: response.headers }
} catch (error) {
throw mapError(error)
}
}
export { ApiError }
+1 -2
View File
@@ -34,6 +34,7 @@ declare module 'vue' {
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
@@ -45,8 +46,6 @@ declare module 'vue' {
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
LoadErrorState: typeof import('./components/common/LoadErrorState.vue')['default']
+7 -3
View File
@@ -15,6 +15,7 @@ export interface QueryOption {
export interface QueryField {
key: string
label?: string
type: QueryFieldType
placeholder?: string
options?: QueryOption[]
@@ -79,7 +80,8 @@ defineExpose({ search, reset })
:aria-label="ariaLabel"
>
<div class="query-toolbar__filters">
<template v-for="field in fields" :key="field.key">
<label v-for="field in fields" :key="field.key" class="query-toolbar__field">
<span v-if="field.label" class="query-toolbar__label">{{ field.label }}</span>
<el-input
v-if="field.type === 'input'"
:model-value="model[field.key] as string"
@@ -113,7 +115,7 @@ defineExpose({ search, reset })
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
/>
</template>
</label>
</div>
<div class="query-toolbar__actions">
<el-button type="primary" @click="search"><Search :size="16" />{{ searchText }}</el-button>
@@ -126,8 +128,10 @@ defineExpose({ search, reset })
<style scoped>
.query-toolbar { display: flex; align-items: flex-end; justify-content: flex-start; flex-wrap: wrap; gap: 10px; padding: 16px; border: 1px solid var(--border); background: #fff; }
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 10px; }
.query-toolbar__field { display: flex; min-width: 0; align-items: center; gap: 9px; }
.query-toolbar__label { flex: 0 0 auto; color: #5f6877; font-size: 12px; white-space: nowrap; }
.query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 260px; max-width: 100%; min-width: 0; }
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__field { display: grid; gap: 6px; } .query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
</style>
+108 -2
View File
@@ -1,4 +1,4 @@
import { apiBlobRequest, apiRequest } from '@/api/http'
import { ApiError, apiBlobRequest, apiBlobResponseRequest, apiRequest } from '@/api/http'
export type ApiFirstLegStatus = 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED' | 'PROCESSING' | 'COMPLETED'
export type ApiTransportMode = 'SEA' | 'AIR' | 'RAIL' | 'TRUCK'
@@ -205,6 +205,18 @@ export interface MerchantFirstLegOrder {
referenceId: string | null
expressCompany?: string | null
expressTrackingNo?: string | null
skuNos?: string[] | null
totalPackageCount?: number | null
totalGrossWeightKg?: number | null
totalNetWeightKg?: number | null
totalVolumeCbm?: number | null
totalQuantity?: number | null
chargeableWeightKg?: number | null
chargeableVolumeCbm?: number | null
inboundCode?: string | null
inboundedAt?: string | null
businessDate?: string | null
internationalOrderNo?: string | null
submittedAt?: string | null
completedAt?: string | null
version: number
@@ -212,6 +224,40 @@ export interface MerchantFirstLegOrder {
updatedAt: string
}
export interface MerchantFirstLegOrderRow {
orderId: number | string
orderNo: string | null
packageCount: number | null
weightKg: number | null
inboundId: number | string | null
inboundCode: string | null
channelName: string | null
destinationType: ApiDestinationType | null
customerName: string | null
receivingCompanyName: string | null
specificationModel: string | null
status: ApiFirstLegStatus
totalQuantity: number | null
totalGrossWeightKg: number | null
totalNetWeightKg: number | null
totalVolumeCbm: number | null
trackingNo: string | null
internationalOrderNo: string | null
referenceId: string | null
remark: string | null
destinationWarehouseName: string | null
expressCompany: string | null
skuNos: string | string[] | null
chargeableWeightKg: number | null
chargeableVolumeCbm: number | null
billingUnitPrice: number | null
inboundedAt: string | null
businessDate: string | null
shipmentId: string | null
createdAt: string
cargoes?: Array<{ skuNo?: string | null }>
}
export interface MerchantFirstLegOrderCargo extends FirstLegOrderCargoPayload {
id: number | string
itemNo: number
@@ -251,6 +297,66 @@ export function downloadMerchantProductImage(fileId: number | string) {
return apiBlobRequest({ method: 'GET', url: `/merchant/v1/files/product-images/${fileId}` })
}
export async function downloadMerchantInboundCartonLabels(inboundId: number | string) {
return downloadMerchantCartonLabelPdf(
`/merchant/v1/first-leg/inbounds/${encodeURIComponent(String(inboundId))}/carton-label-pdf`,
)
}
export async function downloadMerchantOrderCartonLabels(orderId: number | string) {
return downloadMerchantCartonLabelPdf(
`/merchant/v1/first-leg/orders/${encodeURIComponent(String(orderId))}/carton-label-pdf`,
)
}
export async function downloadMerchantOrderCartonLabelsBatch(orderIds: Array<number | string>) {
const response = await apiBlobResponseRequest({
method: 'POST',
url: '/merchant/v1/first-leg/orders/carton-label-pdf',
data: orderIds.map(String),
timeout: 120_000,
})
await requirePdfBlob(response.blob)
return {
blob: response.blob,
failureCount: Number(response.headers['x-carton-label-failure-count'] ?? 0),
failures: String(response.headers['x-carton-label-failures'] ?? ''),
}
}
async function downloadMerchantCartonLabelPdf(url: string) {
const blob = await apiBlobRequest({
method: 'POST',
url,
data: null,
timeout: 60_000,
})
await requirePdfBlob(blob)
return blob
}
async function requirePdfBlob(blob: Blob) {
const signature = new TextDecoder().decode(await blob.slice(0, 5).arrayBuffer())
if (signature === '%PDF-') return
const text = await blob.text()
try {
const error = JSON.parse(text) as { code?: unknown; message?: unknown; traceId?: unknown }
if (typeof error.message === 'string') {
throw new ApiError(
error.message || '箱唛生成失败',
typeof error.code === 'string' ? error.code : 'INVALID_PDF',
undefined,
undefined,
typeof error.traceId === 'string' ? error.traceId : null,
)
}
} catch (error) {
if (error instanceof ApiError) throw error
}
throw new ApiError('箱唛服务未返回有效的 PDF 文件', 'INVALID_PDF')
}
export interface FirstLegOrderQuery {
keyword?: string
status?: ApiFirstLegStatus
@@ -330,7 +436,7 @@ export function estimateMerchantFirstLegPrice(payload: MerchantPriceEstimateRequ
}
export function getMerchantFirstLegOrders(params: FirstLegOrderQuery = {}) {
return apiRequest<PageResult<MerchantFirstLegOrder>>({ method: 'GET', url: '/merchant/v1/orders/first-leg', params: { page: 1, size: 200, ...params } })
return apiRequest<PageResult<MerchantFirstLegOrderRow>>({ method: 'GET', url: '/merchant/v1/orders/first-leg', params: { page: 1, size: 200, ...params } })
}
export function getMerchantFirstLegOrder(id: number | string) {
@@ -73,6 +73,15 @@ export interface MerchantUnifiedOrderRow {
totalPackageCount: number | null
actualGrossWeightKg: number | null
actualVolumeCbm: number | null
skuNos?: string[] | null
expressCompany?: string | null
totalNetWeightKg?: number | null
totalQuantity?: number | null
chargeableVolumeCbm?: number | null
shipmentId?: string | null
referenceId?: string | null
inboundedAt?: string | null
businessDate?: string | null
measurement: MerchantUnifiedMeasurementSummary | null
preload: MerchantUnifiedPreloadSummary | null
loading: MerchantUnifiedLoadingSummary | null
@@ -24,19 +24,21 @@ const emit = defineEmits<{
<footer class="editor-page-footer"><slot name="footer" /></footer>
</section>
<el-drawer
<el-dialog
v-else
class="first-leg-editor"
class="first-leg-editor-dialog"
:model-value="modelValue"
:title="title"
size="1080px"
width="94vw"
top="3vh"
destroy-on-close
append-to-body
:before-close="beforeClose"
@update:model-value="emit('update:modelValue', $event)"
>
<slot />
<template #footer><slot name="footer" /></template>
</el-drawer>
</el-dialog>
</template>
<style scoped>
@@ -48,15 +50,24 @@ const emit = defineEmits<{
.editor-page-footer :deep(.editor-actions) { width: min(100%, 900px); justify-content: flex-start; }
.editor-page-footer :deep(.editor-actions > .el-button) { min-width: 132px; min-height: 42px; }
.editor-page-footer :deep(.editor-actions > .el-button:last-child) { min-width: 172px; }
:global(.first-leg-editor-dialog.el-dialog) { display: flex; max-width: 1500px; height: 94vh; margin-bottom: 0; flex-direction: column; overflow: hidden; border-radius: 6px; }
:global(.first-leg-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin-right: 0; padding: 17px 22px; border-bottom: 1px solid var(--border); }
:global(.first-leg-editor-dialog .el-dialog__title) { color: var(--navy); font-size: 16px; font-weight: 700; }
:global(.first-leg-editor-dialog .el-dialog__headerbtn) { top: 8px; right: 10px; width: 44px; height: 44px; }
:global(.first-leg-editor-dialog .el-dialog__body) { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 22px 24px 28px; overscroll-behavior: contain; }
:global(.first-leg-editor-dialog .el-dialog__footer) { flex: 0 0 auto; padding: 12px 22px; border-top: 1px solid var(--border); background: #fff; }
@media (max-width: 720px) {
.editor-page-nav { align-items: flex-start; flex-direction: column; gap: 2px; padding: 10px 16px; }
.editor-page-nav span { padding-left: 1px; line-height: 1.5; }
.editor-page-body { padding: 18px 16px 24px; }
.editor-page-footer { padding: 10px 16px; }
:global(.first-leg-editor-dialog.el-dialog) { width: calc(100vw - 24px) !important; height: 96vh; margin-top: 2vh; }
}
@media (max-width: 480px) {
.editor-page-footer { position: static; }
:global(.first-leg-editor-dialog.el-dialog) { width: 100vw !important; height: 100vh; margin: 0; border-radius: 0; }
:global(.first-leg-editor-dialog .el-dialog__body) { padding: 16px 12px 22px; }
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Check, CircleDot, Clock3, Paperclip } from '@lucide/vue'
import { Check, CircleDot } from '@lucide/vue'
import type { FirstLegOrder } from '@/modules/first-leg/types/firstLegOrder'
import {
FIRST_LEG_PROGRESS_STAGES,
@@ -11,27 +11,11 @@ import {
const props = defineProps<{ order: FirstLegOrder }>()
const currentIndex = computed(() => getFirstLegProgressIndex(props.order.status))
const timeline = computed(() => [...props.order.timeline].reverse())
function stageState(index: number) {
if (index < currentIndex.value) return 'is-complete'
if (index === currentIndex.value) return 'is-current'
return 'is-pending'
}
function formatDateTime(value: string) {
if (!value) return '时间待更新'
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,
})
}
</script>
<template>
@@ -54,37 +38,17 @@ function formatDateTime(value: string) {
</ol>
<p v-if="order.status === 'DRAFT'" class="draft-progress-note">订单提交后开始记录主状态进度。</p>
<div class="timeline-heading">
<h4>进度记录</h4>
<span>{{ timeline.length }} 条</span>
</div>
<el-empty v-if="timeline.length === 0" class="timeline-empty" description="暂无进度记录" :image-size="52" />
<el-timeline v-else class="order-timeline">
<el-timeline-item v-for="item in timeline" :key="item.id" :timestamp="formatDateTime(item.occurredAt)" placement="top" :type="item.kind === 'MAIN_STATUS' ? 'primary' : 'info'">
<div class="timeline-title">
<strong>{{ item.title }}</strong>
<el-tag v-if="item.kind === 'CUSTOM_NODE'" size="small" effect="plain">业务节点</el-tag>
<el-tag v-else-if="item.status" size="small" effect="plain">{{ getFirstLegStatusLabel(item.status) }}</el-tag>
</div>
<p v-if="item.description">{{ item.description }}</p>
<div class="timeline-meta">
<span>{{ item.operatorName || '平台' }}</span>
<span v-if="item.kind === 'CUSTOM_NODE' && item.plannedAt"><Clock3 :size="13" />计划 {{ formatDateTime(item.plannedAt) }}</span>
<span v-if="item.attachmentCount"><Paperclip :size="13" />{{ item.attachmentCount }} 个附件</span>
</div>
</el-timeline-item>
</el-timeline>
</section>
</template>
<style scoped>
.order-progress { margin-top: 22px; }
.progress-heading, .timeline-heading, .timeline-title, .timeline-meta { display: flex; align-items: center; }
.progress-heading { display: flex; align-items: center; }
.progress-heading { justify-content: space-between; gap: 14px; }
.progress-heading > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.progress-heading h3, .timeline-heading h4 { margin: 0; color: var(--navy); }
.progress-heading h3 { margin: 0; color: var(--navy); }
.progress-heading h3 { font-size: 15px; }
.progress-heading span, .timeline-heading span { color: var(--muted); font-size: 11px; }
.progress-heading span { color: var(--muted); font-size: 11px; }
.progress-stages { display: grid; grid-template-columns: repeat(7, minmax(76px, 1fr)); margin: 16px 0 0; padding: 0; overflow-x: auto; list-style: none; }
.progress-stages li { position: relative; display: flex; min-width: 76px; align-items: center; flex-direction: column; gap: 7px; color: #96a0a6; font-size: 10px; text-align: center; }
.progress-stages li::before { position: absolute; top: 10px; right: 50%; left: -50%; height: 2px; background: #e1e6e8; content: ''; }
@@ -95,15 +59,6 @@ function formatDateTime(value: string) {
.is-complete .stage-marker { background: var(--primary); color: #fff; }
.is-current .stage-label { color: var(--navy); font-weight: 700; }
.draft-progress-note { margin: 12px 0 0; padding: 10px 12px; background: #f6f8f8; color: var(--muted); font-size: 11px; }
.timeline-heading { justify-content: space-between; margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--border); }
.timeline-heading h4 { font-size: 13px; }
.order-timeline { margin-top: 14px; padding-left: 5px; }
.timeline-title { flex-wrap: wrap; gap: 8px; }
.timeline-title strong { color: var(--navy); font-size: 12px; }
.order-timeline p { margin: 6px 0; overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.55; white-space: pre-wrap; }
.timeline-meta { flex-wrap: wrap; gap: 12px; color: var(--muted); font-size: 10px; }
.timeline-meta span { display: inline-flex; align-items: center; gap: 4px; }
.timeline-empty { padding: 12px 0 0; }
@media (max-width: 720px) {
.progress-stages { grid-template-columns: repeat(7, 82px); padding-bottom: 8px; }
.progress-heading { align-items: flex-start; }
@@ -6,6 +6,22 @@ const storeSource = readFileSync(new URL('./firstLegOrders.ts', import.meta.url)
const apiSource = readFileSync(new URL('../api/firstLeg.ts', import.meta.url), 'utf8')
const unifiedApiSource = readFileSync(new URL('../api/unifiedOrders.ts', import.meta.url), 'utf8')
test('maps the first-leg aggregate list fields without loading detail rows', () => {
assert.match(apiSource, /interface MerchantFirstLegOrderRow/)
assert.match(apiSource, /skuNos: string \| string\[\] \| null/)
assert.match(apiSource, /cargoes\?: Array<\{ skuNo\?: string \| null \}>/)
assert.match(apiSource, /apiRequest<PageResult<MerchantFirstLegOrderRow>>/)
assert.match(storeSource, /function mapFirstLegOrderRow/)
for (const field of ['inboundId', 'inboundCode', 'channelName', 'destinationWarehouseName', 'packageCount', 'expressCompany', 'trackingNo', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'remark', 'shipmentId', 'referenceId', 'createdAt']) {
assert.match(storeSource, new RegExp(`row\\.${field}`))
}
assert.match(storeSource, /row\.skuNos/)
assert.match(storeSource, /row\.cargoes/)
assert.match(storeSource, /chargeableWeightKg: null/)
assert.match(storeSource, /chargeableVolumeCbm: null/)
assert.match(storeSource, /orders\.value = orderResult\.value\.records\.map\(mapFirstLegOrderRow\)/)
})
test('declares the documented merchant warehouse and first-leg endpoints', () => {
assert.match(apiSource, /url: '\/merchant\/v1\/warehouses'/)
assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg'/)
@@ -16,6 +32,13 @@ test('declares the documented merchant warehouse and first-leg endpoints', () =>
assert.match(apiSource, /url: '\/merchant\/v1\/first-leg\/pricing\/channels'/)
assert.match(apiSource, /params: \{ originWarehouseId \}/)
assert.match(apiSource, /url: '\/merchant\/v1\/first-leg\/pricing\/estimate'/)
assert.match(apiSource, /`\/merchant\/v1\/first-leg\/inbounds\/\$\{encodeURIComponent\(String\(inboundId\)\)\}\/carton-label-pdf`/)
assert.match(apiSource, /`\/merchant\/v1\/first-leg\/orders\/\$\{encodeURIComponent\(String\(orderId\)\)\}\/carton-label-pdf`/)
assert.match(apiSource, /url: '\/merchant\/v1\/first-leg\/orders\/carton-label-pdf'/)
assert.match(apiSource, /data: orderIds\.map\(String\)/)
assert.doesNotMatch(apiSource, /`\/merchant\/v1\/inbounds\/\$\{encodeURIComponent\(String\(inboundId\)\)\}\/carton-label-pdf`/)
assert.match(apiSource, /signature === '%PDF-'/)
assert.match(apiSource, /throw new ApiError\('箱唛服务未返回有效的 PDF 文件', 'INVALID_PDF'\)/)
})
test('maps transport and status fields instead of treating container mode as transport mode', () => {
+54 -52
View File
@@ -13,6 +13,7 @@ import {
type FirstLegOrderPartyPayload,
type FirstLegOrderSaveRequest,
type MerchantFirstLegOrder,
type MerchantFirstLegOrderRow,
type MerchantFirstLegOrderDetail,
type MerchantBatchDraftResponse,
type MerchantWarehouse,
@@ -305,7 +306,10 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
receivingWarehouseSnapshot: orderWarehouseSnapshot(order, warehouse), expectedDepartureDate: order.expectedShipDate ?? '', etd: '', eta: '',
shipperPartyId: shipper?.id ?? '', shipperPartySnapshot: shipper, shipper: shipper?.name ?? '',
consigneePartyId: consignee?.id ?? '', consigneePartySnapshot: consignee, consignee: consignee?.name ?? '',
expressCompanyName: order.expressCompany ?? '', trackingNumber: order.expressTrackingNo ?? '', priority: 'NORMAL', customerRemark: order.customerRemark ?? '', operatorName: '',
expressCompanyName: order.expressCompany ?? '', trackingNumber: order.expressTrackingNo ?? '', skuNos: order.skuNos ?? [],
totalNetWeightKg: order.totalNetWeightKg ?? null, totalQuantity: order.totalQuantity ?? null,
chargeableVolumeCbm: null, inboundedAt: order.inboundedAt ?? '', businessDate: '',
priority: 'NORMAL', customerRemark: order.customerRemark ?? '', operatorName: '',
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 ?? '',
@@ -323,7 +327,14 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
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,
timeline: detail ? timeline(detail) : [], businessSummary: {
...businessSummary(),
totalPackageCount: order.totalPackageCount ?? null,
actualGrossWeightKg: order.totalGrossWeightKg ?? null,
actualVolumeCbm: order.totalVolumeCbm ?? null,
chargeableWeightKg: order.chargeableWeightKg ?? null,
inboundCode: order.inboundCode ?? '',
}, createdAt: order.createdAt, updatedAt: order.updatedAt,
tradeTerm: order.tradeTerm ?? '', destinationCountryCode: order.destinationCountryCode,
destinationWarehouseId: order.destinationWarehouseId ? String(order.destinationWarehouseId) : '',
destinationWarehouseName: order.destinationWarehouseName, pickupMethod: order.pickupMethod, channelCode: order.channelCode,
@@ -337,23 +348,55 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
function mapUnifiedOrderRow(summary: MerchantUnifiedOrderRow, warehouses: PlatformReceivingWarehouse[]): FirstLegOrder {
const warehouse = warehouses.find((item) => item.id === String(summary.receivingWarehouseId))
return {
id: String(summary.orderId), orderNo: summary.orderNo, referenceNo: '', transportMode: null,
id: String(summary.orderId), orderNo: summary.orderNo, referenceNo: summary.referenceId ?? '', transportMode: null,
businessType: 'EXPORT', origin: '', destination: summary.destinationWarehouseName ?? '',
receivingWarehouseId: summary.receivingWarehouseId == null ? '' : String(summary.receivingWarehouseId),
receivingWarehouseName: summary.receivingWarehouseName ?? '', receivingWarehouseSnapshot: warehouseSnapshot(warehouse),
expectedDepartureDate: summary.expectedShipDate ?? '', etd: '', eta: '', shipperPartyId: '', shipperPartySnapshot: null,
shipper: '', consigneePartyId: '', consigneePartySnapshot: null, consignee: '', expressCompanyName: '',
trackingNumber: summary.delivery?.trackingNo ?? '', priority: 'NORMAL', customerRemark: '', operatorName: '',
shipper: '', consigneePartyId: '', consigneePartySnapshot: null, consignee: '', expressCompanyName: summary.expressCompany ?? '',
trackingNumber: summary.delivery?.trackingNo ?? '', skuNos: summary.skuNos ?? [], totalNetWeightKg: summary.totalNetWeightKg ?? null,
totalQuantity: summary.totalQuantity ?? null, chargeableVolumeCbm: null,
inboundedAt: summary.inboundedAt ?? '', businessDate: '', priority: 'NORMAL', customerRemark: '', operatorName: '',
status: summary.mainStatus, version: 0, items: [], timeline: [], businessSummary: businessSummary(summary),
createdAt: summary.createdAt, updatedAt: summary.updatedAt, tradeTerm: '', destinationCountryCode: '',
destinationWarehouseId: summary.destinationWarehouseId == null ? '' : String(summary.destinationWarehouseId),
destinationWarehouseName: summary.destinationWarehouseName ?? '', pickupMethod: 'SELF_EXPRESS', channelCode: '',
channelName: summary.channelName ?? '', pricingTaxMode: 'TAX_INCLUDED', carrierId: '', carrierName: '', taxMode: 'BUY_DECLARATION', customsInspection: false,
destinationType: 'PRIVATE', amazonWarehouseCode: '', shipmentId: '',
destinationType: 'PRIVATE', amazonWarehouseCode: '', shipmentId: summary.shipmentId ?? '',
receivingTime: '', submittedAt: '', completedAt: '',
}
}
function mapFirstLegOrderRow(row: MerchantFirstLegOrderRow): FirstLegOrder {
const skuNos = Array.isArray(row.skuNos)
? row.skuNos
: (row.skuNos ?? '').split('、').map((value) => value.trim()).filter(Boolean)
const resolvedSkuNos = skuNos.length ? skuNos : (row.cargoes ?? [])
.map((cargo) => cargo.skuNo?.trim())
.filter((value): value is string => Boolean(value))
const uniqueSkuNos = [...new Set(resolvedSkuNos)]
return {
id: String(row.orderId), orderNo: row.orderNo, referenceNo: row.referenceId ?? '', transportMode: null,
businessType: 'EXPORT', origin: '', destination: row.destinationWarehouseName ?? '', receivingWarehouseId: '',
receivingWarehouseName: '', receivingWarehouseSnapshot: null, expectedDepartureDate: '', etd: '', eta: '',
shipperPartyId: '', shipperPartySnapshot: null, shipper: '', consigneePartyId: '', consigneePartySnapshot: null,
consignee: row.receivingCompanyName ?? '', expressCompanyName: row.expressCompany ?? '', trackingNumber: row.trackingNo ?? '',
skuNos: uniqueSkuNos, totalNetWeightKg: row.totalNetWeightKg, totalQuantity: row.totalQuantity,
chargeableVolumeCbm: null, inboundedAt: row.inboundedAt ?? '', businessDate: '', priority: 'NORMAL', customerRemark: row.remark ?? '',
operatorName: '', status: clientStatus(row.status), version: 0, items: [], timeline: [],
businessSummary: {
...businessSummary(), totalPackageCount: row.packageCount, actualGrossWeightKg: row.totalGrossWeightKg ?? row.weightKg,
actualVolumeCbm: row.totalVolumeCbm, chargeableWeightKg: null,
inboundId: row.inboundId == null ? '' : String(row.inboundId), inboundCode: row.inboundCode ?? '',
},
createdAt: row.createdAt, updatedAt: row.createdAt, tradeTerm: '', destinationCountryCode: '', destinationWarehouseId: '',
destinationWarehouseName: row.destinationWarehouseName ?? '', pickupMethod: 'SELF_EXPRESS', channelCode: '',
channelName: row.channelName ?? '', pricingTaxMode: 'TAX_INCLUDED', carrierId: '', carrierName: '', taxMode: 'BUY_DECLARATION',
customsInspection: false, destinationType: row.destinationType ?? 'PRIVATE', amazonWarehouseCode: '',
shipmentId: row.shipmentId ?? row.internationalOrderNo ?? '', receivingTime: '', submittedAt: '', completedAt: '',
}
}
function mapUnifiedOrderDetail(detail: MerchantUnifiedOrderDetail, warehouses: PlatformReceivingWarehouse[]): FirstLegOrder {
if (!detail?.summary || !detail.order?.order) throw new Error('订单详情数据不完整,请刷新列表后重试')
const mapped = mapOrder(detail.order.order, warehouses, detail.order)
@@ -366,8 +409,7 @@ function mapUnifiedOrderDetail(detail: MerchantUnifiedOrderDetail, warehouses: P
}
type OrderListReadResult =
| { source: 'unified'; records: MerchantUnifiedOrderRow[]; drafts: MerchantFirstLegOrder[]; draftError: string }
| { source: 'legacy'; records: MerchantFirstLegOrder[] }
| { source: 'first-leg'; records: MerchantFirstLegOrderRow[] }
interface ClientOrderReadQuery extends MerchantUnifiedOrderQuery {
draftOnly?: boolean
@@ -384,41 +426,9 @@ function legacyQuery(query: ClientOrderReadQuery, status?: ApiFirstLegStatus) {
}
async function getOrdersForClient(query: ClientOrderReadQuery = {}): Promise<OrderListReadResult> {
if (query.draftOnly) {
const drafts = await getMerchantFirstLegOrders(legacyQuery(query, 'DRAFT'))
return { source: 'legacy', records: drafts.records }
}
const includeDrafts = query.includeDrafts ?? true
const unifiedQuery: MerchantUnifiedOrderQuery = {
keyword: query.keyword,
orderNo: query.orderNo,
mainStatus: query.mainStatus,
receivingWarehouseId: query.receivingWarehouseId,
destinationWarehouseId: query.destinationWarehouseId,
createdFrom: query.createdFrom,
createdTo: query.createdTo,
page: query.page,
size: query.size,
}
try {
const response = await getMerchantUnifiedOrders(unifiedQuery)
if (!includeDrafts) return { source: 'unified', records: response.records, drafts: [], draftError: '' }
try {
const drafts = await getMerchantFirstLegOrders(legacyQuery(query, 'DRAFT'))
return { source: 'unified', records: response.records, drafts: drafts.records, draftError: '' }
} catch (error) {
return {
source: 'unified',
records: response.records,
drafts: [],
draftError: error instanceof Error ? error.message : '订单草稿加载失败',
}
}
} catch (error) {
if (!isUnifiedOrderQueryUnavailable(error)) throw error
const response = await getMerchantFirstLegOrders(legacyQuery(query))
return { source: 'legacy', records: response.records }
}
const status = query.draftOnly ? 'DRAFT' : undefined
const response = await getMerchantFirstLegOrders(legacyQuery(query, status))
return { source: 'first-leg', records: response.records }
}
function mapWarehouse(item: MerchantWarehouse): ClientWarehouse {
@@ -483,15 +493,7 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
const [orderResult, warehouseResult] = await Promise.allSettled([getOrdersForClient(query), loadWarehouseType('DOMESTIC', true)])
if (warehouseResult.status === 'rejected' && !warehouseLoadError.value) warehouseLoadError.value = warehouseResult.reason instanceof Error ? warehouseResult.reason.message : '公共仓库数据加载失败'
if (orderResult.status === 'fulfilled') {
if (orderResult.value.source === 'unified') {
draftLoadError.value = orderResult.value.draftError
orders.value = [
...orderResult.value.drafts.map((item) => mapOrder(item, warehouses.value)),
...orderResult.value.records.map((item) => mapUnifiedOrderRow(item, warehouses.value)),
].sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
} else {
orders.value = orderResult.value.records.map((item) => mapOrder(item, warehouses.value))
}
orders.value = orderResult.value.records.map(mapFirstLegOrderRow)
} else {
loadError.value = orderResult.reason instanceof Error ? orderResult.reason.message : '订单数据加载失败'
}
@@ -114,6 +114,12 @@ export interface FirstLegOrder {
consignee: string
expressCompanyName: string
trackingNumber: string
skuNos: string[]
totalNetWeightKg: number | null
totalQuantity: number | null
chargeableVolumeCbm: number | null
inboundedAt: string
businessDate: string
priority: FirstLegPriority
customerRemark: string
operatorName: string
@@ -11,7 +11,7 @@ test('routes the confirmed first-leg path to the implemented order view', () =>
assert.match(routerSource, /path: 'orders\/first-leg'[\s\S]*?component: \(\) => import\('\.\/views\/FirstLegOrdersView\.vue'\)[\s\S]*?release: 'active'/)
})
test('opens order creation as a dedicated protected page while keeping draft editing in a drawer', () => {
test('opens order creation as a dedicated protected page while keeping draft editing in a large dialog', () => {
assert.match(routerSource, /path: 'orders\/first-leg\/create'[\s\S]*?name: 'first-leg-order-create'[\s\S]*?release: 'active'[\s\S]*?permission: 'order:first-leg:create'/)
assert.match(routerSource, /name: 'first-leg-order-create'[\s\S]*?props: \{ createPage: true \}/)
assert.match(source, /router\.push\(\{ name: 'first-leg-order-create' \}\)/)
@@ -19,7 +19,10 @@ test('opens order creation as a dedicated protected page while keeping draft edi
assert.match(source, /const isCreatePage = computed\(\(\) => props\.createPage\)/)
assert.match(source, /<FirstLegOrderEditorShell[^>]*:page="isCreatePage"/)
assert.match(editorShellSource, /<section v-if="page" class="first-leg-editor-page">/)
assert.match(editorShellSource, /<el-drawer[\s\S]*v-else/)
assert.match(editorShellSource, /<el-dialog[\s\S]*v-else/)
assert.match(editorShellSource, /class="first-leg-editor-dialog"/)
assert.match(editorShellSource, /width="94vw"/)
assert.doesNotMatch(editorShellSource, /<el-drawer/)
assert.match(editorShellSource, /返回订单列表/)
})
@@ -56,15 +59,24 @@ test('defers approval checks to the server when the member cannot read the compa
assert.match(source, /store\.submit\([^\n]*knownCompanyAuditStatus\)/)
})
test('renders dynamic cargo lines and responsive drawers', () => {
test('renders dynamic cargo lines and a responsive editor dialog', () => {
assert.match(source, /v-for="\(item, index\) in form\.items"/)
assert.match(source, /function addCargoItem\(\)/)
assert.match(source, /function removeCargoItem\(index: number\)/)
assert.match(source, /:global\(\.first-leg-editor\).*width: 100% !important/)
assert.match(editorShellSource, /first-leg-editor-dialog\.el-dialog\) \{[^}]*height: 94vh;/)
assert.match(editorShellSource, /width: 100vw !important; height: 100vh;/)
assert.match(source, /class="mobile-order-list"/)
assert.match(source, /\.desktop-order-table \{ display: none; \}/)
})
test('uses the full editor dialog width for cargo details and cost estimates', () => {
assert.match(source, /:class="\{ 'is-dialog-order-form': !isCreatePage \}"/)
assert.match(source, /\.order-form\.is-dialog-order-form \{ width: 100%; max-width: none; \}/)
assert.match(source, /\.is-dialog-order-form > \.cargo-section, \.is-dialog-order-form > \.cost-estimate-section \{ width: 100%; max-width: none; \}/)
assert.match(source, /\.cost-estimate-cards \{ display: grid; grid-template-columns: repeat\(3, minmax\(0, 1fr\)\);/)
assert.match(source, /\.cargo-table-wrap \{ width: 100%;/)
})
test('keeps primary mobile order actions touch sized', () => {
assert.match(source, /\.editor-actions :deep\(\.el-button\) \{ min-height: 44px;/)
})
@@ -73,15 +85,106 @@ test('uses the shared query toolbar and data table without metric cards', () =>
assert.match(source, /import QueryToolbar, \{ type QueryField, type QueryModel \}/)
assert.match(source, /import DataTable, \{ type DataTableColumn \}/)
assert.match(source, /const filters = ref<QueryModel>/)
assert.match(source, /<QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders" \/>/)
assert.match(source, /<QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders">/)
assert.match(source, /async function searchOrders\(model: QueryModel\)/)
assert.match(source, /await store\.load\(\{/)
assert.match(source, /<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="orderColumns"/)
assert.match(source, /\{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' \}/)
assert.match(source, /<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="visibleOrderColumns"/)
assert.match(source, /\{ key: 'actions', label: '操作', width: 128, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' \}/)
assert.doesNotMatch(source, /order-metrics|订单概览/)
})
test('shows one unified progress line, C02 business summaries, and optional customer-visible nodes', () => {
test('adds prototype download actions to the order query panel', () => {
assert.match(source, /下载唛头/)
assert.match(source, /导出订单信息/)
assert.match(source, /function downloadShippingMarks\(\)/)
assert.match(source, /downloadMerchantOrderCartonLabelsBatch\(orders\.map\(\(order\) => order\.id\)\)/)
assert.match(source, /error\.traceId \? `,追踪号 \$\{error\.traceId\}`/)
assert.doesNotMatch(source, /order\.businessSummary\.inboundId/)
assert.match(source, /批量箱唛-\$\{orders\.length\}单\.pdf/)
assert.match(source, /function exportOrderInformation\(\)/)
assert.match(source, /selectedOrders\.value\.map/)
assert.match(source, /订单信息\.csv/)
assert.doesNotMatch(source, /exportOrderInformation[\s\S]{0,1200}loadDetail/)
})
test('selects orders before downloading marks or exporting last-mile data', () => {
assert.match(source, /\{ key: 'selection', type: 'selection', width: 48, fixed: 'left', reserveSelection: true \}/)
assert.match(source, /const selectedOrders = ref<FirstLegOrder\[\]>\(\[\]\)/)
assert.match(source, /const selectedOrderCount = computed\(\(\) => selectedOrders\.value\.length\)/)
assert.match(source, /@selection-change="handleOrderSelectionChange"/)
assert.match(source, /:disabled="selectedOrderCount === 0" @click="downloadShippingMarks"/)
assert.match(source, /:disabled="selectedOrderCount === 0" @click="exportOrderInformation"/)
assert.match(source, /const orders = selectedOrders\.value\s*$/m)
assert.match(source, /const rows = selectedOrders\.value\.map/)
assert.doesNotMatch(source, /const orders = filteredOrders\.value\.filter/)
assert.doesNotMatch(source, /const rows = filteredOrders\.value\.map/)
})
test('downloads selected carton labels with one array request', () => {
const downloadStart = source.indexOf('async function downloadShippingMarks()')
const downloadEnd = source.indexOf('function formatExportDateTime', downloadStart)
const downloadSource = source.slice(downloadStart, downloadEnd)
assert.match(downloadSource, /await downloadMerchantOrderCartonLabelsBatch\(orders\.map\(\(order\) => order\.id\)\)/)
assert.match(downloadSource, /saveBlob\(result\.blob/)
assert.match(downloadSource, /result\.failureCount/)
assert.doesNotMatch(downloadSource, /for \(const order of orders\)/)
assert.doesNotMatch(downloadSource, /Promise\.all\(/)
assert.doesNotMatch(downloadSource, /businessSummary\.inboundId\)/)
assert.doesNotMatch(downloadSource, /没有已生成的入库单|未生成入库单/)
})
test('exports every visible order field with normalized dates', () => {
assert.match(source, /function formatExportDateTime\(value: string \| null \| undefined\)/)
assert.match(source, /return `\$\{date\.getFullYear\(\)\}-\$\{pad\(date\.getMonth\(\) \+ 1\)\}-\$\{pad\(date\.getDate\(\)\)\} \$\{pad\(date\.getHours\(\)\)\}:\$\{pad\(date\.getMinutes\(\)\)\}:\$\{pad\(date\.getSeconds\(\)\)\}`/)
for (const label of ['订单号', '入仓码', '渠道', '目的仓', '当前订单状态', '箱数', '快递公司', '快递单号', 'SKUS', '总毛重', '总净重', '总体积', '总数量', '计费单价', '计费体积', '计费重量', '备注', '货件编号', '跟踪编号', '国内仓入库时间', '业务日期', '创建时间']) {
assert.match(source, new RegExp(`'${label}'`))
}
assert.match(source, /formatExportDateTime\(order\.inboundedAt\)/)
assert.match(source, /formatExportDateTime\(order\.businessDate\)/)
assert.match(source, /formatExportDateTime\(order\.createdAt\)/)
})
test('matches the prototype with selectable dense order-list columns', () => {
assert.match(source, /aria-label="设置订单列表显示列"/)
assert.match(source, /<SlidersHorizontal :size="18"/)
assert.match(source, /<el-checkbox-group v-model="visibleColumnKeys"/)
assert.match(source, /draggable="true"/)
assert.match(source, /@drop\.prevent="dropOrderColumn/)
assert.match(source, /function moveOrderColumn/)
assert.match(source, /function resetOrderColumns/)
assert.match(source, />恢复默认<\/el-button>/)
assert.match(source, /const visibleOrderColumns = computed/)
assert.match(source, /:columns="visibleOrderColumns"/)
for (const label of ['入仓码', '渠道', '目的仓', '当前订单状态', '箱数', '快递公司', '快递单号', 'SKUS', '总毛重', '总净重', '总体积', '总数量', '计费单价', '计费体积', '计费重量', '备注', '货件编号', '跟踪编号', '国内仓入库时间', '业务日期']) {
assert.match(source, new RegExp(`label: '${label}'`))
}
assert.match(source, /order\.businessSummary\.totalPackageCount/)
assert.match(source, /summarizeFirstLegCargo\(order\.items\)/)
assert.match(source, /order\.items\.reduce/)
assert.match(source, /<template #chargeableWeight>-<\/template>/)
assert.match(source, /<template #billingUnitPrice>-<\/template>/)
assert.match(source, /<template #chargeableVolume>-<\/template>/)
assert.match(source, /inboundedAt/)
assert.match(source, /businessDate/)
assert.match(source, /<template #businessDate>-<\/template>/)
assert.match(source, /<template #createdAt="\{ row \}">\{\{ formatDateTime\(\(row as FirstLegOrder\)\.createdAt\) \}\}<\/template>/)
assert.match(source, /order\.skuNos\.length/)
assert.doesNotMatch(source, /pagedOrders[\s\S]{0,100}loadDetail/)
})
test('keeps dense order identifiers on one line and aligns columns by content type', () => {
assert.match(source, /key: 'order'[\s\S]{0,160}showOverflowTooltip: true/)
assert.match(source, /key: 'status'[\s\S]{0,100}align: 'center'[\s\S]{0,100}headerAlign: 'center'/)
assert.match(source, /key: 'grossWeight'[\s\S]{0,100}align: 'right'[\s\S]{0,100}headerAlign: 'right'/)
assert.match(source, /class="order-number-link table-code"/)
assert.doesNotMatch(source, /<template #order="\{ row \}">[\s\S]{0,400}class="order-reference"/)
assert.match(source, /\.table-ellipsis, \.table-multivalue \{[^}]*text-overflow: ellipsis;[^}]*white-space: nowrap;/)
assert.match(source, /\.order-number-link \{[^}]*text-overflow: ellipsis;[^}]*white-space: nowrap;/)
assert.match(source, /\.prototype-order-table :deep\(\.el-table__body-wrapper td\.el-table__cell\) \{ height: 50px;/)
})
test('shows one unified progress line and C02 business summaries without detail history', () => {
assert.match(source, /import OrderProgressPanel/)
assert.match(source, /<OrderProgressPanel :order="detailOrder" \/>/)
assert.match(source, /<h3>关键业务摘要<\/h3>/)
@@ -91,9 +194,7 @@ test('shows one unified progress line, C02 business summaries, and optional cust
assert.match(source, /summary\.deliveryTrackingNo/)
assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/)
assert.match(progressSource, /国内装柜进度及后续履约同步/)
assert.match(progressSource, /item\.kind === 'CUSTOM_NODE'/)
assert.match(progressSource, /业务节点/)
assert.match(progressSource, /暂无进度记录/)
assert.doesNotMatch(progressSource, /进度记录|el-timeline|timeline/)
assert.match(source, /报关回执/)
assert.match(source, /船期摘要/)
assert.match(source, /柜次进度/)
@@ -103,8 +204,9 @@ test('shows one unified progress line, C02 business summaries, and optional cust
test('uses only fields present on C02 rows before loading full detail', () => {
assert.match(source, /getOrderCargoSummary/)
assert.match(source, /目的地 \/ 渠道/)
assert.match(source, /运输方式待更新|渠道待更新/)
assert.match(source, /label: '渠道'/)
assert.match(source, /label: '目的仓'/)
assert.match(source, /displayListValue/)
assert.doesNotMatch(source, /key: 'operator'/)
assert.doesNotMatch(source, /key: 'transportMode', type: 'select'/)
assert.match(source, /store\.draftLoadError/)
@@ -114,9 +216,9 @@ test('uses only fields present on C02 rows before loading full detail', () => {
})
test('shows and searches the inbound code after a draft is submitted', () => {
assert.match(source, /placeholder: '订单号、入仓码、渠道或仓库'/)
assert.match(source, /label: '入仓码 \/ SKU'/)
assert.match(source, /order\.businessSummary\.inboundCode/)
assert.match(source, /v-if="\(row as FirstLegOrder\)\.businessSummary\.inboundCode"[^>]*>入仓码/)
assert.match(source, /<template #inboundCode="\{ row \}"><span class="table-ellipsis table-code"[^>]*>\{\{ displayListValue\(\(row as FirstLegOrder\)\.businessSummary\.inboundCode\) \}\}<\/span><\/template>/)
assert.match(source, /v-if="order\.businessSummary\.inboundCode"[^>]*>入仓码/)
assert.match(source, /v-if="detailOrder\.businessSummary\.inboundCode"><dt>入仓码<\/dt>/)
})
@@ -135,10 +237,10 @@ test('keeps the first viewport compact and moves create into the table toolbar',
assert.match(source, /client-main:has\(> \.first-leg-page:not\(\.is-create-route\)\) > \.client-page-heading > div\) \{ position: absolute; width: 1px; height: 1px;/)
})
test('keeps desktop query actions on the first row and only shows the qualification notice before approval', () => {
assert.match(source, /\.first-leg-query-toolbar \{ flex-wrap: nowrap; \}/)
assert.match(source, /query-toolbar__filters\) \{ display: grid; min-width: 0; flex: 1 1 auto;/)
assert.match(source, /query-toolbar__actions\) \{ align-self: flex-start; flex-wrap: nowrap; margin-left: auto;/)
test('uses the prototype query grid and only shows the qualification notice before approval', () => {
assert.match(source, /\.first-leg-query-toolbar \{ display: block;/)
assert.match(source, /query-toolbar__filters\) \{ display: grid; min-width: 0; grid-template-columns: repeat\(4,/)
assert.match(source, /query-toolbar__actions\) \{ justify-content: flex-start; margin-top: 14px;/)
assert.match(source, /v-if="companySubmissionBlocked && !supportingDataErrors\.some/)
assert.doesNotMatch(source, /企业资料已审核,可正常提交订单/)
assert.doesNotMatch(source, /order-eligibility\.is-approved/)
@@ -161,9 +263,9 @@ test('requires an enabled platform receiving warehouse in the order form', () =>
assert.match(source, /v-model="form\.receivingWarehouseId"/)
assert.doesNotMatch(source, /selectedWarehouse\.businessHours/)
assert.doesNotMatch(source, /class="selected-warehouse" aria-label="所选收货仓详情"/)
assert.match(source, /first-leg-editor \.el-drawer__header\) \{[^}]*margin-bottom: 0;[^}]*border-bottom: 1px solid var\(--border\)/)
assert.match(source, /first-leg-editor \.el-drawer__body\) \{[^}]*min-height: 0;[^}]*padding: 18px 22px 32px;[^}]*overscroll-behavior: contain/)
assert.match(source, /first-leg-editor \.el-drawer__footer\) \{[^}]*border-top: 1px solid var\(--border\);[^}]*background: #fff/)
assert.match(editorShellSource, /first-leg-editor-dialog \.el-dialog__header\) \{[^}]*border-bottom: 1px solid var\(--border\)/)
assert.match(editorShellSource, /first-leg-editor-dialog \.el-dialog__body\) \{[^}]*min-height: 0;[^}]*overflow-y: auto;[^}]*overscroll-behavior: contain/)
assert.match(editorShellSource, /first-leg-editor-dialog \.el-dialog__footer\) \{[^}]*border-top: 1px solid var\(--border\);[^}]*background: #fff/)
assert.match(source, /detailOrder\.receivingWarehouseSnapshot\?\.name/)
assert.match(source, /detailOrder\.receivingWarehouseSnapshot\.code/)
assert.match(source, /loadSelectedWarehouse\(detailedOrder\.receivingWarehouseId\)/)
@@ -297,7 +399,7 @@ test('shows fulfillment courier information as read-only client detail', () => {
})
test('shows the full customer-safe order detail without platform-only controls', () => {
const detailSource = source.slice(source.indexOf('<el-drawer v-model="detailOpen"'), source.indexOf('</el-drawer>', source.indexOf('<el-drawer v-model="detailOpen"')))
const detailSource = source.slice(source.indexOf('<el-dialog v-model="detailOpen"'), source.indexOf('</el-dialog>', source.indexOf('<el-dialog v-model="detailOpen"')))
for (const heading of ['订单与运输', '收发货信息', '关键业务摘要', '履约快递信息', '货物明细']) {
assert.match(source, new RegExp(`<h3>${heading}<\\/h3>`))
}
@@ -314,6 +416,21 @@ test('shows the full customer-safe order detail without platform-only controls',
assert.doesNotMatch(detailSource, /内部备注|审核通过|添加备注/)
})
test('opens order detail in a large dialog and paginates cargoes as a read-only table', () => {
assert.match(source, /<el-dialog v-model="detailOpen" class="first-leg-detail-dialog" title="订单详情" width="94vw" top="3vh"/)
assert.doesNotMatch(source, /<el-drawer v-model="detailOpen"/)
assert.match(source, /const detailCargoPage = ref\(1\)/)
assert.match(source, /const detailCargoPageSize = ref\(5\)/)
assert.match(source, /const pagedDetailCargoItems = computed/)
assert.match(source, /items\.slice\(start, start \+ detailCargoPageSize\.value\)/)
assert.match(source, /<el-table :data="pagedDetailCargoItems"[^>]*class="cargo-detail-table"/)
assert.match(source, /v-model:current-page="detailCargoPage"/)
assert.match(source, /v-model:page-size="detailCargoPageSize"/)
assert.match(source, /:total="detailOrder\.items\.length"/)
assert.doesNotMatch(source, /class="cargo-detail-list"/)
assert.match(source, /first-leg-detail-dialog\.el-dialog\) \{[^}]*height: 94vh;/)
})
test('loads merchant inbound billing as an isolated read-only detail section', () => {
assert.match(source, /import InboundBillingPanel/)
assert.match(source, /import \{ getMerchantInboundBilling \}/)
@@ -2,7 +2,7 @@
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, Download, Eye, FilePlus2, ImagePlus, MapPin, PackageCheck, Pencil, Plus, Send, Trash2, Upload } from '@lucide/vue'
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'
@@ -14,6 +14,7 @@ import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBillin
import { getMerchantCustomsByOrder, isMerchantCustomsUnavailable, type MerchantCustomsCaseDetail } from '@/modules/first-leg/api/customs'
import {
estimateMerchantFirstLegPrice,
downloadMerchantOrderCartonLabelsBatch,
downloadMerchantProductImage,
getMerchantPricingChannels,
uploadMerchantProductImage,
@@ -54,13 +55,18 @@ 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>({ keyword: '', status: '', createdRange: [] })
const filters = ref<QueryModel>({ orderNo: '', receivingWarehouseId: '', channelName: '', cargoKeyword: '', createdRange: [], status: '', destinationType: '', trackingNumber: '' })
const appliedFilters = ref<QueryModel>({ ...filters.value })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '订单号、入仓码、渠道或仓库', props: { 'aria-label': '订单关键词' } },
{ key: 'status', type: 'select', placeholder: '全部状态', options: FIRST_LEG_ORDER_STATUSES.map((item) => ({ label: item.label, value: item.code })), props: { 'aria-label': '订单状态' } },
{ key: 'createdRange', type: 'daterange', placeholder: '订单创建日期', props: { 'aria-label': '订单创建日期' } },
]
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('')
@@ -80,17 +86,76 @@ const cargoTemplateColumns = [
] as const
const cargoNumberKeys = new Set<string>(['lengthCm', 'widthCm', 'heightCm', 'unitsPerPackage', 'packageCount', 'grossWeightKg', 'volumeCbm', 'pieces', 'netWeightKg', 'unitDeclaredValue', 'declaredValue', 'productLengthCm', 'productWidthCm', 'productHeightCm'])
const orderColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单', minWidth: 190, slotName: 'order' },
{ key: 'route', label: '目的地 / 渠道', minWidth: 190, slotName: 'route' },
{ key: 'warehouse', label: '收货仓', prop: 'receivingWarehouseName', minWidth: 120 },
{ key: 'cargo', label: '货物摘要', minWidth: 150, slotName: 'cargo' },
{ key: 'status', label: '状态', width: 120, slotName: 'status' },
{ key: 'updatedAt', label: '更新时间', width: 155, slotName: 'updatedAt' },
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)
@@ -100,6 +165,8 @@ 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)
@@ -219,6 +286,14 @@ function formatDateTime(value: string) {
})
}
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: '平台上门提货',
@@ -342,18 +417,27 @@ useUnsavedWorkRegistration({
})
const filteredOrders = computed(() => store.orders.filter((order) => {
const keywordValue = appliedFilters.value.keyword
const statusValue = appliedFilters.value.status
const createdRangeValue = appliedFilters.value.createdRange
const keyword = typeof keywordValue === 'string' ? keywordValue.trim().toLowerCase() : ''
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 matchesKeyword = !keyword || [order.orderNo, order.businessSummary.inboundCode, order.referenceNo, order.origin, order.destination, order.items.map((item) => item.cargoNameCn).join(' ')]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword))
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 matchesKeyword && matchesStatus && matchesDate
return matchesOrderNo && matchesWarehouse && matchesChannel && matchesCargo && matchesDestinationType && matchesTrackingNumber && matchesStatus && matchesDate
}))
const pagedOrders = computed(() => {
const start = (tablePage.value - 1) * tablePageSize.value
@@ -362,6 +446,11 @@ const pagedOrders = computed(() => {
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 []
@@ -397,11 +486,7 @@ const selectedPricingChannel = computed(() => channelOptions.value.find((item) =
const availablePricingTaxModes = computed(() => selectedPricingChannel.value?.pricingTaxModes ?? [])
const hasActiveFilters = computed(() => {
const values = appliedFilters.value
return Boolean(
(typeof values.keyword === 'string' && values.keyword.trim())
|| values.status
|| (Array.isArray(values.createdRange) && values.createdRange.length),
)
return Object.values(values).some((value) => Array.isArray(value) ? value.length > 0 : Boolean(typeof value === 'string' ? value.trim() : value))
})
const emptyDescription = computed(() => hasActiveFilters.value ? '没有符合筛选条件的订单' : '还没有订单')
@@ -927,6 +1012,7 @@ async function openEdit(order: FirstLegOrder) {
async function openDetail(order: FirstLegOrder) {
detailId.value = order.id
detailCargoPage.value = 1
detailOpen.value = true
detailLoading.value = true
detailError.value = ''
@@ -1059,6 +1145,128 @@ function removeCargoItem(index: number) {
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)
@@ -1240,16 +1448,21 @@ async function searchOrders(model: QueryModel) {
tablePage.value = 1
try {
const statusValue = typeof model.status === 'string' ? model.status as FirstLegOrderStatus | '' : ''
const keyword = typeof model.keyword === 'string' ? model.keyword.trim() : ''
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: keyword || undefined,
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.
}
@@ -1259,12 +1472,45 @@ async function searchOrders(model: QueryModel) {
<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" />
<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>
<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 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" />
@@ -1283,7 +1529,7 @@ async function searchOrders(model: QueryModel) {
</div>
<template v-else>
<div class="desktop-order-table">
<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="orderColumns" :loading="store.loading" :total="filteredOrders.length" :page-sizes="[10, 20, 50]" row-key="id" empty-text="暂无订单">
<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>
@@ -1291,14 +1537,29 @@ async function searchOrders(model: QueryModel) {
</div>
</template>
<template #order="{ row }">
<button class="order-number-link" type="button" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '未提交草稿' }}</button>
<span v-if="(row as FirstLegOrder).businessSummary.inboundCode" class="order-reference">入仓码 {{ (row as FirstLegOrder).businessSummary.inboundCode }}</span>
<span class="order-reference">{{ (row as FirstLegOrder).referenceNo || '无 Reference ID' }}</span>
<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 #route="{ row }"><strong>{{ (row as FirstLegOrder).destination || '目的地待更新' }}</strong><span class="order-reference">{{ (row as FirstLegOrder).transportMode ? getFirstLegTransportModeLabel((row as FirstLegOrder).transportMode) : (row as FirstLegOrder).channelName || '渠道待更新' }}</span></template>
<template #cargo="{ row }">{{ getOrderCargoSummary(row as FirstLegOrder) }}</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 #updatedAt="{ row }">{{ formatDateTime((row as FirstLegOrder).updatedAt) }}</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>
@@ -1332,7 +1593,7 @@ async function searchOrders(model: QueryModel) {
<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" scroll-to-error>
<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">
@@ -1448,7 +1709,7 @@ async function searchOrders(model: QueryModel) {
<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-drawer v-model="detailOpen" class="first-leg-detail" title="订单详情" size="820px">
<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" />
@@ -1527,24 +1788,44 @@ async function searchOrders(model: QueryModel) {
<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 NO.</dt><dd>{{ item.sku || '-' }}</dd></div><div><dt>UPC NO.</dt><dd>{{ item.upc || '-' }}</dd></div>
<div><dt>报关 HS CODE</dt><dd>{{ item.hsCode || '-' }}</dd></div><div><dt>清关 HS CODE</dt><dd>{{ item.customsHsCode || '-' }}</dd></div>
<div><dt>箱规(长×宽×高)</dt><dd>{{ item.lengthCm || '-' }} × {{ item.widthCm || '-' }} × {{ item.heightCm || '-' }} cm</dd></div>
<div><dt>每箱数量</dt><dd>{{ item.unitsPerPackage }} 件</dd></div><div><dt>箱数</dt><dd>{{ 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.netWeightKg ? `${item.netWeightKg} kg` : '-' }}</dd></div><div><dt>总体积</dt><dd>{{ item.volumeCbm }} m³</dd></div>
<div><dt>清关申报单价</dt><dd>{{ item.unitDeclaredValue ? `USD ${item.unitDeclaredValue.toLocaleString()}` : '-' }}</dd></div><div><dt>清关申报金额</dt><dd>{{ item.declaredValue ? `USD ${item.declaredValue.toLocaleString()}` : '-' }}</dd></div>
<div><dt>中英文材质</dt><dd>{{ item.material || '-' }}</dd></div><div><dt>中英文用途</dt><dd>{{ item.usage || '-' }}</dd></div><div><dt>型号</dt><dd>{{ item.model || '-' }}</dd></div><div><dt>品牌</dt><dd>{{ item.brand || '-' }}</dd></div>
<div><dt>产品规格(长×宽×高)</dt><dd>{{ item.productLengthCm || '-' }} × {{ item.productWidthCm || '-' }} × {{ item.productHeightCm || '-' }} cm</dd></div>
<div><dt>Shipment ID</dt><dd>{{ item.shipmentId || '-' }}</dd></div><div><dt>Reference ID</dt><dd>{{ item.referenceId || '-' }}</dd></div>
<div><dt>包装类型</dt><dd>{{ packageTypeLabels[item.packageType] || item.packageType || '-' }}</dd></div><div><dt>危险品</dt><dd>{{ item.dangerousGoods ? '是' : '否' }}</dd></div>
</dl>
<p v-if="item.remark">{{ item.remark }}</p>
</article>
<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>
@@ -1560,7 +1841,7 @@ async function searchOrders(model: QueryModel) {
</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-drawer>
</el-dialog>
</section>
</template>
@@ -1568,10 +1849,14 @@ async function searchOrders(model: QueryModel) {
.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 { flex-wrap: nowrap; }
.first-leg-query-toolbar :deep(.query-toolbar__filters) { display: grid; min-width: 0; flex: 1 1 auto; grid-template-columns: minmax(180px, 1.4fr) minmax(120px, .72fr) minmax(210px, 1.15fr); }
.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) { align-self: flex-start; flex-wrap: nowrap; margin-left: auto; }
.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; }
@@ -1588,6 +1873,35 @@ async function searchOrders(model: QueryModel) {
.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; }
@@ -1595,20 +1909,27 @@ async function searchOrders(model: QueryModel) {
.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; padding: 0; border: 0; background: none; color: var(--primary); cursor: pointer; font-weight: 700; text-align: left; }
.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; }
:global(.first-leg-editor .el-drawer__header) { flex: 0 0 auto; min-height: 58px; margin-bottom: 0; padding: 17px 22px; border-bottom: 1px solid var(--border); }
:global(.first-leg-editor .el-drawer__body) { min-height: 0; padding: 18px 22px 32px; overscroll-behavior: contain; }
:global(.first-leg-editor .el-drawer__footer) { flex: 0 0 auto; padding: 12px 22px; border-top: 1px solid var(--border); background: #fff; }
.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; }
@@ -1794,20 +2115,19 @@ async function searchOrders(model: QueryModel) {
.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-list { display: grid; gap: 10px; margin-top: 10px; }
.cargo-detail-list article { padding: 14px 15px; border: 1px solid var(--border); }
.cargo-detail-list header { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: baseline; gap: 10px; }
.cargo-detail-list header span, .cargo-detail-list header small { color: var(--muted); font-size: 10px; }
.cargo-detail-list header strong { overflow-wrap: anywhere; color: var(--navy); font-size: 13px; }
.cargo-detail-list header small { overflow-wrap: anywhere; text-align: right; }
.cargo-detail-list dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px 16px; margin: 13px 0 0; }
.cargo-detail-list dt { color: var(--muted); font-size: 10px; }
.cargo-detail-list dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 11px; font-weight: 600; }
.cargo-detail-list article > p { margin: 11px 0 0; padding-top: 10px; border-top: 1px solid var(--border); 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; }
@@ -1824,12 +2144,11 @@ async function searchOrders(model: QueryModel) {
.mobile-order-card footer > div { display: flex; }
}
@media (min-width: 721px) and (max-width: 1050px) {
.first-leg-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: repeat(2, minmax(140px, 1fr)); }
.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 { flex-wrap: wrap; }
.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; }
@@ -1858,9 +2177,6 @@ async function searchOrders(model: QueryModel) {
.cargo-import-actions, .cargo-table-actions { flex-wrap: wrap; }
.cost-estimate-cards { grid-template-columns: 1fr; }
.cost-estimate-card { min-height: 140px; }
:global(.first-leg-editor .el-drawer__header) { min-height: 56px; padding: 15px 20px; }
:global(.first-leg-editor .el-drawer__body) { padding: 16px 20px 28px; }
:global(.first-leg-editor .el-drawer__footer) { padding: 10px 20px; }
.form-span-full { grid-column: auto; }
.editor-actions :deep(.el-button) { min-height: 44px; }
.desktop-order-table { display: none; }
@@ -1886,16 +2202,15 @@ async function searchOrders(model: QueryModel) {
.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); }
.cargo-detail-list header { grid-template-columns: 1fr; gap: 4px; }
.cargo-detail-list header small { text-align: left; }
.cargo-detail-list dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cabinet-progress-card dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
:global(.first-leg-editor), :global(.first-leg-detail) { width: 100% !important; }
:global(.first-leg-detail-dialog.el-dialog) { width: calc(100vw - 24px) !important; height: 96vh; margin-top: 2vh; }
}
@media (max-width: 360px) {
.cargo-detail-list dl { grid-template-columns: 1fr; }
.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; }
Binary file not shown.