优化构架

This commit is contained in:
2026-08-03 18:52:21 +08:00
parent 3e347dd7a5
commit 35c713a7ca
51 changed files with 0 additions and 0 deletions
+229
View File
@@ -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}`
}
+33
View File
@@ -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>