fix(admin): polish phase five overseas pages

This commit is contained in:
2026-09-03 17:17:11 +08:00
parent a9bfa69e67
commit 393d32bcd9
6 changed files with 71 additions and 11 deletions
@@ -22,13 +22,14 @@ const customerId = computed(() => String(route.query.customerId || '').trim())
const statusLabels: Record<string, string> = { WAIT_DELIVERY: '待派送', DELIVERING: '派送中', DELIVERED: '派送完成', SIGNED: '已签收', REJECTED: '已拒签', RETURN_PENDING: '退货处理中', CANCELLED: '已取消' }
const methodLabels: Record<string, string> = { FBA_PICKUP: 'FBA自提', CUSTOMER_PICKUP: '客户自提', TRUCK: '卡车派送', TRUCK_PALLET: '卡派打托', TRUCK_FLOOR: '卡派地板', EXPRESS: '快递', OUTSOURCED: '外配', DEDICATED_TRUCK: '专车直送', LTL: '零担派送', SELF_DELIVERY: '自送', THIRD_PARTY: '第三方配送' }
const pickupLabels: Record<string, string> = { WAREHOUSE_PICKUP: '仓库提货', DOOR_PICKUP: '上门提货', SELF_PICKUP: '自提' }
const loadingLabels: Record<string, string> = { PALLET: '打托装车', FLOOR: '地板装车', CARTON: '散箱装车' }
const pickupLabels: Record<string, string> = { ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提', DOOR_PICKUP: '上门提货', SELF_PICKUP: '自提' }
const loadingLabels: Record<string, string> = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装', CARTON: '散箱' }
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库' }
const resultLabels: Record<string, string> = { CREATED: '已创建' }
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
function displayPerson(value: unknown) { const text = String(value ?? '').trim(); return text && !/^\d+$/.test(text) ? text : '—' }
function quantity(value: number | null | undefined, unit: string) { return value === null || value === undefined ? '—' : `${value} ${unit}` }
function customerConfirmation() { const signedAt = detail.value?.order.signedAt; return signedAt ? `已签收 · ${formatOverseasDateTime(signedAt)}` : '—' }
function back() { void router.push({ name: 'overseas-delivery-orders' }) }
@@ -67,7 +68,7 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
<h2 id="delivery-order-heading">订单信息</h2>
<dl v-if="detail" class="info-grid">
<div class="info-item"><dt>派送单号</dt><dd>{{ show(detail.order.deliveryNo) }}</dd></div><div class="info-item"><dt>状态</dt><dd>{{ statusLabels[detail.order.status] || detail.order.status }}</dd></div>
<div class="info-item"><dt>仓库</dt><dd>{{ show(detail.order.overseasWarehouseName || detail.order.overseasWarehouseId) }}</dd></div><div class="info-item"><dt>运单号</dt><dd>{{ show(detail.order.waybillNo) }}</dd></div>
<div class="info-item"><dt>仓库</dt><dd>{{ show(detail.order.overseasWarehouseName) }}</dd></div><div class="info-item"><dt>运单号</dt><dd>{{ show(detail.order.waybillNo) }}</dd></div>
<div class="info-item"><dt>派送人</dt><dd>{{ show(detail.order.carrierName) }}</dd></div><div class="info-item"><dt>派送时间</dt><dd>{{ formatOverseasDateTime(detail.order.dispatchedAt || detail.order.plannedDeliveryAt) }}</dd></div>
<div class="info-item"><dt>提货方式</dt><dd>{{ pickupLabels[detail.order.pickupMethod || ''] || show(detail.order.pickupMethod) }}</dd></div><div class="info-item"><dt>装车类型</dt><dd>{{ loadingLabels[detail.order.loadingType || ''] || show(detail.order.loadingType) }}</dd></div>
<div class="info-item"><dt>派送方式</dt><dd>{{ methodLabels[detail.order.deliveryMethod || ''] || show(detail.order.deliveryMethod) }}</dd></div><div class="info-item"><dt>司机/车队</dt><dd>{{ show(detail.order.driverName) }}</dd></div>
@@ -106,7 +107,7 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
<el-table :data="detail?.operations || []" border stripe empty-text="暂无操作记录">
<el-table-column label="操作时间" min-width="180"><template #default="scope">{{ formatOverseasDateTime(scope.row.operatedAt) }}</template></el-table-column>
<el-table-column label="操作节点" min-width="180"><template #default="scope">{{ operationLabels[scope.row.operationNode] || scope.row.operationNode }}</template></el-table-column>
<el-table-column prop="operatorName" label="操作人" min-width="160" /><el-table-column label="处理结果" min-width="280"><template #default="scope">{{ resultLabels[scope.row.result] || scope.row.result }}</template></el-table-column>
<el-table-column label="操作人" min-width="160"><template #default="scope">{{ displayPerson(scope.row.operatorName) }}</template></el-table-column><el-table-column label="处理结果" min-width="280"><template #default="scope">{{ resultLabels[scope.row.result] || scope.row.result }}</template></el-table-column>
</el-table>
</section>
</el-tab-pane>
@@ -50,8 +50,8 @@ const columns: DataTableColumn[] = [
{ key: 'driverName', prop: 'driverName', label: '司机/车队', minWidth: 140 },
{ key: 'plannedDeliveryAt', prop: 'plannedDeliveryAt', label: '期望派送时间', minWidth: 170, formatter: (row: DeliveryOrderRow) => formatOverseasDateTime(row.plannedDeliveryAt) },
{ key: 'dispatchedAt', prop: 'dispatchedAt', label: '派送时间', minWidth: 170, formatter: (row: DeliveryOrderRow) => formatOverseasDateTime(row.dispatchedAt) },
{ key: 'pickupMethod', prop: 'pickupMethod', label: '提货方式', width: 110 },
{ key: 'loadingType', prop: 'loadingType', label: '装车类型', width: 110 },
{ key: 'pickupMethod', prop: 'pickupMethod', label: '提货方式', width: 110, formatter: (row: DeliveryOrderRow) => pickupMethodLabel(row.pickupMethod) },
{ key: 'loadingType', prop: 'loadingType', label: '装车类型', width: 110, formatter: (row: DeliveryOrderRow) => loadingTypeLabel(row.loadingType) },
{ key: 'packageCount', prop: 'packageCount', label: '总箱数', width: 90, align: 'right' },
{ key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 120, align: 'right' },
{ key: 'totalVolumeCbm', prop: 'totalVolumeCbm', label: '总体积(m³)', width: 120, align: 'right' },
@@ -61,6 +61,8 @@ const columns: DataTableColumn[] = [
]
const statusLabels: Record<string, string> = { WAIT_DELIVERY: '待派送', DELIVERING: '派送中', DELIVERED: '派送完成', SIGNED: '已签收', REJECTED: '已拒签', RETURN_PENDING: '退货处理中', CANCELLED: '已取消' }
function deliveryMethodLabel(value?: string | null) { return ({ FBA_PICKUP: 'FBA自提', CUSTOMER_PICKUP: '客户自提', TRUCK_PALLET: '卡派打托', TRUCK_FLOOR: '卡派地板', EXPRESS: '快递', OUTSOURCED: '外配', DEDICATED_TRUCK: '专车直送', LTL: '零担派送', SELF_DELIVERY: '自送', THIRD_PARTY: '第三方配送' } as Record<string, string>)[String(value)] || value || '—' }
function pickupMethodLabel(value?: string | null) { return ({ ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提', DOOR_PICKUP: '上门提货', SELF_PICKUP: '自提' } as Record<string, string>)[String(value)] || value || '—' }
function loadingTypeLabel(value?: string | null) { return ({ PALLET: '托盘', FLOOR: '地板', MIXED: '混装', CARTON: '散箱' } as Record<string, string>)[String(value)] || value || '—' }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const range = Array.isArray(filters.value.plannedAt) ? filters.value.plannedAt.map(String) : []; const result = await listDeliveryOrders({ page: page.value, size: pageSize.value, documentNo: text('documentNo'), dispatchNo: text('dispatchNo'), waybillNo: text('waybillNo'), warehouseId: text('warehouseId'), status: text('status'), vehiclePlate: text('vehiclePlate'), driverName: text('driverName'), deliveryMethod: text('deliveryMethod'), plannedFrom: range[0] ? `${range[0]}T00:00:00` : undefined, plannedTo: range[1] ? `${range[1]}T23:59:59` : undefined }); rows.value = result.records; total.value = result.total } catch (cause) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '派送单列表加载失败') } finally { loading.value = false } }
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
function open(row: DeliveryOrderRow) { void router.push({ name: 'overseas-delivery-order-detail', params: { deliveryNo: row.deliveryNo }, query: { customerId: row.customerId } }) }
@@ -70,7 +70,8 @@ describe('DispatchesView', () => {
})
expect(wrapper.text()).toContain('PC-1')
expect(wrapper.text()).toContain('待发车')
expect(wrapper.text()).toContain('FBA-1 · CONFIRMED')
expect(wrapper.text()).toContain('FBA-1 · 已确认')
expect(wrapper.text()).toContain('已预约')
})
it('keeps long dispatch identifiers as strings', async () => {
@@ -108,9 +108,20 @@ function statusLabel(value: unknown) {
return statusOptions.find((option) => option.value === status)?.label || text(value)
}
function appointmentStatusLabel(value: unknown) {
const code = String(value || '')
const labels: Record<string, string> = {
PENDING: '待预约',
BOOKED: '已预约',
CONFIRMED: '已确认',
CANCELLED: '已取消',
}
return labels[code] || text(value)
}
function appointmentLabel(numberValue: unknown, statusValue: unknown) {
const appointmentNo = text(numberValue)
const appointmentStatus = text(statusValue)
const appointmentStatus = appointmentStatusLabel(statusValue)
if (appointmentNo === '—') return appointmentStatus
return appointmentStatus === '—' ? appointmentNo : `${appointmentNo} · ${appointmentStatus}`
}
@@ -128,7 +139,7 @@ function toListItem(dto: DispatchSummaryDto): DispatchListItem {
overseasWarehouse: text(dto.overseasWarehouseName || '仓库名称待补充'),
departAt: formatOverseasDateTime(dto.departAt),
fbaAppointment: appointmentLabel(dto.fbaAppointmentNo, dto.fbaAppointmentStatus),
vehicleAppointment: text(dto.vehicleAppointmentStatus),
vehicleAppointment: appointmentStatusLabel(dto.vehicleAppointmentStatus),
estimatedArrivalAt: formatOverseasDateTime(dto.estimatedArrivalAt),
status: statusLabel(dto.status),
statusCode: String(dto.status || ''),
@@ -26,6 +26,28 @@ describe('ExceptionOrdersView permissions and creation', () => {
wrapper.unmount()
})
it('keeps the detail action available and hides numeric operator identifiers', async () => {
vi.mocked(listExceptionOrders).mockResolvedValue({
records: [{
id: '19', exceptionNo: 'EXC-1', exceptionType: 'DAMAGED', status: 'PROCESSING', reason: '包装破损',
customerId: '100', customerName: '示例客户', overseasWarehouseName: '洛杉矶仓', handlerName: '1000001',
exceptionAt: '2026-09-03T09:00:00', updatedAt: '2026-09-03T10:00:00',
}],
page: 1, size: 10, total: 1, pages: 1,
})
const wrapper = mountView()
await flushPromises()
const table = wrapper.find('[data-testid="data-table"]')
expect(table.text()).toContain('查看')
await table.find('button').trigger('click')
await flushPromises()
const detail = wrapper.find('[data-dialog="异常订单详情"]')
expect(detail.text()).toContain('示例客户')
expect(detail.text()).toContain('包装破损')
expect(detail.text()).not.toContain('1000001')
wrapper.unmount()
})
it('validates the required relation before submitting', async () => {
canPermission.mockImplementation(code => code.endsWith(':create'))
const wrapper = mountView()
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { Plus } from '@lucide/vue'
import { Eye, Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -26,6 +26,7 @@ const fields: QueryField[] = [
{ key: 'status', type: 'select', label: '处理状态', placeholder: '全部状态', options: [{ label: '待处理', value: 'PENDING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }] },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
{ key: 'exceptionNo', prop: 'exceptionNo', label: '异常订单号', minWidth: 180, fixed: 'left' },
{ key: 'related', label: '关联订单号', minWidth: 230, slotName: 'related' },
{ key: 'exceptionType', label: '异常类型', width: 100, formatter: (row: ExceptionOrderRow) => typeLabels[row.exceptionType] || row.exceptionType },
@@ -38,11 +39,17 @@ const columns: DataTableColumn[] = [
{ key: 'status', label: '处理状态', width: 100, slotName: 'status' },
{ key: 'handlingAction', prop: 'handlingAction', label: '处理动作', minWidth: 140 },
{ key: 'returnLogisticsNo', prop: 'returnLogisticsNo', label: '退回物流单号', minWidth: 160 },
{ key: 'handlerName', prop: 'handlerName', label: '处理人', minWidth: 110 },
{ key: 'handlerName', prop: 'handlerName', label: '处理人', minWidth: 110, slotName: 'handlerName' },
{ key: 'updatedAt', label: '更新时间', minWidth: 170, formatter: (row: ExceptionOrderRow) => formatOverseasDateTime(row.updatedAt) },
]
const typeLabels: Record<string, string> = { RETURN: '退货', REJECT: '拒收', DAMAGED: '破损', OTHER: '其他' }
const statusLabels: Record<string, string> = { PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成' }
const detailOpen = ref(false); const detailRow = ref<ExceptionOrderRow | null>(null)
function openDetail(row: ExceptionOrderRow) { detailRow.value = row; detailOpen.value = true }
function displayHandler(value: unknown) {
const normalized = String(value ?? '').trim()
return normalized && !/^\d+$/.test(normalized) ? normalized : '—'
}
const createOpen = ref(false); const saving = ref(false); const resolving = ref(false); const relation = ref<ExceptionRelation | null>(null); const relationError = ref('')
const form = reactive({ customerId: '', inboundCode: '', lastMileOrderNo: '', exceptionType: '', reason: '', packageCount: undefined as number | undefined, skuCode: '' })
@@ -74,7 +81,9 @@ onMounted(load)
<template v-if="error" #state><LoadErrorState title="异常订单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-exception-orders" row-key="id" empty-text="暂无异常订单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.exceptionOrderCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增异常订单</el-button></template>
<template #actions="{ row }"><el-button size="small" type="primary" plain @click="openDetail(row as ExceptionOrderRow)"><Eye :size="15" />查看</el-button></template>
<template #related="{ row }"><div class="related-orders"><span v-if="row.lastMileOrderNo" class="related-order-reference">尾程 <OrderReferenceText :value="String(row.lastMileOrderNo)" /></span><span v-if="row.deliveryNo">派送 {{ row.deliveryNo }}</span><span v-if="row.inboundCode">入仓 {{ row.inboundCode }}</span><span v-if="row.outboundNo">出库 {{ row.outboundNo }}</span></div></template>
<template #handlerName="{ row }">{{ displayHandler(row.handlerName) }}</template>
<template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status)" /></template><template #pagination-left>共 {{ total }} 条异常订单</template>
</DataTable></section>
</ListPageLayout></AdminPage>
@@ -92,6 +101,20 @@ onMounted(load)
<el-alert v-else-if="relationError" :title="relationError" type="error" :closable="false" />
</el-form>
</AppDialog>
<AppDialog v-model="detailOpen" title="异常订单详情" width="min(760px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭">
<el-descriptions v-if="detailRow" :column="2" border>
<el-descriptions-item label="异常订单号">{{ detailRow.exceptionNo }}</el-descriptions-item>
<el-descriptions-item label="处理状态">{{ statusLabels[String(detailRow.status)] || detailRow.status }}</el-descriptions-item>
<el-descriptions-item label="异常类型">{{ typeLabels[detailRow.exceptionType] || detailRow.exceptionType }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ detailRow.customerName || '—' }}</el-descriptions-item>
<el-descriptions-item label="海外仓">{{ detailRow.overseasWarehouseName || '—' }}</el-descriptions-item>
<el-descriptions-item label="处理人">{{ displayHandler(detailRow.handlerName) }}</el-descriptions-item>
<el-descriptions-item label="异常时间">{{ formatOverseasDateTime(detailRow.exceptionAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatOverseasDateTime(detailRow.updatedAt) }}</el-descriptions-item>
<el-descriptions-item label="异常原因" :span="2">{{ detailRow.reason || '—' }}</el-descriptions-item>
<el-descriptions-item label="处理动作" :span="2">{{ detailRow.handlingAction || '—' }}</el-descriptions-item>
</el-descriptions>
</AppDialog>
</template>
<style scoped>