1210 lines
84 KiB
Vue
1210 lines
84 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, reactive, ref, watch, type Component } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, GitBranch, History, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import {
|
||
getPlatformUnifiedOrder,
|
||
approvePlatformUnifiedOrder,
|
||
addPlatformUnifiedOrderNote,
|
||
listPlatformUnifiedOrderFeeDetails,
|
||
listPlatformUnifiedOrders,
|
||
unifiedOrderStatusOptions,
|
||
type UnifiedOrderDetailResource,
|
||
type UnifiedOrderMainStatus,
|
||
type UnifiedOrderResource,
|
||
type UnifiedOrderFeeDetailResource,
|
||
} from '@/modules/unified-orders/api/unifiedOrders'
|
||
import OrderFulfillmentTreePanel from '@/modules/unified-orders/components/OrderFulfillmentTreePanel.vue'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||
import {
|
||
firstLegBusinessTypeLabel,
|
||
firstLegContainerModeLabel,
|
||
firstLegDestinationTypeLabel,
|
||
firstLegPickupMethodLabel,
|
||
firstLegTaxModeLabel,
|
||
firstLegTradeTermLabel,
|
||
firstLegTransportModeLabel,
|
||
} from '@/shared/utils/firstLegBusinessLabels'
|
||
import { UnifiedOrderWorkflowPanel } from '@/modules/unified-orders/workflows'
|
||
import {
|
||
unifiedOrderHistoryActionLabel,
|
||
unifiedOrderLoadingStatusLabel,
|
||
unifiedOrderReviewStatusLabel,
|
||
unifiedOrderStatusLabel,
|
||
} from '@/modules/unified-orders/status'
|
||
import {
|
||
downloadWeightImage,
|
||
getInboundMeasurement,
|
||
type InboundMeasurementDetail,
|
||
} from '@/modules/inbounds'
|
||
|
||
type FilterModel = QueryModel & {
|
||
tenantId: string
|
||
receivingWarehouseId: string
|
||
destinationWarehouseId: string
|
||
inboundCode: string
|
||
expectedArrivalDate: string
|
||
feeReviewStatus: string
|
||
channelName: string
|
||
containerNo: string
|
||
cabinetNo: string
|
||
sailingDate: string
|
||
loaded: string
|
||
fullContainer: string
|
||
measurementReviewStatus: string
|
||
fbaCode: string
|
||
createdRange: string[]
|
||
}
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const access = useAccessPreviewStore()
|
||
const records = ref<UnifiedOrderResource[]>([])
|
||
const customers = ref<PlatformTenantResource[]>([])
|
||
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
|
||
const page = ref(1)
|
||
const pageSize = ref(20)
|
||
const total = ref(0)
|
||
const loading = ref(false)
|
||
const loadError = ref<ApiErrorDetails | null>(null)
|
||
const customerLoading = ref(false)
|
||
const customerLoadError = ref<ApiErrorDetails | null>(null)
|
||
const warehouseLoading = ref(false)
|
||
const warehouseLoadError = ref<ApiErrorDetails | null>(null)
|
||
const detail = ref<UnifiedOrderDetailResource | null>(null)
|
||
const detailLoading = ref(false)
|
||
const detailError = ref<ApiErrorDetails | null>(null)
|
||
const activeDetailTab = ref('basic')
|
||
const approvalOpen = ref(false)
|
||
const approvalLoadingId = ref('')
|
||
const approvalSaving = ref(false)
|
||
const approvalOrder = ref<UnifiedOrderResource | null>(null)
|
||
const noteOpen = ref(false)
|
||
const noteSaving = ref(false)
|
||
const noteContent = ref('')
|
||
const noteOrder = ref<UnifiedOrderResource | null>(null)
|
||
const relationOpen = ref(false)
|
||
const relationOrder = ref<UnifiedOrderResource | null>(null)
|
||
const feeDetails = ref<UnifiedOrderFeeDetailResource[]>([])
|
||
const feeLoading = ref(false)
|
||
const feeLoadedKey = ref('')
|
||
const measurementDetail = ref<InboundMeasurementDetail | null>(null)
|
||
const measurementLoading = ref(false)
|
||
const measurementLoadedKey = ref('')
|
||
const measurementImageUrls = ref<string[]>([])
|
||
const measurementCollapsed = reactive({ customer: false, outer: false, actual: false })
|
||
let detailLoadingKey = ''
|
||
let detailRequestSerial = 0
|
||
const showAdvancedFilters = ref(false)
|
||
const filters = ref<FilterModel>(emptyFilters())
|
||
const appliedFilters = ref<FilterModel>(emptyFilters())
|
||
const selectedStatus = ref<UnifiedOrderMainStatus | ''>('')
|
||
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
|
||
const detailTenantId = computed(() => typeof route.query.tenantId === 'string' ? route.query.tenantId : '')
|
||
const detailRouteKey = computed(() => orderId.value ? `${detailTenantId.value}:${orderId.value}` : '')
|
||
|
||
const detailTabs = [
|
||
{ key: 'basic', label: '基本信息', icon: Box },
|
||
{ key: 'cargo', label: '货品信息', icon: PackageOpen },
|
||
{ key: 'fees', label: '费用信息', icon: WalletCards },
|
||
{ key: 'history', label: '状态历史', icon: History },
|
||
{ key: 'measurement', label: '测量数据', icon: Ruler },
|
||
{ key: 'other', label: '其他', icon: StickyNote },
|
||
{ key: 'fulfillment', label: '拆分履约', icon: ListChecks },
|
||
]
|
||
|
||
const detailTabOptions = computed(() => detail.value?.summary.inboundId
|
||
? [...detailTabs, { key: 'workflow', label: '后半程作业', icon: ListChecks }]
|
||
: detailTabs)
|
||
|
||
const customerMeasurementColumns = [
|
||
{ key: 'itemNo', label: '#' }, { key: 'lengthCm', label: '箱规长(cm)' }, { key: 'widthCm', label: '箱规宽(cm)' },
|
||
{ key: 'heightCm', label: '箱规高(cm)' }, { key: 'packageCount', label: '箱数' }, { key: 'unitWeightKg', label: '每箱毛重' },
|
||
{ key: 'unitVolumeCbm', label: '每箱体积' }, { key: 'remark', label: '备注' },
|
||
]
|
||
const measuredColumns = [
|
||
...customerMeasurementColumns.slice(0, 7), { key: 'girthCm', label: '箱规周长(cm)' }, { key: 'totalWeightKg', label: '总毛重(T.K.GW)' },
|
||
{ key: 'totalVolumeCbm', label: '总体积(T.CBM)' }, { key: 'volumeWeightKg', label: '总材积' }, { key: 'remark', label: '备注' },
|
||
]
|
||
|
||
const statusTabs = computed(() => [
|
||
{ value: '' as const, label: '全部' },
|
||
...unifiedOrderStatusOptions,
|
||
])
|
||
|
||
const warehouseOptions = computed(() => warehouses.value.map((warehouse) => ({
|
||
value: String(warehouse.id),
|
||
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
|
||
})))
|
||
const receivingWarehouseOptions = computed(() => warehouses.value
|
||
.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
|
||
.map((warehouse) => ({ value: String(warehouse.id), label: `${warehouse.warehouseName}(${warehouse.warehouseCode})` })))
|
||
|
||
const primaryQueryFields = computed<QueryField[]>(() => [
|
||
{ key: 'receivingWarehouseId', type: 'select', width: 220, label: '定位仓库', placeholder: warehouseLoadError.value ? '仓库加载失败' : '请选择定位仓库', options: receivingWarehouseOptions.value, props: { 'aria-label': '定位仓库', filterable: true, loading: warehouseLoading.value, disabled: Boolean(warehouseLoadError.value) } },
|
||
{ key: 'destinationWarehouseId', type: 'select', width: 220, label: '交货仓', placeholder: warehouseLoadError.value ? '仓库加载失败' : '请选择交货仓', options: warehouseOptions.value, props: { 'aria-label': '交货仓', filterable: true, loading: warehouseLoading.value, disabled: Boolean(warehouseLoadError.value) } },
|
||
{ key: 'inboundCode', type: 'input', width: 220, label: '入仓码', placeholder: '请输入入仓码', props: { 'aria-label': '入仓码' } },
|
||
])
|
||
|
||
const advancedQueryFields = computed<QueryField[]>(() => [
|
||
{ key: 'expectedArrivalDate', type: 'date', width: 190, label: '预计到达时间', placeholder: '请选择预计到达日期', props: { 'aria-label': '预计到达时间' } },
|
||
{ key: 'feeReviewStatus', type: 'select', width: 190, label: '费用审核状态', placeholder: '请选择费用审核状态', options: [
|
||
{ value: 'UNRECORDED', label: '未录入' }, { value: 'PENDING', label: '待审核' },
|
||
{ value: 'APPROVED', label: '审核通过' }, { value: 'REJECTED', label: '审核驳回' },
|
||
], props: { 'aria-label': '费用审核状态' } },
|
||
{
|
||
key: 'tenantId',
|
||
type: 'select',
|
||
width: 220,
|
||
label: '公司',
|
||
placeholder: customerLoadError.value ? '客户列表加载失败' : customerLoading.value ? '正在加载客户' : '请选择公司',
|
||
options: customers.value.map((customer) => ({
|
||
value: String(customer.id),
|
||
label: `${customer.legalName?.trim() || customer.tenantName}(${customer.tenantCode || '无客户编码'})`,
|
||
})),
|
||
props: { 'aria-label': '公司', filterable: true, loading: customerLoading.value, disabled: Boolean(customerLoadError.value) },
|
||
},
|
||
{ key: 'containerNo', type: 'input', width: 210, label: '集装箱号', placeholder: '请输入集装箱号', props: { 'aria-label': '集装箱号' } },
|
||
{ key: 'cabinetNo', type: 'input', width: 190, label: '柜号', placeholder: '请输入柜号', props: { 'aria-label': '柜号' } },
|
||
{ key: 'channelName', type: 'input', width: 210, label: '渠道', placeholder: '请输入渠道名称', props: { 'aria-label': '渠道' } },
|
||
{ key: 'sailingDate', type: 'date', width: 190, label: '船期', placeholder: '请选择船期', props: { 'aria-label': '船期' } },
|
||
{ key: 'loaded', type: 'select', width: 180, label: '是否已配载', placeholder: '请选择配载状态', options: [{ value: 'true', label: '已配载' }, { value: 'false', label: '未配载' }], props: { 'aria-label': '是否已配载' } },
|
||
{ key: 'fullContainer', type: 'select', width: 180, label: '是否整柜', placeholder: '请选择装柜方式', options: [{ value: 'true', label: '整柜' }, { value: 'false', label: '拼箱' }], props: { 'aria-label': '是否整柜' } },
|
||
{ key: 'measurementReviewStatus', type: 'select', width: 220, label: '测量数据审核状态', placeholder: '请选择测量审核状态', options: [
|
||
{ value: 'UNRECORDED', label: '未录入' }, { value: 'PENDING', label: '待审核' },
|
||
{ value: 'APPROVED', label: '审核通过' }, { value: 'REJECTED', label: '审核驳回' },
|
||
], props: { 'aria-label': '测量数据审核状态' } },
|
||
{ key: 'fbaCode', type: 'input', width: 220, label: '商品 SKU 编码', placeholder: '请输入商品 SKU 编码', props: { 'aria-label': '商品 SKU 编码' } },
|
||
{ key: 'createdRange', type: 'daterange', width: 300, label: '创建日期', placeholder: '创建日期范围', props: { 'aria-label': '创建日期范围' } },
|
||
])
|
||
|
||
const allQueryFields = computed(() => [...primaryQueryFields.value, ...advancedQueryFields.value])
|
||
const queryFields = computed(() => showAdvancedFilters.value
|
||
? allQueryFields.value
|
||
: primaryQueryFields.value)
|
||
|
||
const columns: DataTableColumn[] = [
|
||
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
||
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 190, slotName: 'order' },
|
||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, slotName: 'inboundCode' },
|
||
{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态', minWidth: 130, slotName: 'mainStatus' },
|
||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'field' },
|
||
{ key: 'receivingWarehouseName', prop: 'receivingWarehouseName', label: '平台收货仓', minWidth: 150, slotName: 'field' },
|
||
{ key: 'destinationWarehouseName', prop: 'destinationWarehouseName', label: '目的仓', minWidth: 150, slotName: 'field' },
|
||
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', minWidth: 100, slotName: 'field' },
|
||
{ key: 'measurementReviewStatus', prop: 'measurementReviewStatus', label: '测量数据审核状态', minWidth: 170, slotName: 'measurementReviewStatus' },
|
||
{ key: 'actualGrossWeightKg', prop: 'actualGrossWeightKg', label: '实测毛重(kg)', minWidth: 130, slotName: 'field' },
|
||
{ key: 'actualVolumeCbm', prop: 'actualVolumeCbm', label: '实测体积(m³)', minWidth: 130, slotName: 'field' },
|
||
{ key: 'chargeableWeightKg', prop: 'chargeableWeightKg', label: '计费毛重', minWidth: 120, slotName: 'field' },
|
||
{ key: 'expectedShipDate', prop: 'expectedShipDate', label: '预计出运日期', minWidth: 150, slotName: 'field' },
|
||
{ key: 'loadingStatus', prop: 'loadingStatus', label: '配载状态', minWidth: 130, slotName: 'field', defaultVisible: false },
|
||
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'field', defaultVisible: false },
|
||
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜号', minWidth: 130, slotName: 'field', defaultVisible: false },
|
||
{ key: 'sailingAt', prop: 'sailingAt', label: '开船时间', minWidth: 150, slotName: 'field', defaultVisible: false },
|
||
{ key: 'portArrivalAt', prop: 'portArrivalAt', label: '到港时间', minWidth: 150, slotName: 'field', defaultVisible: false },
|
||
{ key: 'customerRemark', prop: 'customerRemark', label: '客户备注', minWidth: 180, slotName: 'field', defaultVisible: false },
|
||
{ key: 'latestInternalNote', prop: 'latestInternalNote', label: '内部备注', minWidth: 180, slotName: 'field', defaultVisible: false },
|
||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 150, slotName: 'field', defaultVisible: false },
|
||
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150, slotName: 'field' },
|
||
]
|
||
|
||
function emptyFilters(): FilterModel {
|
||
return {
|
||
tenantId: '', receivingWarehouseId: '', destinationWarehouseId: '',
|
||
inboundCode: '', expectedArrivalDate: '', feeReviewStatus: '', channelName: '',
|
||
containerNo: '', cabinetNo: '', sailingDate: '', loaded: '', fullContainer: '', measurementReviewStatus: '',
|
||
fbaCode: '', createdRange: [],
|
||
}
|
||
}
|
||
|
||
function cloneFilters(source: FilterModel): FilterModel {
|
||
return { ...source, createdRange: [...source.createdRange] }
|
||
}
|
||
|
||
function trimmed(value: string) {
|
||
return value.trim() || undefined
|
||
}
|
||
|
||
function optionalBoolean(value: string) {
|
||
return value === '' ? undefined : value === 'true'
|
||
}
|
||
|
||
function selectStatus(status: UnifiedOrderMainStatus | '') {
|
||
if (selectedStatus.value === status) return
|
||
selectedStatus.value = status
|
||
if (page.value === 1) void loadOrders()
|
||
else page.value = 1
|
||
}
|
||
|
||
function handleTabKeydown(event: KeyboardEvent, index: number) {
|
||
let nextIndex = index
|
||
if (event.key === 'ArrowRight') nextIndex = (index + 1) % statusTabs.value.length
|
||
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + statusTabs.value.length) % statusTabs.value.length
|
||
else if (event.key === 'Home') nextIndex = 0
|
||
else if (event.key === 'End') nextIndex = statusTabs.value.length - 1
|
||
else return
|
||
event.preventDefault()
|
||
const tab = statusTabs.value[nextIndex]
|
||
if (!tab) return
|
||
selectStatus(tab.value)
|
||
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
|
||
buttons?.[nextIndex]?.focus()
|
||
}
|
||
|
||
function can(permission: string) {
|
||
return access.can(permission)
|
||
}
|
||
|
||
function customerName(row: UnifiedOrderResource) {
|
||
return row.customerName?.trim() || customers.value.find((item) => String(item.id) === String(row.tenantId))?.tenantName || `客户 ${row.tenantId}`
|
||
}
|
||
|
||
function orderRelationLabel(row: UnifiedOrderResource) {
|
||
if (row.relation?.role === 'PARENT') return `父单 · ${row.relation.childCount} 个子单`
|
||
if (row.relation?.role === 'CHILD' && row.relation.splitSequence !== null) {
|
||
return `子单 · 第 ${row.relation.splitSequence} 次`
|
||
}
|
||
return '子单'
|
||
}
|
||
|
||
function openOrderRelation(row: UnifiedOrderResource) {
|
||
relationOrder.value = row
|
||
relationOpen.value = true
|
||
}
|
||
|
||
function formatDate(value: string | null | undefined) {
|
||
return value ? value.replace('T', ' ').slice(0, 16) : '—'
|
||
}
|
||
|
||
function formatNumber(value: number | null | undefined, digits = 2) {
|
||
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits })
|
||
}
|
||
|
||
function partyValue(parties: Array<Record<string, unknown>>, type: 'SENDER' | 'RECEIVER', key: string) {
|
||
const party = parties.find((item) => String(item.partyType || '').toUpperCase() === type)
|
||
const value = party?.[key]
|
||
return value === null || value === undefined || value === '' ? '—' : String(value)
|
||
}
|
||
|
||
function partyAddress(parties: Array<Record<string, unknown>>, type: 'SENDER' | 'RECEIVER') {
|
||
const party = parties.find((item) => String(item.partyType || '').toUpperCase() === type)
|
||
if (!party) return '—'
|
||
return ['province', 'city', 'district', 'addressLine'].map((key) => party[key]).filter((value) => typeof value === 'string' && value.trim()).join(' ') || '—'
|
||
}
|
||
|
||
function cargoTotal(cargoes: Array<Record<string, unknown>>, key: string) {
|
||
const total = cargoes.reduce((sum, cargo) => {
|
||
const value = Number(cargo[key])
|
||
return Number.isFinite(value) ? sum + value : sum
|
||
}, 0)
|
||
return total > 0 ? total : null
|
||
}
|
||
|
||
function displayBoolean(value: boolean | null | undefined) {
|
||
return value === null || value === undefined ? '—' : value ? '是' : '否'
|
||
}
|
||
|
||
function displayField(row: UnifiedOrderResource, property: string | undefined) {
|
||
if (!property) return '—'
|
||
const source = row as unknown as Record<string, unknown>
|
||
const nested: Record<string, string> = {
|
||
measurementReviewStatus: 'measurement.reviewStatus', expectedArrivalAt: 'expectedArrivalAt',
|
||
loadingStatus: 'transport.loadingStatus', containerNo: 'transport.containerNo', cabinetNo: 'transport.cabinetNo',
|
||
sailingAt: 'transport.sailingAt', portArrivalAt: 'transport.portArrivalAt',
|
||
actualGrossWeightKg: 'measurement.actualGrossWeightKg', actualVolumeCbm: 'measurement.actualVolumeCbm',
|
||
chargeableWeightKg: 'measurement.chargeableWeightKg', measurementCompletedAt: 'measurement.completedAt',
|
||
expressTrackingNo: 'delivery.trackingNo',
|
||
}
|
||
const path = nested[property] || property
|
||
let value = path.split('.').reduce<unknown>((current, key) => (
|
||
current && typeof current === 'object' ? (current as Record<string, unknown>)[key] : undefined
|
||
), source)
|
||
if ((value === null || value === undefined || value === '') && property === 'internalOrderStatus') value = source.mainStatus
|
||
if (value === null || value === undefined || value === '') return '—'
|
||
if (typeof value === 'boolean') return value ? '是' : '否'
|
||
if (Array.isArray(value)) return value.length ? `${value.length} 个文件` : '—'
|
||
if (typeof value === 'number') return formatNumber(value)
|
||
if (property.endsWith('At') || property.endsWith('Date')) return formatDate(String(value))
|
||
if (property === 'measurementReviewStatus') return measurementStatusLabel(String(value))
|
||
if (property === 'internalOrderStatus') return orderStatusLabel(String(value))
|
||
if (property === 'feeReviewStatus') return feeReviewLabel(String(value))
|
||
if (property === 'loadingStatus') return unifiedOrderLoadingStatusLabel(String(value))
|
||
if (property === 'businessType') return firstLegBusinessTypeLabel(String(value))
|
||
if (property === 'pickupMethod') return firstLegPickupMethodLabel(String(value))
|
||
if (property === 'taxMode') return firstLegTaxModeLabel(String(value))
|
||
if (property === 'destinationType') return firstLegDestinationTypeLabel(String(value))
|
||
return String(value)
|
||
}
|
||
|
||
function statusLabel(value: UnifiedOrderMainStatus | string | null) {
|
||
return unifiedOrderStatusLabel(value, '状态更新')
|
||
}
|
||
|
||
function historyActionLabel(value: string | null | undefined) {
|
||
return unifiedOrderHistoryActionLabel(value)
|
||
}
|
||
|
||
function measurementStatusLabel(value: string | null | undefined) {
|
||
return unifiedOrderReviewStatusLabel(value, '未录入')
|
||
}
|
||
|
||
function orderStatusLabel(value: string | null | undefined) {
|
||
return unifiedOrderStatusLabel(value)
|
||
}
|
||
|
||
function cargoValue(cargo: Record<string, unknown>, key: string) {
|
||
const value = cargo[key]
|
||
if (key === 'circumferenceCm') {
|
||
const width = Number(cargo.widthCm)
|
||
const height = Number(cargo.heightCm)
|
||
return Number.isFinite(width) && Number.isFinite(height) ? formatNumber(2 * (width + height)) : '—'
|
||
}
|
||
if (value === null || value === undefined || value === '') return '—'
|
||
if (typeof value === 'number') return formatNumber(value)
|
||
return String(value)
|
||
}
|
||
|
||
function cargoImage(cargo: Record<string, unknown>) {
|
||
const value = cargo.imageUrl || cargo.cargoImageUrl || cargo.image
|
||
return typeof value === 'string' && value ? value : ''
|
||
}
|
||
|
||
function feeDate(value: string | null | undefined) {
|
||
return value ? formatDate(value) : '—'
|
||
}
|
||
|
||
function feeText(value: string | null | undefined) {
|
||
return value || '—'
|
||
}
|
||
|
||
function feeNumber(value: number | null | undefined) {
|
||
return value === null || value === undefined ? '—' : formatNumber(value, 4)
|
||
}
|
||
|
||
function feeReviewLabel(value: string | null | undefined) {
|
||
return unifiedOrderReviewStatusLabel(value)
|
||
}
|
||
|
||
function feeDirectionLabel(value: string | null | undefined) {
|
||
if (!value) return '—'
|
||
return ({ RECEIVABLE: '应收', PAYABLE: '应付' } as Record<string, string>)[value] || '未知方向'
|
||
}
|
||
|
||
async function loadFeeDetails() {
|
||
const summary = detail.value?.summary
|
||
if (!summary?.inboundId || !can('firstleg:inbound:fee-detail:view')) return
|
||
const key = `${summary.tenantId}:${summary.inboundId}`
|
||
if (feeLoadedKey.value === key || feeLoading.value) return
|
||
feeLoading.value = true
|
||
try {
|
||
feeDetails.value = await listPlatformUnifiedOrderFeeDetails(String(summary.tenantId), String(summary.inboundId))
|
||
feeLoadedKey.value = key
|
||
} catch {
|
||
feeDetails.value = []
|
||
} finally {
|
||
feeLoading.value = false
|
||
}
|
||
}
|
||
|
||
function measurementValue(row: Record<string, unknown>, key: string, customerSource = false) {
|
||
if (customerSource && key === 'unitWeightKg') {
|
||
const total = Number(row.grossWeightKg)
|
||
const count = Number(row.packageCount)
|
||
return Number.isFinite(total) && Number.isFinite(count) && count > 0 ? formatNumber(total / count, 4) : '—'
|
||
}
|
||
if (customerSource && key === 'unitVolumeCbm') {
|
||
const total = Number(row.volumeCbm)
|
||
const count = Number(row.packageCount)
|
||
return Number.isFinite(total) && Number.isFinite(count) && count > 0 ? formatNumber(total / count, 6) : '—'
|
||
}
|
||
if (customerSource && key === 'remark') return String(row.cargoRemark || '—')
|
||
const value = row[key]
|
||
if (value === null || value === undefined || value === '') return '—'
|
||
return typeof value === 'number' ? formatNumber(value, 6) : String(value)
|
||
}
|
||
|
||
interface MeasurementTotals {
|
||
count: number
|
||
gross: number
|
||
volume: number
|
||
volumetric: number
|
||
}
|
||
|
||
function measurementTotals(rows: Record<string, unknown>[], customerSource = false) {
|
||
const values = rows.reduce<MeasurementTotals>((result, row) => {
|
||
const count = Number(row.packageCount)
|
||
const gross = Number(customerSource ? row.grossWeightKg : row.totalWeightKg)
|
||
const volume = Number(customerSource ? row.volumeCbm : row.totalVolumeCbm)
|
||
const volumetric = Number(row.volumeWeightKg)
|
||
if (Number.isFinite(count)) result.count += count
|
||
if (Number.isFinite(gross)) result.gross += gross
|
||
if (Number.isFinite(volume)) result.volume += volume
|
||
if (Number.isFinite(volumetric)) result.volumetric += volumetric
|
||
return result
|
||
}, { count: 0, gross: 0, volume: 0, volumetric: 0 })
|
||
return {
|
||
count: values.count || null, gross: values.gross || null, volume: values.volume || null,
|
||
volumetric: values.volumetric || null, weightVolume: values.volume || null,
|
||
}
|
||
}
|
||
|
||
function revokeMeasurementImages() {
|
||
measurementImageUrls.value.forEach((url) => URL.revokeObjectURL(url))
|
||
measurementImageUrls.value = []
|
||
}
|
||
|
||
async function loadMeasurementDetails() {
|
||
const summary = detail.value?.summary
|
||
if (!summary?.inboundId || !can('platform:firstleg:inbound:view')) return
|
||
const key = `${summary.tenantId}:${summary.inboundId}`
|
||
if (measurementLoadedKey.value === key || measurementLoading.value) return
|
||
measurementLoading.value = true
|
||
try {
|
||
measurementDetail.value = await getInboundMeasurement(String(summary.tenantId), String(summary.inboundId))
|
||
measurementLoadedKey.value = key
|
||
revokeMeasurementImages()
|
||
const fileIds = [...measurementDetail.value.items.map((item) => item.intelligentImageFileId), ...measurementDetail.value.weightResults.map((item) => item.fileId)]
|
||
.filter((value): value is string => Boolean(value))
|
||
const blobs = await Promise.all(fileIds.map((fileId) => downloadWeightImage(String(summary.tenantId), String(summary.inboundId), fileId).catch(() => null)))
|
||
measurementImageUrls.value = blobs.filter((blob): blob is Blob => Boolean(blob)).map((blob) => URL.createObjectURL(blob))
|
||
} catch {
|
||
measurementDetail.value = null
|
||
revokeMeasurementImages()
|
||
} finally {
|
||
measurementLoading.value = false
|
||
}
|
||
}
|
||
|
||
function selectDetailTab(key: string) {
|
||
activeDetailTab.value = key
|
||
if (key === 'fees') void loadFeeDetails()
|
||
if (key === 'measurement') void loadMeasurementDetails()
|
||
}
|
||
|
||
function toggleMeasurementSection(section: keyof typeof measurementCollapsed) {
|
||
measurementCollapsed[section] = !measurementCollapsed[section]
|
||
}
|
||
|
||
function nextActionBoundary(row: UnifiedOrderResource) {
|
||
return row.nextAction === 'CONFIRM_MEASUREMENT' ? '待完成测量' : ''
|
||
}
|
||
|
||
function canApprove(row: UnifiedOrderResource) {
|
||
return row.mainStatus === 'PENDING_REVIEW' && row.nextAction === 'APPROVE_ORDER' && can('platform:order:approve')
|
||
}
|
||
|
||
function openApproval(row: UnifiedOrderResource) {
|
||
if (!canApprove(row)) return
|
||
approvalOrder.value = row
|
||
approvalOpen.value = true
|
||
}
|
||
|
||
function closeApproval() {
|
||
if (approvalSaving.value) return
|
||
approvalOpen.value = false
|
||
approvalOrder.value = null
|
||
}
|
||
|
||
async function approveOrder() {
|
||
if (!approvalOrder.value) return
|
||
const order = approvalOrder.value
|
||
const orderId = String(order.orderId)
|
||
const tenantId = String(order.tenantId)
|
||
approvalSaving.value = true
|
||
approvalLoadingId.value = orderId
|
||
try {
|
||
await approvePlatformUnifiedOrder(tenantId, orderId)
|
||
approvalOpen.value = false
|
||
await loadOrders()
|
||
if (detail.value?.summary.orderId === order.orderId) await loadDetail()
|
||
approvalOrder.value = null
|
||
ElMessage.success('订单审核通过')
|
||
} catch (error) {
|
||
ElMessage.error(getApiErrorDetails(error, '订单审核失败,请稍后重试').message)
|
||
} finally {
|
||
approvalSaving.value = false
|
||
approvalLoadingId.value = ''
|
||
}
|
||
}
|
||
|
||
function openNoteDialog(order?: UnifiedOrderResource) {
|
||
if (!can('platform:order:note')) return
|
||
const target = order || detail.value?.summary || null
|
||
if (!target) return
|
||
noteOrder.value = target
|
||
noteContent.value = ''
|
||
noteOpen.value = true
|
||
}
|
||
|
||
async function saveNote() {
|
||
const content = noteContent.value.trim()
|
||
if (!noteOrder.value || !content) {
|
||
ElMessage.warning('请填写内部备注')
|
||
return
|
||
}
|
||
noteSaving.value = true
|
||
try {
|
||
const target = noteOrder.value
|
||
await addPlatformUnifiedOrderNote(String(target.tenantId), String(target.orderId), content)
|
||
noteOpen.value = false
|
||
await loadOrders()
|
||
if (detail.value?.summary.orderId === target.orderId) await loadDetail(true)
|
||
noteOrder.value = null
|
||
ElMessage.success('内部备注已保存')
|
||
} catch (error) {
|
||
ElMessage.error(getApiErrorDetails(error, '内部备注保存失败,请稍后重试').message)
|
||
} finally {
|
||
noteSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function loadCustomers() {
|
||
customerLoading.value = true
|
||
customerLoadError.value = null
|
||
try {
|
||
const records: PlatformTenantResource[] = []
|
||
let currentPage = 1
|
||
let pages = 1
|
||
do {
|
||
const result = await listPlatformTenants({ page: currentPage, size: 200 })
|
||
records.push(...result.records)
|
||
pages = Math.max(1, result.pages)
|
||
currentPage += 1
|
||
} while (currentPage <= pages)
|
||
customers.value = records
|
||
} catch (error) {
|
||
customers.value = []
|
||
customerLoadError.value = getApiErrorDetails(error, '客户列表加载失败,暂时无法按客户筛选')
|
||
} finally {
|
||
customerLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadWarehouses() {
|
||
warehouseLoading.value = true
|
||
warehouseLoadError.value = null
|
||
try {
|
||
warehouses.value = await listAllPlatformWarehouses({ status: 'ACTIVE' })
|
||
} catch (error) {
|
||
warehouses.value = []
|
||
warehouseLoadError.value = getApiErrorDetails(error, '平台仓库加载失败,暂时无法按仓库筛选')
|
||
} finally {
|
||
warehouseLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadOrders() {
|
||
loading.value = true
|
||
loadError.value = null
|
||
try {
|
||
const activeFilters = appliedFilters.value
|
||
const range = activeFilters.createdRange
|
||
const result = await listPlatformUnifiedOrders({
|
||
tenantId: activeFilters.tenantId || undefined,
|
||
mainStatus: selectedStatus.value || undefined,
|
||
receivingWarehouseId: activeFilters.receivingWarehouseId || undefined,
|
||
destinationWarehouseId: activeFilters.destinationWarehouseId || undefined,
|
||
inboundCode: trimmed(activeFilters.inboundCode),
|
||
expectedArrivalDate: activeFilters.expectedArrivalDate || undefined,
|
||
feeReviewStatus: activeFilters.feeReviewStatus || undefined,
|
||
channelName: trimmed(activeFilters.channelName),
|
||
containerNo: trimmed(activeFilters.containerNo),
|
||
cabinetNo: trimmed(activeFilters.cabinetNo),
|
||
sailingDate: activeFilters.sailingDate || undefined,
|
||
loaded: optionalBoolean(activeFilters.loaded),
|
||
fullContainer: optionalBoolean(activeFilters.fullContainer),
|
||
measurementReviewStatus: activeFilters.measurementReviewStatus || undefined,
|
||
fbaCode: trimmed(activeFilters.fbaCode),
|
||
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
|
||
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
|
||
page: page.value,
|
||
size: pageSize.value,
|
||
})
|
||
records.value = result.records
|
||
total.value = result.total
|
||
} catch (error) {
|
||
records.value = []
|
||
total.value = 0
|
||
loadError.value = getApiErrorDetails(error, '统一订单列表加载失败,请检查 C02 统一查询接口和订单权限')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function search() {
|
||
appliedFilters.value = cloneFilters(filters.value)
|
||
if (page.value !== 1) {
|
||
page.value = 1
|
||
return
|
||
}
|
||
void loadOrders()
|
||
}
|
||
|
||
function reset() {
|
||
filters.value = emptyFilters()
|
||
appliedFilters.value = emptyFilters()
|
||
if (page.value === 1) void loadOrders()
|
||
else page.value = 1
|
||
}
|
||
|
||
async function openDetail(row: UnifiedOrderResource) {
|
||
await router.push({ name: 'unified-order-detail', params: { orderId: String(row.orderId) }, query: { tenantId: String(row.tenantId) } })
|
||
await nextTick()
|
||
window.scrollTo({ top: 0 })
|
||
}
|
||
|
||
function openMeasurement(row: UnifiedOrderResource) {
|
||
if (!row.inboundId || !can('platform:firstleg:inbound:view')) return
|
||
void router.push({ name: 'inbound-order-detail', params: { inboundId: String(row.inboundId) }, query: { tenantId: String(row.tenantId) } })
|
||
}
|
||
|
||
function openInboundFromDetail() {
|
||
const summary = detail.value?.summary
|
||
if (!summary?.inboundId || !can('platform:firstleg:inbound:view')) return
|
||
void router.push({
|
||
name: 'inbound-order-detail',
|
||
params: { inboundId: String(summary.inboundId) },
|
||
query: { tenantId: String(summary.tenantId) },
|
||
})
|
||
}
|
||
|
||
type UnifiedOrderRowAction = 'note' | 'approve' | 'measurement'
|
||
const unifiedOrderRowActionIcons: Record<UnifiedOrderRowAction, Component> = {
|
||
note: StickyNote,
|
||
approve: CircleCheck,
|
||
measurement: Ruler,
|
||
}
|
||
function unifiedOrderSecondaryActions(row: UnifiedOrderResource): UnifiedOrderRowAction[] {
|
||
return [
|
||
...(can('platform:order:note') ? ['note' as const] : []),
|
||
...(canApprove(row) ? ['approve' as const] : []),
|
||
...(row.mainStatus === 'WAIT_MEASURE' && row.inboundId && can('platform:firstleg:inbound:view') ? ['measurement' as const] : []),
|
||
]
|
||
}
|
||
function directUnifiedOrderActions(row: UnifiedOrderResource) {
|
||
return unifiedOrderSecondaryActions(row).slice(0, 2)
|
||
}
|
||
function moreUnifiedOrderActions(row: UnifiedOrderResource) {
|
||
return unifiedOrderSecondaryActions(row).slice(2)
|
||
}
|
||
function moreUnifiedOrderActionsLabel(row: UnifiedOrderResource) {
|
||
const compatibleOrder = row as UnifiedOrderResource & { id?: string | number | null }
|
||
const identifier = [compatibleOrder.inboundCode, compatibleOrder.orderNo, compatibleOrder.orderId, compatibleOrder.id]
|
||
.map((value) => String(value ?? '').trim())
|
||
.find(Boolean)
|
||
return `更多订单操作:${identifier || '当前订单'}`
|
||
}
|
||
function unifiedOrderActionLabel(action: UnifiedOrderRowAction) {
|
||
if (action === 'note') return '添加备注'
|
||
if (action === 'approve') return '审核通过'
|
||
return '测量作业'
|
||
}
|
||
function unifiedOrderActionType(action: UnifiedOrderRowAction) {
|
||
return action === 'approve' ? 'success' : 'primary'
|
||
}
|
||
function runMoreAction(command: string | number | object, row: UnifiedOrderResource) {
|
||
if (command === 'note') openNoteDialog(row)
|
||
if (command === 'approve') openApproval(row)
|
||
if (command === 'measurement') openMeasurement(row)
|
||
}
|
||
|
||
function closeDetail() {
|
||
detail.value = null
|
||
detailError.value = null
|
||
void router.push({ name: 'unified-orders' })
|
||
}
|
||
|
||
async function loadDetail(force = false) {
|
||
if (!orderId.value) return
|
||
const tenantId = detailTenantId.value
|
||
if (!tenantId) {
|
||
detailError.value = { message: '订单详情缺少客户标识,请从订单列表重新打开' }
|
||
return
|
||
}
|
||
const requestKey = `${tenantId}:${orderId.value}`
|
||
if (!force && (detailLoadingKey === requestKey || detail.value?.summary.orderId === orderId.value && String(detail.value.summary.tenantId) === tenantId)) return
|
||
const serial = ++detailRequestSerial
|
||
detailLoadingKey = requestKey
|
||
feeDetails.value = []
|
||
feeLoadedKey.value = ''
|
||
measurementDetail.value = null
|
||
measurementLoadedKey.value = ''
|
||
revokeMeasurementImages()
|
||
detailLoading.value = true
|
||
detailError.value = null
|
||
try {
|
||
const response = await getPlatformUnifiedOrder(tenantId, orderId.value)
|
||
if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return
|
||
detail.value = response
|
||
} catch (error) {
|
||
if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return
|
||
detail.value = null
|
||
detailError.value = getApiErrorDetails(error, '统一订单详情加载失败,请检查 C02 统一详情接口和订单权限')
|
||
} finally {
|
||
if (serial === detailRequestSerial) detailLoading.value = false
|
||
if (serial === detailRequestSerial && detailLoadingKey === requestKey) detailLoadingKey = ''
|
||
}
|
||
}
|
||
|
||
watch([page, pageSize], () => { void loadOrders() })
|
||
watch(detailRouteKey, (value) => {
|
||
if (value) {
|
||
activeDetailTab.value = 'basic'
|
||
detail.value = null
|
||
detailError.value = null
|
||
void loadDetail()
|
||
} else {
|
||
detailRequestSerial += 1
|
||
detail.value = null
|
||
detailError.value = null
|
||
detailLoading.value = false
|
||
}
|
||
}, { immediate: true })
|
||
onMounted(() => {
|
||
void loadCustomers()
|
||
void loadWarehouses()
|
||
if (!orderId.value) void loadOrders()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<AdminPage>
|
||
<section class="business-page unified-orders-page">
|
||
<template v-if="orderId">
|
||
<DetailPageLayout ariaLabel="统一订单详情">
|
||
<header class="order-detail-header">
|
||
<div class="order-detail-heading">
|
||
<el-button link class="back-button" @click="closeDetail"><ArrowLeft :size="16" />返回列表</el-button>
|
||
<span class="order-mark" aria-hidden="true"><Box :size="19" /></span>
|
||
<div class="order-title">
|
||
<h1>
|
||
<el-button v-if="detail?.summary.inboundId && can('platform:firstleg:inbound:view')" link type="primary" class="inbound-code-link" @click="openInboundFromDetail">{{ detail.summary.inboundCode || `入库单 ${detail.summary.inboundId}` }}</el-button>
|
||
<span v-else>{{ detail?.summary.inboundCode || '暂未生成' }}</span>
|
||
</h1>
|
||
<p>{{ detail?.summary.orderNo || `订单 ${orderId}` }}</p>
|
||
</div>
|
||
<StatusBadge v-if="detail" :status="statusLabel(detail.summary.mainStatus)" />
|
||
</div>
|
||
<div v-if="detail && canApprove(detail.summary)" class="detail-header-actions">
|
||
<el-button v-if="canApprove(detail.summary)" type="success" plain :loading="approvalLoadingId === String(detail.summary.orderId)" @click="openApproval(detail.summary)"><CircleCheck :size="15" />审核通过</el-button>
|
||
</div>
|
||
</header>
|
||
|
||
<LoadErrorState v-if="detailError" class="detail-load-error" title="订单详情暂时无法加载" :message="detailError.message" :loading="detailLoading" @retry="loadDetail">
|
||
<template #details><ApiErrorMeta :details="detailError" /></template>
|
||
</LoadErrorState>
|
||
|
||
<div v-else v-loading="detailLoading" class="order-detail-content">
|
||
<template v-if="detail">
|
||
<section class="detail-overview" aria-label="订单概览">
|
||
<article><span>客户</span><strong>{{ customerName(detail.summary) }}</strong><small>{{ detail.summary.channelName || '未配置渠道' }}</small></article>
|
||
<article><span>总箱数</span><strong>{{ detail.summary.totalPackageCount ?? '—' }}<small>箱</small></strong><small>{{ detail.order.cargoes.length }} 条货品明细</small></article>
|
||
<article><span>实测毛重</span><strong>{{ formatNumber(detail.summary.measurement?.actualGrossWeightKg) }}<small>kg</small></strong><small>{{ measurementStatusLabel(detail.summary.measurement?.reviewStatus) }}</small></article>
|
||
<article><span>实测体积</span><strong>{{ formatNumber(detail.summary.measurement?.actualVolumeCbm, 3) }}<small>m³</small></strong><small>最近更新 {{ formatDate(detail.summary.updatedAt) }}</small></article>
|
||
</section>
|
||
|
||
<div class="detail-tabs" role="tablist" aria-label="订单详情模块">
|
||
<button v-for="tab in detailTabOptions" :id="`order-detail-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeDetailTab === tab.key }" :aria-selected="activeDetailTab === tab.key" :aria-controls="`order-detail-panel-${tab.key}`" :tabindex="activeDetailTab === tab.key ? 0 : -1" @click="selectDetailTab(tab.key)"><component :is="tab.icon" :size="17" /><span>{{ tab.label }}</span></button>
|
||
</div>
|
||
|
||
<section :id="`order-detail-panel-${activeDetailTab}`" class="detail-panel" role="tabpanel" :aria-labelledby="`order-detail-tab-${activeDetailTab}`" tabindex="0">
|
||
<template v-if="activeDetailTab === 'basic'">
|
||
<div class="panel-title"><div><h2>订单基本信息</h2><p>{{ detail.summary.orderNo || `订单 ${detail.summary.orderId}` }}</p></div><Box :size="19" /></div>
|
||
<dl class="order-info-grid">
|
||
<div><dt>公司</dt><dd>{{ customerName(detail.summary) }}</dd></div>
|
||
<div><dt>运输方式</dt><dd>{{ firstLegTransportModeLabel(detail.order.order?.transportMode) }}</dd></div>
|
||
<div><dt>当前状态</dt><dd><StatusBadge :status="statusLabel(detail.summary.mainStatus)" /></dd></div>
|
||
<div><dt>内部订单状态</dt><dd>{{ orderStatusLabel(detail.summary.internalOrderStatus || detail.summary.mainStatus) }}</dd></div>
|
||
<div><dt>平台收货仓</dt><dd>{{ detail.summary.receivingWarehouseName || '—' }}</dd></div>
|
||
<div><dt>收货仓地址</dt><dd>{{ detail.order.order?.receivingWarehouseAddress || '—' }}</dd></div>
|
||
<div><dt>目的仓</dt><dd>{{ detail.summary.destinationWarehouseName || '—' }}</dd></div>
|
||
<div><dt>目的国家</dt><dd>{{ detail.order.order?.destinationCountryCode || '—' }}</dd></div>
|
||
<div><dt>目的仓地址</dt><dd>{{ detail.summary.destinationAddress || '—' }}</dd></div>
|
||
<div><dt>目的仓邮编</dt><dd>{{ detail.summary.destinationPostalCode || '—' }}</dd></div>
|
||
<div><dt>目的仓联系人</dt><dd>{{ detail.summary.destinationContact || '—' }}</dd></div>
|
||
<div><dt>目的仓联系人电话</dt><dd>{{ detail.summary.destinationContactPhone || '—' }}</dd></div>
|
||
<div><dt>渠道</dt><dd>{{ detail.summary.channelName || '—' }}</dd></div>
|
||
<div><dt>业务类型</dt><dd>{{ firstLegBusinessTypeLabel(detail.summary.businessType) }}</dd></div>
|
||
<div><dt>贸易条款</dt><dd>{{ firstLegTradeTermLabel(detail.order.order?.tradeTerm) }}</dd></div>
|
||
<div><dt>买单 / 退税</dt><dd>{{ firstLegTaxModeLabel(detail.order.order?.taxMode) }}</dd></div>
|
||
<div><dt>费用审核状态</dt><dd>{{ feeReviewLabel(detail.summary.feeReviewStatus) }}</dd></div>
|
||
<div><dt>是否扣货</dt><dd>{{ displayBoolean(detail.summary.holdCargo) }}</dd></div>
|
||
<div><dt>扣货理由</dt><dd>{{ detail.summary.holdReason || '—' }}</dd></div>
|
||
<div><dt>提货方式</dt><dd>{{ firstLegPickupMethodLabel(detail.order.order?.pickupMethod) }}</dd></div>
|
||
<div><dt>提货时间</dt><dd>{{ formatDate(detail.summary.pickupAt) }}</dd></div>
|
||
<div><dt>到仓时间</dt><dd>{{ detail.order.order?.receivingTime || '—' }}</dd></div>
|
||
<div><dt>货物到仓时间</dt><dd>{{ formatDate(detail.summary.expectedArrivalAt || detail.summary.inboundCompletedAt) }}</dd></div>
|
||
<div><dt>预计出运</dt><dd>{{ formatDate(detail.summary.expectedShipDate) }}</dd></div>
|
||
<div><dt>预计到达时间</dt><dd>{{ formatDate(detail.summary.expectedArrivalAt) }}</dd></div>
|
||
<div><dt>集装箱号</dt><dd>{{ detail.summary.transport?.containerNo || '—' }}</dd></div>
|
||
<div><dt>柜号</dt><dd>{{ detail.summary.transport?.cabinetNo || '—' }}</dd></div>
|
||
<div><dt>船名 / 航次</dt><dd>{{ detail.summary.transport?.vesselName || '—' }} / {{ detail.summary.transport?.voyageNo || '—' }}</dd></div>
|
||
<div><dt>开船时间</dt><dd>{{ formatDate(detail.summary.transport?.sailingAt) }}</dd></div>
|
||
<div><dt>到港时间</dt><dd>{{ formatDate(detail.summary.transport?.portArrivalAt) }}</dd></div>
|
||
<div><dt>配载状态</dt><dd>{{ unifiedOrderLoadingStatusLabel(detail.summary.transport?.loadingStatus) }}</dd></div>
|
||
<div><dt>目的仓类型</dt><dd>{{ firstLegDestinationTypeLabel(detail.order.order?.destinationType) }}</dd></div>
|
||
<div><dt>是否整柜</dt><dd>{{ firstLegContainerModeLabel(detail.order.order?.containerMode) }}</dd></div>
|
||
<div><dt>商检验</dt><dd>{{ displayBoolean(detail.order.order?.customsInspection) }}</dd></div>
|
||
<div><dt>总箱数</dt><dd>{{ detail.summary.totalPackageCount ?? '—' }} 箱</dd></div>
|
||
<div><dt>推荐托盘类型</dt><dd>{{ detail.summary.recommendedPalletType || '—' }}</dd></div>
|
||
<div><dt>总毛重</dt><dd>{{ formatNumber(cargoTotal(detail.order.cargoes, 'grossWeightKg') ?? detail.summary.measurement?.actualGrossWeightKg) }} kg</dd></div>
|
||
<div><dt>总净重</dt><dd>{{ formatNumber(cargoTotal(detail.order.cargoes, 'netWeightKg')) }} kg</dd></div>
|
||
<div><dt>总体积</dt><dd>{{ formatNumber(cargoTotal(detail.order.cargoes, 'volumeCbm') ?? detail.summary.measurement?.actualVolumeCbm, 3) }} m³</dd></div>
|
||
<div><dt>计费毛重</dt><dd>{{ formatNumber(detail.summary.measurement?.chargeableWeightKg) }} kg</dd></div>
|
||
<div><dt>计费体积</dt><dd>{{ formatNumber(detail.summary.chargeableVolumeCbm, 3) }} m³</dd></div>
|
||
<div><dt>总材积重</dt><dd>{{ formatNumber(detail.summary.volumetricWeightKg) }} kg</dd></div>
|
||
<div><dt>总材积量</dt><dd>{{ formatNumber(detail.summary.volumetricWeightKg) }}</dd></div>
|
||
<div><dt>库位位置</dt><dd>{{ detail.summary.locationCodes || '—' }}</dd></div>
|
||
<div><dt>创建时间</dt><dd>{{ formatDate(detail.summary.createdAt) }}</dd></div>
|
||
<div><dt>入库完成时间</dt><dd>{{ formatDate(detail.summary.inboundCompletedAt) }}</dd></div>
|
||
<div><dt>测量完成时间</dt><dd>{{ formatDate(detail.summary.measurementCompletedAt || detail.summary.measurement?.completedAt) }}</dd></div>
|
||
<div class="wide"><dt>提货联系人</dt><dd>{{ partyValue(detail.order.parties, 'SENDER', 'contactName') }} · {{ detail.summary.pickupContactPhone || partyValue(detail.order.parties, 'SENDER', 'phone') }} · {{ detail.summary.pickupContactEmail || partyValue(detail.order.parties, 'SENDER', 'email') }}</dd></div>
|
||
<div class="wide"><dt>提货地址</dt><dd>{{ detail.summary.pickupAddress || partyAddress(detail.order.parties, 'SENDER') }} · {{ detail.summary.pickupPostalCode || partyValue(detail.order.parties, 'SENDER', 'postalCode') }}</dd></div>
|
||
<div class="wide"><dt>目的地联系人</dt><dd>{{ partyValue(detail.order.parties, 'RECEIVER', 'contactName') }} · {{ partyValue(detail.order.parties, 'RECEIVER', 'phone') }} · {{ partyValue(detail.order.parties, 'RECEIVER', 'email') }}</dd></div>
|
||
<div><dt>目的仓联系人邮箱</dt><dd>{{ partyValue(detail.order.parties, 'RECEIVER', 'email') }}</dd></div>
|
||
<div class="wide"><dt>目的地地址</dt><dd>{{ partyAddress(detail.order.parties, 'RECEIVER') }} · {{ partyValue(detail.order.parties, 'RECEIVER', 'postalCode') }}</dd></div>
|
||
<div class="wide"><dt>备注</dt><dd>{{ detail.order.order?.customerRemark || '—' }}</dd></div>
|
||
<div class="wide"><dt>关联文件</dt><dd>{{ detail.summary.associatedFiles?.join('、') || '—' }}</dd></div>
|
||
<div class="wide"><dt>唛头文件</dt><dd>{{ detail.summary.markFiles?.join('、') || '—' }}</dd></div>
|
||
<div class="wide"><dt>PC 端下单文件</dt><dd>{{ detail.summary.pcOrderFiles?.join('、') || '—' }}</dd></div>
|
||
<div><dt>快递公司</dt><dd>{{ detail.summary.expressCompany || detail.order.order?.expressCompany || '—' }}</dd></div>
|
||
<div><dt>快递单号</dt><dd>{{ detail.summary.expressTrackingNo || detail.order.order?.expressTrackingNo || '—' }}</dd></div>
|
||
<div class="wide"><dt>客服内部备注</dt><dd>{{ detail.summary.internalRemark || '—' }}</dd></div>
|
||
<div><dt>创建人</dt><dd>{{ detail.summary.createdBy || '—' }}</dd></div>
|
||
<div><dt>下一步</dt><dd>{{ canApprove(detail.summary) ? '审核订单' : nextActionBoundary(detail.summary) || '—' }}</dd></div>
|
||
</dl>
|
||
</template>
|
||
|
||
<template v-else-if="activeDetailTab === 'cargo'">
|
||
<div class="panel-title"><div><h2>货品信息</h2><p>共 {{ detail.order.cargoes.length }} 条</p></div><PackageOpen :size="19" /></div>
|
||
<div class="detail-table-wrap">
|
||
<el-table :data="detail.order.cargoes" empty-text="暂无货品信息">
|
||
<el-table-column prop="itemNo" label="#" width="64" />
|
||
<el-table-column label="报关HS_CODE" min-width="130"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'hsCode') }}</template></el-table-column>
|
||
<el-table-column label="货品图片" width="110"><template #default="{ row }"><el-image v-if="cargoImage(row as Record<string, unknown>)" :src="cargoImage(row as Record<string, unknown>)" fit="contain" class="cargo-image" /><span v-else>—</span></template></el-table-column>
|
||
<el-table-column label="SKU NO." min-width="150"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'skuNo') }}</template></el-table-column>
|
||
<el-table-column label="中文品名" min-width="170"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'cargoName') }}</template></el-table-column>
|
||
<el-table-column label="英文品名" min-width="170"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'cargoNameEn') }}</template></el-table-column>
|
||
<el-table-column label="清关HS_CODE" min-width="130"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'customsHsCode') }}</template></el-table-column>
|
||
<el-table-column label="箱规长(cm)" min-width="110"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'lengthCm') }}</template></el-table-column>
|
||
<el-table-column label="箱规宽(cm)" min-width="110"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'widthCm') }}</template></el-table-column>
|
||
<el-table-column label="箱规高(cm)" min-width="110"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'heightCm') }}</template></el-table-column>
|
||
<el-table-column label="箱规周长(cm)" min-width="120"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'circumferenceCm') }}</template></el-table-column>
|
||
<el-table-column label="每箱产品数量" min-width="130"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'unitsPerPackage') }}</template></el-table-column>
|
||
<el-table-column label="箱数" width="90"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'packageCount') }}</template></el-table-column>
|
||
<el-table-column label="总毛重(T.KGW)" min-width="140"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'grossWeightKg') }}</template></el-table-column>
|
||
<el-table-column label="总净重(T.KNW)" min-width="140"><template #default="{ row }">{{ cargoValue(row as Record<string, unknown>, 'netWeightKg') }}</template></el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeDetailTab === 'fees'">
|
||
<div class="panel-title"><div><h2>费用信息</h2><p>共 {{ feeDetails.length }} 条应收应付费用明细</p></div><WalletCards :size="19" /></div>
|
||
<el-alert v-if="!can('firstleg:inbound:fee-detail:view')" title="当前账号没有查看费用明细的权限" type="warning" :closable="false" show-icon />
|
||
<div v-else v-loading="feeLoading" class="detail-table-wrap">
|
||
<el-table :data="feeDetails" empty-text="暂无费用数据" row-key="id">
|
||
<el-table-column label="审核状态" min-width="120"><template #default="{ row }"><StatusBadge :status="feeReviewLabel((row as UnifiedOrderFeeDetailResource).reviewStatus)" /></template></el-table-column>
|
||
<el-table-column label="应收/应付" min-width="110"><template #default="{ row }">{{ feeDirectionLabel((row as UnifiedOrderFeeDetailResource).direction) }}</template></el-table-column>
|
||
<el-table-column label="客户公司名" min-width="170"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).customerName) }}</template></el-table-column>
|
||
<el-table-column label="裹箱头" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).invoiceTitle) }}</template></el-table-column>
|
||
<el-table-column label="入仓码" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).inboundCode) }}</template></el-table-column>
|
||
<el-table-column label="业务发生日" min-width="130"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).businessDate) }}</template></el-table-column>
|
||
<el-table-column label="渠道" min-width="130"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).channelName) }}</template></el-table-column>
|
||
<el-table-column label="目的仓" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).destinationName) }}</template></el-table-column>
|
||
<el-table-column label="FBA编号" min-width="130"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).fbaNo) }}</template></el-table-column>
|
||
<el-table-column label="箱数" width="90"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).packageCount) }}</template></el-table-column>
|
||
<el-table-column label="毛重" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).actualGrossWeightKg) }}</template></el-table-column>
|
||
<el-table-column label="体积" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).actualVolumeCbm) }}</template></el-table-column>
|
||
<el-table-column label="费用类型" min-width="150"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).feeTypeNameSnapshot) }}</template></el-table-column>
|
||
<el-table-column label="货币" width="90"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).currency) }}</template></el-table-column>
|
||
<el-table-column label="应收金额" min-width="120"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).amount) }}</template></el-table-column>
|
||
<el-table-column label="计费体称/重量" min-width="150"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).chargeableQuantity) }} {{ (row as UnifiedOrderFeeDetailResource).chargeUnit || '' }}</template></el-table-column>
|
||
<el-table-column label="单价" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).unitPrice) }}</template></el-table-column>
|
||
<el-table-column label="入仓时间" min-width="150"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).inboundedAt) }}</template></el-table-column>
|
||
<el-table-column label="出仓时间" min-width="150"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).outboundAt) }}</template></el-table-column>
|
||
<el-table-column label="仓储天数" min-width="110"><template #default="{ row }">{{ feeNumber((row as UnifiedOrderFeeDetailResource).storageDays) }}</template></el-table-column>
|
||
<el-table-column label="参与结算" min-width="110"><template #default="{ row }">{{ (row as UnifiedOrderFeeDetailResource).settlementIncluded == null ? '—' : (row as UnifiedOrderFeeDetailResource).settlementIncluded ? '是' : '否' }}</template></el-table-column>
|
||
<el-table-column label="账单编号" min-width="140"><template #default="{ row }">{{ feeText((row as UnifiedOrderFeeDetailResource).billNo) }}</template></el-table-column>
|
||
<el-table-column label="结算起始日" min-width="130"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).settlementStartDate) }}</template></el-table-column>
|
||
<el-table-column label="结算结束日" min-width="130"><template #default="{ row }">{{ feeDate((row as UnifiedOrderFeeDetailResource).settlementEndDate) }}</template></el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeDetailTab === 'history'">
|
||
<div class="panel-title"><div><h2>状态历史</h2><p>共 {{ detail.timeline.length }} 条</p></div><History :size="19" /></div>
|
||
<el-empty v-if="detail.timeline.length === 0" description="暂无状态历史" :image-size="64" />
|
||
<ol v-else class="history-list" aria-label="状态历史"><li v-for="(item, index) in detail.timeline" :key="`${item.source || 'event'}-${item.occurredAt || index}`"><span class="history-marker" aria-hidden="true" /><div><div class="history-line"><strong>{{ historyActionLabel(item.action || item.eventType) }}</strong><StatusBadge :status="statusLabel(item.mainStatus)" /></div><p>{{ item.operatorName || '系统' }} · {{ formatDate(item.occurredAt) }}</p><p v-if="item.remark" class="history-remark">{{ item.remark }}</p></div></li></ol>
|
||
</template>
|
||
|
||
<template v-else-if="activeDetailTab === 'fulfillment'">
|
||
<div class="panel-title"><div><h2>拆分履约</h2><p>根单、已完成分片与当前余量</p></div><ListChecks :size="19" /></div>
|
||
<OrderFulfillmentTreePanel :tenant-id="String(detail.summary.tenantId)" :order-id="String(detail.summary.orderId)" />
|
||
</template>
|
||
|
||
<template v-else-if="activeDetailTab === 'workflow'">
|
||
<UnifiedOrderWorkflowPanel
|
||
:tenant-id="String(detail.summary.tenantId)"
|
||
:inbound-id="String(detail.summary.inboundId)"
|
||
:order-id="String(detail.summary.orderId)"
|
||
:main-status="detail.summary.mainStatus"
|
||
:inbound-version="detail.summary.inboundVersion"
|
||
:picking-version="detail.summary.loading?.pickingVersion"
|
||
@updated="loadDetail(true)"
|
||
/>
|
||
</template>
|
||
<template v-else-if="activeDetailTab === 'measurement'">
|
||
<div class="panel-title"><div><h2>测量数据</h2><p>{{ detail.summary.measurement?.completedAt ? `完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}</p></div><Ruler :size="19" /></div>
|
||
<section class="measurement-section">
|
||
<button type="button" class="measurement-section-title" :aria-expanded="!measurementCollapsed.customer" @click="toggleMeasurementSection('customer')"><h3>一、客户下单数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals(detail.order.cargoes, true).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.customer" :size="18" /><ChevronDown v-else :size="18" /></button>
|
||
<div v-if="!measurementCollapsed.customer">
|
||
<div class="detail-table-wrap"><el-table :data="detail.order.cargoes" empty-text="暂无客户下单数据"><el-table-column v-for="column in customerMeasurementColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key, true)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key, true) }}</template></template></el-table-column></el-table></div>
|
||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(detail.order.cargoes, true).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(detail.order.cargoes, true).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volume, 6) }}</span><span>总材积:{{ formatNumber(measurementTotals(detail.order.cargoes, true).volumetric) }}</span></div>
|
||
</div>
|
||
</section>
|
||
<section class="measurement-section">
|
||
<button type="button" class="measurement-section-title" :aria-expanded="!measurementCollapsed.outer" @click="toggleMeasurementSection('outer')"><h3>二、外箱规数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volume, 6) }}</span><span>总材积重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volumetric) }}</span><span>重量体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).weightVolume, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.outer" :size="18" /><ChevronDown v-else :size="18" /></button>
|
||
<div v-if="!measurementCollapsed.outer">
|
||
<div v-loading="measurementLoading" class="detail-table-wrap"><el-table :data="measurementDetail?.items || []" empty-text="暂无外箱规数据"><el-table-column v-for="column in measuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template></el-table-column></el-table></div>
|
||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).gross) }}</span><span>总体积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volume, 6) }}</span><span>总材积:{{ formatNumber(measurementTotals((measurementDetail?.items || []) as unknown as Record<string, unknown>[]).volumetric) }}</span></div>
|
||
</div>
|
||
</section>
|
||
<section class="measurement-section">
|
||
<button type="button" class="measurement-section-title" :aria-expanded="!measurementCollapsed.actual" @click="toggleMeasurementSection('actual')"><h3>三、实际测量数据</h3><span class="measurement-section-summary"><span>总件数:{{ measurementDetail?.actualPackageCount ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }}</span><span>总体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span><span>总材积重:{{ formatNumber(measurementDetail?.volumeWeightKg) }}</span><span>重量体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span></span><ChevronUp v-if="!measurementCollapsed.actual" :size="18" /><ChevronDown v-else :size="18" /></button>
|
||
<div v-if="!measurementCollapsed.actual">
|
||
<div v-loading="measurementLoading" class="detail-table-wrap"><el-table :data="measurementDetail?.items || []" empty-text="暂无实际测量数据"><el-table-column v-for="column in measuredColumns" :key="column.key" :label="column.label" :prop="column.key" min-width="120"><template #default="{ row }"><LineClampText v-if="column.key === 'remark'" :content="measurementValue(row as Record<string, unknown>, column.key)" /><template v-else>{{ measurementValue(row as Record<string, unknown>, column.key) }}</template></template></el-table-column></el-table></div>
|
||
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementDetail?.actualPackageCount ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }}</span><span>总体积:{{ formatNumber(measurementDetail?.actualVolumeCbm, 6) }}</span><span>总材积:{{ formatNumber(measurementDetail?.volumeWeightKg) }}</span></div>
|
||
<div class="measurement-media"><h4>测量图片</h4><div v-if="measurementImageUrls.length" class="measurement-media-grid"><el-image v-for="url in measurementImageUrls" :key="url" :src="url" fit="contain" :preview-src-list="measurementImageUrls" class="measurement-media-image" /></div><el-empty v-else description="暂无测量图片" :image-size="48" /><h4>测量视频</h4><el-empty description="暂无测量视频" :image-size="48" /></div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<div class="panel-title"><div><h2>内部备注</h2><p>仅平台内部可见</p></div><el-button v-if="can('platform:order:note')" type="primary" size="small" @click="openNoteDialog()"><StickyNote :size="15" />添加备注</el-button></div>
|
||
<el-empty v-if="detail.order.internalNotes.length === 0" description="暂无内部备注" :image-size="64" />
|
||
<ul v-else class="notes-list" aria-label="内部备注"><li v-for="item in detail.order.internalNotes" :key="String(item.id)"><p>{{ item.content }}</p><span>操作人 {{ item.createdBy || '系统' }} · {{ formatDate(item.createdAt) }}</span></li></ul>
|
||
</template>
|
||
</section>
|
||
</template>
|
||
</div>
|
||
</DetailPageLayout>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<ListPageLayout ariaLabel="统一订单列表">
|
||
<template #toolbar>
|
||
<div class="orders-query-panel">
|
||
<div class="status-tabs" role="tablist" aria-label="订单主状态">
|
||
<button
|
||
v-for="(tab, index) in statusTabs"
|
||
:key="tab.value || 'ALL'"
|
||
type="button"
|
||
role="tab"
|
||
:aria-selected="selectedStatus === tab.value"
|
||
aria-controls="unified-orders-results"
|
||
:tabindex="selectedStatus === tab.value ? 0 : -1"
|
||
:class="['status-tab', { 'is-active': selectedStatus === tab.value }]"
|
||
@click="selectStatus(tab.value)"
|
||
@keydown="handleTabKeydown($event, index)"
|
||
>{{ tab.label }}</button>
|
||
</div>
|
||
|
||
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" class="orders-query-toolbar" ariaLabel="订单列表筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="unified-orders-query-v2" :search-on-reset="false" @search="search" @reset="reset">
|
||
<template #actions>
|
||
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
|
||
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
|
||
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
|
||
</el-button>
|
||
</template>
|
||
</QueryToolbar>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-if="loadError" #state>
|
||
<LoadErrorState title="订单列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadOrders">
|
||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||
</LoadErrorState>
|
||
</template>
|
||
|
||
<template #default>
|
||
<div v-if="!loadError" id="unified-orders-results" class="surface-panel table-panel table-card" role="tabpanel">
|
||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表">
|
||
<template #actions="{ row }">
|
||
<div class="row-actions">
|
||
<el-button link type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>
|
||
<el-button v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" link :type="unifiedOrderActionType(action)" :loading="action === 'approve' && approvalLoadingId === String((row as UnifiedOrderResource).orderId)" @click="runMoreAction(action, row as UnifiedOrderResource)"><component :is="unifiedOrderRowActionIcons[action]" :size="15" />{{ unifiedOrderActionLabel(action) }}</el-button>
|
||
<el-dropdown v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length" trigger="click" @command="runMoreAction($event, row as UnifiedOrderResource)">
|
||
<el-button link type="primary" :aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" :title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"><MoreHorizontal :size="17" /></el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item v-for="action in moreUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" :command="action"><component :is="unifiedOrderRowActionIcons[action]" :size="15" />{{ unifiedOrderActionLabel(action) }}</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
</template>
|
||
<template #inboundCode="{ row }">
|
||
<div class="inbound-relation-cell">
|
||
<span>{{ (row as UnifiedOrderResource).inboundCode || '—' }}</span>
|
||
<el-button
|
||
v-if="(row as UnifiedOrderResource).relation"
|
||
class="order-relation-link"
|
||
link
|
||
type="primary"
|
||
:aria-label="`查看${orderRelationLabel(row as UnifiedOrderResource)}关联`"
|
||
@click="openOrderRelation(row as UnifiedOrderResource)"
|
||
><GitBranch :size="14" />{{ orderRelationLabel(row as UnifiedOrderResource) }}</el-button>
|
||
</div>
|
||
</template>
|
||
<template #orderStatus="{ row }"><StatusBadge :status="orderStatusLabel((row as UnifiedOrderResource).internalOrderStatus || (row as UnifiedOrderResource).mainStatus)" /></template>
|
||
<template #measurementReviewStatus="{ row }"><StatusBadge :status="measurementStatusLabel((row as UnifiedOrderResource).measurement?.reviewStatus)" /></template>
|
||
<template #field="{ row, column }"><LineClampText v-if="['customerRemark', 'latestInternalNote'].includes(String(column.property))" :content="displayField(row as UnifiedOrderResource, column.property)" :lines="2" /><template v-else>{{ displayField(row as UnifiedOrderResource, column.property) }}</template></template>
|
||
<template #order="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).orderNo || `订单 ${(row as UnifiedOrderResource).orderId}` }}</strong><span v-if="(row as UnifiedOrderResource).inboundCode">入仓码 {{ (row as UnifiedOrderResource).inboundCode }}</span></div></template>
|
||
<template #mainStatus="{ row }"><StatusBadge :status="statusLabel((row as UnifiedOrderResource).mainStatus)" /></template>
|
||
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong><span>客户 ID {{ (row as UnifiedOrderResource).tenantId }}</span></div></template>
|
||
<template #receivingWarehouseName="{ row }">{{ (row as UnifiedOrderResource).receivingWarehouseName || '—' }}</template>
|
||
<template #destinationWarehouseName="{ row }">{{ (row as UnifiedOrderResource).destinationWarehouseName || '—' }}</template>
|
||
<template #transport="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).transport?.containerNo || unifiedOrderLoadingStatusLabel((row as UnifiedOrderResource).transport?.loadingStatus) }}</strong><span v-if="(row as UnifiedOrderResource).transport?.vesselName">{{ (row as UnifiedOrderResource).transport?.vesselName }} · {{ (row as UnifiedOrderResource).transport?.voyageNo || '—' }}</span></div></template>
|
||
<template #cargo="{ row }"><span>{{ (row as UnifiedOrderResource).totalPackageCount ?? '—' }} 箱</span><span v-if="(row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== null && (row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualGrossWeightKg) }} kg</span><span v-if="(row as UnifiedOrderResource).measurement?.actualVolumeCbm !== null && (row as UnifiedOrderResource).measurement?.actualVolumeCbm !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualVolumeCbm, 3) }} m³</span></template>
|
||
<template #expectedShipDate="{ row }">{{ formatDate((row as UnifiedOrderResource).expectedShipDate) }}</template>
|
||
<template #updatedAt="{ row }">{{ formatDate((row as UnifiedOrderResource).updatedAt) }}</template>
|
||
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template>
|
||
</DataTable>
|
||
</div>
|
||
</template>
|
||
</ListPageLayout>
|
||
</template>
|
||
|
||
<AppDrawer v-model="relationOpen" title="父子订单关联" size="min(860px, 94vw)" :show-footer="false" destroy-on-close>
|
||
<OrderFulfillmentTreePanel
|
||
v-if="relationOrder"
|
||
:tenant-id="String(relationOrder.tenantId)"
|
||
:order-id="String(relationOrder.orderId)"
|
||
/>
|
||
</AppDrawer>
|
||
|
||
<AppDialog v-model="noteOpen" title="添加内部备注" confirm-text="保存备注" :confirm-loading="noteSaving" @confirm="saveNote">
|
||
<el-input v-model="noteContent" type="textarea" :rows="5" maxlength="2000" show-word-limit placeholder="请输入仅平台内部可见的备注" aria-label="内部备注内容" />
|
||
</AppDialog>
|
||
|
||
<AppDialog v-model="approvalOpen" title="审核订单" confirm-text="审核通过" confirm-type="success" :confirm-loading="approvalSaving" destroy-on-close @confirm="approveOrder" @cancel="closeApproval">
|
||
<el-alert title="确认订单资料无误后审核通过,订单将进入待测量阶段。" type="info" :closable="false" show-icon />
|
||
</AppDialog>
|
||
|
||
</section>
|
||
</AdminPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.unified-orders-page { min-width: 0; gap: 16px; }
|
||
.page-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||
.page-intro h1 { margin: 0; color: var(--cc-text); font-size: 22px; line-height: 1.35; }
|
||
.page-intro p { margin: 5px 0 0; color: var(--cc-muted); font-size: 13px; line-height: 1.6; }
|
||
.page-intro :deep(.el-button) { flex: 0 0 auto; }
|
||
.orders-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||
.status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid var(--cc-border); scrollbar-width: thin; }
|
||
.status-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }
|
||
.status-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }
|
||
.status-tab:hover { color: var(--cc-primary); background: #f6f9fd; }
|
||
.status-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
|
||
.status-tab.is-active { color: var(--cc-primary); font-weight: 600; }
|
||
.status-tab.is-active::after { background: var(--cc-primary); }
|
||
.orders-query-toolbar { border: 0; }
|
||
.table-card { overflow: hidden; }
|
||
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||
.row-actions :deep(.el-button) { margin-left: 0; }
|
||
.inbound-relation-cell { display: grid; min-width: 0; gap: 5px; }
|
||
.inbound-relation-cell > span { overflow-wrap: anywhere; }
|
||
.order-relation-link { width: fit-content; max-width: 100%; height: auto; justify-content: flex-start; padding: 0; font-size: 12px; }
|
||
.order-relation-link :deep(span) { min-width: 0; white-space: normal; text-align: left; }
|
||
.primary-cell { display: grid; min-width: 0; gap: 4px; }
|
||
.primary-cell strong { overflow-wrap: anywhere; }
|
||
.primary-cell span { color: var(--cc-muted); font-size: 12px; overflow-wrap: anywhere; }
|
||
.order-detail-header { display: flex; min-width: 0; min-height: 42px; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
||
.order-detail-heading { display: flex; min-width: 0; align-items: center; gap: 10px; }
|
||
.back-button { flex: 0 0 auto; padding: 0; }
|
||
.order-mark { display: grid; width: 36px; height: 36px; flex: 0 0 36px; place-items: center; border-radius: 7px; background: #1d2939; color: #fff; }
|
||
.order-title { min-width: 0; }
|
||
.order-title h1 { margin: 0; overflow: hidden; color: #172033; font-size: 18px; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
|
||
.inbound-code-link { width: 100%; max-width: 100%; overflow: hidden; justify-content: flex-start; padding: 0; color: var(--cc-primary); font: inherit; font-weight: 700; vertical-align: baseline; }
|
||
.inbound-code-link :deep(span) { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.order-title p { margin: 2px 0 0; color: var(--cc-muted); font-size: 11px; }
|
||
.detail-header-actions { display: flex; flex: 0 0 auto; gap: 8px; }
|
||
.detail-header-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||
.detail-load-error { min-height: 280px; }
|
||
.order-detail-content { min-height: 520px; }
|
||
.detail-overview { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; margin-bottom: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||
.detail-overview article { min-width: 0; padding: 18px 20px; border-right: 1px solid var(--cc-border); }
|
||
.detail-overview article:last-child { border-right: 0; }
|
||
.detail-overview article > span, .detail-overview article > small { display: block; overflow: hidden; color: var(--cc-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.detail-overview strong { display: block; margin: 7px 0 5px; overflow: hidden; color: #172033; font-size: 20px; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
|
||
.detail-overview strong small { margin-left: 5px; color: #475467; font-size: 11px; }
|
||
.detail-tabs { display: grid; grid-template-columns: repeat(6, minmax(124px, 1fr)); overflow-x: auto; border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 8px 8px 0 0; background: #fff; }
|
||
.detail-tabs button { position: relative; display: flex; min-height: 56px; align-items: center; justify-content: center; gap: 7px; padding: 0 14px; border: 0; border-right: 1px solid #edf0f4; background: transparent; color: #667085; font-weight: 650; cursor: pointer; }
|
||
.detail-tabs button:last-child { border-right: 0; }
|
||
.detail-tabs button::after { position: absolute; right: 14px; bottom: 0; left: 14px; height: 3px; background: transparent; content: ''; }
|
||
.detail-tabs button:hover { background: #fafbff; color: #344054; }
|
||
.detail-tabs button.active { background: #f7f8ff; color: var(--cc-primary); }
|
||
.detail-tabs button.active::after { background: var(--cc-primary); }
|
||
.detail-panel { min-width: 0; min-height: 390px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 0 0 8px 8px; background: #fff; }
|
||
.panel-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--cc-border); }
|
||
.panel-title h2 { margin: 0; color: #344054; font-size: 16px; }
|
||
.panel-title p { margin: 5px 0 0; color: var(--cc-muted); font-size: 11px; }
|
||
.panel-title > svg { color: var(--cc-primary); }
|
||
.order-info-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0; margin: 0; overflow: hidden; border: 1px solid #edf0f4; background: #fff; }
|
||
.order-info-grid > div, .measurement-grid > div { display: flex; min-width: 0; min-height: 38px; align-items: baseline; gap: 8px; padding: 8px 12px; border-right: 1px solid #edf0f4; border-bottom: 1px solid #edf0f4; background: #fff; }
|
||
.order-info-grid > div.wide { grid-column: span 2; }
|
||
.order-info-grid dt, .measurement-grid dt { flex: 0 0 auto; margin: 0; color: var(--cc-muted); font-size: 11px; white-space: nowrap; }
|
||
.order-info-grid dd, .measurement-grid dd { min-width: 0; margin: 0; color: #25324a; font-size: 12px; font-weight: 650; line-height: 1.35; overflow-wrap: anywhere; }
|
||
.detail-table-wrap { overflow-x: auto; }
|
||
.detail-table-wrap :deep(.el-table) { min-width: 720px; --el-table-header-bg-color: #f8fafc; }
|
||
.cargo-image { width: 72px; height: 56px; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
|
||
.measurement-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
||
.measurement-section { margin-top: 18px; }
|
||
.measurement-section-title { display: flex; width: 100%; min-height: 48px; align-items: center; gap: 16px; padding: 9px 14px; border: 1px solid var(--cc-border); border-radius: 7px 7px 0 0; background: #f8fafc; color: var(--cc-text); font: inherit; text-align: left; cursor: pointer; }
|
||
.measurement-section-title:hover { background: #f2f6fb; }
|
||
.measurement-section-title:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
|
||
.measurement-section-title[aria-expanded='false'] { border-radius: 7px; }
|
||
.measurement-section-title h3 { flex: 0 0 auto; margin: 0; color: var(--cc-text); font-size: 14px; }
|
||
.measurement-section-summary { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: flex-end; gap: 18px; color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
||
.measurement-section-title > svg { flex: 0 0 auto; color: var(--cc-muted); }
|
||
.measurement-total { display: flex; min-height: 42px; align-items: center; justify-content: flex-end; gap: 24px; padding: 9px 14px; border: 1px solid var(--cc-border); background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
|
||
.measurement-total { border-top: 0; border-radius: 0 0 7px 7px; }
|
||
.measurement-total strong { margin-right: auto; color: var(--cc-text); font-size: 13px; }
|
||
.measurement-media { margin-top: 20px; }
|
||
.measurement-media h4 { margin: 18px 0 10px; color: var(--cc-text); font-size: 14px; }
|
||
.measurement-media-grid { display: flex; flex-wrap: wrap; gap: 12px; }
|
||
.measurement-media-image { width: 110px; height: 90px; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
|
||
.history-list, .notes-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
|
||
.history-list li { position: relative; display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 12px; padding-bottom: 20px; }
|
||
.history-list li:not(:last-child)::after { position: absolute; top: 15px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); content: ''; }
|
||
.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 3px; border: 3px solid var(--cc-primary); border-radius: 50%; background: #fff; }
|
||
.history-line { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||
.history-list strong { color: #344054; font-size: 13px; }
|
||
.history-list p, .notes-list p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; }
|
||
.history-list .history-remark { margin-top: 8px; padding: 10px 12px; background: #f8fafc; color: #475467; }
|
||
.notes-list li { padding: 14px 0; border-bottom: 1px solid #edf0f4; }
|
||
.notes-list li:first-child { padding-top: 0; }
|
||
.notes-list li:last-child { padding-bottom: 0; border-bottom: 0; }
|
||
.notes-list li > p { margin: 0; color: var(--cc-text); font-size: 13px; }
|
||
.notes-list span { display: block; margin-top: 5px; color: var(--cc-muted); font-size: 12px; }
|
||
@media (max-width: 960px) { .detail-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } .detail-overview article:nth-child(2) { border-right: 0; } .detail-overview article:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); } .order-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .measurement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { display: grid; grid-template-columns: auto 36px minmax(0, 1fr) auto; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .detail-overview, .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .detail-overview article { border-right: 0; border-bottom: 1px solid var(--cc-border); } .detail-overview article:last-child { border-bottom: 0; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs { grid-template-columns: repeat(6, 132px); } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
|
||
@media (max-width: 420px) { .order-detail-heading { grid-template-columns: auto minmax(0, 1fr) auto; } .order-mark { display: none; } }
|
||
</style>
|