756 lines
49 KiB
Vue
756 lines
49 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { AlertTriangle, Box, Building2, CheckCircle2, Eye, FilePlus2, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Search, Send, Trash2 } from '@lucide/vue'
|
||
import { FIRST_LEG_ORDER_STATUSES, FIRST_LEG_TRANSPORT_MODES, type FirstLegOrderStatus } from '@chongchong/shared'
|
||
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 router = useRouter()
|
||
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 = reactive({ keyword: '', status: '' as FirstLegOrderStatus | '', transportMode: '' })
|
||
const dateRange = ref<[string, string] | []>([])
|
||
const editorOpen = ref(false)
|
||
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 editorBaseline = ref('')
|
||
let cargoSequence = 0
|
||
|
||
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: '美国头程渠道',
|
||
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 && serializedDraft.value !== editorBaseline.value)
|
||
|
||
const filteredOrders = computed(() => store.orders.filter((order) => {
|
||
const keyword = filters.keyword.trim().toLowerCase()
|
||
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 = !filters.status || order.status === filters.status
|
||
const matchesMode = !filters.transportMode || order.transportMode === filters.transportMode
|
||
const matchesDate = dateRange.value.length !== 2 || (order.createdAt.slice(0, 10) >= dateRange.value[0] && order.createdAt.slice(0, 10) <= dateRange.value[1])
|
||
return matchesKeyword && matchesStatus && matchesMode && matchesDate
|
||
}))
|
||
|
||
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(() => Boolean(filters.keyword.trim() || filters.status || filters.transportMode || dateRange.value.length))
|
||
const emptyDescription = computed(() => hasActiveFilters.value ? '没有符合筛选条件的头程订单' : '还没有头程订单')
|
||
|
||
async function loadPageData() {
|
||
try {
|
||
const requests: Promise<unknown>[] = [store.load(), firstLegPartySource.load()]
|
||
if (auth.hasPermission('company:profile:view')) requests.push(tenantStore.loadCompanyContext())
|
||
await Promise.all(requests)
|
||
availableShippers.value = firstLegPartySource.listAvailable('SHIPPER')
|
||
availableConsignees.value = firstLegPartySource.listAvailable('CONSIGNEE')
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '订单与往来单位数据加载失败')
|
||
}
|
||
}
|
||
|
||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||
if (!editorDirty.value) return
|
||
event.preventDefault()
|
||
event.returnValue = ''
|
||
}
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||
void loadPageData()
|
||
})
|
||
|
||
onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnload))
|
||
|
||
onBeforeRouteLeave(async () => {
|
||
if (!editorDirty.value) return true
|
||
return confirmDiscardChanges()
|
||
})
|
||
|
||
function markEditorClean() {
|
||
editorBaseline.value = serializedDraft.value
|
||
}
|
||
|
||
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
|
||
} else {
|
||
form.consigneePartyId = party.id
|
||
form.consigneePartySnapshot = snapshot
|
||
form.consignee = party.name
|
||
}
|
||
}
|
||
|
||
function clearParty(role: FirstLegPartyRole) {
|
||
if (role === 'SHIPPER') {
|
||
form.shipperPartyId = ''
|
||
form.shipperPartySnapshot = null
|
||
form.shipper = ''
|
||
} else {
|
||
form.consigneePartyId = ''
|
||
form.consigneePartySnapshot = null
|
||
form.consignee = ''
|
||
}
|
||
}
|
||
|
||
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) {
|
||
if (!id) return
|
||
try {
|
||
await store.loadWarehouseDetail(id)
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '仓库地址与收货时间加载失败')
|
||
}
|
||
}
|
||
|
||
function openCreate() {
|
||
if (availableWarehouses.value.length === 0) {
|
||
ElMessage.warning('当前没有可用的平台收货仓,请联系平台维护仓库后再创建订单')
|
||
return
|
||
}
|
||
editingId.value = undefined
|
||
replaceForm(emptyDraft())
|
||
editorOpen.value = true
|
||
}
|
||
|
||
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
|
||
} 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
|
||
})
|
||
}
|
||
|
||
function addCargoItem() {
|
||
form.items.push(createEmptyCargoItem(`new-cargo-${++cargoSequence}`))
|
||
}
|
||
|
||
function removeCargoItem(index: number) {
|
||
if (form.items.length === 1) {
|
||
ElMessage.warning('订单至少需要保留一条货物明细')
|
||
return
|
||
}
|
||
form.items.splice(index, 1)
|
||
}
|
||
|
||
async function saveDraft() {
|
||
if (saving.value) return
|
||
const errors = validateFirstLegOrderDraft(form)
|
||
if (errors.length > 0) {
|
||
ElMessage.warning(errors[0])
|
||
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
|
||
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
|
||
}
|
||
const errors = validateFirstLegOrderDraft(form)
|
||
if (errors.length > 0) {
|
||
ElMessage.warning(errors[0])
|
||
return
|
||
}
|
||
|
||
try {
|
||
saving.value = true
|
||
const order = await store.submit(cloneDraft(form), tenantStore.company.companyName, editingId.value, tenantStore.company.auditStatus)
|
||
markEditorClean()
|
||
editorOpen.value = false
|
||
detailId.value = order.id
|
||
detailOpen.value = true
|
||
ElMessage.success('订单已提交,等待平台审核')
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '订单提交失败')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
function resetFilters() {
|
||
filters.keyword = ''
|
||
filters.status = ''
|
||
filters.transportMode = ''
|
||
dateRange.value = []
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="first-leg-page">
|
||
<div class="order-eligibility" :class="companyApproved ? 'is-approved' : 'is-pending'" role="status">
|
||
<component :is="companyApproved ? CheckCircle2 : AlertTriangle" :size="20" />
|
||
<div><strong>{{ companyApproved ? '企业资料已审核,可正常提交订单' : canCreateOrder ? '企业资料待审核,当前仅可保存订单草稿' : '企业资料待审核,当前订单为只读' }}</strong><span>{{ companyApproved ? '提交后订单将进入平台审核。' : 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 class="order-metrics" aria-label="订单概览">
|
||
<article><span>全部订单</span><strong>{{ store.orders.length }}</strong></article>
|
||
<article><span>进行中</span><strong>{{ store.activeCount }}</strong></article>
|
||
<article><span>待平台审核</span><strong>{{ store.pendingCount }}</strong></article>
|
||
</div>
|
||
|
||
<div class="order-toolbar client-card">
|
||
<div class="order-filters">
|
||
<el-input v-model="filters.keyword" clearable placeholder="订单号、参考号、路线或品名" aria-label="订单关键词">
|
||
<template #prefix><Search :size="16" /></template>
|
||
</el-input>
|
||
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="订单状态">
|
||
<el-option v-for="item in FIRST_LEG_ORDER_STATUSES" :key="item.code" :label="item.label" :value="item.code" />
|
||
</el-select>
|
||
<el-select v-model="filters.transportMode" clearable placeholder="全部运输方式" aria-label="运输方式">
|
||
<el-option v-for="item in FIRST_LEG_TRANSPORT_MODES" :key="item.code" :label="item.label" :value="item.code" />
|
||
</el-select>
|
||
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="创建开始" end-placeholder="创建结束" aria-label="订单创建日期" />
|
||
</div>
|
||
<div class="order-toolbar-actions">
|
||
<el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button>
|
||
<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>
|
||
|
||
<div v-loading="store.loading" class="client-card order-table-card">
|
||
<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>
|
||
<el-table v-if="filteredOrders.length" class="desktop-order-table" :data="filteredOrders" row-key="id">
|
||
<el-table-column label="操作" width="180" fixed="left">
|
||
<template #default="{ 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>
|
||
</el-table-column>
|
||
<el-table-column label="订单" min-width="190">
|
||
<template #default="{ 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>
|
||
</el-table-column>
|
||
<el-table-column label="运输路线" min-width="190">
|
||
<template #default="{ row }"><strong>{{ (row as FirstLegOrder).origin }} → {{ (row as FirstLegOrder).destination }}</strong><span class="order-reference">{{ getFirstLegTransportModeLabel((row as FirstLegOrder).transportMode) }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="收货仓" min-width="120" prop="receivingWarehouseName" />
|
||
<el-table-column label="货物" min-width="150">
|
||
<template #default="{ 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>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }"><el-tag :type="getFirstLegStatusTone((row as FirstLegOrder).status)" effect="light">{{ getFirstLegStatusLabel((row as FirstLegOrder).status) }}</el-tag></template>
|
||
</el-table-column>
|
||
<el-table-column label="负责人" prop="operatorName" width="110" />
|
||
<el-table-column label="更新时间" width="155"><template #default="{ row }">{{ formatDateTime((row as FirstLegOrder).updatedAt) }}</template></el-table-column>
|
||
</el-table>
|
||
<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>
|
||
</template>
|
||
</div>
|
||
|
||
<el-drawer v-model="editorOpen" class="first-leg-editor" :title="editingId ? '编辑订单草稿' : '创建头程订单'" size="760px" destroy-on-close :before-close="requestCloseEditor">
|
||
<el-form label-position="top" class="order-form">
|
||
<section class="order-form-section">
|
||
<h3>运输信息</h3>
|
||
<div class="order-form-grid">
|
||
<el-form-item label="运输方式" required><el-select v-model="form.transportMode"><el-option v-for="item in FIRST_LEG_TRANSPORT_MODES" :key="item.code" :label="item.label" :value="item.code" /></el-select></el-form-item>
|
||
<el-form-item label="业务类型" required><el-radio-group v-model="form.businessType"><el-radio-button value="EXPORT">出口</el-radio-button><el-radio-button value="IMPORT">进口</el-radio-button></el-radio-group></el-form-item>
|
||
<el-form-item label="起运地" required><el-input v-model="form.origin" placeholder="例如:宁波" /></el-form-item>
|
||
<el-form-item label="目的地" required><el-input v-model="form.destination" placeholder="例如:洛杉矶" /></el-form-item>
|
||
<el-form-item label="平台收货仓" required><el-select v-model="form.receivingWarehouseId" filterable placeholder="请选择送货仓库" @change="loadSelectedWarehouse"><el-option v-for="warehouse in availableWarehouses" :key="warehouse.id" :label="`${warehouse.name} · ${warehouse.city}`" :value="warehouse.id" /></el-select></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.referenceNo" maxlength="100" placeholder="贵司内部单号(选填)" /></el-form-item>
|
||
<el-form-item label="优先级"><el-select v-model="form.priority"><el-option label="普通" value="NORMAL" /><el-option label="紧急" value="URGENT" /><el-option label="加急" value="EXPEDITED" /></el-select></el-form-item>
|
||
<el-form-item label="目的国代码" required><el-input v-model="form.destinationCountryCode" maxlength="2" placeholder="例如:US" /></el-form-item>
|
||
<el-form-item label="目的仓名称" required><el-input v-model="form.destinationWarehouseName" maxlength="128" placeholder="例如:ONT8" /></el-form-item>
|
||
<el-form-item label="渠道编码" required><el-input v-model="form.channelCode" maxlength="64" /></el-form-item>
|
||
<el-form-item label="渠道名称" required><el-input v-model="form.channelName" maxlength="128" /></el-form-item>
|
||
<el-form-item label="提货方式" required><el-select v-model="form.pickupMethod"><el-option label="自送快递" value="SELF_EXPRESS" /><el-option label="上门提货" value="PICKUP" /><el-option label="自送卡车" value="SELF_TRUCK" /></el-select></el-form-item>
|
||
<el-form-item label="报关模式" required><el-select v-model="form.taxMode"><el-option label="买单报关" value="BUY_DECLARATION" /><el-option label="退税报关" value="TAX_REFUND" /></el-select></el-form-item>
|
||
<el-form-item label="目的仓类型" required><el-select v-model="form.destinationType"><el-option label="FBA" value="FBA" /><el-option label="海外仓" value="OVERSEAS" /><el-option label="私人地址" value="PRIVATE" /></el-select></el-form-item>
|
||
<el-form-item label="贸易术语"><el-input v-model="form.tradeTerm" maxlength="16" placeholder="例如:FOB" /></el-form-item>
|
||
<el-form-item v-if="form.destinationType === 'FBA'" label="Amazon 仓库编码"><el-input v-model="form.amazonWarehouseCode" maxlength="32" placeholder="例如:ONT8" /></el-form-item>
|
||
<el-form-item v-if="form.destinationType === 'FBA'" label="Shipment ID"><el-input v-model="form.shipmentId" maxlength="64" /></el-form-item>
|
||
</div>
|
||
<div v-if="selectedWarehouse" class="selected-warehouse"><MapPin :size="17" /><div><strong>{{ selectedWarehouse.name }}</strong><span>{{ selectedWarehouse.province }}{{ selectedWarehouse.city }} · {{ selectedWarehouse.address }}</span><small>收货时间:{{ selectedWarehouse.businessHours }}</small></div></div>
|
||
</section>
|
||
|
||
<section class="order-form-section">
|
||
<div class="order-section-heading"><div><h3>收发货单位</h3><p>仅可选择平台已启用的往来单位;下方联系人和地址调整只影响本订单快照。</p></div></div>
|
||
<div class="party-editor-grid">
|
||
<article class="party-editor">
|
||
<header><span><Building2 :size="17" /><strong>发货单位</strong></span><small>必填</small></header>
|
||
<el-form-item label="选择发货单位" 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-snapshot-fields">
|
||
<div class="party-name"><strong>{{ form.shipperPartySnapshot.name }}</strong><span>{{ form.shipperPartySnapshot.code }}</span></div>
|
||
<el-form-item label="联系人"><el-input v-model="form.shipperPartySnapshot.contactName" maxlength="50" /></el-form-item>
|
||
<el-form-item label="联系电话"><el-input v-model="form.shipperPartySnapshot.phone" maxlength="30" /></el-form-item>
|
||
<el-form-item label="联系邮箱"><el-input v-model="form.shipperPartySnapshot.email" maxlength="100" /></el-form-item>
|
||
<el-form-item label="本单发货地址"><el-input v-model="form.shipperPartySnapshot.address" maxlength="300" /></el-form-item>
|
||
</div>
|
||
<div v-else class="party-empty">选择后可核对本单联系人和发货地址</div>
|
||
</article>
|
||
|
||
<article class="party-editor">
|
||
<header><span><Building2 :size="17" /><strong>收货单位</strong></span><small>必填</small></header>
|
||
<el-form-item label="选择收货单位" 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-snapshot-fields">
|
||
<div class="party-name"><strong>{{ form.consigneePartySnapshot.name }}</strong><span>{{ form.consigneePartySnapshot.code }}</span></div>
|
||
<el-form-item label="联系人"><el-input v-model="form.consigneePartySnapshot.contactName" maxlength="50" /></el-form-item>
|
||
<el-form-item label="联系电话"><el-input v-model="form.consigneePartySnapshot.phone" maxlength="30" /></el-form-item>
|
||
<el-form-item label="联系邮箱"><el-input v-model="form.consigneePartySnapshot.email" maxlength="100" /></el-form-item>
|
||
<el-form-item label="本单收货地址"><el-input v-model="form.consigneePartySnapshot.address" maxlength="300" /></el-form-item>
|
||
</div>
|
||
<div v-else class="party-empty">选择后可核对本单联系人和收货地址</div>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="order-form-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-editor-card">
|
||
<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-form-grid">
|
||
<el-form-item label="中文品名" required><el-input v-model="item.cargoNameCn" /></el-form-item>
|
||
<el-form-item label="英文品名"><el-input v-model="item.cargoNameEn" /></el-form-item>
|
||
<el-form-item label="客户 SKU"><el-input v-model="item.sku" /></el-form-item>
|
||
<el-form-item label="包装类型" required><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="HS Code"><el-input v-model="item.hsCode" maxlength="20" /></el-form-item>
|
||
<el-form-item label="件数" required><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)" required><el-input-number v-model="item.grossWeightKg" :min="0" :precision="2" controls-position="right" /></el-form-item>
|
||
<el-form-item label="体积 (m³)" required><el-input-number v-model="item.volumeCbm" :min="0" :precision="3" controls-position="right" /></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" /></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="箱型" required><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="柜量" required><el-input-number v-model="item.containerCount" :min="0" controls-position="right" /></el-form-item>
|
||
</template>
|
||
<el-form-item label="货物备注" class="cargo-remark"><el-input v-model="item.remark" maxlength="300" /></el-form-item>
|
||
</div>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="order-form-section">
|
||
<h3>订单备注</h3>
|
||
<el-form-item label="客户备注"><el-input v-model="form.customerRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="平台工作人员可以看到该备注" /></el-form-item>
|
||
</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>
|
||
</el-drawer>
|
||
|
||
<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; }
|
||
.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-approved { border-color: #bcd9d2; background: #eff8f5; color: #20745f; }
|
||
.order-eligibility.is-pending { border-color: #e5d2a5; background: #fff9eb; color: #9a6a12; }
|
||
.order-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||
.order-metrics article { display: flex; align-items: center; justify-content: space-between; min-height: 76px; padding: 16px 20px; border: 1px solid var(--border); background: #fff; }
|
||
.order-metrics span { color: var(--muted); font-size: 12px; }
|
||
.order-metrics strong { color: var(--navy); font-size: 25px; }
|
||
.order-toolbar { display: flex; align-items: stretch; flex-direction: column; gap: 12px; padding: 16px; }
|
||
.order-filters { display: grid; width: 100%; grid-template-columns: minmax(190px, 1.35fr) minmax(130px, .65fr) minmax(150px, .75fr) minmax(230px, 1fr); gap: 10px; }
|
||
.order-toolbar-actions, .order-row-actions, .editor-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||
.order-table-card { padding: 0; overflow: hidden; }
|
||
.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-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; }
|
||
.order-form { padding: 0 2px 28px; }
|
||
.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 :deep(.el-select), .order-form-grid :deep(.el-date-editor), .cargo-form-grid :deep(.el-select), .cargo-form-grid :deep(.el-input-number) { width: 100%; }
|
||
.selected-warehouse { display: flex; gap: 10px; margin-top: -2px; padding: 12px 14px; border: 1px solid #cfe1dd; background: #f3f8f7; 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: 4px; }
|
||
.selected-warehouse span, .selected-warehouse small { color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||
.party-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||
.party-editor { min-width: 0; padding: 16px; border: 1px solid var(--border); background: #f9fbfb; }
|
||
.party-editor > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||
.party-editor > header span { display: flex; min-width: 0; align-items: center; gap: 8px; color: var(--navy); }
|
||
.party-editor > header svg { flex: 0 0 17px; color: var(--primary); }
|
||
.party-editor > header small { color: var(--muted); font-size: 11px; }
|
||
.party-editor :deep(.el-select) { width: 100%; }
|
||
.party-snapshot-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||
.party-name { display: flex; min-width: 0; grid-column: 1 / -1; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||
.party-name strong { min-width: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 13px; }
|
||
.party-name span { flex: 0 0 auto; color: var(--muted); font-size: 10px; }
|
||
.party-snapshot-fields .el-form-item:last-child { grid-column: 1 / -1; }
|
||
.party-empty { display: grid; min-height: 150px; place-items: center; border-top: 1px solid var(--border); color: var(--muted); font-size: 12px; text-align: center; }
|
||
.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; }
|
||
.cargo-editor-card { padding: 16px; border: 1px solid var(--border); background: #f9fbfb; }
|
||
.cargo-editor-card + .cargo-editor-card { margin-top: 12px; }
|
||
.cargo-editor-card header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||
.cargo-form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 14px; }
|
||
.cargo-remark { grid-column: 1 / -1; }
|
||
.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: 1100px) { .order-toolbar-actions { align-self: flex-end; } .order-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||
@media (max-width: 1280px) {
|
||
.desktop-order-table { display: none; }
|
||
.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 (max-width: 720px) {
|
||
.order-eligibility { flex-wrap: wrap; }
|
||
.order-eligibility .el-button { width: 100%; min-height: 44px; }
|
||
.order-metrics { grid-template-columns: 1fr; }
|
||
.order-metrics article { min-height: 60px; }
|
||
.order-filters, .order-form-grid, .cargo-form-grid, .order-detail-grid, .party-editor-grid, .party-snapshot-fields { grid-template-columns: 1fr; }
|
||
.order-toolbar-actions { width: 100%; align-self: stretch; }
|
||
.order-toolbar-actions :deep(.el-button) { min-height: 44px; flex: 1; }
|
||
.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; }
|
||
.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; }
|
||
.party-name, .party-snapshot-fields .el-form-item:last-child { grid-column: auto; }
|
||
.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; }
|
||
}
|
||
</style>
|