Files
admin-web/src/modules/unified-orders/pages/UnifiedOrdersView.vue
T
2026-08-06 19:03:13 +08:00

841 lines
54 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, ClipboardList, Eye, History, ListChecks, PackageOpen, RefreshCw, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
approvePlatformUnifiedOrder,
assignPlatformUnifiedOrderCarrier,
addPlatformUnifiedOrderNote,
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'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.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
orderNo: string
mainStatus: UnifiedOrderMainStatus | ''
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
createdRange: string[]
}
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const records = ref<UnifiedOrderResource[]>([])
const selectedOrders = 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 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: '' })
const noteOpen = ref(false)
const noteSaving = ref(false)
const noteContent = ref('')
const showAdvancedFilters = ref(false)
const filters = ref<FilterModel>(emptyFilters())
const carrierRules: FormRules = {
carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }],
}
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
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 },
]
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: '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: 'keyword', type: 'input', width: 260, label: '综合搜索', placeholder: '请输入订单号', props: { 'aria-label': '订单号关键词' } },
{ key: 'orderNo', type: 'input', width: 220, label: '订单号', placeholder: '请输入订单号', props: { 'aria-label': '订单号' } },
{ key: 'createdRange', type: 'daterange', width: 300, label: '创建日期', placeholder: '创建日期范围', props: { 'aria-label': '创建日期范围' } },
])
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: [
{ value: 'SUBMITTED', label: '待审核' }, { value: 'APPROVED', label: '已审核' },
{ value: 'ACCEPTED', label: '已受理' }, { value: 'PROCESSING', label: '处理中' }, { value: 'COMPLETED', label: '已完成' },
], props: { 'aria-label': '订单状态' } },
{ 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: '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: '商品 FBA 编码', placeholder: '请输入商品 FBA 编码', props: { 'aria-label': '商品 FBA 编码' } },
])
const allQueryFields = computed(() => [...primaryQueryFields.value, ...advancedQueryFields.value])
const queryFields = computed(() => showAdvancedFilters.value
? allQueryFields.value
: primaryQueryFields.value)
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 52, fixed: 'left', align: 'center', selectable: canBulkReview },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, slotName: 'field' },
{ key: 'internalOrderStatus', prop: 'internalOrderStatus', label: '订单状态', minWidth: 130, slotName: 'field' },
{ key: 'measurementReviewStatus', prop: 'measurementReviewStatus', label: '测量数据审核状态', minWidth: 170, slotName: 'field' },
{ key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到达时间', minWidth: 150, slotName: 'field' },
{ key: 'feeReviewStatus', prop: 'feeReviewStatus', label: '费用审核状态', minWidth: 140, slotName: 'field' },
{ 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: 'loadingStatus', prop: 'loadingStatus', label: '配载状态', minWidth: 130, slotName: 'field' },
{ key: 'holdCargo', prop: 'holdCargo', label: '是否扣货', minWidth: 110, slotName: 'field' },
{ key: 'holdReason', prop: 'holdReason', label: '扣货理由', minWidth: 160, slotName: 'field' },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'field' },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜号', minWidth: 130, slotName: 'field' },
{ key: 'companyName', prop: 'companyName', label: '公司', minWidth: 150, slotName: 'field' },
{ key: 'businessType', prop: 'businessType', label: '业务类型', minWidth: 130, slotName: 'field' },
{ key: 'pickupMethod', prop: 'pickupMethod', label: '提货方式', minWidth: 130, slotName: 'field' },
{ key: 'pickupAt', prop: 'pickupAt', label: '提货时间', minWidth: 150, slotName: 'field' },
{ key: 'pickupContactPhone', prop: 'pickupContactPhone', label: '提货联系人电话', minWidth: 150, slotName: 'field' },
{ key: 'pickupAddress', prop: 'pickupAddress', label: '提货地址', minWidth: 200, slotName: 'field' },
{ key: 'pickupPostalCode', prop: 'pickupPostalCode', label: '提货邮编', minWidth: 110, slotName: 'field' },
{ key: 'pickupContactEmail', prop: 'pickupContactEmail', label: '提货联系人邮箱', minWidth: 190, slotName: 'field' },
{ key: 'channelName', prop: 'channelName', label: '渠道', minWidth: 130, slotName: 'field' },
{ key: 'taxMode', prop: 'taxMode', label: '买单/退税', minWidth: 130, slotName: 'field' },
{ key: 'fullContainer', prop: 'fullContainer', label: '是否整柜', minWidth: 110, slotName: 'field' },
{ key: 'customsInspection', prop: 'customsInspection', label: '商检验', minWidth: 110, slotName: 'field' },
{ key: 'sailingAt', prop: 'sailingAt', label: '开船时间', minWidth: 150, slotName: 'field' },
{ key: 'portArrivalAt', prop: 'portArrivalAt', label: '到港时间', minWidth: 150, slotName: 'field' },
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', minWidth: 100, slotName: 'field' },
{ key: 'totalGrossWeightKg', prop: 'totalGrossWeightKg', label: '总毛重(T.K.GW)', minWidth: 140, slotName: 'field' },
{ key: 'chargeableWeightKg', prop: 'chargeableWeightKg', label: '计费毛重', minWidth: 120, slotName: 'field' },
{ key: 'totalVolumeCbm', prop: 'totalVolumeCbm', label: '总体积(m³)', minWidth: 120, slotName: 'field' },
{ key: 'chargeableVolumeCbm', prop: 'chargeableVolumeCbm', label: '计费体积(m³)', minWidth: 130, slotName: 'field' },
{ key: 'totalPalletCount', prop: 'totalPalletCount', label: '总托盘数', minWidth: 110, slotName: 'field' },
{ key: 'location', prop: 'location', label: '库位位置', minWidth: 130, slotName: 'field' },
{ key: 'volumetricWeightKg', prop: 'volumetricWeightKg', label: '总材积重', minWidth: 120, slotName: 'field' },
{ key: 'totalNetWeightKg', prop: 'totalNetWeightKg', label: '总净重(T.K.NW)', minWidth: 140, slotName: 'field' },
{ key: 'destinationType', prop: 'destinationType', label: '目的仓类型', minWidth: 130, slotName: 'field' },
{ key: 'destinationAddress', prop: 'destinationAddress', label: '地址', minWidth: 200, slotName: 'field' },
{ key: 'destinationPostalCode', prop: 'destinationPostalCode', label: '邮编', minWidth: 100, slotName: 'field' },
{ key: 'destinationContact', prop: 'destinationContact', label: '联系人', minWidth: 120, slotName: 'field' },
{ key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '联系人电话', minWidth: 140, slotName: 'field' },
{ key: 'measurementCompletedAt', prop: 'measurementCompletedAt', label: '测量完成时间', minWidth: 150, slotName: 'field' },
{ key: 'inboundCompletedAt', prop: 'inboundCompletedAt', label: '入库完成时间', minWidth: 150, slotName: 'field' },
{ key: 'associatedFiles', prop: 'associatedFiles', label: '关联文件', minWidth: 120, slotName: 'field' },
{ key: 'markFiles', prop: 'markFiles', label: '唛头文件', minWidth: 120, slotName: 'field' },
{ key: 'pcOrderFiles', prop: 'pcOrderFiles', label: 'PC端下单文件', minWidth: 140, slotName: 'field' },
{ key: 'expressCompany', prop: 'expressCompany', label: '快递公司', minWidth: 130, slotName: 'field' },
{ key: 'expressTrackingNo', prop: 'expressTrackingNo', label: '快递单号', minWidth: 150, slotName: 'field' },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, slotName: 'field' },
{ key: 'internalRemark', prop: 'internalRemark', label: '客服内部备注', minWidth: 180, slotName: 'field' },
{ key: 'createdBy', prop: 'createdBy', label: '创建人', minWidth: 120, slotName: 'field' },
{ key: 'createdAt', prop: 'createdAt', label: '创建日期', minWidth: 150, slotName: 'field' },
]
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()
}
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 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',
chargeableWeightKg: 'measurement.chargeableWeightKg', measurementCompletedAt: 'measurement.completedAt',
totalGrossWeightKg: 'measurement.actualGrossWeightKg', totalVolumeCbm: 'measurement.actualVolumeCbm',
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 ({ SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || String(value)
}
return String(value)
}
function statusLabel(value: UnifiedOrderMainStatus | string | null) {
return unifiedOrderStatusOptions.find((item) => item.value === value)?.label || value || '状态更新'
}
function measurementStatusLabel(value: string | null | undefined) {
return ({ UNRECORDED: '未录入', PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' } as Record<string, string>)[value || ''] || value || '未录入'
}
const nextActionLabels: Record<string, string> = {
APPROVE_ORDER: '审核通过',
CONFIRM_MEASUREMENT: '完成测量',
SMART_SPLIT: '智能分柜',
CONFIRM_PRELOAD: '确认预装清单',
COMPLETE_LOADING: '确认装柜完成',
COMPLETE_DELIVERY: '确认派送完成',
}
function nextActionBoundary(row: UnifiedOrderResource) {
if (row.nextAction === 'APPROVE_ORDER') return ''
const label = row.nextAction ? nextActionLabels[row.nextAction] || row.nextAction : ''
return label ? `待接入:${label}` : ''
}
function canApprove(row: UnifiedOrderResource) {
return row.mainStatus === 'PENDING_REVIEW' && row.nextAction === 'APPROVE_ORDER' && can('platform:order:approve')
}
function canBulkReview(row: UnifiedOrderResource) {
return canApprove(row)
}
function handleSelectionChange(rows: UnifiedOrderResource[]) {
selectedOrders.value = rows
}
function openBulkReviewPlaceholder() {
if (selectedOrders.value.length === 0) return
ElMessage.info('批量审核接口待接入,请先逐笔审核')
}
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 = ''
}
}
function openNoteDialog() {
if (!detail.value || !can('platform:order:note')) return
noteContent.value = ''
noteOpen.value = true
}
async function saveNote() {
const content = noteContent.value.trim()
if (!detail.value || !content) {
ElMessage.warning('请填写内部备注')
return
}
noteSaving.value = true
try {
await addPlatformUnifiedOrderNote(String(detail.value.summary.tenantId), String(detail.value.summary.orderId), content)
noteOpen.value = false
await loadDetail()
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 range = filters.value.createdRange
const result = await listPlatformUnifiedOrders({
tenantId: filters.value.tenantId || undefined,
keyword: trimmed(filters.value.keyword),
orderNo: trimmed(filters.value.orderNo),
mainStatus: filters.value.mainStatus || undefined,
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),
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
selectedOrders.value = []
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() {
filters.value = emptyFilters()
page.value = 1
void loadOrders()
}
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 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() {
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: '订单详情缺少客户标识,请从订单列表重新打开' }
return
}
detailLoading.value = true
detailError.value = null
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) {
activeDetailTab.value = 'basic'
void loadDetail()
}
}, { immediate: true })
onMounted(() => {
void loadCustomers()
void loadWarehouses()
void loadOrders()
})
</script>
<template>
<section class="unified-orders-page">
<template v-if="orderId">
<header class="order-detail-header">
<div class="order-detail-heading">
<el-button class="back-button" @click="closeDetail"><ArrowLeft :size="17" />返回订单列表</el-button>
<span class="order-mark" aria-hidden="true"><Box :size="23" /></span>
<div class="order-title">
<span>入仓码</span>
<h1>{{ detail?.summary.inboundCode || '暂未生成' }}</h1>
<p>{{ detail?.summary.orderNo || `订单 ${orderId}` }}</p>
</div>
<StatusBadge v-if="detail" :status="statusLabel(detail.summary.mainStatus)" />
</div>
<div v-if="detail" 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>
<el-button v-if="workflowEligible(detail.summary) && can('platform:firstleg:inbound:view')" type="primary" @click="openWorkflow(detail.summary)"><ClipboardList :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 detailTabs" :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="activeDetailTab = 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>{{ detail.order.order?.transportMode || '—' }}</dd></div>
<div><dt>当前状态</dt><dd><StatusBadge :status="statusLabel(detail.summary.mainStatus)" /></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?.destinationCountryCode || '—' }}</dd></div>
<div><dt>渠道</dt><dd>{{ detail.summary.channelName || '—' }}</dd></div>
<div><dt>承运商</dt><dd>{{ detail.order.order?.carrierName || '待平台指派' }}</dd></div>
<div><dt>预计出运</dt><dd>{{ formatDate(detail.summary.expectedShipDate) }}</dd></div>
<div><dt>集装箱号</dt><dd>{{ detail.summary.transport?.containerNo || '—' }}</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>{{ canApprove(detail.summary) ? '选择承运商并审核' : nextActionBoundary(detail.summary) || '—' }}</dd></div>
<div><dt>创建时间</dt><dd>{{ formatDate(detail.summary.createdAt) }}</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 prop="cargoName" label="品名" min-width="170" /><el-table-column prop="packageType" label="包装" min-width="110" /><el-table-column prop="packageCount" label="箱数" width="90" /><el-table-column prop="grossWeightKg" label="毛重(kg)" min-width="110" /><el-table-column prop="volumeCbm" label="体积(m³)" min-width="110" />
</el-table>
</div>
</template>
<template v-else-if="activeDetailTab === 'fees'">
<div class="panel-title"><div><h2>费用信息</h2><p>订单费用与审核结果</p></div><WalletCards :size="19" /></div><el-empty description="暂无费用数据" :image-size="64" />
</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>{{ 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 === 'measurement'">
<div class="panel-title"><div><h2>测量数据</h2><p>{{ detail.summary.measurement?.completedAt ? `完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}</p></div><Ruler :size="19" /></div>
<dl class="measurement-grid"><div><dt>审核状态</dt><dd>{{ measurementStatusLabel(detail.summary.measurement?.reviewStatus) }}</dd></div><div><dt>实测毛重</dt><dd>{{ formatNumber(detail.summary.measurement?.actualGrossWeightKg) }} kg</dd></div><div><dt>实测体积</dt><dd>{{ formatNumber(detail.summary.measurement?.actualVolumeCbm, 3) }} m³</dd></div><div><dt>计费重量</dt><dd>{{ formatNumber(detail.summary.measurement?.chargeableWeightKg) }} kg</dd></div></dl>
</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>
</template>
<template v-else>
<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="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" :available-fields="allQueryFields" preference-key="unified-orders-query" :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>
<LoadErrorState v-if="loadError" class="orders-load-error" title="订单列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadOrders">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<div v-else id="unified-orders-results" class="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="统一订单列表" @selection-change="handleSelectionChange">
<template #toolbar-start>
<el-button type="primary" plain :disabled="selectedOrders.length === 0" title="批量审核接口待接入" @click="openBulkReviewPlaceholder"><ListChecks :size="16" />批量审核<span v-if="selectedOrders.length">({{ selectedOrders.length }})</span></el-button>
</template>
<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-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>
<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 #field="{ row, column }">{{ displayField(row as UnifiedOrderResource, column.property) }}</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>
</template>
<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" :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>
</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; }
.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-panel :deep(.query-toolbar) { border: 0; }
.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; }
.carrier-form { margin-top: 18px; }
.carrier-form :deep(.el-select) { width: 100%; }
.carrier-load-error { margin-top: 14px; }
.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; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 18px; }
.order-detail-heading { display: flex; min-width: 0; align-items: center; gap: 12px; }
.back-button { flex: 0 0 auto; }
.order-mark { display: grid; width: 44px; height: 44px; flex: 0 0 44px; place-items: center; border-radius: 8px; background: #1d2939; color: #fff; }
.order-title { min-width: 0; }
.order-title > span { display: block; margin-bottom: 2px; color: var(--cc-muted); font-size: 11px; }
.order-title h1 { margin: 0; overflow: hidden; color: #172033; font-size: 22px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
.order-title p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
.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: 22px; 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: 18px; padding-bottom: 14px; 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: 1px; margin: 0; overflow: hidden; border: 1px solid #edf0f4; background: #edf0f4; }
.order-info-grid > div, .measurement-grid > div { min-width: 0; padding: 14px 16px; background: #fff; }
.order-info-grid dt, .measurement-grid dt { margin-bottom: 6px; color: var(--cc-muted); font-size: 11px; }
.order-info-grid dd, .measurement-grid dd { margin: 0; color: #25324a; font-size: 13px; font-weight: 650; line-height: 1.5; overflow-wrap: anywhere; }
.detail-table-wrap { overflow-x: auto; }
.detail-table-wrap :deep(.el-table) { min-width: 720px; --el-table-header-bg-color: #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); }
.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 40px minmax(0, 1fr) auto; } .order-mark { width: 40px; height: 40px; } .detail-header-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } .detail-header-actions :deep(.el-button) { min-height: 44px; } .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; } .detail-tabs { grid-template-columns: repeat(6, 132px); } .detail-panel { padding: 16px; } }
@media (max-width: 420px) { .order-detail-heading { grid-template-columns: 1fr auto; } .back-button { grid-column: 1 / -1; justify-self: start; } .order-mark { display: none; } .detail-header-actions { grid-template-columns: 1fr; } }
</style>