This commit is contained in:
2026-08-17 18:00:42 +08:00
parent c7334b2cf8
commit ec3dbb3fe4
23 changed files with 407 additions and 999 deletions
@@ -5,7 +5,6 @@ import {
acceptPlatformFirstLegOrder,
addPlatformFirstLegOrderNote,
approvePlatformFirstLegOrder,
batchApprovePlatformFirstLegOrders,
cancelPlatformFirstLegOrder,
completePlatformFirstLegOrder,
getPlatformFirstLegOrder,
@@ -97,22 +96,6 @@ describe('platform first-leg order API', () => {
expect(http.post).toHaveBeenNthCalledWith(6, '/platform/v1/orders/first-leg/90001/internal-notes', { content: '已联系客户确认' }, { params: { tenantId: '10001' } })
})
it('uses the formal tenant-scoped batch approval contract and preserves long order ids', async () => {
vi.mocked(http.post).mockResolvedValue({
code: '200', message: 'success',
data: { total: 2, successCount: 1, failureCount: 1, successOrderIds: ['2089223263733518337'], failedOrders: [{ orderId: '2089223263733518338', code: 'A0409', message: '订单状态已变化' }] },
})
await expect(batchApprovePlatformFirstLegOrders('2089176869176389634', [
'2089223263733518337', '2089223263733518338',
])).resolves.toMatchObject({ total: 2, successCount: 1, failureCount: 1 })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/orders/first-leg/batch-approve',
{ orderIds: ['2089223263733518337', '2089223263733518338'] },
{ params: { tenantId: '2089176869176389634' } },
)
})
it('writes the backend express contract as names, not a guessed master-data ID', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
@@ -115,20 +115,6 @@ export interface PlatformTenantOrderStatistics {
asOf: string
}
export interface PlatformOrderBatchReviewFailure {
orderId: number | string
code: string
message: string
}
export interface PlatformOrderBatchReviewResult {
total: number
successCount: number
failureCount: number
successOrderIds: Array<number | string>
failedOrders: PlatformOrderBatchReviewFailure[]
}
type PlatformFirstLegOrderPagePayload = Omit<PlatformFirstLegOrderPageResource, 'page' | 'size' | 'total' | 'pages'> & {
page: number | string
size: number | string
@@ -190,14 +176,6 @@ export function approvePlatformFirstLegOrder(tenantId: string, orderId: string)
return postData<void>(`/platform/v1/orders/first-leg/${orderId}/approve`, undefined, { tenantId })
}
export function batchApprovePlatformFirstLegOrders(tenantId: string, orderIds: string[]) {
return postData<PlatformOrderBatchReviewResult>(
'/platform/v1/orders/first-leg/batch-approve',
{ orderIds },
{ tenantId },
)
}
export function acceptPlatformFirstLegOrder(tenantId: string, orderId: string) {
return postData<void>(`/platform/v1/orders/first-leg/${orderId}/accept`, undefined, { tenantId })
}
@@ -1,130 +0,0 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.vue'), 'utf8')
describe('PlatformFirstLegOrdersPanel review queue', () => {
it('loads one cross-tenant page by default and keeps enterprise as an optional filter', () => {
expect(source).toContain("status: 'SUBMITTED'")
expect(source).toContain("tenantId: filters.value.tenantId || undefined")
expect(source).toContain('listPlatformTenants')
expect(source).toContain("key: 'tenantId'")
expect(source).toContain('customerDisplay(row as PlatformFirstLegOrderResource).name')
expect(source).toContain('result.pages')
const queryFieldsSource = source.slice(source.indexOf('const queryFields'), source.indexOf('const orderColumns'))
expect(source).toContain("listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })")
expect(queryFieldsSource).toContain("key: 'tenantId'")
expect(queryFieldsSource).toContain("type: 'select'")
expect(queryFieldsSource).toContain('filterable: true')
expect(queryFieldsSource).toContain('tenant.legalName?.trim() || tenant.tenantName')
expect(queryFieldsSource).not.toContain('tenantCode')
expect(queryFieldsSource).not.toContain('客户编码')
})
it('does not retain the historical fixed tenant', () => {
expect(source).not.toContain('PlatformTenantSelector')
expect(source).not.toContain('selectedTenantId')
})
it('resets to the pending review queue instead of all statuses', () => {
expect(source).toContain(':search-on-reset="false"')
expect(source).toContain("filters.value = { ...filters.value, status: 'SUBMITTED' }")
expect(source).toContain('await nextTick()')
})
it('supports tenant-aware batch approval with partial failure feedback', () => {
expect(source).toContain("key: 'selection', type: 'selection'")
expect(source).toContain("row.status === 'SUBMITTED' && !batchReviewLoading.value")
expect(source).toContain('@selection-change="handleReviewSelectionChange"')
expect(source).toContain('<template #toolbar-actions>')
expect(source).toContain('批量审核通过')
expect(source).toContain('groupOrdersByTenant(selected)')
expect(source.indexOf('batchReviewLoading.value = true')).toBeLessThan(source.indexOf('await ElMessageBox.confirm('))
expect(source).toContain('batchApprovePlatformFirstLegOrders(')
expect(source).toContain('successCount += result.successCount')
expect(source).toContain('failureCount += result.failureCount')
expect(source).toContain('成功 ${successCount} 个,失败 ${failureCount} 个')
})
it('keeps the operation column first before the batch selection column', () => {
const actionColumn = source.indexOf("{ key: 'actions', label: '操作'")
const selectionColumn = source.indexOf("key: 'selection', type: 'selection'")
expect(actionColumn).toBeGreaterThan(0)
expect(selectionColumn).toBeGreaterThan(actionColumn)
})
it('shows up to three row actions directly and overflows only the fourth action onward', () => {
expect(source).toContain('class="table-actions order-row-actions"')
expect(source).toContain('directSecondaryActionCount')
expect(source).toContain('Math.max(0, 3 - primaryActionCount)')
expect(source).toContain("isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'express')")
expect(source).toContain("isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'note')")
expect(source).toContain("isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'cancel')")
expect(source).toContain('v-if="hasOverflowActions(row as PlatformFirstLegOrderResource)"')
expect(source).toContain(':aria-label="moreOrderActionsLabel(row as PlatformFirstLegOrderResource)"')
expect(source).toContain(':title="moreOrderActionsLabel(row as PlatformFirstLegOrderResource)"')
expect(source).toContain('维护快递')
expect(source).toContain('内部备注')
expect(source).toContain('取消订单')
expect(source).toContain("minWidth: 288, fixed: 'left'")
expect(source).toContain('size="small" plain type="success"')
expect(source).toContain('size="small" plain type="primary"')
expect(source).toContain('size="small" plain type="danger"')
expect(source).toContain('<Eye :size="15" />详情')
expect(source).not.toContain('v-if="actionLabel(row as PlatformFirstLegOrderResource)" link')
expect(source).toContain('@media (max-width: 360px)')
expect(source).toContain('.order-row-actions :deep(.el-button) { width: 100%; min-height: 40px; }')
})
it('distinguishes overflow action buttons by order and keeps a stable missing-field fallback', () => {
expect(source).toContain('function moreOrderActionsLabel(order: PlatformFirstLegOrderResource)')
expect(source).toContain('[compatibleOrder.orderNo, compatibleOrder.id, compatibleOrder.orderId]')
expect(source).toContain("identifier || '当前订单'")
expect(source).not.toContain('aria-label="更多订单操作"')
})
it('uses structured list regions so toolbar and table keep one layout gap', () => {
expect(source).toContain('<ListPageLayout class="first-leg-order-list"')
expect(source).toContain('<template #toolbar>')
expect(source).toContain('<template v-if="tenantLoadError || loadError" #state>')
expect(source).toContain('<template #default>')
expect(source).toContain('<section v-if="!loadError" class="surface-panel table-panel">')
expect(source).toContain('.first-leg-order-list { gap: 16px; }')
})
it('prioritizes order status and removes noisy customer and time metadata', () => {
const statusColumn = source.indexOf("{ key: 'status', label: '状态'")
const orderColumn = source.indexOf("{ key: 'orderInfo', label: '订单信息'")
expect(statusColumn).toBeGreaterThan(0)
expect(statusColumn).toBeLessThan(orderColumn)
expect(source).toContain("replace('T', ' ').slice(0, 16)")
expect(source).toContain('客户编码 ${code}')
expect(source).not.toContain('customerName }} · {{ (row as PlatformFirstLegOrderResource).tenantId')
})
it('passes the order tenant through detail and lifecycle actions', () => {
expect(source).toContain('tenantId: String(order.tenantId)')
expect(source).toContain('getPlatformFirstLegOrder(tenantId, orderId)')
expect(source).toContain('approvePlatformFirstLegOrder(tenantId, String(order.id))')
expect(source).not.toContain('loadedTenantId.value')
})
it('does not expose unknown backend status or history action codes', () => {
expect(source).toContain("?.label || '未知状态'")
expect(source).toContain('item.reason || historyActionLabel(item.action)')
expect(source).not.toContain('item.reason || item.action')
})
it('formats cargo measurements without exposing floating-point tails', () => {
expect(source).toContain("numeric.toLocaleString('zh-CN', { maximumFractionDigits: 3 })")
expect(source).toContain('formatMeasurement(row.grossWeightKg)')
expect(source).toContain('formatMeasurement(row.volumeCbm)')
expect(source).not.toContain('{{ row.grossWeightKg }} kg / {{ row.volumeCbm }} m³')
})
it('renders cargo business data with the native table grid contract', () => {
expect(source).toContain('<el-table :data="detail.cargoes" size="small" border stripe')
})
})
@@ -1,744 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { ArrowLeft, CircleCheckBig, CircleX, Eye, FilePenLine, MoreHorizontal, PackageCheck } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
acceptPlatformFirstLegOrder,
addPlatformFirstLegOrderNote,
approvePlatformFirstLegOrder,
batchApprovePlatformFirstLegOrders,
cancelPlatformFirstLegOrder,
completePlatformFirstLegOrder,
getPlatformFirstLegOrder,
listPlatformFirstLegOrders,
startPlatformFirstLegOrder,
updatePlatformFirstLegOrderExpress,
type PlatformFirstLegOrderDetailResource,
type PlatformFirstLegOrderResource,
type PlatformFirstLegOrderStatus,
} from '@/modules/first-leg-orders/api/platformFirstLegOrders'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const orders = ref<PlatformFirstLegOrderResource[]>([])
const tenants = ref<PlatformTenantResource[]>([])
const tenantLoadError = ref<ApiErrorDetails | null>(null)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const actionLoadingId = ref('')
const batchReviewLoading = ref(false)
const selectedReviewOrders = ref<PlatformFirstLegOrderResource[]>([])
const loadError = ref<ApiErrorDetails | null>(null)
const detailLoading = ref(false)
const detail = ref<PlatformFirstLegOrderDetailResource | null>(null)
const noteDialogOpen = ref(false)
const expressDialogOpen = ref(false)
const selectedOrder = ref<PlatformFirstLegOrderResource | null>(null)
const noteForm = reactive({ content: '' })
const noteSaving = ref(false)
const expressSaving = ref(false)
const expressFormRef = ref<FormInstance>()
const noteFormRef = ref<FormInstance>()
const expressForm = reactive({ expressCompany: '', expressTrackingNo: '' })
const filters = ref<QueryModel & {
tenantId: string
keyword: string
status: PlatformFirstLegOrderStatus | ''
dateRange: string[]
}>({
tenantId: '',
keyword: '',
status: 'SUBMITTED',
dateRange: [],
})
const queryFields = computed<QueryField[]>(() => [
{
key: 'tenantId',
type: 'select',
label: '企业客户',
width: 160,
placeholder: tenantLoadError.value ? '企业列表加载失败' : '全部企业',
props: { 'aria-label': '企业客户', filterable: true, disabled: Boolean(tenantLoadError.value) },
options: tenants.value.map((tenant) => ({ value: String(tenant.id), label: tenant.legalName?.trim() || tenant.tenantName })),
},
{
key: 'status',
type: 'select',
label: '订单状态',
width: 120,
placeholder: '全部状态',
props: { 'aria-label': '订单状态' },
options: [
{ value: 'DRAFT', label: '草稿' },
{ value: 'SUBMITTED', label: '待审核' },
{ value: 'APPROVED', label: '已审核' },
{ value: 'ACCEPTED', label: '已受理' },
{ value: 'PROCESSING', label: '处理中' },
{ value: 'COMPLETED', label: '已完成' },
{ value: 'CANCELLED', label: '已取消' },
{ value: 'REJECTED', label: '已驳回(旧契约)' },
],
},
{ key: 'keyword', type: 'input', label: '订单关键词', placeholder: '订单号或发货客户名称', props: { 'aria-label': '订单关键词' } },
{ key: 'dateRange', type: 'daterange', label: '订单创建日期', props: { 'aria-label': '订单创建日期' } },
])
const orderColumns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', minWidth: 288, fixed: 'left', slotName: 'actions' },
...(can('platform:order:approve') ? [{
key: 'selection', type: 'selection', width: 48, fixed: 'left',
selectable: (row: PlatformFirstLegOrderResource) => row.status === 'SUBMITTED' && !batchReviewLoading.value,
} satisfies DataTableColumn] : []),
{ key: 'status', label: '状态', width: 108, slotName: 'status' },
{ key: 'orderInfo', label: '订单信息', minWidth: 210, slotName: 'orderInfo' },
{ key: 'customer', label: '客户', minWidth: 190, showOverflowTooltip: true, slotName: 'customer' },
{ key: 'transport', label: '运输方案', minWidth: 180, slotName: 'transport' },
{ key: 'destination', label: '目的地', minWidth: 150, slotName: 'destination' },
{ key: 'receivingWarehouse', label: '平台收货仓', minWidth: 180, slotName: 'receivingWarehouse' },
{ key: 'express', label: '履约快递', minWidth: 160, slotName: 'express' },
])
const expressRules: FormRules = {
expressCompany: [{ required: true, message: '请填写快递公司', trigger: 'blur' }],
expressTrackingNo: [
{ required: true, message: '请填写快递单号', trigger: 'blur' },
{ pattern: /^[A-Za-z0-9-]{4,128}$/, message: '快递单号格式不正确', trigger: 'blur' },
],
}
const noteRules: FormRules = {
content: [{ required: true, message: '请填写内部备注', trigger: 'blur' }],
}
const detailOrderId = computed(() => {
const orderId = route.query.orderId
return typeof orderId === 'string' && orderId ? orderId : ''
})
const detailTenantId = computed(() => {
const tenantId = route.query.tenantId
return typeof tenantId === 'string' && tenantId ? tenantId : ''
})
const isDetailPage = computed(() => Boolean(detailOrderId.value))
const statusOptions: Array<{ value: PlatformFirstLegOrderStatus; label: string }> = [
{ value: 'DRAFT', label: '草稿' },
{ value: 'SUBMITTED', label: '待审核' },
{ value: 'APPROVED', label: '已审核' },
{ value: 'ACCEPTED', label: '已受理' },
{ value: 'PROCESSING', label: '处理中' },
{ value: 'COMPLETED', label: '已完成' },
{ value: 'CANCELLED', label: '已取消' },
{ value: 'REJECTED', label: '已驳回(旧契约)' },
]
function statusLabel(status: PlatformFirstLegOrderStatus) {
return statusOptions.find((item) => item.value === status)?.label || '未知状态'
}
function historyActionLabel(action: string | null | undefined) {
return ({
CREATE: '创建订单', CREATED: '创建订单', SUBMIT: '提交订单', SUBMITTED: '提交订单',
APPROVE: '审核通过', APPROVED: '审核通过', REJECT: '审核驳回', REJECTED: '审核驳回',
CANCEL: '取消订单', CANCELLED: '取消订单', STATUS_UPDATE: '状态更新',
} as Record<string, string>)[action || ''] || '状态已更新。'
}
function transportLabel(order: PlatformFirstLegOrderResource) {
if (order.transportMode === 'SEA') return order.containerMode === 'FULL_CONTAINER' ? '海运整箱 FCL' : '海运拼箱 LCL'
if (order.transportMode === 'AIR') return '空运'
if (order.transportMode === 'RAIL') return '铁路运输'
return '陆运'
}
function formattedDate(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 16) : '-'
}
function formatMeasurement(value: unknown) {
const numeric = Number(value)
return value === null || value === undefined || value === '' || !Number.isFinite(numeric)
? '—'
: numeric.toLocaleString('zh-CN', { maximumFractionDigits: 3 })
}
function tenantFor(tenantId: number | string) {
return tenants.value.find((tenant) => String(tenant.id) === String(tenantId))
}
function tenantName(tenantId: number | string) {
return tenantFor(tenantId)?.tenantName || `企业 ${tenantId}`
}
function customerDisplay(order: PlatformFirstLegOrderResource) {
const tenant = tenantFor(order.tenantId)
const name = tenant?.legalName?.trim() || tenant?.tenantName?.trim() || order.customerName?.trim() || '未命名企业'
const code = tenant?.tenantCode?.trim()
const secondary = code ? `客户编码 ${code}` : order.customerName?.trim()
return { name, secondary: secondary && secondary !== name ? secondary : '' }
}
async function loadTenants() {
tenantLoadError.value = null
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
tenants.value = records
} catch (error) {
tenants.value = []
tenantLoadError.value = getApiErrorDetails(error, '企业列表加载失败,暂时无法按企业筛选;订单仍可正常审核')
}
}
function can(action: string) {
return access.can(action)
}
function canCancel(order: PlatformFirstLegOrderResource) {
return can('platform:order:cancel') && ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED'].includes(order.status)
}
function canMaintainExpress(order: PlatformFirstLegOrderResource) {
return can('platform:order:fulfillment') && !['DRAFT', 'REJECTED', 'CANCELLED'].includes(order.status)
}
type SecondaryAction = 'express' | 'note' | 'cancel'
function secondaryActions(order: PlatformFirstLegOrderResource): SecondaryAction[] {
const actions: SecondaryAction[] = []
if (canMaintainExpress(order)) actions.push('express')
if (can('platform:order:note')) actions.push('note')
if (canCancel(order)) actions.push('cancel')
return actions
}
function directSecondaryActionCount(order: PlatformFirstLegOrderResource) {
const primaryActionCount = actionLabel(order) ? 2 : 1
return Math.max(0, 3 - primaryActionCount)
}
function isDirectSecondaryAction(order: PlatformFirstLegOrderResource, action: SecondaryAction) {
const index = secondaryActions(order).indexOf(action)
return index >= 0 && index < directSecondaryActionCount(order)
}
function isOverflowSecondaryAction(order: PlatformFirstLegOrderResource, action: SecondaryAction) {
const index = secondaryActions(order).indexOf(action)
return index >= directSecondaryActionCount(order)
}
function hasOverflowActions(order: PlatformFirstLegOrderResource) {
return secondaryActions(order).length > directSecondaryActionCount(order)
}
function moreOrderActionsLabel(order: PlatformFirstLegOrderResource) {
const compatibleOrder = order as PlatformFirstLegOrderResource & { orderId?: string | number | null }
const identifier = [compatibleOrder.orderNo, compatibleOrder.id, compatibleOrder.orderId]
.map((value) => String(value ?? '').trim())
.find(Boolean)
return `更多订单操作:${identifier || '当前订单'}`
}
function runSecondaryAction(command: string | number | object, order: PlatformFirstLegOrderResource) {
if (command === 'express') openExpress(order)
if (command === 'note') openNote(order)
if (command === 'cancel') void cancelOrder(order)
}
function actionLabel(order: PlatformFirstLegOrderResource) {
if (order.status === 'SUBMITTED' && can('platform:order:approve')) return '审核通过'
if (order.status === 'APPROVED' && can('platform:order:accept')) return '受理订单'
if (order.status === 'ACCEPTED' && can('platform:order:process')) return '开始处理'
if (order.status === 'PROCESSING' && can('platform:order:complete')) return '完成订单'
return ''
}
async function loadOrders() {
loadError.value = null
selectedReviewOrders.value = []
loading.value = true
try {
const result = await listPlatformFirstLegOrders({
tenantId: filters.value.tenantId || undefined,
keyword: filters.value.keyword.trim() || undefined,
status: filters.value.status || undefined,
createdFrom: filters.value.dateRange.length === 2 ? `${filters.value.dateRange[0]}T00:00:00` : undefined,
createdTo: filters.value.dateRange.length === 2 ? `${filters.value.dateRange[1]}T23:59:59` : undefined,
page: page.value,
size: pageSize.value,
})
orders.value = result.records
total.value = result.total
} catch (error) {
orders.value = []
total.value = 0
loadError.value = getApiErrorDetails(error, '订单列表加载失败,请检查 Gateway、租户范围和订单权限')
} finally {
loading.value = false
}
}
function handleReviewSelectionChange(rows: PlatformFirstLegOrderResource[]) {
selectedReviewOrders.value = rows.filter((row) => row.status === 'SUBMITTED')
}
function groupOrdersByTenant(selected: PlatformFirstLegOrderResource[]) {
const groups = new Map<string, PlatformFirstLegOrderResource[]>()
for (const order of selected) {
const tenantId = String(order.tenantId)
groups.set(tenantId, [...(groups.get(tenantId) || []), order])
}
return groups
}
async function batchApproveSelectedOrders() {
const selected = [...selectedReviewOrders.value]
if (!selected.length || batchReviewLoading.value) return
const customerCount = new Set(selected.map((order) => String(order.tenantId))).size
batchReviewLoading.value = true
try {
await ElMessageBox.confirm(
`确认批量审核通过已勾选的 ${selected.length} 个订单吗?涉及 ${customerCount} 个客户,审核成功后订单将进入待测量。`,
'批量审核通过',
{ confirmButtonText: '确认批量审核', cancelButtonText: '取消', type: 'warning' },
)
let successCount = 0
let failureCount = 0
const failureMessages: string[] = []
for (const [tenantId, tenantOrders] of groupOrdersByTenant(selected)) {
try {
const result = await batchApprovePlatformFirstLegOrders(
tenantId,
tenantOrders.map((order) => String(order.id)),
)
successCount += result.successCount
failureCount += result.failureCount
failureMessages.push(...result.failedOrders.map((failure) => failure.message))
} catch (error) {
failureCount += tenantOrders.length
failureMessages.push(getApiErrorDetails(error, '该客户订单批量审核失败').message)
}
}
await loadOrders()
const reasons = [...new Set(failureMessages.filter(Boolean))].slice(0, 3).join(';')
if (failureCount === 0) {
ElMessage.success(`已批量审核通过 ${successCount} 个订单`)
} else if (successCount > 0) {
ElMessage.warning(`批量审核完成:成功 ${successCount} 个,失败 ${failureCount} 个${reasons ? `。${reasons}` : ''}`)
} else {
ElMessage.error(`批量审核失败:${failureCount} 个订单未通过${reasons ? `。${reasons}` : ''}`)
}
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(getApiErrorDetails(error, '批量审核失败,请稍后重试').message)
} finally {
batchReviewLoading.value = false
}
}
function searchOrders() {
if (page.value !== 1) {
page.value = 1
return
}
void loadOrders()
}
async function resetOrders() {
filters.value = { ...filters.value, status: 'SUBMITTED' }
await nextTick()
searchOrders()
}
watch([page, pageSize], () => {
void loadOrders()
})
async function openDetail(order: PlatformFirstLegOrderResource) {
selectedOrder.value = order
await router.push({
path: route.path,
query: { ...route.query, orderId: String(order.id), tenantId: String(order.tenantId) },
})
}
async function loadDetail(orderId: string, tenantId = detailTenantId.value) {
if (!tenantId) {
detail.value = null
ElMessage.error('订单详情缺少企业租户标识,请从订单列表重新打开')
return
}
detail.value = null
detailLoading.value = true
try {
detail.value = await getPlatformFirstLegOrder(tenantId, orderId)
selectedOrder.value = detail.value.order
} catch (error) {
const details = getApiErrorDetails(error, '订单详情加载失败')
ElMessage.error(details.message)
} finally {
detailLoading.value = false
}
}
async function refreshAfterAction(orderId: string) {
await loadOrders()
if (detailOrderId.value === orderId) {
await loadDetail(orderId)
}
}
function closeDetailPage() {
const { orderId: _orderId, tenantId: _tenantId, ...query } = route.query
void router.replace({ path: route.path, query })
}
function getFieldValidationMessage(error: unknown, field: string, fallback: string) {
const details = getApiErrorDetails(error, fallback)
if (error instanceof ApiResponseError && error.details && typeof error.details === 'object') {
const fieldMessage = (error.details as Record<string, unknown>)[field]
if (typeof fieldMessage === 'string' && fieldMessage) {
return `${details.message}:${fieldMessage}`
}
}
return details.message
}
async function runLifecycleAction(order: PlatformFirstLegOrderResource) {
const label = actionLabel(order)
if (!label) return
try {
await ElMessageBox.confirm(`确认${label}“${order.orderNo || order.id}”吗?该操作会写入订单状态历史。`, label, {
confirmButtonText: `确认${label}`,
cancelButtonText: '取消',
type: 'warning',
})
actionLoadingId.value = String(order.id)
const tenantId = String(order.tenantId)
if (order.status === 'SUBMITTED') await approvePlatformFirstLegOrder(tenantId, String(order.id))
if (order.status === 'APPROVED') await acceptPlatformFirstLegOrder(tenantId, String(order.id))
if (order.status === 'ACCEPTED') await startPlatformFirstLegOrder(tenantId, String(order.id))
if (order.status === 'PROCESSING') await completePlatformFirstLegOrder(tenantId, String(order.id))
await refreshAfterAction(String(order.id))
ElMessage.success(`${label}已完成`)
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(getApiErrorDetails(error, `${label}失败,请稍后重试`).message)
} finally {
actionLoadingId.value = ''
}
}
async function cancelOrder(order: PlatformFirstLegOrderResource) {
try {
const result = await ElMessageBox.prompt('请填写取消原因。后端要求该字段必填,并将写入订单状态历史。', '取消订单', {
confirmButtonText: '确认取消',
cancelButtonText: '返回',
inputPattern: /\S+/,
inputErrorMessage: '请填写取消原因',
inputPlaceholder: '例如:客户取消本次运输安排',
})
actionLoadingId.value = String(order.id)
await cancelPlatformFirstLegOrder(String(order.tenantId), String(order.id), result.value.trim())
await refreshAfterAction(String(order.id))
ElMessage.success('订单已取消')
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(getApiErrorDetails(error, '取消订单失败,请稍后重试').message)
} finally {
actionLoadingId.value = ''
}
}
function openExpress(order: PlatformFirstLegOrderResource) {
selectedOrder.value = order
expressForm.expressCompany = order.expressCompany || ''
expressForm.expressTrackingNo = order.expressTrackingNo || ''
expressDialogOpen.value = true
void nextTick(() => expressFormRef.value?.clearValidate())
}
async function saveExpress() {
if (!selectedOrder.value) return
const valid = await expressFormRef.value?.validate().catch(() => false)
if (!valid) return
expressSaving.value = true
try {
await updatePlatformFirstLegOrderExpress(String(selectedOrder.value.tenantId), String(selectedOrder.value.id), {
expressCompany: expressForm.expressCompany.trim(),
expressTrackingNo: expressForm.expressTrackingNo.trim(),
})
expressDialogOpen.value = false
await refreshAfterAction(String(selectedOrder.value.id))
ElMessage.success('履约快递信息已保存')
} catch (error) {
ElMessage.error(getFieldValidationMessage(error, 'expressTrackingNo', '履约快递信息保存失败'))
} finally {
expressSaving.value = false
}
}
function openNote(order: PlatformFirstLegOrderResource) {
selectedOrder.value = order
noteForm.content = ''
noteDialogOpen.value = true
void nextTick(() => noteFormRef.value?.clearValidate())
}
async function saveNote() {
if (!selectedOrder.value) return
const valid = await noteFormRef.value?.validate().catch(() => false)
if (!valid) return
noteSaving.value = true
try {
await addPlatformFirstLegOrderNote(String(selectedOrder.value.tenantId), String(selectedOrder.value.id), noteForm.content.trim())
noteDialogOpen.value = false
await refreshAfterAction(String(selectedOrder.value.id))
ElMessage.success('内部备注已保存')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '内部备注保存失败').message)
} finally {
noteSaving.value = false
}
}
watch([detailOrderId, detailTenantId], ([orderId, tenantId]) => {
if (orderId) {
void loadDetail(orderId, tenantId)
return
}
detail.value = null
selectedOrder.value = null
void loadOrders()
}, { immediate: true })
onMounted(() => void loadTenants())
</script>
<template>
<AdminPage>
<div class="business-page">
<template v-if="isDetailPage">
<section class="surface-panel detail-page" v-loading="detailLoading">
<DetailPageLayout ariaLabel="头程订单详情">
<template #header>
<header class="detail-page-header">
<div>
<h1>订单详情</h1>
<p>查看订单信息、履约快递、内部备注和状态历史。</p>
</div>
<el-button @click="closeDetailPage"><ArrowLeft :size="16" />返回订单列表</el-button>
</header>
</template>
<template v-if="detail">
<div class="order-detail-head">
<div>
<span>{{ transportLabel(detail.order) }}</span>
<h2>{{ detail.order.orderNo || detail.order.id }}</h2>
<p>{{ tenantName(detail.order.tenantId) }} · {{ detail.order.customerName }} · {{ detail.order.referenceId || '无客户参考号' }}</p>
</div>
<StatusBadge :status="statusLabel(detail.order.status)" />
</div>
<div class="detail-section">
<h3>订单概要</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="目标仓 / 国家">{{ detail.order.destinationWarehouseName || '-' }} · {{ detail.order.destinationCountryCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="平台收货仓">{{ detail.order.receivingWarehouseName || '-' }}</el-descriptions-item>
<el-descriptions-item label="收货地址" :span="2">{{ detail.order.receivingWarehouseAddress || '-' }}</el-descriptions-item>
<el-descriptions-item label="渠道">{{ detail.order.channelName || '-' }}({{ detail.order.channelCode || '-' }})</el-descriptions-item>
<el-descriptions-item label="预计出运">{{ detail.order.expectedShipDate || '-' }}</el-descriptions-item>
<el-descriptions-item label="客户备注" :span="2">{{ detail.order.customerRemark || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<div class="detail-section">
<h3>货物明细</h3>
<div class="detail-table-scroll">
<el-table :data="detail.cargoes" size="small" border stripe empty-text="暂无货物明细">
<el-table-column prop="itemNo" label="#" width="52" />
<el-table-column prop="cargoName" label="品名" min-width="150" />
<el-table-column prop="packageType" label="包装" min-width="100" />
<el-table-column prop="packageCount" label="件/箱" width="86" />
<el-table-column label="毛重 / 体积" min-width="150"><template #default="{ row }">{{ formatMeasurement(row.grossWeightKg) }} kg / {{ formatMeasurement(row.volumeCbm) }} m³</template></el-table-column>
</el-table>
</div>
</div>
<div class="detail-section">
<h3>收发货方</h3>
<div class="party-grid">
<div v-for="party in detail.parties" :key="party.id">
<span>{{ party.partyType === 'SENDER' ? '发货方' : '收货方' }}</span>
<strong>{{ party.companyName }}</strong>
<p>{{ party.contactName }} · {{ party.phone }}</p>
<p>{{ [party.province, party.city, party.district, party.addressLine].filter(Boolean).join(' ') }}</p>
</div>
</div>
</div>
<div class="detail-section">
<h3>履约快递</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="快递公司">{{ detail.order.expressCompany || '-' }}</el-descriptions-item>
<el-descriptions-item label="快递单号">{{ detail.order.expressTrackingNo || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<div class="detail-section">
<h3>内部备注</h3>
<el-empty v-if="!detail.internalNotes.length" description="暂无内部备注" :image-size="52" />
<el-timeline v-else><el-timeline-item v-for="note in detail.internalNotes" :key="note.id" :timestamp="formattedDate(note.createdAt)" placement="top"><p>{{ note.content }}</p></el-timeline-item></el-timeline>
</div>
<div class="detail-section">
<h3>状态历史</h3>
<el-timeline><el-timeline-item v-for="item in detail.statusHistory" :key="item.id" :timestamp="`${formattedDate(item.occurredAt)} · ${item.operatorName || '系统'}`" placement="top"><strong>{{ statusLabel(item.toStatus) }}</strong><p>{{ item.reason || historyActionLabel(item.action) }}</p></el-timeline-item></el-timeline>
</div>
</template>
<el-empty v-else-if="!detailLoading" description="订单详情暂不可用"><el-button type="primary" @click="closeDetailPage"><ArrowLeft :size="16" />返回订单列表</el-button></el-empty>
</DetailPageLayout>
</section>
</template>
<template v-else>
<ListPageLayout class="first-leg-order-list" ariaLabel="头程订单列表">
<template #toolbar>
<QueryToolbar
v-model="filters"
ariaLabel="头程订单筛选"
:fields="queryFields"
preference-key="first-leg-orders-query"
search-text="查询"
:search-on-reset="false"
@search="searchOrders"
@reset="resetOrders"
/>
</template>
<template v-if="tenantLoadError || loadError" #state>
<el-alert v-if="tenantLoadError" type="warning" :closable="false" show-icon role="alert">
<template #title><div class="api-error-alert-title"><span>{{ tenantLoadError.message }}</span><ApiErrorMeta :details="tenantLoadError" /></div></template>
<el-button link type="warning" @click="loadTenants">重新加载企业列表</el-button>
</el-alert>
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
<el-button link type="danger" @click="loadOrders">重新加载</el-button>
</el-alert>
</template>
<template #default>
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="orders"
:columns="orderColumns"
preference-key="first-leg-orders-table"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100]"
empty-text="当前没有符合条件的头程订单"
row-key="id"
@selection-change="handleReviewSelectionChange"
>
<template #toolbar-actions>
<el-button
v-if="can('platform:order:approve')"
type="success"
:loading="batchReviewLoading"
:disabled="loading || batchReviewLoading || selectedReviewOrders.length === 0"
@click="batchApproveSelectedOrders"
><CircleCheckBig :size="16" />批量审核通过<span v-if="selectedReviewOrders.length">({{ selectedReviewOrders.length }})</span></el-button>
</template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条订单</span></template>
<template #actions="{ row }">
<div class="table-actions order-row-actions">
<el-button link type="primary" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="openDetail(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button>
<el-button v-if="actionLabel(row as PlatformFirstLegOrderResource)" size="small" plain type="success" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="runLifecycleAction(row as PlatformFirstLegOrderResource)"><CircleCheckBig :size="15" />{{ actionLabel(row as PlatformFirstLegOrderResource) }}</el-button>
<el-button v-if="isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'express')" size="small" plain type="primary" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="openExpress(row as PlatformFirstLegOrderResource)"><PackageCheck :size="15" />维护快递</el-button>
<el-button v-if="isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'note')" size="small" plain type="primary" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="openNote(row as PlatformFirstLegOrderResource)"><FilePenLine :size="15" />内部备注</el-button>
<el-button v-if="isDirectSecondaryAction(row as PlatformFirstLegOrderResource, 'cancel')" size="small" plain type="danger" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="cancelOrder(row as PlatformFirstLegOrderResource)"><CircleX :size="15" />取消订单</el-button>
<el-dropdown v-if="hasOverflowActions(row as PlatformFirstLegOrderResource)" trigger="click" popper-class="first-leg-action-menu" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @command="runSecondaryAction($event, row as PlatformFirstLegOrderResource)">
<el-button link type="primary" :aria-label="moreOrderActionsLabel(row as PlatformFirstLegOrderResource)" :title="moreOrderActionsLabel(row as PlatformFirstLegOrderResource)"><MoreHorizontal :size="17" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="isOverflowSecondaryAction(row as PlatformFirstLegOrderResource, 'express')" command="express"><PackageCheck :size="15" />维护快递</el-dropdown-item>
<el-dropdown-item v-if="isOverflowSecondaryAction(row as PlatformFirstLegOrderResource, 'note')" command="note"><FilePenLine :size="15" />内部备注</el-dropdown-item>
<el-dropdown-item v-if="isOverflowSecondaryAction(row as PlatformFirstLegOrderResource, 'cancel')" command="cancel" divided><CircleX :size="15" />取消订单</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<template #status="{ row }"><StatusBadge :status="statusLabel((row as PlatformFirstLegOrderResource).status)" /></template>
<template #orderInfo="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).orderNo || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).referenceId || '无客户参考号' }} · {{ formattedDate((row as PlatformFirstLegOrderResource).createdAt) }}</span></div></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerDisplay(row as PlatformFirstLegOrderResource).name }}</strong><span v-if="customerDisplay(row as PlatformFirstLegOrderResource).secondary">{{ customerDisplay(row as PlatformFirstLegOrderResource).secondary }}</span></div></template>
<template #transport="{ row }"><div class="primary-cell"><strong>{{ transportLabel(row as PlatformFirstLegOrderResource) }}</strong><span>{{ (row as PlatformFirstLegOrderResource).channelName || '未填写渠道' }}</span></div></template>
<template #destination="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '-' }}</span></div></template>
<template #receivingWarehouse="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseAddress || '-' }}</span></div></template>
<template #express="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).expressCompany || '未维护' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).expressTrackingNo || '—' }}</span></div></template>
</DataTable>
</section>
</template>
</ListPageLayout>
</template>
<el-dialog v-model="expressDialogOpen" title="维护履约快递" width="min(520px, calc(100vw - 28px))">
<el-form ref="expressFormRef" :model="expressForm" :rules="expressRules" label-position="top" class="delivery-form"><el-form-item label="快递公司" prop="expressCompany" required><el-input v-model.trim="expressForm.expressCompany" maxlength="64" placeholder="例如 顺丰速运" /></el-form-item><el-form-item label="快递单号" prop="expressTrackingNo" required><el-input v-model.trim="expressForm.expressTrackingNo" maxlength="128" placeholder="请输入完整快递单号" /></el-form-item></el-form>
<template #footer><el-button :disabled="expressSaving" @click="expressDialogOpen = false">取消</el-button><el-button type="primary" :loading="expressSaving" @click="saveExpress">保存快递信息</el-button></template>
</el-dialog>
<el-dialog v-model="noteDialogOpen" title="添加内部备注" width="min(520px, calc(100vw - 28px))">
<el-form ref="noteFormRef" :model="noteForm" :rules="noteRules" label-position="top"><el-form-item label="内部备注" prop="content" required><el-input v-model.trim="noteForm.content" maxlength="2000" type="textarea" :rows="4" show-word-limit placeholder="仅平台内部可见" /></el-form-item></el-form>
<template #footer><el-button :disabled="noteSaving" @click="noteDialogOpen = false">取消</el-button><el-button type="primary" :loading="noteSaving" @click="saveNote">保存备注</el-button></template>
</el-dialog>
</div>
</AdminPage>
</template>
<style scoped>
.detail-page { overflow: hidden; }
.first-leg-order-list { gap: 16px; }
.detail-page-header { display: flex; min-height: 88px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border); }
.detail-page-header h1 { margin: 0; font-size: 20px; line-height: 1.35; }
.detail-page-header p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.detail-table-scroll { overflow-x: auto; }
.party-grid p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
.order-row-actions { gap: 8px; white-space: nowrap; }
.order-row-actions :deep(.el-button + .el-button) { margin-left: 0; }
:global(.first-leg-action-menu .el-dropdown-menu__item) { min-height: 44px; gap: 8px; }
@media (max-width: 720px) {
.detail-page-header { align-items: flex-start; flex-direction: column; padding: 16px; }
.detail-page-header .el-button { width: 100%; min-height: 40px; }
.detail-table-scroll :deep(.el-table) { min-width: 560px; }
}
@media (max-width: 360px) {
.order-row-actions { align-items: stretch; flex-direction: column; }
.order-row-actions :deep(.el-button) { width: 100%; min-height: 40px; }
}
</style>
@@ -1,7 +0,0 @@
<script setup lang="ts">
import PlatformFirstLegOrdersPanel from '@/modules/first-leg-orders/components/PlatformFirstLegOrdersPanel.vue'
</script>
<template>
<PlatformFirstLegOrdersPanel />
</template>
-10
View File
@@ -1,10 +0,0 @@
import type { RouteRecordRaw } from 'vue-router'
export const firstLegOrderRoutes = [
{
path: 'orders/first-leg',
name: 'first-leg-orders',
redirect: '/orders',
meta: { title: '订单列表', description: '旧头程订单入口已合并到统一订单列表', permission: 'platform:order:list' },
},
] satisfies RouteRecordRaw[]