优化
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user