This commit is contained in:
2026-08-06 15:32:02 +08:00
parent 9e7e1afac8
commit 33a0c48b78
22 changed files with 685 additions and 402 deletions
@@ -14,16 +14,32 @@ describe('unified orders API contract', () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: '2', size: '20', total: '0', pages: '0' } })
await expect(listPlatformUnifiedOrders({
keyword: 'CC', orderNo: 'CC-1', mainStatus: 'WAIT_SPLIT', customerId: '30001',
receivingWarehouseId: '11', destinationWarehouseId: '22', page: 2, size: 20,
receivingWarehouseId: '11', destinationWarehouseId: '22', inboundCode: 'RK-1', internalOrderStatus: 'ACCEPTED',
expectedArrivalDate: '2026-08-10', feeReviewStatus: 'APPROVED', channelName: '海运', containerNo: 'CONT-1',
cabinetNo: 'CAB-1', sailingDate: '2026-08-12', loaded: true, fullContainer: false,
measurementReviewStatus: 'PENDING', fbaCode: 'FBA-1', createdFrom: '2026-08-01T00:00:00',
createdTo: '2026-08-31T23:59:59', page: 2, size: 20,
})).resolves.toMatchObject({ page: 2, size: 20, total: 0, pages: 0 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders', {
params: {
keyword: 'CC', orderNo: 'CC-1', mainStatus: 'WAIT_SPLIT', customerId: '30001',
receivingWarehouseId: '11', destinationWarehouseId: '22', page: 2, size: 20,
receivingWarehouseId: '11', destinationWarehouseId: '22', inboundCode: 'RK-1', internalOrderStatus: 'ACCEPTED',
expectedArrivalDate: '2026-08-10', feeReviewStatus: 'APPROVED', channelName: '海运', containerNo: 'CONT-1',
cabinetNo: 'CAB-1', sailingDate: '2026-08-12', loaded: true, fullContainer: false,
measurementReviewStatus: 'PENDING', fbaCode: 'FBA-1', createdFrom: '2026-08-01T00:00:00',
createdTo: '2026-08-31T23:59:59', page: 2, size: 20,
},
})
})
it('omits empty optional filters while preserving false boolean values', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
await listPlatformUnifiedOrders({ keyword: '', loaded: false, fullContainer: false, page: 1, size: 20 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders', {
params: { loaded: false, fullContainer: false, page: 1, size: 20 },
})
})
it('normalizes C02 row IDs and keeps inboundId for measurement routing', async () => {
vi.mocked(http.get).mockResolvedValue({ data: {
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', measurement: null, preload: null, loading: null, delivery: null, transport: null }],
@@ -168,6 +168,18 @@ export interface UnifiedOrderQuery {
customerId?: string
receivingWarehouseId?: string
destinationWarehouseId?: string
inboundCode?: string
internalOrderStatus?: string
expectedArrivalDate?: string
feeReviewStatus?: string
channelName?: string
containerNo?: string
cabinetNo?: string
sailingDate?: string
loaded?: boolean
fullContainer?: boolean
measurementReviewStatus?: string
fbaCode?: string
createdFrom?: string
createdTo?: string
page: number
@@ -15,6 +15,32 @@ describe('UnifiedOrdersView contract', () => {
}
})
it('renders accessible current-status tabs with keyboard navigation', () => {
expect(source).toContain('role="tablist"')
expect(source).toContain('role="tab"')
expect(source).toContain(':aria-selected="filters.mainStatus === tab.value"')
expect(source).toContain("event.key === 'ArrowRight'")
expect(source).toContain("event.key === 'ArrowLeft'")
expect(source).toContain("event.key === 'Home'")
expect(source).toContain("event.key === 'End'")
})
it('provides labeled prototype-aligned advanced filters backed by real query fields', () => {
for (const key of [
'receivingWarehouseId', 'destinationWarehouseId', 'inboundCode', 'internalOrderStatus', 'expectedArrivalDate',
'feeReviewStatus', 'containerNo', 'cabinetNo', 'channelName', 'sailingDate', 'loaded', 'fullContainer',
'measurementReviewStatus', 'fbaCode', 'createdRange',
]) expect(source).toContain(`key: '${key}'`)
for (const label of ['定位仓库', '交货仓', '费用审核状态', '是否已配载', '是否整柜', '测量数据审核状态', '商品 FBA 编码']) {
expect(source).toContain(`label: '${label}'`)
}
expect(source).toContain('listAllPlatformWarehouses')
expect(source).toContain("{ status: 'ACTIVE' }")
expect(source).toContain("warehouse.warehouseType === 'DOMESTIC'")
expect(source).toContain("loaded: optionalBoolean(filters.value.loaded)")
expect(source).toContain("fullContainer: optionalBoolean(filters.value.fullContainer)")
})
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="订单列表暂时无法加载"')
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ClipboardList, Eye, RefreshCw } from '@lucide/vue'
import { ChevronDown, ChevronUp, ClipboardList, Eye, RefreshCw, SlidersHorizontal } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
@@ -12,6 +12,7 @@ import {
} from '@/modules/unified-orders/api/unifiedOrders'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
@@ -23,7 +24,22 @@ 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[]
}
@@ -32,6 +48,7 @@ const router = useRouter()
const access = useAccessPreviewStore()
const records = ref<UnifiedOrderResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
@@ -39,19 +56,36 @@ 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 detailOpen = ref(false)
const filters = ref<FilterModel>({ tenantId: '', keyword: '', mainStatus: '', createdRange: [] })
const showAdvancedFilters = ref(false)
const filters = ref<FilterModel>(emptyFilters())
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const queryFields = computed<QueryField[]>(() => [
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),
@@ -59,18 +93,39 @@ const queryFields = computed<QueryField[]>(() => [
})),
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': '创建日期范围' } },
{ 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: 'DRAFT', label: '草稿' }, { value: 'SUBMITTED', label: '待审核' }, { value: 'APPROVED', label: '已审核' },
{ value: 'ACCEPTED', label: '已受理' }, { value: 'PROCESSING', label: '处理中' }, { value: 'COMPLETED', label: '已完成' }, { value: 'CANCELLED', 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: '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: '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 queryFields = computed(() => showAdvancedFilters.value
? [...primaryQueryFields.value, ...advancedQueryFields.value]
: primaryQueryFields.value)
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单信息', minWidth: 220, slotName: 'order' },
@@ -84,6 +139,43 @@ const columns: DataTableColumn[] = [
{ key: 'updatedAt', label: '最近更新', minWidth: 150, slotName: 'updatedAt' },
]
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)
}
@@ -140,6 +232,19 @@ async function loadCustomers() {
}
}
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
@@ -147,8 +252,23 @@ async function loadOrders() {
const range = filters.value.createdRange
const result = await listPlatformUnifiedOrders({
tenantId: filters.value.tenantId || undefined,
keyword: filters.value.keyword.trim() || 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,
@@ -174,7 +294,7 @@ function search() {
}
function reset() {
filters.value = { tenantId: '', keyword: '', mainStatus: '', createdRange: [] }
filters.value = emptyFilters()
page.value = 1
void loadOrders()
}
@@ -236,6 +356,7 @@ watch(orderId, (value) => {
}, { immediate: true })
onMounted(() => {
void loadCustomers()
void loadWarehouses()
void loadOrders()
})
</script>
@@ -250,12 +371,34 @@ onMounted(() => {
<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" />
<div class="status-tabs" role="tablist" aria-label="订单主状态">
<button
v-for="(tab, index) in statusTabs"
:key="tab.value || 'ALL'"
type="button"
role="tab"
:aria-selected="filters.mainStatus === tab.value"
aria-controls="unified-orders-results"
:tabindex="filters.mainStatus === tab.value ? 0 : -1"
:class="['status-tab', { 'is-active': filters.mainStatus === tab.value }]"
@click="selectStatus(tab.value)"
@keydown="handleTabKeydown($event, index)"
>{{ tab.label }}</button>
</div>
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" ariaLabel="订单列表筛选" :fields="queryFields" :search-on-reset="false" @search="search" @reset="reset">
<template #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
</el-button>
</template>
</QueryToolbar>
<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">
<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" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表">
<template #actions="{ row }">
<div class="row-actions">
@@ -297,6 +440,14 @@ onMounted(() => {
.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; }
.status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid var(--cc-border); scrollbar-width: thin; }
.status-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }
.status-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }
.status-tab:hover { color: var(--cc-primary); background: #f6f9fd; }
.status-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
.status-tab.is-active { color: var(--cc-primary); font-weight: 600; }
.status-tab.is-active::after { background: var(--cc-primary); }
.status-tabs + :deep(.query-toolbar) { border-top: 0; }
.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; }
@@ -323,5 +474,5 @@ onMounted(() => {
.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; } }
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .detail-grid { grid-template-columns: 1fr; } .detail-summary__header { flex-direction: column; } .detail-summary__actions { justify-content: flex-start; } .row-actions { flex-wrap: wrap; } }
</style>