Files
admin-web/src/modules/unified-orders/pages/UnifiedOrdersView.vue
T

585 lines
34 KiB
Vue
Raw Normal View History

2026-08-06 13:56:04 +08:00
<script setup lang="ts">
2026-08-06 18:02:04 +08:00
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ChevronDown, ChevronUp, CircleCheck, ClipboardList, Eye, RefreshCw, SlidersHorizontal } from '@lucide/vue'
2026-08-06 13:56:04 +08:00
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
2026-08-06 18:02:04 +08:00
approvePlatformUnifiedOrder,
assignPlatformUnifiedOrderCarrier,
2026-08-06 13:56:04 +08:00
listPlatformUnifiedOrders,
unifiedOrderStatusOptions,
type UnifiedOrderDetailResource,
type UnifiedOrderMainStatus,
type UnifiedOrderResource,
} from '@/modules/unified-orders/api/unifiedOrders'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
2026-08-06 18:02:04 +08:00
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
2026-08-06 15:32:02 +08:00
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
2026-08-06 13:56:04 +08:00
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-06 18:02:04 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
2026-08-06 13:56:04 +08:00
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
type FilterModel = QueryModel & {
tenantId: string
keyword: string
2026-08-06 15:32:02 +08:00
orderNo: string
2026-08-06 13:56:04 +08:00
mainStatus: UnifiedOrderMainStatus | ''
2026-08-06 15:32:02 +08:00
receivingWarehouseId: string
destinationWarehouseId: string
inboundCode: string
internalOrderStatus: string
expectedArrivalDate: string
feeReviewStatus: string
channelName: string
containerNo: string
cabinetNo: string
sailingDate: string
loaded: string
fullContainer: string
measurementReviewStatus: string
fbaCode: string
2026-08-06 13:56:04 +08:00
createdRange: string[]
}
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const records = ref<UnifiedOrderResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
2026-08-06 15:32:02 +08:00
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
2026-08-06 13:56:04 +08:00
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)
2026-08-06 15:32:02 +08:00
const warehouseLoading = ref(false)
const warehouseLoadError = ref<ApiErrorDetails | null>(null)
2026-08-06 13:56:04 +08:00
const detail = ref<UnifiedOrderDetailResource | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
2026-08-06 18:02:04 +08:00
const approvalOpen = ref(false)
const approvalLoadingId = ref('')
const approvalSaving = ref(false)
const carrierLoading = ref(false)
const carrierLoadError = ref<ApiErrorDetails | null>(null)
const carriers = ref<PlatformPartnerResource[]>([])
const approvalOrder = ref<UnifiedOrderResource | null>(null)
const carrierFormRef = ref<FormInstance>()
const carrierForm = reactive({ carrierId: '' })
2026-08-06 15:32:02 +08:00
const showAdvancedFilters = ref(false)
const filters = ref<FilterModel>(emptyFilters())
2026-08-06 18:02:04 +08:00
const carrierRules: FormRules = {
carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }],
}
2026-08-06 13:56:04 +08:00
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
2026-08-06 15:32:02 +08:00
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[]>(() => [
2026-08-06 13:56:04 +08:00
{
key: 'tenantId',
type: 'select',
width: 220,
2026-08-06 15:32:02 +08:00
label: '公司',
2026-08-06 13:56:04 +08:00
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) },
},
2026-08-06 18:34:57 +08:00
{ key: 'keyword', type: 'input', width: 260, label: '综合搜索', placeholder: '请输入订单号', props: { 'aria-label': '订单号关键词' } },
2026-08-06 15:32:02 +08:00
{ key: 'orderNo', type: 'input', width: 220, label: '订单号', placeholder: '请输入订单号', props: { 'aria-label': '订单号' } },
{ key: 'createdRange', type: 'daterange', width: 300, label: '创建日期', placeholder: '创建日期范围', props: { 'aria-label': '创建日期范围' } },
2026-08-06 13:56:04 +08:00
])
2026-08-06 15:32:02 +08:00
const advancedQueryFields = 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': '入仓码' } },
{ key: 'internalOrderStatus', type: 'select', width: 190, label: '订单状态', placeholder: '请选择订单状态', options: [
2026-08-06 15:36:28 +08:00
{ value: 'SUBMITTED', label: '待审核' }, { value: 'APPROVED', label: '已审核' },
{ value: 'ACCEPTED', label: '已受理' }, { value: 'PROCESSING', label: '处理中' }, { value: 'COMPLETED', label: '已完成' },
2026-08-06 15:32:02 +08:00
], props: { 'aria-label': '订单状态' } },
{ key: 'expectedArrivalDate', type: 'date', width: 190, label: '预计到达时间', placeholder: '请选择预计到达日期', props: { 'aria-label': '预计到达时间' } },
{ key: 'feeReviewStatus', type: 'select', width: 190, label: '费用审核状态', placeholder: '请选择费用审核状态', options: [
2026-08-06 15:36:28 +08:00
{ value: 'UNRECORDED', label: '未录入' }, { value: 'PENDING', label: '待审核' },
{ value: 'APPROVED', label: '审核通过' }, { value: 'REJECTED', label: '审核驳回' },
2026-08-06 15:32:02 +08:00
], props: { 'aria-label': '费用审核状态' } },
{ 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: [
2026-08-06 15:36:28 +08:00
{ value: 'UNRECORDED', label: '未录入' }, { value: 'PENDING', label: '待审核' },
{ value: 'APPROVED', label: '审核通过' }, { value: 'REJECTED', label: '审核驳回' },
2026-08-06 15:32:02 +08:00
], props: { 'aria-label': '测量数据审核状态' } },
{ key: 'fbaCode', type: 'input', width: 220, label: '商品 FBA 编码', placeholder: '请输入商品 FBA 编码', props: { 'aria-label': '商品 FBA 编码' } },
])
const queryFields = computed(() => showAdvancedFilters.value
? [...primaryQueryFields.value, ...advancedQueryFields.value]
: primaryQueryFields.value)
2026-08-06 13:56:04 +08:00
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单信息', minWidth: 220, slotName: 'order' },
{ key: 'mainStatus', label: '主状态', width: 132, slotName: 'mainStatus' },
{ key: 'customer', label: '客户', minWidth: 180, slotName: 'customer' },
{ key: 'receivingWarehouseName', label: '平台收货仓', minWidth: 160, slotName: 'receivingWarehouseName' },
{ key: 'destinationWarehouseName', label: '目的仓', minWidth: 160, slotName: 'destinationWarehouseName' },
{ key: 'transport', label: '运输/装柜', minWidth: 180, slotName: 'transport' },
{ key: 'cargo', label: '货物数据', minWidth: 180, slotName: 'cargo' },
{ key: 'expectedShipDate', label: '预计出运', minWidth: 150, slotName: 'expectedShipDate' },
{ key: 'updatedAt', label: '最近更新', minWidth: 150, slotName: 'updatedAt' },
]
2026-08-06 15:32:02 +08:00
function emptyFilters(): FilterModel {
return {
tenantId: '', keyword: '', orderNo: '', mainStatus: '', receivingWarehouseId: '', destinationWarehouseId: '',
inboundCode: '', internalOrderStatus: '', expectedArrivalDate: '', feeReviewStatus: '', channelName: '',
containerNo: '', cabinetNo: '', sailingDate: '', loaded: '', fullContainer: '', measurementReviewStatus: '',
fbaCode: '', createdRange: [],
}
}
function trimmed(value: string) {
return value.trim() || undefined
}
function optionalBoolean(value: string) {
return value === '' ? undefined : value === 'true'
}
function selectStatus(status: UnifiedOrderMainStatus | '') {
filters.value = { ...filters.value, mainStatus: status }
search()
}
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()
}
2026-08-06 13:56:04 +08:00
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 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 statusLabel(value: UnifiedOrderMainStatus | string | null) {
return unifiedOrderStatusOptions.find((item) => item.value === value)?.label || value || '状态更新'
}
const nextActionLabels: Record<string, string> = {
APPROVE_ORDER: '审核通过',
CONFIRM_MEASUREMENT: '完成测量',
SMART_SPLIT: '智能分柜',
CONFIRM_PRELOAD: '确认预装清单',
COMPLETE_LOADING: '确认装柜完成',
COMPLETE_DELIVERY: '确认派送完成',
}
function nextActionBoundary(row: UnifiedOrderResource) {
2026-08-06 18:02:04 +08:00
if (row.nextAction === 'APPROVE_ORDER') return ''
2026-08-06 13:56:04 +08:00
const label = row.nextAction ? nextActionLabels[row.nextAction] || row.nextAction : ''
return label ? `待接入:${label}` : ''
}
2026-08-06 18:02:04 +08:00
function canApprove(row: UnifiedOrderResource) {
return row.mainStatus === 'PENDING_REVIEW' && row.nextAction === 'APPROVE_ORDER' && can('platform:order:approve')
}
async function loadCarriers() {
carrierLoading.value = true
carrierLoadError.value = null
try {
const records: PlatformPartnerResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformPartners({ page: currentPage, size: 200, type: 'CARRIER', status: 'ACTIVE' })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
carriers.value = records.filter((item) => item.status === 'ACTIVE' && item.types.includes('CARRIER'))
} catch (error) {
carriers.value = []
carrierLoadError.value = getApiErrorDetails(error, '承运商列表加载失败,请稍后重试')
} finally {
carrierLoading.value = false
}
}
async function openApproval(row: UnifiedOrderResource) {
if (!canApprove(row)) return
approvalOrder.value = row
const currentSnapshot = detail.value?.summary.orderId === row.orderId ? detail.value.order.order : null
carrierForm.carrierId = currentSnapshot?.carrierId == null ? '' : String(currentSnapshot.carrierId)
approvalOpen.value = true
await nextTick()
carrierFormRef.value?.clearValidate()
await loadCarriers()
}
function closeApproval() {
if (approvalSaving.value) return
approvalOpen.value = false
approvalOrder.value = null
carrierForm.carrierId = ''
}
async function assignCarrierAndApprove() {
if (!approvalOrder.value) return
const valid = await carrierFormRef.value?.validate().catch(() => false)
if (!valid) return
const carrier = carriers.value.find((item) => String(item.id) === carrierForm.carrierId)
if (!carrier) {
ElMessage.error('所选承运商已停用或不存在,请重新选择')
return
}
const order = approvalOrder.value
const orderId = String(order.orderId)
const tenantId = String(order.tenantId)
approvalSaving.value = true
approvalLoadingId.value = orderId
try {
await assignPlatformUnifiedOrderCarrier(tenantId, orderId, String(carrier.id))
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 = ''
}
}
2026-08-06 13:56:04 +08:00
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
}
}
2026-08-06 15:32:02 +08:00
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
}
}
2026-08-06 13:56:04 +08:00
async function loadOrders() {
loading.value = true
loadError.value = null
try {
const range = filters.value.createdRange
const result = await listPlatformUnifiedOrders({
tenantId: filters.value.tenantId || undefined,
2026-08-06 15:32:02 +08:00
keyword: trimmed(filters.value.keyword),
orderNo: trimmed(filters.value.orderNo),
2026-08-06 13:56:04 +08:00
mainStatus: filters.value.mainStatus || undefined,
2026-08-06 15:32:02 +08:00
receivingWarehouseId: filters.value.receivingWarehouseId || undefined,
destinationWarehouseId: filters.value.destinationWarehouseId || undefined,
inboundCode: trimmed(filters.value.inboundCode),
internalOrderStatus: filters.value.internalOrderStatus || undefined,
expectedArrivalDate: filters.value.expectedArrivalDate || undefined,
feeReviewStatus: filters.value.feeReviewStatus || undefined,
channelName: trimmed(filters.value.channelName),
containerNo: trimmed(filters.value.containerNo),
cabinetNo: trimmed(filters.value.cabinetNo),
sailingDate: filters.value.sailingDate || undefined,
loaded: optionalBoolean(filters.value.loaded),
fullContainer: optionalBoolean(filters.value.fullContainer),
measurementReviewStatus: filters.value.measurementReviewStatus || undefined,
fbaCode: trimmed(filters.value.fbaCode),
2026-08-06 13:56:04 +08:00
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() {
if (page.value !== 1) {
page.value = 1
return
}
void loadOrders()
}
function reset() {
2026-08-06 15:32:02 +08:00
filters.value = emptyFilters()
2026-08-06 13:56:04 +08:00
page.value = 1
void loadOrders()
}
function openDetail(row: UnifiedOrderResource) {
void router.push({ name: 'unified-order-detail', params: { orderId: String(row.orderId) }, query: { tenantId: String(row.tenantId) } })
}
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 workflowEligible(row: UnifiedOrderResource) {
return Boolean(row.inboundId && ['WAIT_SPLIT', 'LOADING', 'LOADED'].includes(row.mainStatus))
}
function openWorkflow(row: UnifiedOrderResource) {
if (!workflowEligible(row) || !can('platform:firstleg:inbound:view')) return
void router.push({
name: 'unified-order-workflow',
params: { orderId: String(row.orderId) },
query: { tenantId: String(row.tenantId) },
})
}
function closeDetail() {
detailOpen.value = false
detail.value = null
detailError.value = null
void router.push({ name: 'unified-orders' })
}
async function loadDetail() {
if (!orderId.value) return
const tenantId = typeof route.query.tenantId === 'string' ? route.query.tenantId : ''
if (!tenantId) {
detailError.value = { message: '订单详情缺少客户标识,请从订单列表重新打开' }
detailOpen.value = true
return
}
detailLoading.value = true
detailError.value = null
detailOpen.value = true
try {
detail.value = await getPlatformUnifiedOrder(tenantId, orderId.value)
} catch (error) {
detail.value = null
detailError.value = getApiErrorDetails(error, '统一订单详情加载失败,请检查 C02 统一详情接口和订单权限')
} finally {
detailLoading.value = false
}
}
watch([page, pageSize], () => { void loadOrders() })
watch(orderId, (value) => {
if (value) void loadDetail()
else detailOpen.value = false
}, { immediate: true })
onMounted(() => {
void loadCustomers()
2026-08-06 15:32:02 +08:00
void loadWarehouses()
2026-08-06 13:56:04 +08:00
void loadOrders()
})
</script>
<template>
<section class="unified-orders-page">
2026-08-06 15:32:02 +08:00
<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="filters.mainStatus === tab.value"
aria-controls="unified-orders-results"
:tabindex="filters.mainStatus === tab.value ? 0 : -1"
:class="['status-tab', { 'is-active': filters.mainStatus === tab.value }]"
@click="selectStatus(tab.value)"
@keydown="handleTabKeydown($event, index)"
>{{ tab.label }}</button>
</div>
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" ariaLabel="订单列表筛选" :fields="queryFields" :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>
2026-08-06 13:56:04 +08:00
<LoadErrorState v-if="loadError" class="orders-load-error" title="订单列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadOrders">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
2026-08-06 15:32:02 +08:00
<div v-else id="unified-orders-results" class="table-card" role="tabpanel">
2026-08-06 13:56:04 +08:00
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" :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>
2026-08-06 18:02:04 +08:00
<el-button v-if="canApprove(row as UnifiedOrderResource)" link type="success" :loading="approvalLoadingId === String((row as UnifiedOrderResource).orderId)" @click="openApproval(row as UnifiedOrderResource)"><CircleCheck :size="15" />审核通过</el-button>
2026-08-06 13:56:04 +08:00
<el-button v-if="(row as UnifiedOrderResource).mainStatus === 'WAIT_MEASURE' && (row as UnifiedOrderResource).inboundId && can('platform:firstleg:inbound:view')" link type="primary" @click="openMeasurement(row as UnifiedOrderResource)">测量作业</el-button>
<el-button v-if="workflowEligible(row as UnifiedOrderResource) && can('platform:firstleg:inbound:view')" link type="primary" @click="openWorkflow(row as UnifiedOrderResource)"><ClipboardList :size="15" />后半程作业</el-button>
<span v-if="nextActionBoundary(row as UnifiedOrderResource)" class="action-boundary">{{ nextActionBoundary(row as UnifiedOrderResource) }}</span>
</div>
</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 || (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>
</DataTable>
</div>
<AppDrawer v-model="detailOpen" :title="detail ? `订单详情 · ${detail.summary.orderNo || detail.summary.orderId}` : '订单详情'" size="min(760px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
<LoadErrorState v-if="detailError" title="订单详情暂时无法加载" :message="detailError.message" :loading="detailLoading" @retry="loadDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<div v-else-if="detail" class="detail-content">
<section class="detail-summary" aria-label="订单概览">
2026-08-06 18:02:04 +08:00
<div class="detail-summary__header"><div><span class="eyebrow">订单主状态</span><h2>{{ detail.summary.orderNo || `订单 ${detail.summary.orderId}` }}</h2></div><div class="detail-summary__actions"><StatusBadge :status="statusLabel(detail.summary.mainStatus)" /><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><el-button v-if="workflowEligible(detail.summary) && can('platform:firstleg:inbound:view')" type="primary" plain @click="openWorkflow(detail.summary)"><ClipboardList :size="15" />后半程作业</el-button></div></div>
<dl class="detail-grid"><div><dt>客户</dt><dd>{{ customerName(detail.summary) }}</dd></div><div><dt>入仓码</dt><dd>{{ detail.summary.inboundCode || '—' }}</dd></div><div><dt>平台收货仓</dt><dd>{{ detail.summary.receivingWarehouseName || '—' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.summary.destinationWarehouseName || '—' }}</dd></div><div><dt>承运商</dt><dd>{{ detail.order.order?.carrierName || '待平台指派' }}</dd></div><div><dt>下一步</dt><dd>{{ canApprove(detail.summary) ? '选择承运商并审核' : nextActionBoundary(detail.summary) || '—' }}</dd></div><div><dt>最近更新</dt><dd>{{ formatDate(detail.summary.updatedAt) }}</dd></div></dl>
2026-08-06 13:56:04 +08:00
</section>
<section class="detail-section" aria-label="状态历史"><h3>状态历史</h3><el-empty v-if="detail.timeline.length === 0" description="暂无状态历史" :image-size="56" /><ol v-else class="history-list"><li v-for="(item, index) in detail.timeline" :key="`${item.source || 'event'}-${item.occurredAt || index}`"><StatusBadge :status="statusLabel(item.mainStatus)" /><div><strong>{{ item.action || item.eventType || '状态更新' }}</strong><p>{{ item.operatorName || '系统' }} · {{ formatDate(item.occurredAt) }}</p><p v-if="item.remark">{{ item.remark }}</p></div></li></ol></section>
<section class="detail-section" aria-label="内部备注"><h3>内部备注</h3><el-empty v-if="detail.order.internalNotes.length === 0" description="暂无内部备注" :image-size="56" /><ul v-else class="notes-list"><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></section>
</div>
</AppDrawer>
2026-08-06 18:02:04 +08:00
<AppDialog v-model="approvalOpen" title="选择承运商并审核" confirm-text="指派并审核通过" confirm-type="success" :loading="carrierLoading" :confirm-loading="approvalSaving" :confirm-disabled="Boolean(carrierLoadError) || carriers.length === 0" destroy-on-close @confirm="assignCarrierAndApprove" @cancel="closeApproval">
<el-alert title="订单审核通过后将进入履约流程;承运商名称由后端公共主数据生成快照。" type="info" :closable="false" show-icon />
<el-alert v-if="carrierLoadError" class="carrier-load-error" type="error" :closable="false" show-icon>
<template #title><div class="api-error-alert-title"><span>{{ carrierLoadError.message }}</span><ApiErrorMeta :details="carrierLoadError" /></div></template>
<el-button link type="danger" :loading="carrierLoading" @click="loadCarriers">重新加载</el-button>
</el-alert>
<el-alert v-else-if="!carrierLoading && carriers.length === 0" class="carrier-load-error" title="暂无启用的承运商,请先到往来单位维护承运商主数据。" type="warning" :closable="false" show-icon />
<el-form ref="carrierFormRef" :model="carrierForm" :rules="carrierRules" label-position="top" class="carrier-form" :disabled="carrierLoading || approvalSaving || Boolean(carrierLoadError)">
<el-form-item label="承运商" prop="carrierId" required>
<el-select v-model="carrierForm.carrierId" filterable :loading="carrierLoading" placeholder="请选择启用的承运商">
<el-option v-for="carrier in carriers" :key="String(carrier.id)" :label="`${carrier.partnerName}(${carrier.partnerCode})`" :value="String(carrier.id)" />
</el-select>
</el-form-item>
</el-form>
</AppDialog>
2026-08-06 13:56:04 +08:00
</section>
</template>
<style scoped>
.unified-orders-page { min-width: 0; }
.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; }
2026-08-06 15:32:02 +08:00
.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); }
.status-tabs + :deep(.query-toolbar) { border-top: 0; }
2026-08-06 13:56:04 +08:00
.orders-load-error { margin-top: 12px; }
.table-card { margin-top: 16px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.row-actions :deep(.el-button) { margin-left: 0; }
2026-08-06 18:02:04 +08:00
.carrier-form { margin-top: 18px; }
.carrier-form :deep(.el-select) { width: 100%; }
.carrier-load-error { margin-top: 14px; }
2026-08-06 13:56:04 +08:00
.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; }
.detail-content { display: grid; gap: 22px; min-width: 0; }
.detail-summary, .detail-section { padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.detail-summary__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.detail-summary__actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.eyebrow { color: var(--cc-muted); font-size: 12px; }
.detail-summary h2 { margin: 4px 0 0; color: var(--cc-text); font-size: 18px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 20px; margin: 20px 0 0; }
.detail-grid div { min-width: 0; }
.detail-grid dt { color: var(--cc-muted); font-size: 12px; }
.detail-grid dd { margin: 4px 0 0; color: var(--cc-text); overflow-wrap: anywhere; }
.detail-section h3 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
.history-list, .notes-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.history-list li { display: flex; align-items: flex-start; gap: 10px; padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
.history-list li:last-child, .notes-list li:last-child { padding-bottom: 0; border-bottom: 0; }
.history-list strong { font-size: 13px; }
.history-list p, .notes-list p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.notes-list li { padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
.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; }
2026-08-06 15:32:02 +08:00
@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; } .detail-grid { grid-template-columns: 1fr; } .detail-summary__header { flex-direction: column; } .detail-summary__actions { justify-content: flex-start; } .row-actions { flex-wrap: wrap; } }
2026-08-06 13:56:04 +08:00
</style>