优化构架
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import { apiRequest } from '@/api/http'
|
||||
|
||||
export type ApiFirstLegStatus = 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED' | 'PROCESSING' | 'COMPLETED'
|
||||
export type ApiTransportMode = 'SEA' | 'AIR' | 'RAIL' | 'TRUCK'
|
||||
export type ApiPickupMethod = 'SELF_EXPRESS' | 'PICKUP' | 'SELF_TRUCK'
|
||||
export type ApiTaxMode = 'BUY_DECLARATION' | 'TAX_REFUND'
|
||||
export type ApiContainerMode = 'FULL_CONTAINER' | 'LCL'
|
||||
export type ApiDestinationType = 'FBA' | 'OVERSEAS' | 'PRIVATE'
|
||||
|
||||
export interface PageResult<T> {
|
||||
records: T[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface MerchantWarehouse {
|
||||
id: number | string
|
||||
warehouseCode: string
|
||||
warehouseName: string
|
||||
warehouseType: string
|
||||
countryCode: string
|
||||
timezone: string | null
|
||||
contactName: string | null
|
||||
contactPhone: string | null
|
||||
contactEmail: string | null
|
||||
status: 'ACTIVE' | 'INACTIVE'
|
||||
remark: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface MerchantWarehouseAddress {
|
||||
countryCode: string
|
||||
stateProvince: string | null
|
||||
city: string | null
|
||||
district: string | null
|
||||
postalCode: string | null
|
||||
addressLine1: string
|
||||
addressLine2: string | null
|
||||
}
|
||||
|
||||
export interface MerchantWarehouseCapability {
|
||||
capabilityCode: string
|
||||
capabilityName: string
|
||||
enabled: boolean
|
||||
cutoffTime: string | null
|
||||
description: string | null
|
||||
}
|
||||
|
||||
export interface MerchantWarehouseDetail {
|
||||
warehouse: MerchantWarehouse
|
||||
addresses: MerchantWarehouseAddress[]
|
||||
capabilities: MerchantWarehouseCapability[]
|
||||
}
|
||||
|
||||
export interface FirstLegOrderPartyPayload {
|
||||
companyName: string
|
||||
contactName: string
|
||||
phone: string
|
||||
email?: string
|
||||
countryCode: string
|
||||
province?: string
|
||||
city: string
|
||||
district?: string
|
||||
addressLine: string
|
||||
postalCode?: string
|
||||
}
|
||||
|
||||
export interface FirstLegOrderCargoPayload {
|
||||
cargoName: string
|
||||
hsCode?: string
|
||||
packageType: string
|
||||
packageCount: number
|
||||
grossWeightKg: number
|
||||
volumeCbm: number
|
||||
declaredValue?: number
|
||||
currency?: string
|
||||
dangerousGoods?: boolean
|
||||
cargoRemark?: string
|
||||
skuNo?: string
|
||||
upcNo?: string
|
||||
cargoNameEn?: string
|
||||
totalQuantity?: number
|
||||
netWeightKg?: number
|
||||
}
|
||||
|
||||
export interface FirstLegOrderSaveRequest {
|
||||
customerId?: number | string
|
||||
customerName: string
|
||||
transportMode: ApiTransportMode
|
||||
tradeTerm?: string
|
||||
destinationCountryCode: string
|
||||
destinationWarehouseId?: number | string
|
||||
destinationWarehouseName: string
|
||||
expectedShipDate?: string
|
||||
customerRemark?: string
|
||||
sender: FirstLegOrderPartyPayload
|
||||
receiver: FirstLegOrderPartyPayload
|
||||
cargoes: FirstLegOrderCargoPayload[]
|
||||
pickupMethod: ApiPickupMethod
|
||||
receivingWarehouseId: number | string
|
||||
receivingWarehouseName: string
|
||||
receivingWarehouseAddress?: string | null
|
||||
receivingTime?: string | null
|
||||
channelCode: string
|
||||
channelName: string
|
||||
taxMode: ApiTaxMode
|
||||
containerMode?: ApiContainerMode
|
||||
customsInspection: boolean
|
||||
destinationType: ApiDestinationType
|
||||
amazonWarehouseCode?: string
|
||||
shipmentId?: string
|
||||
referenceId?: string
|
||||
}
|
||||
|
||||
export interface MerchantFirstLegOrder {
|
||||
id: number | string
|
||||
orderNo: string | null
|
||||
customerId: number | string | null
|
||||
customerName: string
|
||||
status: ApiFirstLegStatus
|
||||
transportMode: ApiTransportMode
|
||||
tradeTerm: string | null
|
||||
destinationCountryCode: string
|
||||
destinationWarehouseId: number | string | null
|
||||
destinationWarehouseName: string
|
||||
expectedShipDate: string | null
|
||||
customerRemark: string | null
|
||||
pickupMethod: ApiPickupMethod
|
||||
receivingWarehouseId: number | string
|
||||
receivingWarehouseName: string
|
||||
receivingWarehouseAddress?: string | null
|
||||
channelCode: string
|
||||
channelName: string
|
||||
taxMode: ApiTaxMode
|
||||
containerMode: ApiContainerMode | null
|
||||
customsInspection: boolean
|
||||
destinationType: ApiDestinationType
|
||||
amazonWarehouseCode: string | null
|
||||
shipmentId: string | null
|
||||
referenceId: string | null
|
||||
expressCompany?: string | null
|
||||
expressTrackingNo?: string | null
|
||||
version: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface MerchantFirstLegOrderCargo extends FirstLegOrderCargoPayload {
|
||||
id: number | string
|
||||
itemNo: number
|
||||
}
|
||||
|
||||
export interface MerchantFirstLegOrderParty extends FirstLegOrderPartyPayload {
|
||||
id: number | string
|
||||
partyType: 'SENDER' | 'RECEIVER' | string
|
||||
}
|
||||
|
||||
export interface MerchantOrderStatusHistory {
|
||||
id: number | string
|
||||
fromStatus: ApiFirstLegStatus | null
|
||||
toStatus: ApiFirstLegStatus
|
||||
action: string
|
||||
reason: string | null
|
||||
operatorName: string | null
|
||||
occurredAt: string
|
||||
}
|
||||
|
||||
export interface MerchantFirstLegOrderDetail {
|
||||
order: MerchantFirstLegOrder
|
||||
cargoes: MerchantFirstLegOrderCargo[]
|
||||
parties: MerchantFirstLegOrderParty[]
|
||||
internalNotes: []
|
||||
statusHistory: MerchantOrderStatusHistory[]
|
||||
}
|
||||
|
||||
export interface FirstLegOrderQuery {
|
||||
keyword?: string
|
||||
status?: ApiFirstLegStatus
|
||||
customerId?: number | string
|
||||
destinationWarehouseId?: number | string
|
||||
createdFrom?: string
|
||||
createdTo?: string
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export function getMerchantWarehouses() {
|
||||
return apiRequest<PageResult<MerchantWarehouse>>({ method: 'GET', url: '/merchant/v1/warehouses', params: { page: 1, size: 200, status: 'ACTIVE' } })
|
||||
}
|
||||
|
||||
export function getMerchantWarehouseDetail(id: number | string) {
|
||||
return apiRequest<MerchantWarehouseDetail>({ method: 'GET', url: `/merchant/v1/warehouses/${id}` })
|
||||
}
|
||||
|
||||
export function getMerchantFirstLegOrders(params: FirstLegOrderQuery = {}) {
|
||||
return apiRequest<PageResult<MerchantFirstLegOrder>>({ method: 'GET', url: '/merchant/v1/orders/first-leg', params: { page: 1, size: 200, ...params } })
|
||||
}
|
||||
|
||||
export function getMerchantFirstLegOrder(id: number | string) {
|
||||
return apiRequest<MerchantFirstLegOrderDetail>({ method: 'GET', url: `/merchant/v1/orders/first-leg/${id}` })
|
||||
}
|
||||
|
||||
export function createMerchantFirstLegOrder(payload: FirstLegOrderSaveRequest) {
|
||||
return apiRequest<number | string>({
|
||||
method: 'POST',
|
||||
url: '/merchant/v1/orders/first-leg',
|
||||
data: payload,
|
||||
headers: { 'Idempotency-Key': createIdempotencyKey('first-leg-create') },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateMerchantFirstLegOrder(id: number | string, payload: FirstLegOrderSaveRequest) {
|
||||
return apiRequest<void>({ method: 'PUT', url: `/merchant/v1/orders/first-leg/${id}`, data: payload })
|
||||
}
|
||||
|
||||
export function submitMerchantFirstLegOrder(id: number | string) {
|
||||
return apiRequest<void>({
|
||||
method: 'POST',
|
||||
url: `/merchant/v1/orders/first-leg/${id}/submit`,
|
||||
headers: { 'Idempotency-Key': createIdempotencyKey(`first-leg-submit-${id}`) },
|
||||
})
|
||||
}
|
||||
|
||||
function createIdempotencyKey(scope: string) {
|
||||
const randomPart = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
return `${scope}-${randomPart}`
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { apiRequest } from '@/api/http'
|
||||
|
||||
export type MerchantPartnerType = 'CUSTOMER' | 'SUPPLIER' | 'CARRIER' | 'CUSTOMS_BROKER' | 'SHIPPER' | 'CONSIGNEE'
|
||||
|
||||
export interface MerchantPartner {
|
||||
id: number | string
|
||||
partnerCode: string
|
||||
partnerName: string
|
||||
partnerType: MerchantPartnerType | 'BOTH' | string
|
||||
types?: MerchantPartnerType[]
|
||||
contactName: string | null
|
||||
contactPhone: string | null
|
||||
contactEmail: string | null
|
||||
countryCode: string | null
|
||||
province: string | null
|
||||
city: string | null
|
||||
district: string | null
|
||||
addressLine: string | null
|
||||
postalCode: string | null
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'DISABLED' | string
|
||||
}
|
||||
|
||||
export function getMerchantPartnerCandidates(type?: MerchantPartnerType, keyword?: string) {
|
||||
return apiRequest<MerchantPartner[]>({
|
||||
method: 'GET',
|
||||
url: '/merchant/v1/partners/candidates',
|
||||
params: { type, keyword: keyword?.trim() || undefined },
|
||||
})
|
||||
}
|
||||
|
||||
export function getMerchantPartner(id: number | string) {
|
||||
return apiRequest<MerchantPartner>({ method: 'GET', url: `/merchant/v1/partners/${id}` })
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
page: boolean
|
||||
title: string
|
||||
beforeClose: (done: () => void) => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
back: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="page" class="first-leg-editor-page">
|
||||
<div class="editor-page-nav">
|
||||
<el-button link type="primary" @click="emit('back')"><ArrowLeft :size="17" />返回订单列表</el-button>
|
||||
<span>可先保存草稿,确认无误后再提交平台审核</span>
|
||||
</div>
|
||||
<div class="editor-page-body"><slot /></div>
|
||||
<footer class="editor-page-footer"><slot name="footer" /></footer>
|
||||
</section>
|
||||
|
||||
<el-drawer
|
||||
v-else
|
||||
class="first-leg-editor"
|
||||
:model-value="modelValue"
|
||||
:title="title"
|
||||
size="1080px"
|
||||
destroy-on-close
|
||||
:before-close="beforeClose"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<slot />
|
||||
<template #footer><slot name="footer" /></template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.first-leg-editor-page { min-width: 0; border: 1px solid var(--border); background: #fff; }
|
||||
.editor-page-nav { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 20px; border-bottom: 1px solid var(--border); }
|
||||
.editor-page-nav span { color: var(--muted); font-size: 12px; }
|
||||
.editor-page-body { padding: 22px 24px 30px; }
|
||||
.editor-page-footer { position: sticky; z-index: 3; bottom: 0; display: flex; justify-content: flex-end; padding: 12px 24px; border-top: 1px solid var(--border); background: rgba(255, 255, 255, 0.96); }
|
||||
|
||||
@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; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.editor-page-footer { position: static; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
export type FirstLegPartyRole = 'SHIPPER' | 'CONSIGNEE'
|
||||
|
||||
export interface FirstLegParty {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
roles: FirstLegPartyRole[]
|
||||
contactName: string
|
||||
phone: string
|
||||
email: string
|
||||
countryRegion: string
|
||||
province: string
|
||||
city: string
|
||||
postalCode: string
|
||||
address: string
|
||||
status: 'ACTIVE' | 'INACTIVE'
|
||||
}
|
||||
|
||||
export interface FirstLegPartySnapshot extends Omit<FirstLegParty, 'roles' | 'status'> {
|
||||
role: FirstLegPartyRole
|
||||
}
|
||||
|
||||
export function createFirstLegPartySnapshot(party: FirstLegParty, role: FirstLegPartyRole): FirstLegPartySnapshot {
|
||||
return {
|
||||
id: party.id,
|
||||
code: party.code,
|
||||
name: party.name,
|
||||
role,
|
||||
contactName: party.contactName,
|
||||
phone: party.phone,
|
||||
email: party.email,
|
||||
countryRegion: party.countryRegion,
|
||||
province: party.province,
|
||||
city: party.city,
|
||||
postalCode: party.postalCode,
|
||||
address: party.address,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./firstLegPartySource.ts', import.meta.url), 'utf8')
|
||||
const viewSource = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8')
|
||||
const apiSource = readFileSync(new URL('../api/partners.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('keeps counterparty availability behind a replaceable read-only source', () => {
|
||||
assert.match(source, /export interface FirstLegPartySource/)
|
||||
assert.match(source, /listAvailable\(role/)
|
||||
assert.match(source, /findAvailable\(id, role/)
|
||||
assert.match(source, /async load\(\)/)
|
||||
assert.match(source, /party\.status === 'ACTIVE' && party\.roles\.includes\(role\)/)
|
||||
assert.match(source, /getMerchantPartnerCandidates/)
|
||||
assert.match(source, /partner\.types\?\.length/)
|
||||
assert.match(source, /partner\.types\?\.includes\(role\)/)
|
||||
assert.match(source, /: \['SHIPPER', 'CONSIGNEE'\]/)
|
||||
assert.match(source, /await getMerchantPartnerCandidates\(\)/)
|
||||
assert.doesNotMatch(source, /getMerchantPartnerCandidates\('SENDER'\)|getMerchantPartnerCandidates\('RECEIVER'\)/)
|
||||
assert.match(viewSource, /firstLegPartySource\.listAvailable\('SHIPPER'\)/)
|
||||
assert.match(viewSource, /firstLegPartySource\.listAvailable\('CONSIGNEE'\)/)
|
||||
assert.match(viewSource, /firstLegPartySource\.findAvailable\(id, role\)/)
|
||||
assert.match(viewSource, /firstLegPartySource\.load\(\)/)
|
||||
assert.doesNotMatch(viewSource, /DEMO_FIRST_LEG_PARTIES/)
|
||||
assert.match(apiSource, /url: '\/merchant\/v1\/partners\/candidates'/)
|
||||
assert.match(apiSource, /'CARRIER' \| 'CUSTOMS_BROKER' \| 'SHIPPER' \| 'CONSIGNEE'/)
|
||||
assert.match(apiSource, /types\?: MerchantPartnerType\[\]/)
|
||||
assert.match(apiSource, /url: `\/merchant\/v1\/partners\/\$\{id\}`/)
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { getMerchantPartnerCandidates, type MerchantPartner } from '@/api/partners'
|
||||
import type { FirstLegParty, FirstLegPartyRole } from '@/data/firstLegParties'
|
||||
|
||||
export interface FirstLegPartySource {
|
||||
listAvailable(role: FirstLegPartyRole): readonly FirstLegParty[]
|
||||
findAvailable(id: string, role: FirstLegPartyRole): FirstLegParty | undefined
|
||||
load(): Promise<void>
|
||||
}
|
||||
|
||||
function isAvailableForRole(party: FirstLegParty, role: FirstLegPartyRole) {
|
||||
return party.status === 'ACTIVE' && party.roles.includes(role)
|
||||
}
|
||||
|
||||
const parties: FirstLegParty[] = []
|
||||
|
||||
function mapPartner(partner: MerchantPartner): FirstLegParty {
|
||||
const roles: FirstLegPartyRole[] = partner.types?.length
|
||||
? (['SHIPPER', 'CONSIGNEE'] as FirstLegPartyRole[]).filter((role) => partner.types?.includes(role))
|
||||
: ['SHIPPER', 'CONSIGNEE']
|
||||
return {
|
||||
id: String(partner.id),
|
||||
code: partner.partnerCode,
|
||||
name: partner.partnerName,
|
||||
// 新契约按稳定类型限制订单角色;旧响应无 types 时保持原有兼容行为。
|
||||
roles,
|
||||
contactName: partner.contactName ?? '',
|
||||
phone: partner.contactPhone ?? '',
|
||||
email: partner.contactEmail ?? '',
|
||||
countryRegion: partner.countryCode ?? '',
|
||||
province: partner.province ?? '',
|
||||
city: partner.city ?? '',
|
||||
postalCode: partner.postalCode ?? '',
|
||||
address: [partner.district, partner.addressLine].filter(Boolean).join(''),
|
||||
status: partner.status === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE',
|
||||
}
|
||||
}
|
||||
|
||||
export const firstLegPartySource: FirstLegPartySource = {
|
||||
listAvailable(role) {
|
||||
return parties.filter((party) => isAvailableForRole(party, role))
|
||||
},
|
||||
findAvailable(id, role) {
|
||||
return parties.find((party) => party.id === id && isAvailableForRole(party, role))
|
||||
},
|
||||
async load() {
|
||||
parties.splice(0, parties.length)
|
||||
parties.push(...(await getMerchantPartnerCandidates()).map(mapPartner))
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const storeSource = readFileSync(new URL('./firstLegOrders.ts', import.meta.url), 'utf8')
|
||||
const apiSource = readFileSync(new URL('../api/firstLeg.ts', import.meta.url), 'utf8')
|
||||
|
||||
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'/)
|
||||
assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}`/)
|
||||
assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}\/submit`/)
|
||||
assert.match(apiSource, /`\/merchant\/v1\/warehouses\/\$\{id\}`/)
|
||||
})
|
||||
|
||||
test('maps transport and status fields instead of treating container mode as transport mode', () => {
|
||||
assert.match(storeSource, /order\.transportMode === 'SEA'.*order\.containerMode === 'FULL_CONTAINER' \? 'FCL' : 'LCL'/s)
|
||||
assert.match(storeSource, /draft\.transportMode === 'ROAD'\) return 'TRUCK'/)
|
||||
assert.match(storeSource, /status === 'SUBMITTED'\) return 'PENDING_REVIEW'/)
|
||||
assert.match(storeSource, /status === 'REJECTED'\) return 'PENDING_REVIEW'/)
|
||||
assert.match(storeSource, /status === 'PROCESSING'\) return 'IN_TRANSIT'/)
|
||||
})
|
||||
|
||||
test('uses real API data without falling back to demo warehouses or orders', () => {
|
||||
assert.match(storeSource, /orders\.value = \[\]/)
|
||||
assert.match(storeSource, /warehouses\.value = \[\]/)
|
||||
assert.match(storeSource, /Promise\.all\(\[getMerchantFirstLegOrders\(\), getMerchantWarehouses\(\)\]\)/)
|
||||
assert.doesNotMatch(storeSource, /DEMO_PLATFORM_RECEIVING_WAREHOUSES|initialOrders/)
|
||||
})
|
||||
|
||||
test('sends backend-required channel, customs, destination and cargo fields', () => {
|
||||
for (const field of ['pickupMethod', 'channelCode', 'channelName', 'taxMode', 'customsInspection', 'destinationType', 'packageType']) {
|
||||
assert.match(storeSource, new RegExp(field))
|
||||
}
|
||||
assert.match(storeSource, /containerMode: draft\.transportMode === 'FCL' \? 'FULL_CONTAINER'/)
|
||||
assert.match(storeSource, /customerId: draft\.shipperPartyId/)
|
||||
})
|
||||
|
||||
test('does not expose the out-of-scope cancellation write', () => {
|
||||
assert.doesNotMatch(storeSource, /cancelMerchant|function cancel|\/cancel/)
|
||||
assert.doesNotMatch(apiSource, /\/cancel/)
|
||||
})
|
||||
|
||||
test('sends an idempotency key for create and submit writes', () => {
|
||||
assert.match(apiSource, /'Idempotency-Key': createIdempotencyKey\('first-leg-create'\)/)
|
||||
assert.match(apiSource, /'Idempotency-Key': createIdempotencyKey\(`first-leg-submit-\$\{id\}`\)/)
|
||||
})
|
||||
|
||||
test('does not expose the contradictory backend rejected state in the client model', () => {
|
||||
assert.match(storeSource, /status === 'REJECTED'\) return 'PENDING_REVIEW'/)
|
||||
assert.match(storeSource, /item\.toStatus === 'SUBMITTED' \|\| item\.toStatus === 'REJECTED' \? 'SUBMITTED'/)
|
||||
})
|
||||
|
||||
test('maps backend warehouse snapshots and fulfillment courier fields', () => {
|
||||
assert.match(storeSource, /order\.receivingWarehouseAddress/)
|
||||
assert.match(storeSource, /order\.expressCompany \?\? ''/)
|
||||
assert.match(storeSource, /order\.expressTrackingNo \?\? ''/)
|
||||
assert.match(storeSource, /getMerchantWarehouseDetail\(id\)/)
|
||||
assert.match(storeSource, /receivingCapability\.cutoffTime/)
|
||||
})
|
||||
|
||||
test('keeps failed order loads distinct from empty results', () => {
|
||||
assert.match(storeSource, /const loadError = ref\(''\)/)
|
||||
assert.match(storeSource, /loadError\.value = error instanceof Error/)
|
||||
assert.match(storeSource, /return \{ orders, warehouses, loading, loadedFromApi, loadError/)
|
||||
})
|
||||
@@ -0,0 +1,257 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
createMerchantFirstLegOrder,
|
||||
getMerchantFirstLegOrder,
|
||||
getMerchantFirstLegOrders,
|
||||
getMerchantWarehouseDetail,
|
||||
getMerchantWarehouses,
|
||||
submitMerchantFirstLegOrder,
|
||||
updateMerchantFirstLegOrder,
|
||||
type ApiFirstLegStatus,
|
||||
type FirstLegOrderPartyPayload,
|
||||
type FirstLegOrderSaveRequest,
|
||||
type MerchantFirstLegOrder,
|
||||
type MerchantFirstLegOrderDetail,
|
||||
type MerchantWarehouse,
|
||||
} from '@/api/firstLeg'
|
||||
import type { MerchantTenantStatus } from '@/api/merchantIam'
|
||||
import type { PlatformReceivingWarehouse, PlatformReceivingWarehouseSnapshot } from '@chongchong/shared'
|
||||
import type { FirstLegPartySnapshot } from '@/data/firstLegParties'
|
||||
import type { FirstLegOrder, FirstLegOrderDraft, FirstLegOrderTimelineItem } from '@/types/firstLegOrder'
|
||||
import { canClientEditFirstLegOrder } from '@/utils/firstLegOrder'
|
||||
|
||||
const countryCodes: Readonly<Record<string, string>> = {
|
||||
中国: 'CN', 美国: 'US', 德国: 'DE', 英国: 'GB', 加拿大: 'CA', 日本: 'JP', 澳大利亚: 'AU',
|
||||
}
|
||||
|
||||
function clientStatus(status: ApiFirstLegStatus): FirstLegOrder['status'] {
|
||||
if (status === 'SUBMITTED') return 'PENDING_REVIEW'
|
||||
// 后端联调任务曾出现 REJECTED,但一期已确认订单不设驳回状态;兼容旧数据时仍按待审核展示。
|
||||
if (status === 'REJECTED') return 'PENDING_REVIEW'
|
||||
if (status === 'APPROVED' || status === 'ACCEPTED') return 'ACCEPTED'
|
||||
if (status === 'PROCESSING') return 'IN_TRANSIT'
|
||||
return status
|
||||
}
|
||||
|
||||
function clientTransport(order: MerchantFirstLegOrder): FirstLegOrder['transportMode'] {
|
||||
if (order.transportMode === 'SEA') return order.containerMode === 'FULL_CONTAINER' ? 'FCL' : 'LCL'
|
||||
if (order.transportMode === 'AIR') return 'AIR'
|
||||
return 'ROAD'
|
||||
}
|
||||
|
||||
function apiTransport(draft: FirstLegOrderDraft): FirstLegOrderSaveRequest['transportMode'] {
|
||||
if (draft.transportMode === 'AIR') return 'AIR'
|
||||
if (draft.transportMode === 'ROAD') return 'TRUCK'
|
||||
return 'SEA'
|
||||
}
|
||||
|
||||
function partyPayload(snapshot: FirstLegPartySnapshot): FirstLegOrderPartyPayload {
|
||||
return {
|
||||
companyName: snapshot.name,
|
||||
contactName: snapshot.contactName,
|
||||
phone: snapshot.phone,
|
||||
email: snapshot.email || undefined,
|
||||
countryCode: countryCodes[snapshot.countryRegion] ?? snapshot.countryRegion.toUpperCase().slice(0, 2),
|
||||
province: snapshot.province || undefined,
|
||||
city: snapshot.city,
|
||||
addressLine: snapshot.address,
|
||||
postalCode: snapshot.postalCode || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function savePayload(draft: FirstLegOrderDraft, warehouse: PlatformReceivingWarehouse, customerName: string): FirstLegOrderSaveRequest {
|
||||
if (!draft.shipperPartySnapshot || !draft.consigneePartySnapshot) throw new Error('请完整填写收发货信息')
|
||||
return {
|
||||
// 后端把 customerId 作为订单主往来单位校验并以服务端名称覆盖 customerName;
|
||||
// 当前商户下单以所选发货单位作为主往来单位,收发货详情仍分别保存订单快照。
|
||||
customerId: draft.shipperPartyId,
|
||||
customerName,
|
||||
transportMode: apiTransport(draft),
|
||||
tradeTerm: draft.tradeTerm || undefined,
|
||||
destinationCountryCode: draft.destinationCountryCode.trim().toUpperCase(),
|
||||
destinationWarehouseName: draft.destinationWarehouseName,
|
||||
expectedShipDate: draft.expectedDepartureDate || undefined,
|
||||
customerRemark: draft.customerRemark || undefined,
|
||||
sender: partyPayload(draft.shipperPartySnapshot),
|
||||
receiver: partyPayload(draft.consigneePartySnapshot),
|
||||
cargoes: draft.items.map((item) => ({
|
||||
cargoName: item.cargoNameCn,
|
||||
cargoNameEn: item.cargoNameEn || undefined,
|
||||
hsCode: item.hsCode || undefined,
|
||||
packageType: item.packageType,
|
||||
packageCount: item.packageCount,
|
||||
grossWeightKg: item.grossWeightKg,
|
||||
volumeCbm: item.volumeCbm,
|
||||
declaredValue: item.declaredValue || undefined,
|
||||
currency: item.currency || undefined,
|
||||
dangerousGoods: item.dangerousGoods,
|
||||
cargoRemark: item.remark || undefined,
|
||||
skuNo: item.sku || undefined,
|
||||
upcNo: item.upc || undefined,
|
||||
totalQuantity: item.pieces,
|
||||
})),
|
||||
pickupMethod: draft.pickupMethod,
|
||||
receivingWarehouseId: warehouse.id,
|
||||
receivingWarehouseName: warehouse.name,
|
||||
channelCode: draft.channelCode,
|
||||
channelName: draft.channelName,
|
||||
taxMode: draft.taxMode,
|
||||
containerMode: draft.transportMode === 'FCL' ? 'FULL_CONTAINER' : draft.transportMode === 'LCL' ? 'LCL' : undefined,
|
||||
customsInspection: draft.customsInspection,
|
||||
destinationType: draft.destinationType,
|
||||
amazonWarehouseCode: draft.amazonWarehouseCode || undefined,
|
||||
shipmentId: draft.shipmentId || undefined,
|
||||
referenceId: draft.referenceNo || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function warehouseSnapshot(warehouse?: PlatformReceivingWarehouse): PlatformReceivingWarehouseSnapshot | null {
|
||||
return warehouse ? { id: warehouse.id, code: warehouse.code, name: warehouse.name, province: warehouse.province, city: warehouse.city, address: warehouse.address } : null
|
||||
}
|
||||
|
||||
function orderWarehouseSnapshot(order: MerchantFirstLegOrder, warehouse?: PlatformReceivingWarehouse): PlatformReceivingWarehouseSnapshot | null {
|
||||
if (order.receivingWarehouseAddress) {
|
||||
return {
|
||||
id: String(order.receivingWarehouseId),
|
||||
code: warehouse?.code ?? '',
|
||||
name: order.receivingWarehouseName,
|
||||
province: warehouse?.province ?? '',
|
||||
city: warehouse?.city ?? '',
|
||||
address: order.receivingWarehouseAddress,
|
||||
}
|
||||
}
|
||||
return warehouseSnapshot(warehouse)
|
||||
}
|
||||
|
||||
function partySnapshot(detail: MerchantFirstLegOrderDetail, type: 'SENDER' | 'RECEIVER'): FirstLegPartySnapshot | null {
|
||||
const party = detail.parties.find((item) => item.partyType === type)
|
||||
if (!party) return null
|
||||
return {
|
||||
id: String(party.id), code: type, name: party.companyName, role: type === 'SENDER' ? 'SHIPPER' : 'CONSIGNEE',
|
||||
contactName: party.contactName, phone: party.phone, email: party.email ?? '', countryRegion: party.countryCode,
|
||||
province: party.province ?? '', city: party.city, postalCode: party.postalCode ?? '', address: party.addressLine,
|
||||
}
|
||||
}
|
||||
|
||||
function timeline(detail: MerchantFirstLegOrderDetail): FirstLegOrderTimelineItem[] {
|
||||
return detail.statusHistory.map((item) => ({
|
||||
id: String(item.id),
|
||||
event: item.toStatus === 'SUBMITTED' || item.toStatus === 'REJECTED' ? 'SUBMITTED' : item.toStatus === 'CANCELLED' ? 'CANCELLED' : item.toStatus === 'COMPLETED' ? 'COMPLETED' : item.toStatus === 'ACCEPTED' || item.toStatus === 'APPROVED' ? 'ACCEPTED' : 'IN_TRANSIT',
|
||||
title: item.toStatus === 'REJECTED' ? '平台要求补充订单信息' : item.action || `状态更新为 ${item.toStatus}`,
|
||||
description: item.reason ?? '',
|
||||
operatorName: item.operatorName ?? '',
|
||||
occurredAt: item.occurredAt,
|
||||
}))
|
||||
}
|
||||
|
||||
function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWarehouse[], detail?: MerchantFirstLegOrderDetail): FirstLegOrder {
|
||||
const warehouse = warehouses.find((item) => item.id === String(order.receivingWarehouseId))
|
||||
const shipper = detail ? partySnapshot(detail, 'SENDER') : null
|
||||
const consignee = detail ? partySnapshot(detail, 'RECEIVER') : null
|
||||
return {
|
||||
id: String(order.id), orderNo: order.orderNo, referenceNo: order.referenceId ?? '', transportMode: clientTransport(order),
|
||||
businessType: 'EXPORT', origin: shipper?.city ?? '', destination: order.destinationWarehouseName || order.destinationCountryCode,
|
||||
receivingWarehouseId: String(order.receivingWarehouseId), receivingWarehouseName: order.receivingWarehouseName,
|
||||
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: '',
|
||||
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 ?? '',
|
||||
pieces: item.totalQuantity ?? item.packageCount, packageCount: item.packageCount, grossWeightKg: item.grossWeightKg,
|
||||
volumeCbm: item.volumeCbm, containerType: '', containerCount: 0, remark: item.cargoRemark ?? '',
|
||||
packageType: item.packageType, hsCode: item.hsCode ?? '', declaredValue: item.declaredValue ?? 0,
|
||||
currency: item.currency ?? 'USD', dangerousGoods: item.dangerousGoods ?? false,
|
||||
})),
|
||||
timeline: detail ? timeline(detail) : [], createdAt: order.createdAt, updatedAt: order.updatedAt,
|
||||
tradeTerm: order.tradeTerm ?? '', destinationCountryCode: order.destinationCountryCode,
|
||||
destinationWarehouseName: order.destinationWarehouseName, pickupMethod: order.pickupMethod, channelCode: order.channelCode,
|
||||
channelName: order.channelName, taxMode: order.taxMode, customsInspection: order.customsInspection,
|
||||
destinationType: order.destinationType, amazonWarehouseCode: order.amazonWarehouseCode ?? '', shipmentId: order.shipmentId ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function mapWarehouse(item: MerchantWarehouse): PlatformReceivingWarehouse {
|
||||
return {
|
||||
id: String(item.id), code: item.warehouseCode, name: item.warehouseName, province: '',
|
||||
city: '', address: '', contactName: item.contactName ?? '', contactPhone: item.contactPhone ?? '',
|
||||
businessHours: '', status: item.status, remark: item.remark ?? '', updatedAt: item.updatedAt ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () => {
|
||||
const orders = ref<FirstLegOrder[]>([])
|
||||
const warehouses = ref<PlatformReceivingWarehouse[]>([])
|
||||
const loading = ref(false)
|
||||
const loadedFromApi = ref(false)
|
||||
const loadError = ref('')
|
||||
const activeCount = computed(() => orders.value.filter((item) => ['PENDING_REVIEW', 'ACCEPTED', 'BOOKED', 'IN_TRANSIT'].includes(item.status)).length)
|
||||
const pendingCount = computed(() => orders.value.filter((item) => item.status === 'PENDING_REVIEW').length)
|
||||
|
||||
function getById(id: string) { return orders.value.find((item) => item.id === id) }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadedFromApi.value = true
|
||||
loadError.value = ''
|
||||
orders.value = []
|
||||
warehouses.value = []
|
||||
try {
|
||||
const [orderPage, warehousePage] = await Promise.all([getMerchantFirstLegOrders(), getMerchantWarehouses()])
|
||||
warehouses.value = warehousePage.records.filter((item) => item.status === 'ACTIVE').map(mapWarehouse)
|
||||
orders.value = orderPage.records.map((item) => mapOrder(item, warehouses.value))
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : '订单与仓库数据加载失败'
|
||||
throw error
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadDetail(id: string) {
|
||||
const detail = await getMerchantFirstLegOrder(id)
|
||||
const mapped = mapOrder(detail.order, warehouses.value, detail)
|
||||
const index = orders.value.findIndex((item) => item.id === id)
|
||||
if (index >= 0) orders.value[index] = mapped
|
||||
else orders.value.unshift(mapped)
|
||||
return mapped
|
||||
}
|
||||
|
||||
async function loadWarehouseDetail(id: string) {
|
||||
const detail = await getMerchantWarehouseDetail(id)
|
||||
const mapped = mapWarehouse(detail.warehouse)
|
||||
const address = detail.addresses[0]
|
||||
const receivingCapability = detail.capabilities.find((item) => item.enabled && item.cutoffTime)
|
||||
if (address) {
|
||||
mapped.province = address.stateProvince ?? ''
|
||||
mapped.city = address.city ?? ''
|
||||
mapped.address = [address.district, address.addressLine1, address.addressLine2].filter(Boolean).join(' ')
|
||||
}
|
||||
if (receivingCapability?.cutoffTime) mapped.businessHours = `每日 ${receivingCapability.cutoffTime.slice(0, 5)} 前`
|
||||
const index = warehouses.value.findIndex((item) => item.id === id)
|
||||
if (index >= 0) warehouses.value[index] = mapped
|
||||
return mapped
|
||||
}
|
||||
|
||||
async function saveDraft(draft: FirstLegOrderDraft, customerName: string, id?: string) {
|
||||
const warehouse = warehouses.value.find((item) => item.id === draft.receivingWarehouseId)
|
||||
if (!warehouse) throw new Error('所选收货仓当前不可用')
|
||||
if (id) {
|
||||
const order = getById(id)
|
||||
if (!order || !canClientEditFirstLegOrder(order.status)) throw new Error('当前订单状态不允许编辑')
|
||||
await updateMerchantFirstLegOrder(id, savePayload(draft, warehouse, customerName))
|
||||
return loadDetail(id)
|
||||
}
|
||||
const createdId = await createMerchantFirstLegOrder(savePayload(draft, warehouse, customerName))
|
||||
return loadDetail(String(createdId))
|
||||
}
|
||||
|
||||
async function submit(draft: FirstLegOrderDraft, customerName: string, id?: string, companyAuditStatus: MerchantTenantStatus = 'ACTIVE') {
|
||||
if (companyAuditStatus !== 'ACTIVE') throw new Error('企业资料尚未审核通过,请先完善企业资料并等待平台审核')
|
||||
const order = await saveDraft(draft, customerName, id)
|
||||
await submitMerchantFirstLegOrder(order.id)
|
||||
return loadDetail(order.id)
|
||||
}
|
||||
|
||||
return { orders, warehouses, loading, loadedFromApi, loadError, activeCount, pendingCount, getById, load, loadDetail, loadWarehouseDetail, saveDraft, submit }
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import type {
|
||||
FirstLegOrderStatus,
|
||||
FirstLegTransportMode,
|
||||
PlatformReceivingWarehouseSnapshot,
|
||||
} from '@chongchong/shared'
|
||||
import type { FirstLegPartySnapshot } from '../data/firstLegParties.ts'
|
||||
|
||||
export type FirstLegBusinessType = 'EXPORT' | 'IMPORT'
|
||||
export type FirstLegPriority = 'NORMAL' | 'URGENT' | 'EXPEDITED'
|
||||
|
||||
export interface FirstLegCargoItem {
|
||||
id: string
|
||||
sku: string
|
||||
upc: string
|
||||
cargoNameCn: string
|
||||
cargoNameEn: string
|
||||
pieces: number
|
||||
packageCount: number
|
||||
grossWeightKg: number
|
||||
volumeCbm: number
|
||||
containerType: string
|
||||
containerCount: number
|
||||
remark: string
|
||||
packageType: string
|
||||
hsCode: string
|
||||
declaredValue: number
|
||||
currency: string
|
||||
dangerousGoods: boolean
|
||||
}
|
||||
|
||||
export interface FirstLegOrderTimelineItem {
|
||||
id: string
|
||||
event: 'DRAFT_SAVED' | 'SUBMITTED' | 'CANCELLED' | 'ACCEPTED' | 'BOOKED' | 'IN_TRANSIT' | 'COMPLETED'
|
||||
title: string
|
||||
description: string
|
||||
operatorName: string
|
||||
occurredAt: string
|
||||
}
|
||||
|
||||
export interface FirstLegOrder {
|
||||
id: string
|
||||
orderNo: string | null
|
||||
referenceNo: string
|
||||
transportMode: FirstLegTransportMode
|
||||
businessType: FirstLegBusinessType
|
||||
origin: string
|
||||
destination: string
|
||||
receivingWarehouseId: string
|
||||
receivingWarehouseName: string
|
||||
receivingWarehouseSnapshot: PlatformReceivingWarehouseSnapshot | null
|
||||
expectedDepartureDate: string
|
||||
etd: string
|
||||
eta: string
|
||||
shipperPartyId: string
|
||||
shipperPartySnapshot: FirstLegPartySnapshot | null
|
||||
shipper: string
|
||||
consigneePartyId: string
|
||||
consigneePartySnapshot: FirstLegPartySnapshot | null
|
||||
consignee: string
|
||||
expressCompanyName: string
|
||||
trackingNumber: string
|
||||
priority: FirstLegPriority
|
||||
customerRemark: string
|
||||
operatorName: string
|
||||
status: FirstLegOrderStatus
|
||||
version: number
|
||||
items: FirstLegCargoItem[]
|
||||
timeline: FirstLegOrderTimelineItem[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
tradeTerm: string
|
||||
destinationCountryCode: string
|
||||
destinationWarehouseName: string
|
||||
pickupMethod: 'SELF_EXPRESS' | 'PICKUP' | 'SELF_TRUCK'
|
||||
channelCode: string
|
||||
channelName: string
|
||||
taxMode: 'BUY_DECLARATION' | 'TAX_REFUND'
|
||||
customsInspection: boolean
|
||||
destinationType: 'FBA' | 'OVERSEAS' | 'PRIVATE'
|
||||
amazonWarehouseCode: string
|
||||
shipmentId: string
|
||||
}
|
||||
|
||||
export type FirstLegOrderDraft = Pick<
|
||||
FirstLegOrder,
|
||||
| 'referenceNo'
|
||||
| 'transportMode'
|
||||
| 'businessType'
|
||||
| 'origin'
|
||||
| 'destination'
|
||||
| 'receivingWarehouseId'
|
||||
| 'expectedDepartureDate'
|
||||
| 'shipperPartyId'
|
||||
| 'shipperPartySnapshot'
|
||||
| 'shipper'
|
||||
| 'consigneePartyId'
|
||||
| 'consigneePartySnapshot'
|
||||
| 'consignee'
|
||||
| 'priority'
|
||||
| 'customerRemark'
|
||||
| 'items'
|
||||
| 'tradeTerm'
|
||||
| 'destinationCountryCode'
|
||||
| 'destinationWarehouseName'
|
||||
| 'pickupMethod'
|
||||
| 'channelCode'
|
||||
| 'channelName'
|
||||
| 'taxMode'
|
||||
| 'customsInspection'
|
||||
| 'destinationType'
|
||||
| 'amazonWarehouseCode'
|
||||
| 'shipmentId'
|
||||
>
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createFirstLegPartySnapshot } from '../data/firstLegParties.ts'
|
||||
import {
|
||||
canClientCancelFirstLegOrder,
|
||||
canClientEditFirstLegOrder,
|
||||
createEmptyCargoItem,
|
||||
getFirstLegStatusLabel,
|
||||
getFirstLegTransportModeLabel,
|
||||
summarizeFirstLegCargo,
|
||||
validateFirstLegOrderDraft,
|
||||
} from './firstLegOrder.ts'
|
||||
|
||||
const shipperParty = {
|
||||
id: 'CP001', code: 'SHIPPER-001', name: '测试发货企业', roles: ['SHIPPER'], contactName: '测试联系人',
|
||||
phone: '13800000000', email: 'shipper@example.test', countryRegion: '中国', province: '浙江省', city: '宁波市',
|
||||
postalCode: '315000', address: '测试发货地址', status: 'ACTIVE',
|
||||
}
|
||||
const consigneeParty = {
|
||||
id: 'CP002', code: 'CONSIGNEE-001', name: 'TEST CONSIGNEE LLC', roles: ['CONSIGNEE'], contactName: 'Test Receiver',
|
||||
phone: '+1 555 0100', email: 'receiver@example.test', countryRegion: '美国', province: 'California', city: 'Los Angeles',
|
||||
postalCode: '90001', address: '100 Test Logistics Ave', status: 'ACTIVE',
|
||||
}
|
||||
|
||||
function validDraft() {
|
||||
return {
|
||||
referenceNo: 'REF-001',
|
||||
transportMode: 'FCL',
|
||||
businessType: 'EXPORT',
|
||||
origin: '宁波',
|
||||
destination: '洛杉矶',
|
||||
receivingWarehouseId: 'WH005',
|
||||
expectedDepartureDate: '2026-08-08',
|
||||
shipperPartyId: 'CP001',
|
||||
shipperPartySnapshot: createFirstLegPartySnapshot(shipperParty, 'SHIPPER'),
|
||||
shipper: shipperParty.name,
|
||||
consigneePartyId: 'CP003',
|
||||
consigneePartySnapshot: createFirstLegPartySnapshot(consigneeParty, 'CONSIGNEE'),
|
||||
consignee: consigneeParty.name,
|
||||
priority: 'NORMAL',
|
||||
customerRemark: '',
|
||||
tradeTerm: 'FOB',
|
||||
destinationCountryCode: 'US',
|
||||
destinationWarehouseName: 'ONT8',
|
||||
pickupMethod: 'SELF_EXPRESS',
|
||||
channelCode: 'SEA-US-FBA',
|
||||
channelName: '美国海运渠道',
|
||||
taxMode: 'BUY_DECLARATION',
|
||||
customsInspection: false,
|
||||
destinationType: 'FBA',
|
||||
amazonWarehouseCode: 'ONT8',
|
||||
shipmentId: 'FBA-001',
|
||||
items: [{ ...createEmptyCargoItem('cargo-1'), sku: 'SKU-001', upc: '012345678905', cargoNameCn: '家居用品', pieces: 100, grossWeightKg: 1200, volumeCbm: 3.5, containerType: '40HQ', containerCount: 1 }],
|
||||
}
|
||||
}
|
||||
|
||||
test('uses the confirmed status and transport labels', () => {
|
||||
assert.equal(getFirstLegStatusLabel('BOOKED'), '已订舱')
|
||||
assert.equal(getFirstLegStatusLabel('IN_TRANSIT'), '运输中')
|
||||
assert.equal(getFirstLegTransportModeLabel('FCL'), '海运整箱 FCL')
|
||||
})
|
||||
|
||||
test('only allows draft orders to be edited in the confirmed phase-one flow', () => {
|
||||
assert.equal(canClientEditFirstLegOrder('DRAFT'), true)
|
||||
assert.equal(canClientEditFirstLegOrder('PENDING_REVIEW'), false)
|
||||
assert.equal(canClientCancelFirstLegOrder('DRAFT'), true)
|
||||
assert.equal(canClientCancelFirstLegOrder('PENDING_REVIEW'), true)
|
||||
assert.equal(canClientCancelFirstLegOrder('ACCEPTED'), false)
|
||||
assert.equal(canClientCancelFirstLegOrder('BOOKED'), false)
|
||||
})
|
||||
|
||||
test('validates the confirmed minimum submission fields', () => {
|
||||
assert.deepEqual(validateFirstLegOrderDraft(validDraft()), [])
|
||||
|
||||
const invalid = validDraft()
|
||||
invalid.origin = ''
|
||||
invalid.receivingWarehouseId = ''
|
||||
invalid.items[0].grossWeightKg = 0
|
||||
invalid.items[0].containerType = ''
|
||||
invalid.items[0].containerCount = 0
|
||||
|
||||
const errors = validateFirstLegOrderDraft(invalid)
|
||||
assert.ok(errors.includes('请填写起运地'))
|
||||
assert.ok(errors.includes('请选择平台收货仓'))
|
||||
assert.ok(errors.includes('第 1 条货物毛重必须大于 0'))
|
||||
assert.ok(errors.includes('第 1 条货物需要填写箱型和柜量'))
|
||||
})
|
||||
|
||||
test('requires enabled-party snapshots and order addresses before submission', () => {
|
||||
const draft = validDraft()
|
||||
draft.shipperPartyId = ''
|
||||
draft.shipperPartySnapshot = null
|
||||
draft.consigneePartySnapshot.address = ''
|
||||
|
||||
const errors = validateFirstLegOrderDraft(draft)
|
||||
assert.ok(errors.includes('请选择发货单位'))
|
||||
assert.ok(errors.includes('请填写本单收货地址'))
|
||||
})
|
||||
|
||||
test('requires FBA reference, warehouse, shipment, SKU and UPC fields', () => {
|
||||
const draft = validDraft()
|
||||
draft.referenceNo = ''
|
||||
draft.amazonWarehouseCode = ''
|
||||
draft.shipmentId = ''
|
||||
draft.items[0].sku = ''
|
||||
draft.items[0].upc = ''
|
||||
|
||||
const errors = validateFirstLegOrderDraft(draft)
|
||||
assert.ok(errors.includes('FBA 订单请填写客户参考号'))
|
||||
assert.ok(errors.includes('FBA 订单请填写 Amazon 仓库编码'))
|
||||
assert.ok(errors.includes('FBA 订单请填写 Shipment ID'))
|
||||
assert.ok(errors.includes('第 1 条货物缺少客户 SKU'))
|
||||
assert.ok(errors.includes('第 1 条货物缺少 UPC'))
|
||||
})
|
||||
|
||||
test('does not require container details for non-FCL cargo', () => {
|
||||
const draft = validDraft()
|
||||
draft.transportMode = 'AIR'
|
||||
draft.items[0].containerType = ''
|
||||
draft.items[0].containerCount = 0
|
||||
|
||||
assert.deepEqual(validateFirstLegOrderDraft(draft), [])
|
||||
})
|
||||
|
||||
test('summarizes cargo values from line items', () => {
|
||||
const first = validDraft().items[0]
|
||||
const second = { ...first, id: 'cargo-2', pieces: 40, packageCount: 4, grossWeightKg: 300, volumeCbm: 1.25 }
|
||||
|
||||
assert.deepEqual(summarizeFirstLegCargo([first, second]), {
|
||||
pieces: 140,
|
||||
packageCount: 5,
|
||||
grossWeightKg: 1500,
|
||||
volumeCbm: 4.75,
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
FIRST_LEG_ORDER_STATUSES,
|
||||
FIRST_LEG_TRANSPORT_MODES,
|
||||
type FirstLegOrderStatus,
|
||||
type FirstLegTransportMode,
|
||||
} from '@chongchong/shared'
|
||||
import type { FirstLegCargoItem, FirstLegOrderDraft } from '../types/firstLegOrder.ts'
|
||||
|
||||
const statusLabels = new Map<FirstLegOrderStatus, string>(
|
||||
FIRST_LEG_ORDER_STATUSES.map((item) => [item.code, item.label]),
|
||||
)
|
||||
|
||||
const transportModeLabels = new Map<FirstLegTransportMode, string>(
|
||||
FIRST_LEG_TRANSPORT_MODES.map((item) => [item.code, item.label]),
|
||||
)
|
||||
|
||||
export function getFirstLegStatusLabel(status: FirstLegOrderStatus) {
|
||||
return statusLabels.get(status) ?? status
|
||||
}
|
||||
|
||||
export function getFirstLegTransportModeLabel(mode: FirstLegTransportMode) {
|
||||
return transportModeLabels.get(mode) ?? mode
|
||||
}
|
||||
|
||||
export function getFirstLegStatusTone(status: FirstLegOrderStatus): 'success' | 'warning' | 'info' | 'danger' {
|
||||
if (status === 'COMPLETED') return 'success'
|
||||
if (status === 'DRAFT' || status === 'PENDING_REVIEW') return 'warning'
|
||||
if (status === 'CANCELLED') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
export function canClientEditFirstLegOrder(status: FirstLegOrderStatus) {
|
||||
return status === 'DRAFT'
|
||||
}
|
||||
|
||||
export function canClientCancelFirstLegOrder(status: FirstLegOrderStatus) {
|
||||
return status === 'DRAFT' || status === 'PENDING_REVIEW'
|
||||
}
|
||||
|
||||
export function createEmptyCargoItem(id = `cargo-${Date.now()}`): FirstLegCargoItem {
|
||||
return {
|
||||
id,
|
||||
sku: '',
|
||||
upc: '',
|
||||
cargoNameCn: '',
|
||||
cargoNameEn: '',
|
||||
pieces: 1,
|
||||
packageCount: 1,
|
||||
grossWeightKg: 0,
|
||||
volumeCbm: 0,
|
||||
containerType: '',
|
||||
containerCount: 0,
|
||||
remark: '',
|
||||
packageType: 'CARTON',
|
||||
hsCode: '',
|
||||
declaredValue: 0,
|
||||
currency: 'USD',
|
||||
dangerousGoods: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!draft.origin.trim()) errors.push('请填写起运地')
|
||||
if (!draft.destination.trim()) errors.push('请填写目的地')
|
||||
if (!/^[A-Za-z]{2}$/.test(draft.destinationCountryCode.trim())) errors.push('请输入两位英文字母的目的国代码')
|
||||
if (!draft.destinationWarehouseName.trim()) errors.push('请填写目的仓名称')
|
||||
if (!draft.channelCode.trim() || !draft.channelName.trim()) errors.push('请填写渠道编码和渠道名称')
|
||||
if (!draft.receivingWarehouseId) errors.push('请选择平台收货仓')
|
||||
if (!draft.shipperPartyId || !draft.shipperPartySnapshot || !draft.shipper.trim()) errors.push('请选择发货单位')
|
||||
if (draft.shipperPartySnapshot && !draft.shipperPartySnapshot.contactName.trim()) errors.push('请填写发货联系人')
|
||||
if (draft.shipperPartySnapshot && !draft.shipperPartySnapshot.phone.trim()) errors.push('请填写发货联系电话')
|
||||
if (draft.shipperPartySnapshot && !draft.shipperPartySnapshot.address.trim()) errors.push('请填写本单发货地址')
|
||||
if (!draft.consigneePartyId || !draft.consigneePartySnapshot || !draft.consignee.trim()) errors.push('请选择收货单位')
|
||||
if (draft.consigneePartySnapshot && !draft.consigneePartySnapshot.contactName.trim()) errors.push('请填写收货联系人')
|
||||
if (draft.consigneePartySnapshot && !draft.consigneePartySnapshot.phone.trim()) errors.push('请填写收货联系电话')
|
||||
if (draft.consigneePartySnapshot && !draft.consigneePartySnapshot.address.trim()) errors.push('请填写本单收货地址')
|
||||
if (draft.items.length === 0) errors.push('请至少添加一条货物明细')
|
||||
|
||||
if (draft.destinationType === 'FBA') {
|
||||
if (!draft.referenceNo.trim()) errors.push('FBA 订单请填写客户参考号')
|
||||
if (!draft.amazonWarehouseCode.trim()) errors.push('FBA 订单请填写 Amazon 仓库编码')
|
||||
if (!draft.shipmentId.trim()) errors.push('FBA 订单请填写 Shipment ID')
|
||||
}
|
||||
|
||||
draft.items.forEach((item, index) => {
|
||||
const line = `第 ${index + 1} 条货物`
|
||||
if (!item.cargoNameCn.trim()) errors.push(`${line}缺少中文品名`)
|
||||
if (!item.packageType.trim()) errors.push(`${line}缺少包装类型`)
|
||||
if (draft.destinationType === 'FBA' && !item.sku.trim()) errors.push(`${line}缺少客户 SKU`)
|
||||
if (draft.destinationType === 'FBA' && !item.upc.trim()) errors.push(`${line}缺少 UPC`)
|
||||
if (!Number.isInteger(item.pieces) || item.pieces <= 0) errors.push(`${line}件数必须为正整数`)
|
||||
if (item.grossWeightKg <= 0) errors.push(`${line}毛重必须大于 0`)
|
||||
if (item.volumeCbm <= 0) errors.push(`${line}体积必须大于 0`)
|
||||
if (draft.transportMode === 'FCL' && (!item.containerType || item.containerCount <= 0)) {
|
||||
errors.push(`${line}需要填写箱型和柜量`)
|
||||
}
|
||||
})
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
export function summarizeFirstLegCargo(items: FirstLegCargoItem[]) {
|
||||
return items.reduce(
|
||||
(summary, item) => ({
|
||||
pieces: summary.pieces + item.pieces,
|
||||
packageCount: summary.packageCount + item.packageCount,
|
||||
grossWeightKg: summary.grossWeightKg + item.grossWeightKg,
|
||||
volumeCbm: summary.volumeCbm + item.volumeCbm,
|
||||
}),
|
||||
{ pieces: 0, packageCount: 0, grossWeightKg: 0, volumeCbm: 0 },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/views/prototype/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="一键导入" description="批量导入头程订单" /></template>
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url), 'utf8')
|
||||
const routerSource = readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8')
|
||||
const editorShellSource = readFileSync(new URL('../components/first-leg/FirstLegOrderEditorShell.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('routes the confirmed first-leg path to the implemented order view', () => {
|
||||
assert.match(routerSource, /path: 'orders\/first-leg'.*FirstLegOrdersView\.vue/)
|
||||
})
|
||||
|
||||
test('opens order creation as a dedicated protected page while keeping draft editing in a drawer', () => {
|
||||
assert.match(routerSource, /path: 'orders\/first-leg\/create'.*name: 'first-leg-order-create'.*permission: 'order:first-leg:create'/)
|
||||
assert.match(source, /router\.push\(\{ name: 'first-leg-order-create' \}\)/)
|
||||
assert.match(source, /const isCreatePage = computed\(\(\) => route\.name === 'first-leg-order-create'\)/)
|
||||
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, /返回订单列表/)
|
||||
})
|
||||
|
||||
test('exposes real list, draft, submit, and detail workflow entries without cancellation', () => {
|
||||
assert.match(source, /function openCreate\(\)/)
|
||||
assert.match(source, /async function saveDraft\(\)/)
|
||||
assert.match(source, /function submitOrder\(\)/)
|
||||
assert.match(source, /async function openDetail\(order: FirstLegOrder\)/)
|
||||
assert.match(source, /Promise\.allSettled\(\[/)
|
||||
assert.match(source, /store\.load\(\),\s+firstLegPartySource\.load\(\)/)
|
||||
assert.match(source, /tenantStore\.loadCompanyContext\(\)/)
|
||||
assert.match(source, /await store\.loadDetail\(order\.id\)/)
|
||||
assert.doesNotMatch(source, /cancelOrder|取消订单/)
|
||||
})
|
||||
|
||||
test('blocks order submission before company approval while preserving draft actions', () => {
|
||||
assert.match(source, /companyApproved/)
|
||||
assert.match(source, /企业资料待审核,当前仅可保存订单草稿/)
|
||||
assert.match(source, /暂时无法提交订单/)
|
||||
assert.match(source, /router\.push\('\/company\/profile'\)/)
|
||||
assert.match(source, /auth\.hasPermission\('order:first-leg:submit'\)/)
|
||||
})
|
||||
|
||||
test('renders dynamic cargo lines and responsive drawers', () => {
|
||||
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(source, /class="mobile-order-list"/)
|
||||
assert.match(source, /\.desktop-order-table \{ display: none; \}/)
|
||||
})
|
||||
|
||||
test('keeps primary mobile order actions touch sized', () => {
|
||||
assert.match(source, /\.editor-actions :deep\(\.el-button\) \{ min-height: 44px;/)
|
||||
})
|
||||
|
||||
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, /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.doesNotMatch(source, /order-metrics|订单概览/)
|
||||
})
|
||||
|
||||
test('keeps the first viewport compact and moves create into the table toolbar', () => {
|
||||
assert.match(source, /class="order-table-toolbar"/)
|
||||
assert.match(source, /<strong>订单列表<\/strong><span>共 \{\{ filteredOrders\.length \}\} 条<\/span>/)
|
||||
assert.match(source, /class="order-table-toolbar"[\s\S]*order:first-leg:create[\s\S]*创建订单[\s\S]*class="order-eligibility is-pending"/)
|
||||
assert.doesNotMatch(source, /<QueryToolbar[\s\S]*?<template #actions>/)
|
||||
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;/)
|
||||
assert.match(source, /v-if="!companyApproved && !supportingDataErrors\.some/)
|
||||
assert.doesNotMatch(source, /企业资料已审核,可正常提交订单/)
|
||||
assert.doesNotMatch(source, /order-eligibility\.is-approved/)
|
||||
})
|
||||
|
||||
test('does not expose arbitrary status mutation controls to customers', () => {
|
||||
assert.doesNotMatch(source, /setStatusForPreview/)
|
||||
assert.doesNotMatch(source, /状态更新|更新状态/)
|
||||
})
|
||||
|
||||
test('requires an enabled platform receiving warehouse in the order form', () => {
|
||||
assert.match(source, /computed\(\(\) => store\.warehouses\)/)
|
||||
assert.match(source, /availableWarehouses\.value\.length === 0/)
|
||||
assert.match(source, /label="平台收货仓"[^>]*required/)
|
||||
assert.match(source, /v-model="form\.receivingWarehouseId"/)
|
||||
assert.match(source, /selectedWarehouse\.businessHours/)
|
||||
assert.match(source, /class="selected-warehouse" aria-label="所选收货仓详情"/)
|
||||
assert.doesNotMatch(source, /class="selected-warehouse"[^>]*>[\s\S]*?<strong>\{\{ selectedWarehouse\.name \}\}<\/strong>/)
|
||||
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(source, /detailOrder\.receivingWarehouseSnapshot\?\.name/)
|
||||
assert.match(source, /detailOrder\.receivingWarehouseSnapshot\.code/)
|
||||
assert.match(source, /loadSelectedWarehouse\(detailedOrder\.receivingWarehouseId\)/)
|
||||
})
|
||||
|
||||
test('collects the backend-required first-leg fields', () => {
|
||||
for (const model of ['destinationCountryCode', 'destinationWarehouseName', 'channelCode', 'pickupMethod', 'taxMode', 'destinationType', 'packageType', 'upc']) {
|
||||
assert.match(source, new RegExp(`v-model="(?:form|item)\\.${model}"`))
|
||||
}
|
||||
assert.match(source, /form\.channelName = channel\?\.name/)
|
||||
})
|
||||
|
||||
test('distills order creation into the confirmed prototype sections without fake pricing', () => {
|
||||
assert.match(source, /<h3>下单信息<\/h3>/)
|
||||
assert.match(source, /<h3>发货与目的地<\/h3>/)
|
||||
assert.match(source, /<h3>货物明细<\/h3>/)
|
||||
assert.match(source, /class="segmented-options"/)
|
||||
assert.match(source, /class="cargo-primary-grid"/)
|
||||
assert.match(source, /title="更多申报信息"/)
|
||||
assert.doesNotMatch(source, /费用预估|查看最新报价|cost-estimate/)
|
||||
assert.doesNotMatch(source, /v-model="form\.(?:businessType|origin|destination|priority|tradeTerm)"/)
|
||||
})
|
||||
|
||||
test('shows field-level validation and concise guidance for required order inputs', () => {
|
||||
assert.match(source, /ref="orderFormRef"[^>]*:model="form" :rules="orderFormRules"/)
|
||||
assert.match(source, /prop="shipperPartySnapshot\.contactName" required/)
|
||||
assert.match(source, /prop="consigneePartySnapshot\.address" required/)
|
||||
assert.match(source, /:prop="`items\.\$\{index\}\.cargoNameCn`" required/)
|
||||
assert.match(source, /:prop="`items\.\$\{index\}\.grossWeightKg`" required/)
|
||||
assert.match(source, /label="UPC".*required.*v-model="item\.upc"/)
|
||||
assert.match(source, /placeholder="例如:蓝牙音箱"/)
|
||||
assert.match(source, /\.order-form :deep\(\.el-form-item__error\).*position: static/)
|
||||
})
|
||||
|
||||
test('selects enabled counterparties and stores editable order snapshots', () => {
|
||||
assert.match(source, /firstLegPartySource\.listAvailable\('SHIPPER'\)/)
|
||||
assert.match(source, /firstLegPartySource\.findAvailable\(id, role\)/)
|
||||
assert.match(source, /firstLegPartySource\.load\(\)/)
|
||||
assert.match(source, /v-model="form\.shipperPartyId"/)
|
||||
assert.match(source, /v-model="form\.consigneePartyId"/)
|
||||
assert.match(source, /form\.shipperPartySnapshot\.contactName/)
|
||||
assert.match(source, /form\.consigneePartySnapshot\.address/)
|
||||
assert.match(source, /保存为本订单快照/)
|
||||
assert.match(source, /form\.shipperPartySnapshot\.name\} · 历史快照/)
|
||||
assert.match(source, /form\.consigneePartySnapshot\.name\} · 历史快照/)
|
||||
})
|
||||
|
||||
test('shows fulfillment courier information as read-only client detail', () => {
|
||||
assert.match(source, /履约快递信息/)
|
||||
assert.match(source, /detailOrder\.expressCompanyName/)
|
||||
assert.match(source, /detailOrder\.trackingNumber/)
|
||||
assert.match(source, /客户端仅只读展示/)
|
||||
assert.doesNotMatch(source, /v-model="form\.expressCompanyName"/)
|
||||
assert.doesNotMatch(source, /v-model="form\.trackingNumber"/)
|
||||
})
|
||||
|
||||
test('protects unsaved drafts and exposes recoverable load failures', () => {
|
||||
assert.match(source, /onBeforeRouteLeave/)
|
||||
assert.match(source, /beforeunload/)
|
||||
assert.match(source, /confirmDiscardChanges/)
|
||||
assert.match(source, /:before-close="requestCloseEditor"/)
|
||||
assert.match(source, /store\.loadError/)
|
||||
assert.match(source, /订单数据加载失败/)
|
||||
assert.match(source, /订单详情加载失败/)
|
||||
assert.match(source, /部分订单配套数据加载失败/)
|
||||
assert.match(source, /往来单位加载失败/)
|
||||
assert.match(source, /企业资料加载失败/)
|
||||
assert.match(source, /@click="loadPageData"/)
|
||||
assert.match(source, /@click="retryDetail"/)
|
||||
})
|
||||
|
||||
test('copies reactive order drafts into plain request data', () => {
|
||||
assert.match(source, /function cloneDraft\(draft: FirstLegOrderDraft\)/)
|
||||
assert.match(source, /items: draft\.items\.map\(\(item\) => \(\{ \.\.\.item \}\)\)/)
|
||||
assert.match(source, /store\.saveDraft\(cloneDraft\(form\)/)
|
||||
assert.match(source, /store\.submit\(cloneDraft\(form\)/)
|
||||
assert.doesNotMatch(source, /structuredClone\((?:form|order\.)/)
|
||||
})
|
||||
|
||||
test('loads complete order detail before editing and rejects an unavailable historical warehouse', () => {
|
||||
assert.match(source, /async function openEdit\(order: FirstLegOrder\)/)
|
||||
assert.match(source, /const detailedOrder = await store\.loadDetail\(order\.id\)/)
|
||||
assert.match(source, /draftFromOrder\(detailedOrder\)/)
|
||||
assert.match(source, /draft\.receivingWarehouseId = ''/)
|
||||
assert.match(source, /原平台收货仓当前不可用,请重新选择可用收货仓/)
|
||||
})
|
||||
@@ -0,0 +1,925 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormItemRule, type FormRules } from 'element-plus'
|
||||
import { AlertTriangle, Box, Eye, FilePlus2, MapPin, PackageCheck, Pencil, Plus, Send, Trash2 } from '@lucide/vue'
|
||||
import { FIRST_LEG_ORDER_STATUSES, FIRST_LEG_TRANSPORT_MODES, type FirstLegOrderStatus } from '@chongchong/shared'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
import FirstLegOrderEditorShell from '@/components/first-leg/FirstLegOrderEditorShell.vue'
|
||||
import { useFirstLegOrdersStore } from '@/stores/firstLegOrders'
|
||||
import { useTenantAdministrationStore } from '@/stores/tenantAdministration'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { createFirstLegPartySnapshot, type FirstLegPartyRole } from '@/data/firstLegParties'
|
||||
import { firstLegPartySource } from '@/data/firstLegPartySource'
|
||||
import type { FirstLegOrder, FirstLegOrderDraft } from '@/types/firstLegOrder'
|
||||
import {
|
||||
canClientEditFirstLegOrder,
|
||||
createEmptyCargoItem,
|
||||
getFirstLegStatusLabel,
|
||||
getFirstLegStatusTone,
|
||||
getFirstLegTransportModeLabel,
|
||||
summarizeFirstLegCargo,
|
||||
validateFirstLegOrderDraft,
|
||||
} from '@/utils/firstLegOrder'
|
||||
|
||||
const store = useFirstLegOrdersStore()
|
||||
const tenantStore = useTenantAdministrationStore()
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isCreatePage = computed(() => route.name === 'first-leg-order-create')
|
||||
const companyApproved = computed(() => tenantStore.company.auditStatus === 'ACTIVE')
|
||||
const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create'))
|
||||
const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit'))
|
||||
const availableWarehouses = computed(() => store.warehouses)
|
||||
const availableShippers = ref(firstLegPartySource.listAvailable('SHIPPER'))
|
||||
const availableConsignees = ref(firstLegPartySource.listAvailable('CONSIGNEE'))
|
||||
const filters = ref<QueryModel>({ keyword: '', status: '', transportMode: '', createdRange: [] })
|
||||
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: 'transportMode', type: 'select', placeholder: '全部运输方式', options: FIRST_LEG_TRANSPORT_MODES.map((item) => ({ label: item.label, value: item.code })), props: { 'aria-label': '运输方式' } },
|
||||
{ key: 'createdRange', type: 'daterange', placeholder: '订单创建日期', props: { 'aria-label': '订单创建日期' } },
|
||||
]
|
||||
const channelOptions = [
|
||||
{ code: 'SEA-US-GENERAL', name: '美线普货专线' },
|
||||
{ code: 'SEA-US-SENSITIVE', name: '美线敏感货专线' },
|
||||
{ code: 'SEA-US-EXPRESS', name: '美线快线' },
|
||||
{ code: 'SEA-US-FBA', name: '美线 FBA 直送' },
|
||||
]
|
||||
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: 'operator', label: '负责人', width: 110, slotName: 'operator' },
|
||||
{ key: 'updatedAt', label: '更新时间', width: 155, slotName: 'updatedAt' },
|
||||
]
|
||||
const tablePage = ref(1)
|
||||
const tablePageSize = ref(10)
|
||||
const editorOpen = ref(false)
|
||||
const orderFormRef = ref<FormInstance>()
|
||||
const detailOpen = ref(false)
|
||||
const editingId = ref<string>()
|
||||
const openingEditor = ref(false)
|
||||
const detailId = ref<string>()
|
||||
const saving = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref('')
|
||||
const supportingDataLoading = ref(false)
|
||||
const supportingDataErrors = ref<string[]>([])
|
||||
const editorBaseline = ref('')
|
||||
let cargoSequence = 0
|
||||
|
||||
const orderFormRules: FormRules = {
|
||||
transportMode: [{ required: true, message: '请选择运输方式', trigger: 'change' }],
|
||||
businessType: [{ required: true, message: '请选择业务类型', trigger: 'change' }],
|
||||
origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }],
|
||||
destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
|
||||
receivingWarehouseId: [{ required: true, message: '请选择平台收货仓', trigger: 'change' }],
|
||||
destinationCountryCode: [
|
||||
{ required: true, message: '请输入目的国代码', trigger: 'blur' },
|
||||
{ pattern: /^[A-Za-z]{2}$/, message: '请输入两位英文字母,例如 US', trigger: 'blur' },
|
||||
],
|
||||
destinationWarehouseName: [{ required: true, message: '请输入目的仓名称', trigger: 'blur' }],
|
||||
channelCode: [{ required: true, message: '请输入渠道编码', trigger: 'blur' }],
|
||||
channelName: [{ required: true, message: '请输入渠道名称', trigger: 'blur' }],
|
||||
pickupMethod: [{ required: true, message: '请选择提货方式', trigger: 'change' }],
|
||||
taxMode: [{ required: true, message: '请选择报关模式', trigger: 'change' }],
|
||||
destinationType: [{ required: true, message: '请选择目的仓类型', trigger: 'change' }],
|
||||
shipperPartyId: [{ required: true, message: '请选择发货单位', trigger: 'change' }],
|
||||
consigneePartyId: [{ required: true, message: '请选择收货单位', trigger: 'change' }],
|
||||
}
|
||||
|
||||
function requiredTextRules(message: string): FormItemRule[] {
|
||||
return [{ required: true, message, trigger: 'blur' }]
|
||||
}
|
||||
|
||||
function requiredSelectRules(message: string): FormItemRule[] {
|
||||
return [{ required: true, message, trigger: 'change' }]
|
||||
}
|
||||
|
||||
function positiveNumberRules(message: string, integer = false): FormItemRule[] {
|
||||
return [{ type: integer ? 'integer' : 'number', min: integer ? 1 : 0.001, message, trigger: 'change' }]
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value || '-'
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
|
||||
function emptyDraft(): FirstLegOrderDraft {
|
||||
return {
|
||||
referenceNo: '',
|
||||
transportMode: 'FCL',
|
||||
businessType: 'EXPORT',
|
||||
origin: '',
|
||||
destination: '',
|
||||
receivingWarehouseId: '',
|
||||
expectedDepartureDate: '',
|
||||
shipperPartyId: '',
|
||||
shipperPartySnapshot: null,
|
||||
shipper: '',
|
||||
consigneePartyId: '',
|
||||
consigneePartySnapshot: null,
|
||||
consignee: '',
|
||||
priority: 'NORMAL',
|
||||
customerRemark: '',
|
||||
tradeTerm: 'FOB',
|
||||
destinationCountryCode: 'US',
|
||||
destinationWarehouseName: '',
|
||||
pickupMethod: 'SELF_EXPRESS',
|
||||
channelCode: 'SEA-US-FBA',
|
||||
channelName: '美线 FBA 直送',
|
||||
taxMode: 'BUY_DECLARATION',
|
||||
customsInspection: false,
|
||||
destinationType: 'FBA',
|
||||
amazonWarehouseCode: '',
|
||||
shipmentId: '',
|
||||
items: [createEmptyCargoItem(`new-cargo-${++cargoSequence}`)],
|
||||
}
|
||||
}
|
||||
|
||||
const form = reactive<FirstLegOrderDraft>(emptyDraft())
|
||||
const serializedDraft = computed(() => JSON.stringify(form))
|
||||
const editorDirty = computed(() => (editorOpen.value || isCreatePage.value) && serializedDraft.value !== editorBaseline.value)
|
||||
|
||||
const filteredOrders = computed(() => store.orders.filter((order) => {
|
||||
const keywordValue = appliedFilters.value.keyword
|
||||
const statusValue = appliedFilters.value.status
|
||||
const transportModeValue = appliedFilters.value.transportMode
|
||||
const createdRangeValue = appliedFilters.value.createdRange
|
||||
const keyword = typeof keywordValue === 'string' ? keywordValue.trim().toLowerCase() : ''
|
||||
const status = typeof statusValue === 'string' ? statusValue as FirstLegOrderStatus | '' : ''
|
||||
const transportMode = typeof transportModeValue === 'string' ? transportModeValue : ''
|
||||
const createdRange = Array.isArray(createdRangeValue) ? createdRangeValue.filter((value): value is string => typeof value === 'string') : []
|
||||
const matchesKeyword = !keyword || [order.orderNo, order.referenceNo, order.origin, order.destination, order.items.map((item) => item.cargoNameCn).join(' ')]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(keyword))
|
||||
const matchesStatus = !status || order.status === status
|
||||
const matchesMode = !transportMode || order.transportMode === transportMode
|
||||
const matchesDate = createdRange.length !== 2 || (order.createdAt.slice(0, 10) >= createdRange[0] && order.createdAt.slice(0, 10) <= createdRange[1])
|
||||
return matchesKeyword && matchesStatus && matchesMode && matchesDate
|
||||
}))
|
||||
const pagedOrders = computed(() => {
|
||||
const start = (tablePage.value - 1) * tablePageSize.value
|
||||
return filteredOrders.value.slice(start, start + tablePageSize.value)
|
||||
})
|
||||
|
||||
const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined)
|
||||
const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null)
|
||||
const selectedWarehouse = computed(() => availableWarehouses.value.find((item) => item.id === form.receivingWarehouseId))
|
||||
const hasActiveFilters = computed(() => {
|
||||
const values = appliedFilters.value
|
||||
return Boolean(
|
||||
(typeof values.keyword === 'string' && values.keyword.trim())
|
||||
|| values.status
|
||||
|| values.transportMode
|
||||
|| (Array.isArray(values.createdRange) && values.createdRange.length),
|
||||
)
|
||||
})
|
||||
const emptyDescription = computed(() => hasActiveFilters.value ? '没有符合筛选条件的头程订单' : '还没有头程订单')
|
||||
|
||||
async function loadPageData() {
|
||||
supportingDataLoading.value = true
|
||||
supportingDataErrors.value = []
|
||||
availableShippers.value = []
|
||||
availableConsignees.value = []
|
||||
|
||||
const companyRequest = auth.hasPermission('company:profile:view')
|
||||
? tenantStore.loadCompanyContext()
|
||||
: Promise.resolve()
|
||||
const [ordersResult, partiesResult, companyResult] = await Promise.allSettled([
|
||||
store.load(),
|
||||
firstLegPartySource.load(),
|
||||
companyRequest,
|
||||
])
|
||||
|
||||
if (partiesResult.status === 'fulfilled') {
|
||||
availableShippers.value = firstLegPartySource.listAvailable('SHIPPER')
|
||||
availableConsignees.value = firstLegPartySource.listAvailable('CONSIGNEE')
|
||||
} else {
|
||||
supportingDataErrors.value.push(`往来单位加载失败:${errorMessage(partiesResult.reason)}`)
|
||||
}
|
||||
if (companyResult.status === 'rejected') {
|
||||
supportingDataErrors.value.push(`企业资料加载失败:${errorMessage(companyResult.reason)}`)
|
||||
}
|
||||
if (ordersResult.status === 'rejected' && !store.loadError) {
|
||||
supportingDataErrors.value.push(`订单与仓库加载失败:${errorMessage(ordersResult.reason)}`)
|
||||
}
|
||||
supportingDataLoading.value = false
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '服务暂时不可用'
|
||||
}
|
||||
|
||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!editorDirty.value) return
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
void loadPageData()
|
||||
})
|
||||
|
||||
watch(isCreatePage, (isCreate, wasCreate) => {
|
||||
if (!isCreate || wasCreate) return
|
||||
editingId.value = undefined
|
||||
replaceForm(emptyDraft())
|
||||
resetEditorValidation()
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnload))
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (!editorDirty.value) return true
|
||||
return confirmDiscardChanges()
|
||||
})
|
||||
|
||||
function markEditorClean() {
|
||||
editorBaseline.value = serializedDraft.value
|
||||
}
|
||||
|
||||
function resetEditorValidation() {
|
||||
void nextTick(() => orderFormRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
function cloneDraft(draft: FirstLegOrderDraft): FirstLegOrderDraft {
|
||||
return {
|
||||
...draft,
|
||||
shipperPartySnapshot: draft.shipperPartySnapshot ? { ...draft.shipperPartySnapshot } : null,
|
||||
consigneePartySnapshot: draft.consigneePartySnapshot ? { ...draft.consigneePartySnapshot } : null,
|
||||
items: draft.items.map((item) => ({ ...item })),
|
||||
}
|
||||
}
|
||||
|
||||
function replaceForm(draft: FirstLegOrderDraft) {
|
||||
Object.assign(form, cloneDraft(draft))
|
||||
markEditorClean()
|
||||
}
|
||||
|
||||
function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
|
||||
return {
|
||||
referenceNo: order.referenceNo,
|
||||
transportMode: order.transportMode,
|
||||
businessType: order.businessType,
|
||||
origin: order.origin,
|
||||
destination: order.destination,
|
||||
receivingWarehouseId: order.receivingWarehouseId,
|
||||
expectedDepartureDate: order.expectedDepartureDate,
|
||||
shipperPartyId: order.shipperPartyId,
|
||||
shipperPartySnapshot: order.shipperPartySnapshot ? { ...order.shipperPartySnapshot } : null,
|
||||
shipper: order.shipper,
|
||||
consigneePartyId: order.consigneePartyId,
|
||||
consigneePartySnapshot: order.consigneePartySnapshot ? { ...order.consigneePartySnapshot } : null,
|
||||
consignee: order.consignee,
|
||||
priority: order.priority,
|
||||
customerRemark: order.customerRemark,
|
||||
tradeTerm: order.tradeTerm,
|
||||
destinationCountryCode: order.destinationCountryCode,
|
||||
destinationWarehouseName: order.destinationWarehouseName,
|
||||
pickupMethod: order.pickupMethod,
|
||||
channelCode: order.channelCode,
|
||||
channelName: order.channelName,
|
||||
taxMode: order.taxMode,
|
||||
customsInspection: order.customsInspection,
|
||||
destinationType: order.destinationType,
|
||||
amazonWarehouseCode: order.amazonWarehouseCode,
|
||||
shipmentId: order.shipmentId,
|
||||
items: order.items.map((item) => ({ ...item })),
|
||||
}
|
||||
}
|
||||
|
||||
function selectParty(role: FirstLegPartyRole, id: string) {
|
||||
const party = firstLegPartySource.findAvailable(id, role)
|
||||
if (!party) return
|
||||
const snapshot = createFirstLegPartySnapshot(party, role)
|
||||
if (role === 'SHIPPER') {
|
||||
form.shipperPartyId = party.id
|
||||
form.shipperPartySnapshot = snapshot
|
||||
form.shipper = party.name
|
||||
form.origin = [snapshot.province, snapshot.city].filter(Boolean).join(' ') || snapshot.countryRegion || snapshot.name
|
||||
} else {
|
||||
form.consigneePartyId = party.id
|
||||
form.consigneePartySnapshot = snapshot
|
||||
form.consignee = party.name
|
||||
form.destination = [snapshot.province, snapshot.city].filter(Boolean).join(' ') || snapshot.countryRegion || snapshot.name
|
||||
if (/^[A-Za-z]{2}$/.test(snapshot.countryRegion)) form.destinationCountryCode = snapshot.countryRegion.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
function clearParty(role: FirstLegPartyRole) {
|
||||
if (role === 'SHIPPER') {
|
||||
form.shipperPartyId = ''
|
||||
form.shipperPartySnapshot = null
|
||||
form.shipper = ''
|
||||
form.origin = ''
|
||||
} else {
|
||||
form.consigneePartyId = ''
|
||||
form.consigneePartySnapshot = null
|
||||
form.consignee = ''
|
||||
form.destination = ''
|
||||
}
|
||||
}
|
||||
|
||||
function selectChannel(code: string) {
|
||||
const channel = channelOptions.find((item) => item.code === code)
|
||||
form.channelName = channel?.name ?? ''
|
||||
}
|
||||
|
||||
function syncAmazonWarehouse(value: string) {
|
||||
if (form.destinationType === 'FBA') form.destinationWarehouseName = value.trim()
|
||||
}
|
||||
|
||||
function selectShipper(id: string) {
|
||||
selectParty('SHIPPER', id)
|
||||
}
|
||||
|
||||
function selectConsignee(id: string) {
|
||||
selectParty('CONSIGNEE', id)
|
||||
}
|
||||
|
||||
function clearShipper() {
|
||||
clearParty('SHIPPER')
|
||||
}
|
||||
|
||||
function clearConsignee() {
|
||||
clearParty('CONSIGNEE')
|
||||
}
|
||||
|
||||
async function loadSelectedWarehouse(id: string | number | boolean | undefined) {
|
||||
const warehouseId = typeof id === 'string' ? id : typeof id === 'number' ? String(id) : ''
|
||||
if (!warehouseId) return
|
||||
try {
|
||||
await store.loadWarehouseDetail(warehouseId)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '仓库地址与收货时间加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function openCreate() {
|
||||
if (availableWarehouses.value.length === 0) {
|
||||
ElMessage.warning('当前没有可用的平台收货仓,请联系平台维护仓库后再创建订单')
|
||||
return
|
||||
}
|
||||
editingId.value = undefined
|
||||
replaceForm(emptyDraft())
|
||||
resetEditorValidation()
|
||||
await router.push({ name: 'first-leg-order-create' })
|
||||
}
|
||||
|
||||
async function openEdit(order: FirstLegOrder) {
|
||||
if (openingEditor.value || !canEditOrder.value || !canClientEditFirstLegOrder(order.status)) return
|
||||
openingEditor.value = true
|
||||
try {
|
||||
const detailedOrder = await store.loadDetail(order.id)
|
||||
const draft = draftFromOrder(detailedOrder)
|
||||
const warehouseAvailable = availableWarehouses.value.some((item) => item.id === detailedOrder.receivingWarehouseId)
|
||||
if (!warehouseAvailable) {
|
||||
draft.receivingWarehouseId = ''
|
||||
ElMessage.warning('原平台收货仓当前不可用,请重新选择可用收货仓')
|
||||
}
|
||||
editingId.value = detailedOrder.id
|
||||
replaceForm(draft)
|
||||
if (warehouseAvailable) void loadSelectedWarehouse(detailedOrder.receivingWarehouseId)
|
||||
detailOpen.value = false
|
||||
editorOpen.value = true
|
||||
resetEditorValidation()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '订单详情加载失败,暂时无法编辑')
|
||||
} finally {
|
||||
openingEditor.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(order: FirstLegOrder) {
|
||||
detailId.value = order.id
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
detailError.value = ''
|
||||
try {
|
||||
await store.loadDetail(order.id)
|
||||
} catch (error) {
|
||||
detailError.value = error instanceof Error ? error.message : '订单详情加载失败'
|
||||
ElMessage.error(detailError.value)
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retryDetail() {
|
||||
const order = detailId.value ? store.getById(detailId.value) : undefined
|
||||
if (order) await openDetail(order)
|
||||
}
|
||||
|
||||
async function confirmDiscardChanges() {
|
||||
try {
|
||||
await ElMessageBox.confirm('当前订单还有未保存的修改。离开后这些内容将丢失。', '放弃未保存的修改?', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '放弃修改',
|
||||
cancelButtonText: '继续编辑',
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function requestCloseEditor(done: () => void) {
|
||||
if (saving.value) return
|
||||
if (!editorDirty.value || await confirmDiscardChanges()) done()
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
void requestCloseEditor(() => {
|
||||
editorOpen.value = false
|
||||
if (isCreatePage.value) {
|
||||
markEditorClean()
|
||||
void router.push({ name: 'first-leg-orders' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function addCargoItem() {
|
||||
form.items.push(createEmptyCargoItem(`new-cargo-${++cargoSequence}`))
|
||||
resetEditorValidation()
|
||||
}
|
||||
|
||||
function removeCargoItem(index: number) {
|
||||
if (form.items.length === 1) {
|
||||
ElMessage.warning('订单至少需要保留一条货物明细')
|
||||
return
|
||||
}
|
||||
form.items.splice(index, 1)
|
||||
resetEditorValidation()
|
||||
}
|
||||
|
||||
async function validateEditorForm() {
|
||||
let fieldsValid = false
|
||||
try {
|
||||
fieldsValid = await orderFormRef.value?.validate() ?? false
|
||||
} catch {
|
||||
fieldsValid = false
|
||||
}
|
||||
if (!fieldsValid) {
|
||||
ElMessage.warning('请检查并完善表单中标红的必填信息')
|
||||
return false
|
||||
}
|
||||
|
||||
const errors = validateFirstLegOrderDraft(form)
|
||||
if (errors.length > 0) {
|
||||
ElMessage.warning(errors[0])
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (saving.value) return
|
||||
if (!await validateEditorForm()) return
|
||||
saving.value = true
|
||||
try {
|
||||
const order = await store.saveDraft(cloneDraft(form), tenantStore.company.companyName, editingId.value)
|
||||
editingId.value = order.id
|
||||
markEditorClean()
|
||||
editorOpen.value = false
|
||||
if (isCreatePage.value) await router.push({ name: 'first-leg-orders' })
|
||||
ElMessage.success('订单草稿已保存')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '草稿保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOrder() {
|
||||
if (saving.value) return
|
||||
if (!companyApproved.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm('企业资料审核通过后才能提交订单。您可以继续保存草稿,或前往企业资料核对并补充信息。', '暂时无法提交订单', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '前往企业资料',
|
||||
cancelButtonText: '继续编辑草稿',
|
||||
})
|
||||
markEditorClean()
|
||||
editorOpen.value = false
|
||||
await router.push('/company/profile')
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
throw error
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!await validateEditorForm()) return
|
||||
|
||||
try {
|
||||
saving.value = true
|
||||
const order = await store.submit(cloneDraft(form), tenantStore.company.companyName, editingId.value, tenantStore.company.auditStatus)
|
||||
markEditorClean()
|
||||
editorOpen.value = false
|
||||
if (isCreatePage.value) await router.push({ name: 'first-leg-orders' })
|
||||
detailId.value = order.id
|
||||
detailOpen.value = true
|
||||
ElMessage.success('订单已提交,等待平台审核')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '订单提交失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function searchOrders(model: QueryModel) {
|
||||
const createdRange = model.createdRange
|
||||
appliedFilters.value = {
|
||||
...model,
|
||||
createdRange: Array.isArray(createdRange) ? createdRange.filter((value): value is string => typeof value === 'string') : [],
|
||||
}
|
||||
tablePage.value = 1
|
||||
try {
|
||||
await store.load()
|
||||
} catch {
|
||||
// Store keeps the recoverable error state rendered below the query toolbar.
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="first-leg-page" :class="{ 'is-create-route': isCreatePage }">
|
||||
<template v-if="!isCreatePage">
|
||||
<QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="头程订单查询" @search="searchOrders" />
|
||||
|
||||
<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" :disabled="availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>
|
||||
</div>
|
||||
<div v-if="!companyApproved && !supportingDataErrors.some((message) => message.startsWith('企业资料'))" class="order-eligibility is-pending" role="status">
|
||||
<AlertTriangle :size="20" />
|
||||
<div><strong>{{ canCreateOrder ? '企业资料待审核,当前仅可保存订单草稿' : '企业资料待审核,当前订单为只读' }}</strong><span>{{ canCreateOrder ? '平台审核通过后即可提交;您仍可创建、编辑和保存草稿。' : '您当前拥有只读权限,可以继续查看订单进展。' }}</span></div>
|
||||
<el-button v-if="!companyApproved && auth.hasPermission('company:profile:view')" link type="primary" @click="router.push('/company/profile')">查看企业资料</el-button>
|
||||
</div>
|
||||
<div v-if="supportingDataErrors.length" class="supporting-data-error" role="alert">
|
||||
<AlertTriangle :size="20" />
|
||||
<div><strong>部分订单配套数据加载失败</strong><span v-for="message in supportingDataErrors" :key="message">{{ message }}</span></div>
|
||||
<el-button :loading="supportingDataLoading || store.loading" @click="loadPageData">重新加载</el-button>
|
||||
</div>
|
||||
<div v-if="store.loadError" class="order-load-state" role="alert">
|
||||
<AlertTriangle :size="24" />
|
||||
<div><strong>订单数据加载失败</strong><span>{{ store.loadError }}</span></div>
|
||||
<el-button :loading="store.loading" @click="loadPageData">重新加载</el-button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="desktop-order-table">
|
||||
<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="orderColumns" :loading="store.loading" :total="filteredOrders.length" :page-sizes="[10, 20, 50]" row-key="id" empty-text="暂无头程订单">
|
||||
<template #actions="{ row }">
|
||||
<div class="order-row-actions">
|
||||
<el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #order="{ row }">
|
||||
<button class="order-number-link" type="button" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '未提交草稿' }}</button>
|
||||
<span class="order-reference">{{ (row as FirstLegOrder).referenceNo || '无客户参考号' }}</span>
|
||||
</template>
|
||||
<template #route="{ row }"><strong>{{ (row as FirstLegOrder).origin }} → {{ (row as FirstLegOrder).destination }}</strong><span class="order-reference">{{ getFirstLegTransportModeLabel((row as FirstLegOrder).transportMode) }}</span></template>
|
||||
<template #cargo="{ row }">{{ (row as FirstLegOrder).items[0]?.cargoNameCn || '-' }}<span v-if="(row as FirstLegOrder).items.length > 1" class="order-reference">等 {{ (row as FirstLegOrder).items.length }} 项</span></template>
|
||||
<template #status="{ row }"><el-tag :type="getFirstLegStatusTone((row as FirstLegOrder).status)" effect="light">{{ getFirstLegStatusLabel((row as FirstLegOrder).status) }}</el-tag></template>
|
||||
<template #operator="{ row }">{{ (row as FirstLegOrder).operatorName || '-' }}</template>
|
||||
<template #updatedAt="{ row }">{{ formatDateTime((row as FirstLegOrder).updatedAt) }}</template>
|
||||
<template #empty>
|
||||
<el-empty :description="emptyDescription">
|
||||
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓' }}</el-button>
|
||||
</el-empty>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
<div v-loading="store.loading" class="mobile-order-surface">
|
||||
<div v-if="filteredOrders.length" class="mobile-order-list">
|
||||
<article v-for="order in filteredOrders" :key="order.id" class="mobile-order-card">
|
||||
<header><button type="button" @click="openDetail(order)">{{ order.orderNo || '未提交草稿' }}</button><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></header>
|
||||
<span class="order-reference">{{ order.referenceNo || '无客户参考号' }}</span>
|
||||
<dl><div><dt>运输路线</dt><dd>{{ order.origin }} → {{ order.destination }}</dd></div><div><dt>收货仓</dt><dd>{{ order.receivingWarehouseName }}</dd></div><div><dt>货物</dt><dd>{{ order.items[0]?.cargoNameCn || '-' }}<span v-if="order.items.length > 1">等 {{ order.items.length }} 项</span></dd></div><div><dt>负责人</dt><dd>{{ order.operatorName || '-' }}</dd></div></dl>
|
||||
<footer><span>更新于 {{ formatDateTime(order.updatedAt) }}</span><div><el-button link type="primary" @click="openDetail(order)"><Eye :size="15" />详情</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" :loading="openingEditor" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button></div></footer>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty v-else :description="emptyDescription">
|
||||
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓' }}</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="isCreatePage && supportingDataErrors.length" class="supporting-data-error" role="alert">
|
||||
<AlertTriangle :size="20" />
|
||||
<div><strong>下单配套数据加载失败</strong><span v-for="message in supportingDataErrors" :key="message">{{ message }}</span></div>
|
||||
<el-button :loading="supportingDataLoading || store.loading" @click="loadPageData">重新加载</el-button>
|
||||
</div>
|
||||
<el-alert v-if="isCreatePage && !supportingDataLoading && availableWarehouses.length === 0" title="当前没有可用的平台收货仓" description="请联系平台维护并启用收货仓后再创建订单。" type="warning" :closable="false" show-icon />
|
||||
|
||||
<FirstLegOrderEditorShell v-model="editorOpen" :page="isCreatePage" :title="editingId ? '编辑订单草稿' : '创建头程订单'" :before-close="requestCloseEditor" @back="closeEditor">
|
||||
<el-form ref="orderFormRef" v-loading="isCreatePage && (supportingDataLoading || store.loading)" :model="form" :rules="orderFormRules" label-position="top" class="order-form prototype-order-form" scroll-to-error>
|
||||
<section class="order-form-section prototype-section">
|
||||
<div class="order-section-heading"><div><h3>下单信息</h3><p>选择送货方式、收货仓和运输渠道。</p></div></div>
|
||||
<div class="order-form-grid">
|
||||
<el-form-item label="取货方式" prop="pickupMethod" required class="form-span-full"><el-radio-group v-model="form.pickupMethod" class="segmented-options"><el-radio-button value="SELF_EXPRESS">自送货 · 快递</el-radio-button><el-radio-button value="PICKUP">我司提货</el-radio-button><el-radio-button value="SELF_TRUCK">自送货 · 卡车</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item label="平台收货仓" prop="receivingWarehouseId" required class="form-span-full"><el-radio-group v-model="form.receivingWarehouseId" class="warehouse-options" @change="loadSelectedWarehouse"><el-radio-button v-for="warehouse in availableWarehouses" :key="warehouse.id" :value="warehouse.id">{{ warehouse.city ? `${warehouse.name} · ${warehouse.city}` : warehouse.name }}</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item label="渠道" prop="channelCode" required><el-select v-model="form.channelCode" placeholder="请选择运输渠道" @change="selectChannel"><el-option v-if="form.channelCode && !channelOptions.some((channel) => channel.code === form.channelCode)" :label="form.channelName || form.channelCode" :value="form.channelCode" /><el-option v-for="channel in channelOptions" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select></el-form-item>
|
||||
<el-form-item label="买单 / 退税" prop="taxMode" required><el-radio-group v-model="form.taxMode"><el-radio value="BUY_DECLARATION">买单报关</el-radio><el-radio value="TAX_REFUND">退税报关</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item label="运输方式" prop="transportMode" required><el-radio-group v-model="form.transportMode"><el-radio v-for="item in FIRST_LEG_TRANSPORT_MODES" :key="item.code" :value="item.code">{{ item.label }}</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item label="是否商检" prop="customsInspection" required><el-radio-group v-model="form.customsInspection"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item label="期望出货日期"><el-date-picker v-model="form.expectedDepartureDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择期望出货日期" /></el-form-item>
|
||||
<el-form-item label="订单备注"><el-input v-model="form.customerRemark" maxlength="1000" placeholder="选填,例如:入仓前请电话联系" /></el-form-item>
|
||||
</div>
|
||||
<div v-if="selectedWarehouse" class="selected-warehouse" aria-label="所选收货仓详情"><MapPin :size="17" /><div><span>{{ selectedWarehouse.address ? `${selectedWarehouse.province}${selectedWarehouse.city} · ${selectedWarehouse.address}` : '仓库地址详情暂不可用' }}</span><small>{{ selectedWarehouse.businessHours ? `收货时间:${selectedWarehouse.businessHours}` : '收货时间暂不可用' }}</small></div></div>
|
||||
</section>
|
||||
|
||||
<section class="order-form-section prototype-section destination-section">
|
||||
<div class="order-section-heading"><div><h3>发货与目的地</h3><p>单位信息会保存为本订单快照,后续主数据变更不会影响本单。</p></div></div>
|
||||
<el-form-item label="目的仓类型" prop="destinationType" required><el-radio-group v-model="form.destinationType" class="segmented-options"><el-radio-button value="FBA">FBA 仓</el-radio-button><el-radio-button value="OVERSEAS">海外仓</el-radio-button><el-radio-button value="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item>
|
||||
<div class="party-select-grid">
|
||||
<div class="party-panel">
|
||||
<h4>发货信息</h4>
|
||||
<el-alert v-if="availableShippers.length === 0" title="暂无可用发货单位" description="请先在平台往来单位中启用“发货单位”类型。" type="warning" :closable="false" show-icon />
|
||||
<el-form-item label="发货单位" prop="shipperPartyId" required><el-select v-model="form.shipperPartyId" filterable clearable placeholder="请选择发货单位" @change="selectShipper" @clear="clearShipper"><el-option v-if="form.shipperPartySnapshot && !availableShippers.some((party) => party.id === form.shipperPartyId)" :label="`${form.shipperPartySnapshot.name} · 历史快照`" :value="form.shipperPartyId" disabled /><el-option v-for="party in availableShippers" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" /></el-select></el-form-item>
|
||||
<div v-if="form.shipperPartySnapshot" class="party-contact-grid">
|
||||
<el-form-item label="联系人" prop="shipperPartySnapshot.contactName" required :rules="requiredTextRules('请输入发货联系人姓名')"><el-input v-model="form.shipperPartySnapshot.contactName" maxlength="50" placeholder="请输入联系人" /></el-form-item>
|
||||
<el-form-item label="联系电话" prop="shipperPartySnapshot.phone" required :rules="requiredTextRules('请输入发货联系电话')"><el-input v-model="form.shipperPartySnapshot.phone" maxlength="30" placeholder="请输入联系电话" /></el-form-item>
|
||||
<el-form-item label="发货地址" prop="shipperPartySnapshot.address" required :rules="requiredTextRules('请输入本单实际发货地址')" class="form-span-full"><el-input v-model="form.shipperPartySnapshot.address" maxlength="300" placeholder="请输入本单实际发货地址" /></el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="party-panel">
|
||||
<h4>目的地信息</h4>
|
||||
<el-alert v-if="availableConsignees.length === 0" title="暂无可用收货单位" description="请先在平台往来单位中启用“收货单位”类型。" type="warning" :closable="false" show-icon />
|
||||
<el-form-item label="收货单位" prop="consigneePartyId" required><el-select v-model="form.consigneePartyId" filterable clearable placeholder="请选择收货单位" @change="selectConsignee" @clear="clearConsignee"><el-option v-if="form.consigneePartySnapshot && !availableConsignees.some((party) => party.id === form.consigneePartyId)" :label="`${form.consigneePartySnapshot.name} · 历史快照`" :value="form.consigneePartyId" disabled /><el-option v-for="party in availableConsignees" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" /></el-select></el-form-item>
|
||||
<div v-if="form.consigneePartySnapshot" class="party-contact-grid">
|
||||
<el-form-item label="收件人" prop="consigneePartySnapshot.contactName" required :rules="requiredTextRules('请输入收货联系人姓名')"><el-input v-model="form.consigneePartySnapshot.contactName" maxlength="50" placeholder="请输入收件人姓名" /></el-form-item>
|
||||
<el-form-item label="联系电话" prop="consigneePartySnapshot.phone" required :rules="requiredTextRules('请输入收货联系电话')"><el-input v-model="form.consigneePartySnapshot.phone" maxlength="30" placeholder="请输入收件人电话" /></el-form-item>
|
||||
<el-form-item label="收件地址" prop="consigneePartySnapshot.address" required :rules="requiredTextRules('请输入本单实际收货地址')" class="form-span-full"><el-input v-model="form.consigneePartySnapshot.address" maxlength="300" placeholder="请输入收件详细地址" /></el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="destination-fields">
|
||||
<el-form-item label="目的国代码" prop="destinationCountryCode" required><el-input v-model="form.destinationCountryCode" maxlength="2" placeholder="例如:US" /></el-form-item>
|
||||
<el-form-item v-if="form.destinationType === 'FBA'" label="亚马逊仓代码" prop="amazonWarehouseCode" required :rules="requiredTextRules('请输入 Amazon 仓库编码')"><el-input v-model="form.amazonWarehouseCode" maxlength="32" placeholder="例如:ONT8" @input="syncAmazonWarehouse" /></el-form-item>
|
||||
<el-form-item v-else label="目的仓名称" prop="destinationWarehouseName" required><el-input v-model="form.destinationWarehouseName" maxlength="128" placeholder="请输入海外仓或私人仓名称" /></el-form-item>
|
||||
<el-form-item v-if="form.destinationType === 'FBA'" label="货件编号" prop="shipmentId" required :rules="requiredTextRules('请输入 Shipment ID')"><el-input v-model="form.shipmentId" maxlength="64" placeholder="Shipment ID" /></el-form-item>
|
||||
<el-form-item label="跟踪编号" prop="referenceNo" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 订单请输入 Reference ID') : undefined"><el-input v-model="form.referenceNo" maxlength="100" :placeholder="form.destinationType === 'FBA' ? 'Reference ID' : '选填,贵司内部跟踪编号'" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="order-form-section prototype-section cargo-section">
|
||||
<div class="order-section-heading"><div><h3>货物明细</h3><p>先填写常用字段,申报和整柜信息可按需展开。</p></div><el-button @click="addCargoItem"><Plus :size="16" />添加货物</el-button></div>
|
||||
<article v-for="(item, index) in form.items" :key="item.id" class="cargo-entry">
|
||||
<header><strong>货物 {{ index + 1 }}</strong><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="16" />删除</el-button></header>
|
||||
<div class="cargo-primary-grid">
|
||||
<el-form-item label="SKU" :prop="`items.${index}.sku`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入客户 SKU') : undefined"><el-input v-model="item.sku" placeholder="SKU NO." /></el-form-item>
|
||||
<el-form-item label="中文品名" :prop="`items.${index}.cargoNameCn`" required :rules="requiredTextRules('请输入中文品名')"><el-input v-model="item.cargoNameCn" placeholder="例如:蓝牙音箱" /></el-form-item>
|
||||
<el-form-item label="英文品名"><el-input v-model="item.cargoNameEn" placeholder="Bluetooth speakers" /></el-form-item>
|
||||
<el-form-item v-if="form.destinationType === 'FBA'" label="UPC" :prop="`items.${index}.upc`" required :rules="requiredTextRules('FBA 货物请输入 UPC')"><el-input v-model="item.upc" maxlength="32" placeholder="UPC NO." /></el-form-item>
|
||||
<el-form-item label="包装类型" :prop="`items.${index}.packageType`" required :rules="requiredSelectRules('请选择包装类型')"><el-select v-model="item.packageType"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item>
|
||||
<el-form-item label="总数量" :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('件数必须为大于 0 的整数', true)"><el-input-number v-model="item.pieces" :min="1" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="箱数"><el-input-number v-model="item.packageCount" :min="0" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="总毛重 kg" :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="2" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="总体积 m³" :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="3" controls-position="right" /></el-form-item>
|
||||
</div>
|
||||
<el-collapse class="cargo-more"><el-collapse-item title="更多申报信息"><div class="cargo-secondary-grid">
|
||||
<el-form-item label="HS Code"><el-input v-model="item.hsCode" maxlength="20" placeholder="例如:8518210000" /></el-form-item>
|
||||
<el-form-item label="申报货值"><el-input-number v-model="item.declaredValue" :min="0" :precision="2" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="币种"><el-input v-model="item.currency" maxlength="3" placeholder="例如:USD" /></el-form-item>
|
||||
<el-form-item label="危险品"><el-switch v-model="item.dangerousGoods" /></el-form-item>
|
||||
<template v-if="form.transportMode === 'FCL'"><el-form-item label="箱型" :prop="`items.${index}.containerType`" required :rules="requiredSelectRules('请选择箱型')"><el-select v-model="item.containerType"><el-option v-for="type in ['20GP', '40GP', '40HQ']" :key="type" :label="type" :value="type" /></el-select></el-form-item><el-form-item label="柜量" :prop="`items.${index}.containerCount`" required :rules="positiveNumberRules('柜量必须为大于 0 的整数', true)"><el-input-number v-model="item.containerCount" :min="0" controls-position="right" /></el-form-item></template>
|
||||
<el-form-item label="货物备注" class="form-span-full"><el-input v-model="item.remark" maxlength="300" placeholder="选填,例如:注意防潮" /></el-form-item>
|
||||
</div></el-collapse-item></el-collapse>
|
||||
</article>
|
||||
<p class="cargo-tip">FBA 货物必须填写 SKU、UPC、货件编号和跟踪编号;提交前请核对重量与体积。</p>
|
||||
</section>
|
||||
</el-form>
|
||||
<template #footer><div class="editor-actions"><el-button :disabled="saving" @click="closeEditor">返回</el-button><el-button v-if="editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" />{{ companyApproved ? '提交订单' : '提交前需完成资料审核' }}</el-button></div></template>
|
||||
</FirstLegOrderEditorShell>
|
||||
|
||||
<el-drawer v-model="detailOpen" class="first-leg-detail" title="订单详情" size="680px">
|
||||
<div v-loading="detailLoading" class="order-detail-content">
|
||||
<div v-if="detailError" class="order-load-state is-compact" role="alert">
|
||||
<AlertTriangle :size="24" />
|
||||
<div><strong>订单详情加载失败</strong><span>{{ detailError }}</span></div>
|
||||
<el-button :loading="detailLoading" @click="retryDetail">重新加载</el-button>
|
||||
</div>
|
||||
<template v-else-if="detailOrder">
|
||||
<div class="order-detail-heading">
|
||||
<div><span>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</span><h2>{{ detailOrder.orderNo || '未提交草稿' }}</h2><p>{{ detailOrder.origin }} → {{ detailOrder.destination }}</p></div>
|
||||
<el-tag :type="getFirstLegStatusTone(detailOrder.status)" effect="light">{{ getFirstLegStatusLabel(detailOrder.status) }}</el-tag>
|
||||
</div>
|
||||
<dl class="order-detail-grid">
|
||||
<div><dt>客户参考号</dt><dd>{{ detailOrder.referenceNo || '-' }}</dd></div><div><dt>运输方式</dt><dd>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</dd></div>
|
||||
<div><dt>发货方</dt><dd>{{ detailOrder.shipperPartySnapshot?.name || detailOrder.shipper }}<span v-if="detailOrder.shipperPartySnapshot" class="detail-subline">{{ detailOrder.shipperPartySnapshot.code }} · {{ detailOrder.shipperPartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.shipperPartySnapshot.phone || '未填写电话' }}<br />{{ detailOrder.shipperPartySnapshot.countryRegion }} {{ detailOrder.shipperPartySnapshot.province }} {{ detailOrder.shipperPartySnapshot.city }} {{ detailOrder.shipperPartySnapshot.address }}</span></dd></div>
|
||||
<div><dt>收货方</dt><dd>{{ detailOrder.consigneePartySnapshot?.name || detailOrder.consignee }}<span v-if="detailOrder.consigneePartySnapshot" class="detail-subline">{{ detailOrder.consigneePartySnapshot.code }} · {{ detailOrder.consigneePartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.consigneePartySnapshot.phone || '未填写电话' }}<br />{{ detailOrder.consigneePartySnapshot.countryRegion }} {{ detailOrder.consigneePartySnapshot.province }} {{ detailOrder.consigneePartySnapshot.city }} {{ detailOrder.consigneePartySnapshot.address }}</span></dd></div>
|
||||
<div><dt>平台收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div><div><dt>负责人</dt><dd>{{ detailOrder.operatorName || '未提供' }}</dd></div>
|
||||
<div><dt>期望出发</dt><dd>{{ detailOrder.expectedDepartureDate || '-' }}</dd></div><div><dt>ETD / ETA</dt><dd>{{ detailOrder.etd || '-' }} / {{ detailOrder.eta || '-' }}</dd></div>
|
||||
</dl>
|
||||
<section class="detail-section">
|
||||
<h3>履约快递信息</h3>
|
||||
<div v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><PackageCheck :size="20" /><div><span>快递公司</span><strong>{{ detailOrder.expressCompanyName || '待平台补充' }}</strong></div><div><span>快递单号</span><strong>{{ detailOrder.trackingNumber || '待平台补充' }}</strong></div></div>
|
||||
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>平台尚未维护快递信息</strong><span>快递公司和快递单号由平台后台维护,客户端仅只读展示。</span></div></div>
|
||||
</section>
|
||||
<section class="detail-section"><h3>货物汇总</h3><div v-if="detailSummary" class="cargo-summary"><div><Box :size="18" /><span>明细</span><strong>{{ detailOrder.items.length }} 项</strong></div><div><span>件数</span><strong>{{ detailSummary.pieces }}</strong></div><div><span>毛重</span><strong>{{ detailSummary.grossWeightKg.toLocaleString() }} kg</strong></div><div><span>体积</span><strong>{{ detailSummary.volumeCbm }} m³</strong></div></div><ul class="cargo-detail-list"><li v-for="item in detailOrder.items" :key="item.id"><strong>{{ item.cargoNameCn }}</strong><span>{{ item.sku || '无 SKU' }} · {{ item.pieces }} 件 · {{ item.grossWeightKg }} kg</span></li></ul></section>
|
||||
<section class="detail-section"><h3>处理时间线</h3><el-timeline><el-timeline-item v-for="item in [...detailOrder.timeline].reverse()" :key="item.id" :timestamp="item.occurredAt" placement="top"><strong>{{ item.title }}</strong><p>{{ item.description || '-' }}</p><span>{{ item.operatorName || '未提供' }}</span></el-timeline-item></el-timeline></section>
|
||||
</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>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.first-leg-page { display: flex; flex-direction: column; gap: 14px; }
|
||||
:global(.client-main:has(> .first-leg-page:not(.is-create-route)) > .client-page-heading) { margin-bottom: 0; }
|
||||
:global(.client-main:has(> .first-leg-page:not(.is-create-route)) > .client-page-heading > div) { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; border: 0; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.first-leg-query-toolbar { flex-wrap: nowrap; }
|
||||
.first-leg-query-toolbar :deep(.query-toolbar__filters) { display: grid; min-width: 0; flex: 1 1 auto; grid-template-columns: minmax(160px, 1.4fr) minmax(100px, .72fr) minmax(100px, .72fr) minmax(190px, 1.15fr); }
|
||||
.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; }
|
||||
.order-eligibility { display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px; border: 1px solid; }
|
||||
.order-eligibility svg { flex: 0 0 20px; margin-top: 1px; }
|
||||
.order-eligibility div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
|
||||
.order-eligibility strong { color: var(--navy); font-size: 13px; }
|
||||
.order-eligibility span { color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||
.order-eligibility.is-pending { border-color: #e5d2a5; background: #fff9eb; color: #9a6a12; }
|
||||
.supporting-data-error { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 14px 16px; border: 1px solid #e5d2a5; background: #fff9eb; color: #9a6a12; }
|
||||
.supporting-data-error > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.supporting-data-error strong { color: var(--navy); font-size: 13px; }
|
||||
.supporting-data-error span { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||
.order-row-actions, .editor-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.order-table-card { padding: 0; overflow: hidden; }
|
||||
.order-table-toolbar { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 16px; border-bottom: 1px solid var(--border); }
|
||||
.order-table-toolbar > div { display: flex; min-width: 0; align-items: baseline; gap: 8px; }
|
||||
.order-table-toolbar strong { color: var(--navy); font-size: 14px; }
|
||||
.order-table-toolbar span { color: var(--muted); font-size: 11px; }
|
||||
.order-table-card > .order-eligibility, .order-table-card > .supporting-data-error { border-width: 0 0 1px; }
|
||||
.order-load-state { display: grid; min-height: 220px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 28px; color: #9a5a09; }
|
||||
.order-load-state > div { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
|
||||
.order-load-state strong { color: var(--navy); font-size: 14px; }
|
||||
.order-load-state span { overflow-wrap: anywhere; color: var(--muted); font-size: 12px; line-height: 1.55; }
|
||||
.order-load-state.is-compact { min-height: 180px; }
|
||||
.order-detail-content { min-height: 180px; }
|
||||
.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: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-form { width: min(100%, 1000px); margin: 0 auto; padding: 0 0 12px; }
|
||||
.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: 24px; padding-top: 22px; border-top: 1px solid var(--border); }
|
||||
.order-form-section h3 { margin: 0 0 16px; color: var(--navy); font-size: 16px; }
|
||||
.order-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 18px; }
|
||||
.order-form-grid > *, .destination-fields > *, .party-contact-grid > *, .cargo-primary-grid > *, .cargo-secondary-grid > * { min-width: 0; }
|
||||
.order-form :deep(.el-select), .order-form :deep(.el-date-editor), .order-form :deep(.el-input-number) { width: 100%; }
|
||||
.form-span-full { grid-column: 1 / -1; }
|
||||
.prototype-section { padding: 2px 2px 0; }
|
||||
.segmented-options { display: flex; width: min(100%, 620px); }
|
||||
.segmented-options :deep(.el-radio-button) { flex: 1; }
|
||||
.segmented-options :deep(.el-radio-button__inner) { width: 100%; min-height: 42px; padding: 11px 16px; }
|
||||
.warehouse-options { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.warehouse-options :deep(.el-radio-button__inner) { min-height: 40px; border: 1px solid var(--border); border-radius: 6px !important; box-shadow: none !important; }
|
||||
.warehouse-options :deep(.el-radio-button.is-active .el-radio-button__inner) { border-color: var(--primary); }
|
||||
.selected-warehouse { display: flex; align-items: flex-start; gap: 10px; margin-top: 0; padding: 11px 13px; border: 1px solid #d9def8; background: #f6f7fe; color: var(--primary); }
|
||||
.selected-warehouse svg { flex: 0 0 17px; margin-top: 2px; }
|
||||
.selected-warehouse div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
|
||||
.selected-warehouse span, .selected-warehouse small { color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||
.order-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.order-section-heading h3 { margin-bottom: 5px; }
|
||||
.order-section-heading p { margin: 0; color: var(--muted); font-size: 11px; }
|
||||
.party-select-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.party-panel { min-width: 0; padding: 16px; border: 1px solid var(--border); background: #fbfcfc; }
|
||||
.party-panel h4 { margin: 0 0 14px; color: var(--navy); font-size: 14px; }
|
||||
.party-panel :deep(.el-alert) { margin-bottom: 14px; }
|
||||
.party-contact-grid, .destination-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 12px; }
|
||||
.destination-fields { margin-top: 16px; }
|
||||
.cargo-entry { padding: 16px; border: 1px solid var(--border); background: #fbfcfc; }
|
||||
.cargo-entry + .cargo-entry { margin-top: 12px; }
|
||||
.cargo-entry > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.cargo-primary-grid { display: grid; grid-template-columns: repeat(5, minmax(130px, 1fr)); column-gap: 12px; }
|
||||
.cargo-secondary-grid { display: grid; grid-template-columns: repeat(4, minmax(130px, 1fr)); column-gap: 12px; padding-top: 6px; }
|
||||
.cargo-more { border-top: 1px solid var(--border); border-bottom: 0; }
|
||||
.cargo-more :deep(.el-collapse-item__header), .cargo-more :deep(.el-collapse-item__wrap) { background: transparent; }
|
||||
.cargo-more :deep(.el-collapse-item__content) { padding-bottom: 0; }
|
||||
.cargo-tip { margin: 12px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||
.order-detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
|
||||
.order-detail-heading span { color: var(--muted); font-size: 11px; }
|
||||
.order-detail-heading h2 { margin: 6px 0; font-size: 21px; }
|
||||
.order-detail-heading p { margin: 0; color: var(--muted); }
|
||||
.order-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; margin: 18px 0; border: 1px solid var(--border); }
|
||||
.order-detail-grid div { padding: 13px; border-bottom: 1px solid var(--border); }
|
||||
.order-detail-grid div:nth-child(odd) { border-right: 1px solid var(--border); }
|
||||
.order-detail-grid dt { color: var(--muted); font-size: 11px; }
|
||||
.order-detail-grid dd { margin: 5px 0 0; font-size: 13px; font-weight: 600; }
|
||||
.detail-subline { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; font-weight: 400; line-height: 1.5; }
|
||||
.detail-section { margin-top: 22px; }
|
||||
.detail-section h3 { margin: 0 0 12px; font-size: 15px; }
|
||||
.express-information { display: grid; grid-template-columns: auto repeat(2, minmax(0, 1fr)); align-items: center; gap: 16px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); }
|
||||
.express-information > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.express-information span { color: var(--muted); font-size: 11px; }
|
||||
.express-information strong { overflow-wrap: anywhere; color: var(--navy); font-size: 13px; }
|
||||
.express-empty { display: flex; align-items: flex-start; gap: 12px; padding: 15px 16px; border: 1px dashed #cbd5da; color: var(--muted); }
|
||||
.express-empty svg { flex: 0 0 20px; color: var(--primary); }
|
||||
.express-empty div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.express-empty strong { color: var(--navy); font-size: 13px; }
|
||||
.express-empty span { font-size: 11px; line-height: 1.5; }
|
||||
.cargo-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--border); }
|
||||
.cargo-summary div { display: flex; min-height: 74px; align-items: center; justify-content: center; flex-direction: column; gap: 4px; border-right: 1px solid var(--border); }
|
||||
.cargo-summary div:last-child { border-right: 0; }
|
||||
.cargo-summary span { color: var(--muted); font-size: 11px; }
|
||||
.cargo-detail-list { margin: 10px 0 0; padding: 0; list-style: none; }
|
||||
.cargo-detail-list li { display: flex; justify-content: space-between; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
|
||||
.cargo-detail-list span, :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) {
|
||||
.desktop-order-table { display: none; }
|
||||
.mobile-order-surface { display: block; }
|
||||
.mobile-order-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 10px; }
|
||||
.mobile-order-card { min-width: 0; padding: 14px; border: 1px solid var(--border); background: #fff; }
|
||||
.mobile-order-card header, .mobile-order-card footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.mobile-order-card header button { min-width: 0; overflow: hidden; padding: 0; border: 0; background: none; color: var(--primary); font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mobile-order-card header button:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
|
||||
.mobile-order-card dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
|
||||
.mobile-order-card dl div { min-width: 0; }
|
||||
.mobile-order-card dt, .mobile-order-card footer > span { color: var(--muted); font-size: 10px; }
|
||||
.mobile-order-card dd { overflow: hidden; margin: 4px 0 0; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mobile-order-card dd span { margin-left: 4px; color: var(--muted); font-weight: 400; }
|
||||
.mobile-order-card footer { align-items: flex-end; padding-top: 10px; border-top: 1px solid var(--border); }
|
||||
.mobile-order-card footer > div { display: flex; }
|
||||
}
|
||||
@media (min-width: 721px) and (max-width: 1050px) {
|
||||
.first-leg-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: repeat(2, minmax(140px, 1fr)); }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.first-leg-query-toolbar { flex-wrap: wrap; }
|
||||
.first-leg-query-toolbar :deep(.query-toolbar__filters) { display: flex; }
|
||||
:global(.client-main:has(> .first-leg-page:not(.is-create-route)) > .client-page-heading) { margin-bottom: 10px; }
|
||||
.order-table-toolbar { align-items: stretch; flex-direction: column; gap: 10px; }
|
||||
.order-table-toolbar > div { justify-content: space-between; }
|
||||
.order-table-toolbar .el-button { width: 100%; min-height: 40px; }
|
||||
.order-eligibility { flex-wrap: wrap; }
|
||||
.order-eligibility .el-button { width: 100%; min-height: 44px; }
|
||||
.order-form-grid, .order-detail-grid, .party-select-grid, .party-contact-grid, .destination-fields, .cargo-primary-grid, .cargo-secondary-grid { grid-template-columns: 1fr; }
|
||||
.segmented-options { width: 100%; align-items: stretch; flex-direction: column; }
|
||||
.segmented-options :deep(.el-radio-button__inner) { border: 1px solid var(--border); border-radius: 6px !important; box-shadow: none !important; }
|
||||
.warehouse-options { display: grid; width: 100%; grid-template-columns: 1fr; }
|
||||
.warehouse-options :deep(.el-radio-button), .warehouse-options :deep(.el-radio-button__inner) { width: 100%; }
|
||||
: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; }
|
||||
.order-load-state { min-height: 240px; grid-template-columns: 1fr; justify-items: start; }
|
||||
.supporting-data-error { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.supporting-data-error .el-button { grid-column: 1 / -1; width: 100%; min-height: 44px; }
|
||||
.order-load-state .el-button { width: 100%; min-height: 44px; }
|
||||
.mobile-order-list { grid-template-columns: 1fr; }
|
||||
.mobile-order-card { padding: 14px; border: 1px solid var(--border); background: #fff; }
|
||||
.mobile-order-card header, .mobile-order-card footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.mobile-order-card header button { padding: 0; border: 0; background: none; color: var(--primary); font-weight: 750; }
|
||||
.mobile-order-card header button:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
|
||||
.mobile-order-card dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
|
||||
.mobile-order-card dl div { min-width: 0; }
|
||||
.mobile-order-card dt, .mobile-order-card footer > span { color: var(--muted); font-size: 10px; }
|
||||
.mobile-order-card dd { overflow: hidden; margin: 4px 0 0; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mobile-order-card dd span { margin-left: 4px; color: var(--muted); font-weight: 400; }
|
||||
.mobile-order-card footer { align-items: flex-end; padding-top: 10px; border-top: 1px solid var(--border); }
|
||||
.mobile-order-card footer > div { display: flex; }
|
||||
.order-detail-grid div:nth-child(odd) { border-right: 0; }
|
||||
.express-information { grid-template-columns: auto 1fr; }
|
||||
.express-information > div:last-child { grid-column: 2; }
|
||||
.cargo-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cargo-summary div:nth-child(2) { border-right: 0; }
|
||||
.cargo-summary div:nth-child(-n+2) { border-bottom: 1px solid var(--border); }
|
||||
.cargo-detail-list li { flex-direction: column; }
|
||||
:global(.first-leg-editor), :global(.first-leg-detail) { width: 100% !important; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.editor-actions { display: grid; width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.editor-actions :deep(.el-button) { width: 100%; min-width: 0; margin-left: 0; }
|
||||
.editor-actions :deep(.el-button:nth-child(3)) { grid-column: 1 / -1; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/views/prototype/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="快速下单" description="快速创建头程订单" /></template>
|
||||
Reference in New Issue
Block a user