This commit is contained in:
2026-08-06 13:56:04 +08:00
parent 6736893128
commit a813426588
35 changed files with 2822 additions and 489 deletions
@@ -0,0 +1,51 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { unifiedOrderStatusOptions } from '../api/unifiedOrders'
const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/pages/UnifiedOrdersView.vue'), 'utf8')
describe('UnifiedOrdersView contract', () => {
it('only exposes the seven product main statuses', () => {
expect(unifiedOrderStatusOptions.map((item) => item.label)).toEqual([
'待审核', '待测量数据', '已入库', '待分柜', '装柜中', '装柜完成', '派送完成',
])
for (const legacyLabel of ['待客户确认', '差异处理中', '派送中', '进入分柜']) {
expect(source).not.toContain(legacyLabel)
}
})
it('covers real list states and keeps operation column first and left-fixed', () => {
expect(source).toContain("{ key: 'actions', label: '操作', width: 180, fixed: 'left'")
expect(source).toContain('title="订单列表暂时无法加载"')
expect(source).toContain('empty-text="暂无符合条件的订单"')
expect(source).toContain(':loading="loading"')
expect(source).toContain('v-model:page="page"')
expect(source).toContain('v-model:page-size="pageSize"')
})
it('keeps business actions as explicit C01/C03/C04/C06 boundaries', () => {
expect(source).toContain('nextActionBoundary')
expect(source).toContain('待接入:${label}')
expect(source).toContain('CONFIRM_PRELOAD')
expect(source).toContain("can('platform:firstleg:inbound:view')")
expect(source).toContain('openMeasurement')
expect(source).not.toContain('updateMainStatus')
expect(source).not.toContain('人工进入分柜')
expect(source).not.toContain('performPlatformUnifiedOrderAction')
expect(source).not.toContain('/actions/')
})
it('provides unified detail, history and internal note states', () => {
expect(source).toContain('getPlatformUnifiedOrder')
expect(source).toContain('aria-label="状态历史"')
expect(source).toContain('aria-label="内部备注"')
expect(source).toContain('订单详情暂时无法加载')
})
it('exposes the C06 workflow entry only for inbound-backed downstream states', () => {
expect(source).toContain("name: 'unified-order-workflow'")
expect(source).toContain("['WAIT_SPLIT', 'LOADING', 'LOADED'].includes(row.mainStatus)")
expect(source).toContain('后半程作业')
})
})
@@ -0,0 +1,327 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ClipboardList, Eye, RefreshCw } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
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 { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
type FilterModel = QueryModel & {
tenantId: string
keyword: string
mainStatus: UnifiedOrderMainStatus | ''
createdRange: string[]
}
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const records = ref<UnifiedOrderResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
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 detail = ref<UnifiedOrderDetailResource | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const filters = ref<FilterModel>({ tenantId: '', keyword: '', mainStatus: '', createdRange: [] })
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const queryFields = computed<QueryField[]>(() => [
{
key: 'tenantId',
type: 'select',
width: 220,
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: 'mainStatus',
type: 'select',
width: 160,
placeholder: '全部主状态',
options: unifiedOrderStatusOptions,
props: { 'aria-label': '订单主状态' },
},
{ key: 'keyword', type: 'input', width: 260, placeholder: '订单号、入仓码或客户名称', props: { 'aria-label': '订单关键词' } },
{ key: 'createdRange', type: 'daterange', width: 300, placeholder: '创建日期范围', props: { 'aria-label': '创建日期范围' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单信息', minWidth: 220, slotName: 'order' },
{ key: 'mainStatus', label: '主状态', width: 132, slotName: 'mainStatus' },
{ key: 'customer', label: '客户', minWidth: 180, slotName: 'customer' },
{ key: 'receivingWarehouseName', label: '平台收货仓', minWidth: 160, slotName: 'receivingWarehouseName' },
{ key: 'destinationWarehouseName', label: '目的仓', minWidth: 160, slotName: 'destinationWarehouseName' },
{ key: 'transport', label: '运输/装柜', minWidth: 180, slotName: 'transport' },
{ key: 'cargo', label: '货物数据', minWidth: 180, slotName: 'cargo' },
{ key: 'expectedShipDate', label: '预计出运', minWidth: 150, slotName: 'expectedShipDate' },
{ key: 'updatedAt', label: '最近更新', minWidth: 150, slotName: 'updatedAt' },
]
function can(permission: string) {
return access.can(permission)
}
function customerName(row: UnifiedOrderResource) {
return row.customerName?.trim() || customers.value.find((item) => String(item.id) === String(row.tenantId))?.tenantName || `客户 ${row.tenantId}`
}
function formatDate(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 16) : '—'
}
function formatNumber(value: number | null | undefined, digits = 2) {
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
function statusLabel(value: UnifiedOrderMainStatus | string | null) {
return unifiedOrderStatusOptions.find((item) => item.value === value)?.label || value || '状态更新'
}
const nextActionLabels: Record<string, string> = {
APPROVE_ORDER: '审核通过',
CONFIRM_MEASUREMENT: '完成测量',
SMART_SPLIT: '智能分柜',
CONFIRM_PRELOAD: '确认预装清单',
COMPLETE_LOADING: '确认装柜完成',
COMPLETE_DELIVERY: '确认派送完成',
}
function nextActionBoundary(row: UnifiedOrderResource) {
const label = row.nextAction ? nextActionLabels[row.nextAction] || row.nextAction : ''
return label ? `待接入:${label}` : ''
}
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 loadOrders() {
loading.value = true
loadError.value = null
try {
const range = filters.value.createdRange
const result = await listPlatformUnifiedOrders({
tenantId: filters.value.tenantId || undefined,
keyword: filters.value.keyword.trim() || undefined,
mainStatus: filters.value.mainStatus || undefined,
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
page: page.value,
size: pageSize.value,
})
records.value = result.records
total.value = result.total
} catch (error) {
records.value = []
total.value = 0
loadError.value = getApiErrorDetails(error, '统一订单列表加载失败,请检查 C02 统一查询接口和订单权限')
} finally {
loading.value = false
}
}
function search() {
if (page.value !== 1) {
page.value = 1
return
}
void loadOrders()
}
function reset() {
filters.value = { tenantId: '', keyword: '', mainStatus: '', createdRange: [] }
page.value = 1
void loadOrders()
}
function openDetail(row: UnifiedOrderResource) {
void router.push({ name: 'unified-order-detail', params: { orderId: String(row.orderId) }, query: { tenantId: String(row.tenantId) } })
}
function openMeasurement(row: UnifiedOrderResource) {
if (!row.inboundId || !can('platform:firstleg:inbound:view')) return
void router.push({ name: 'inbound-order-detail', params: { inboundId: String(row.inboundId) }, query: { tenantId: String(row.tenantId) } })
}
function workflowEligible(row: UnifiedOrderResource) {
return Boolean(row.inboundId && ['WAIT_SPLIT', 'LOADING', 'LOADED'].includes(row.mainStatus))
}
function openWorkflow(row: UnifiedOrderResource) {
if (!workflowEligible(row) || !can('platform:firstleg:inbound:view')) return
void router.push({
name: 'unified-order-workflow',
params: { orderId: String(row.orderId) },
query: { tenantId: String(row.tenantId) },
})
}
function closeDetail() {
detailOpen.value = false
detail.value = null
detailError.value = null
void router.push({ name: 'unified-orders' })
}
async function loadDetail() {
if (!orderId.value) return
const tenantId = typeof route.query.tenantId === 'string' ? route.query.tenantId : ''
if (!tenantId) {
detailError.value = { message: '订单详情缺少客户标识,请从订单列表重新打开' }
detailOpen.value = true
return
}
detailLoading.value = true
detailError.value = null
detailOpen.value = true
try {
detail.value = await getPlatformUnifiedOrder(tenantId, orderId.value)
} catch (error) {
detail.value = null
detailError.value = getApiErrorDetails(error, '统一订单详情加载失败,请检查 C02 统一详情接口和订单权限')
} finally {
detailLoading.value = false
}
}
watch([page, pageSize], () => { void loadOrders() })
watch(orderId, (value) => {
if (value) void loadDetail()
else detailOpen.value = false
}, { immediate: true })
onMounted(() => {
void loadCustomers()
void loadOrders()
})
</script>
<template>
<section class="unified-orders-page">
<div class="page-intro">
<div>
<h1>订单列表</h1>
<p>平台围绕同一订单主线跟进审核、测量、分柜、装柜和派送。</p>
</div>
<el-button :loading="loading" @click="loadOrders"><RefreshCw :size="16" />刷新</el-button>
</div>
<QueryToolbar v-model="filters" ariaLabel="订单列表筛选" :fields="queryFields" :search-on-reset="false" @search="search" @reset="reset" />
<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 class="table-card">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表">
<template #actions="{ row }">
<div class="row-actions">
<el-button link type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>
<el-button v-if="(row as UnifiedOrderResource).mainStatus === 'WAIT_MEASURE' && (row as UnifiedOrderResource).inboundId && can('platform:firstleg:inbound:view')" link type="primary" @click="openMeasurement(row as UnifiedOrderResource)">测量作业</el-button>
<el-button v-if="workflowEligible(row as UnifiedOrderResource) && can('platform:firstleg:inbound:view')" link type="primary" @click="openWorkflow(row as UnifiedOrderResource)"><ClipboardList :size="15" />后半程作业</el-button>
<span v-if="nextActionBoundary(row as UnifiedOrderResource)" class="action-boundary">{{ nextActionBoundary(row as UnifiedOrderResource) }}</span>
</div>
</template>
<template #order="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).orderNo || `订单 ${(row as UnifiedOrderResource).orderId}` }}</strong><span v-if="(row as UnifiedOrderResource).inboundCode">入仓码 {{ (row as UnifiedOrderResource).inboundCode }}</span></div></template>
<template #mainStatus="{ row }"><StatusBadge :status="statusLabel((row as UnifiedOrderResource).mainStatus)" /></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong><span>客户 ID {{ (row as UnifiedOrderResource).tenantId }}</span></div></template>
<template #receivingWarehouseName="{ row }">{{ (row as UnifiedOrderResource).receivingWarehouseName || '—' }}</template>
<template #destinationWarehouseName="{ row }">{{ (row as UnifiedOrderResource).destinationWarehouseName || '—' }}</template>
<template #transport="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).transport?.containerNo || (row as UnifiedOrderResource).transport?.loadingStatus || '—' }}</strong><span v-if="(row as UnifiedOrderResource).transport?.vesselName">{{ (row as UnifiedOrderResource).transport?.vesselName }} · {{ (row as UnifiedOrderResource).transport?.voyageNo || '—' }}</span></div></template>
<template #cargo="{ row }"><span>{{ (row as UnifiedOrderResource).totalPackageCount ?? '—' }} 箱</span><span v-if="(row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== null && (row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualGrossWeightKg) }} kg</span><span v-if="(row as UnifiedOrderResource).measurement?.actualVolumeCbm !== null && (row as UnifiedOrderResource).measurement?.actualVolumeCbm !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualVolumeCbm, 3) }} m³</span></template>
<template #expectedShipDate="{ row }">{{ formatDate((row as UnifiedOrderResource).expectedShipDate) }}</template>
<template #updatedAt="{ row }">{{ formatDate((row as UnifiedOrderResource).updatedAt) }}</template>
</DataTable>
</div>
<AppDrawer v-model="detailOpen" :title="detail ? `订单详情 · ${detail.summary.orderNo || detail.summary.orderId}` : '订单详情'" size="min(760px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
<LoadErrorState v-if="detailError" title="订单详情暂时无法加载" :message="detailError.message" :loading="detailLoading" @retry="loadDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<div v-else-if="detail" class="detail-content">
<section class="detail-summary" aria-label="订单概览">
<div class="detail-summary__header"><div><span class="eyebrow">订单主状态</span><h2>{{ detail.summary.orderNo || `订单 ${detail.summary.orderId}` }}</h2></div><div class="detail-summary__actions"><StatusBadge :status="statusLabel(detail.summary.mainStatus)" /><el-button v-if="workflowEligible(detail.summary) && can('platform:firstleg:inbound:view')" type="primary" plain @click="openWorkflow(detail.summary)"><ClipboardList :size="15" />后半程作业</el-button></div></div>
<dl class="detail-grid"><div><dt>客户</dt><dd>{{ customerName(detail.summary) }}</dd></div><div><dt>入仓码</dt><dd>{{ detail.summary.inboundCode || '—' }}</dd></div><div><dt>平台收货仓</dt><dd>{{ detail.summary.receivingWarehouseName || '—' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.summary.destinationWarehouseName || '—' }}</dd></div><div><dt>下一步</dt><dd>{{ nextActionBoundary(detail.summary) || '—' }}</dd></div><div><dt>最近更新</dt><dd>{{ formatDate(detail.summary.updatedAt) }}</dd></div></dl>
</section>
<section class="detail-section" aria-label="状态历史"><h3>状态历史</h3><el-empty v-if="detail.timeline.length === 0" description="暂无状态历史" :image-size="56" /><ol v-else class="history-list"><li v-for="(item, index) in detail.timeline" :key="`${item.source || 'event'}-${item.occurredAt || index}`"><StatusBadge :status="statusLabel(item.mainStatus)" /><div><strong>{{ item.action || item.eventType || '状态更新' }}</strong><p>{{ item.operatorName || '系统' }} · {{ formatDate(item.occurredAt) }}</p><p v-if="item.remark">{{ item.remark }}</p></div></li></ol></section>
<section class="detail-section" aria-label="内部备注"><h3>内部备注</h3><el-empty v-if="detail.order.internalNotes.length === 0" description="暂无内部备注" :image-size="56" /><ul v-else class="notes-list"><li v-for="item in detail.order.internalNotes" :key="String(item.id)"><p>{{ item.content }}</p><span>操作人 {{ item.createdBy || '系统' }} · {{ formatDate(item.createdAt) }}</span></li></ul></section>
</div>
</AppDrawer>
</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-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; }
.primary-cell { display: grid; min-width: 0; gap: 4px; }
.primary-cell strong { overflow-wrap: anywhere; }
.primary-cell span { color: var(--cc-muted); font-size: 12px; overflow-wrap: anywhere; }
.detail-content { display: grid; gap: 22px; min-width: 0; }
.detail-summary, .detail-section { padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.detail-summary__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.detail-summary__actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.eyebrow { color: var(--cc-muted); font-size: 12px; }
.detail-summary h2 { margin: 4px 0 0; color: var(--cc-text); font-size: 18px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 20px; margin: 20px 0 0; }
.detail-grid div { min-width: 0; }
.detail-grid dt { color: var(--cc-muted); font-size: 12px; }
.detail-grid dd { margin: 4px 0 0; color: var(--cc-text); overflow-wrap: anywhere; }
.detail-section h3 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
.history-list, .notes-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.history-list li { display: flex; align-items: flex-start; gap: 10px; padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
.history-list li:last-child, .notes-list li:last-child { padding-bottom: 0; border-bottom: 0; }
.history-list strong { font-size: 13px; }
.history-list p, .notes-list p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.notes-list li { padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
.notes-list li > p { margin: 0; color: var(--cc-text); font-size: 13px; }
.notes-list span { display: block; margin-top: 5px; color: var(--cc-muted); font-size: 12px; }
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .detail-grid { grid-template-columns: 1fr; } .detail-summary__header { flex-direction: column; } .detail-summary__actions { justify-content: flex-start; } .row-actions { flex-wrap: wrap; } }
</style>