优化优化

This commit is contained in:
2026-08-11 18:06:35 +08:00
parent 8a8c5a3faa
commit 03e1cd0c80
40 changed files with 2092 additions and 354 deletions
@@ -211,6 +211,8 @@ export interface UnifiedOrderDetailResource {
export interface UnifiedOrderFeeDetailResource {
id: number | string
sourceFeeId?: number | string | null
sourceFeeVersion?: number | null
inboundId: number | string
inboundCode: string | null
reviewStatus: string | null
+6
View File
@@ -0,0 +1,6 @@
export {
listPlatformUnifiedOrderFeeDetails,
listPlatformUnifiedOrders,
type UnifiedOrderFeeDetailResource,
type UnifiedOrderResource,
} from './api/unifiedOrders'
@@ -0,0 +1,118 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders'
import UnifiedOrdersView from './UnifiedOrdersView.vue'
vi.mock('vue-router', () => ({
useRoute: () => ({ params: {}, query: {} }),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) }))
vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn() }))
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
addPlatformUnifiedOrderNote: vi.fn(),
approvePlatformUnifiedOrder: vi.fn(),
getPlatformUnifiedOrder: vi.fn(),
listPlatformUnifiedOrderFeeDetails: vi.fn(),
listPlatformUnifiedOrders: vi.fn(),
unifiedOrderStatusOptions: [
{ value: 'PENDING_REVIEW', label: '待审核' },
{ value: 'WAIT_MEASURE', label: '待测量数据' },
{ value: 'INBOUNDED', label: '已入库' },
{ value: 'WAIT_SPLIT', label: '待分柜' },
{ value: 'LOADING', label: '装柜中' },
{ value: 'LOADED', label: '装柜完成' },
{ value: 'DELIVERED', label: '派送完成' },
],
}))
function emptyPage() {
return { records: [], page: 1, size: 20, total: 0, pages: 0 }
}
function mountView() {
return shallowMount(UnifiedOrdersView, {
global: {
directives: { loading: () => undefined },
stubs: {
AdminPage: { template: '<main><slot /></main>' },
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue'], emits: ['update:modelValue', 'search', 'reset'], template: '<div data-testid="query-toolbar" />' },
DataTable: { name: 'DataTable', props: ['page', 'pageSize'], emits: ['update:page', 'update:pageSize'], template: '<div data-testid="data-table" />' },
'el-button': { template: '<button><slot /></button>' },
},
},
})
}
describe('UnifiedOrdersView list request coordination', () => {
beforeEach(() => {
vi.mocked(listPlatformUnifiedOrders).mockReset().mockResolvedValue(emptyPage())
})
it('loads once initially and once for query, reset, status tab and pagination', async () => {
const wrapper = mountView()
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1)
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
toolbar.vm.$emit('search')
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(2)
toolbar.vm.$emit('reset')
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(3)
await wrapper.findAll('.status-tab')[1]!.trigger('click')
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(4)
wrapper.getComponent({ name: 'DataTable' }).vm.$emit('update:page', 2)
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(5)
})
it('does not apply draft filters to pagination until query is submitted', async () => {
const wrapper = mountView()
await flushPromises()
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
const draft = { ...toolbar.props('modelValue'), inboundCode: ' DRAFT-CODE ' }
toolbar.vm.$emit('update:modelValue', draft)
await wrapper.vm.$nextTick()
wrapper.getComponent({ name: 'DataTable' }).vm.$emit('update:page', 2)
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, inboundCode: undefined }))
toolbar.vm.$emit('search')
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(3)
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, inboundCode: 'DRAFT-CODE' }))
})
it('resets a later page and changes page size with one request per interaction', async () => {
const wrapper = mountView()
await flushPromises()
const table = wrapper.getComponent({ name: 'DataTable' })
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
table.vm.$emit('update:page', 3)
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(2)
toolbar.vm.$emit('reset')
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(3)
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
table.vm.$emit('update:pageSize', 50)
table.vm.$emit('update:page', 1)
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(4)
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 50 }))
})
})
@@ -19,7 +19,7 @@ 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(':aria-selected="selectedStatus === tab.value"')
expect(source).toContain("event.key === 'ArrowRight'")
expect(source).toContain("event.key === 'ArrowLeft'")
expect(source).toContain("event.key === 'Home'")
@@ -38,8 +38,8 @@ describe('UnifiedOrdersView contract', () => {
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)")
expect(source).toContain("loaded: optionalBoolean(activeFilters.loaded)")
expect(source).toContain("fullContainer: optionalBoolean(activeFilters.fullContainer)")
expect(source).toContain("{ value: 'UNRECORDED', label: '未录入' }")
expect(source).not.toContain("{ value: 'DRAFT', label: '草稿' }")
expect(source).not.toContain("{ value: 'CANCELLED', label: '已取消' }")
@@ -59,7 +59,7 @@ describe('UnifiedOrdersView contract', () => {
})
it('uses tabs as the only order-status filter and keeps advanced controls inside QueryToolbar', () => {
expect(source).toContain('filters.value.mainStatus || undefined')
expect(source).toContain('mainStatus: selectedStatus.value || undefined')
expect(source).toContain('<template #actions>')
expect(source).toContain(':aria-expanded="showAdvancedFilters"')
expect(source).not.toContain('class="filter-mode-actions"')
@@ -102,6 +102,14 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length"')
})
it('keeps editable query values separate from the applied list request', () => {
expect(source).toContain('const appliedFilters = ref<FilterModel>(emptyFilters())')
expect(source).toContain('const activeFilters = appliedFilters.value')
expect(source).toContain('appliedFilters.value = cloneFilters(filters.value)')
expect(source).toContain('appliedFilters.value = emptyFilters()')
expect(source).not.toContain('filters.value.mainStatus')
})
it('keeps only confirmed business actions as explicit boundaries', () => {
expect(source).toContain('nextActionBoundary')
expect(source).not.toContain('class="action-boundary"')
@@ -39,7 +39,6 @@ import {
type FilterModel = QueryModel & {
tenantId: string
mainStatus: UnifiedOrderMainStatus | ''
receivingWarehouseId: string
destinationWarehouseId: string
inboundCode: string
@@ -96,6 +95,8 @@ let detailLoadingKey = ''
let detailRequestSerial = 0
const showAdvancedFilters = ref(false)
const filters = ref<FilterModel>(emptyFilters())
const appliedFilters = ref<FilterModel>(emptyFilters())
const selectedStatus = ref<UnifiedOrderMainStatus | ''>('')
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const detailTenantId = computed(() => typeof route.query.tenantId === 'string' ? route.query.tenantId : '')
const detailRouteKey = computed(() => orderId.value ? `${detailTenantId.value}:${orderId.value}` : '')
@@ -240,13 +241,17 @@ const columns: DataTableColumn[] = [
function emptyFilters(): FilterModel {
return {
tenantId: '', mainStatus: '', receivingWarehouseId: '', destinationWarehouseId: '',
tenantId: '', receivingWarehouseId: '', destinationWarehouseId: '',
inboundCode: '', expectedArrivalDate: '', feeReviewStatus: '', channelName: '',
containerNo: '', cabinetNo: '', sailingDate: '', loaded: '', fullContainer: '', measurementReviewStatus: '',
fbaCode: '', createdRange: [],
}
}
function cloneFilters(source: FilterModel): FilterModel {
return { ...source, createdRange: [...source.createdRange] }
}
function trimmed(value: string) {
return value.trim() || undefined
}
@@ -256,8 +261,10 @@ function optionalBoolean(value: string) {
}
function selectStatus(status: UnifiedOrderMainStatus | '') {
filters.value = { ...filters.value, mainStatus: status }
search()
if (selectedStatus.value === status) return
selectedStatus.value = status
if (page.value === 1) void loadOrders()
else page.value = 1
}
function handleTabKeydown(event: KeyboardEvent, index: number) {
@@ -625,23 +632,24 @@ async function loadOrders() {
loading.value = true
loadError.value = null
try {
const range = filters.value.createdRange
const activeFilters = appliedFilters.value
const range = activeFilters.createdRange
const result = await listPlatformUnifiedOrders({
tenantId: filters.value.tenantId || undefined,
mainStatus: filters.value.mainStatus || undefined,
receivingWarehouseId: filters.value.receivingWarehouseId || undefined,
destinationWarehouseId: filters.value.destinationWarehouseId || undefined,
inboundCode: trimmed(filters.value.inboundCode),
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),
tenantId: activeFilters.tenantId || undefined,
mainStatus: selectedStatus.value || undefined,
receivingWarehouseId: activeFilters.receivingWarehouseId || undefined,
destinationWarehouseId: activeFilters.destinationWarehouseId || undefined,
inboundCode: trimmed(activeFilters.inboundCode),
expectedArrivalDate: activeFilters.expectedArrivalDate || undefined,
feeReviewStatus: activeFilters.feeReviewStatus || undefined,
channelName: trimmed(activeFilters.channelName),
containerNo: trimmed(activeFilters.containerNo),
cabinetNo: trimmed(activeFilters.cabinetNo),
sailingDate: activeFilters.sailingDate || undefined,
loaded: optionalBoolean(activeFilters.loaded),
fullContainer: optionalBoolean(activeFilters.fullContainer),
measurementReviewStatus: activeFilters.measurementReviewStatus || undefined,
fbaCode: trimmed(activeFilters.fbaCode),
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
page: page.value,
@@ -660,6 +668,7 @@ async function loadOrders() {
}
function search() {
appliedFilters.value = cloneFilters(filters.value)
if (page.value !== 1) {
page.value = 1
return
@@ -669,6 +678,7 @@ function search() {
function reset() {
filters.value = emptyFilters()
appliedFilters.value = emptyFilters()
if (page.value === 1) void loadOrders()
else page.value = 1
}
@@ -1026,10 +1036,10 @@ onMounted(() => {
:key="tab.value || 'ALL'"
type="button"
role="tab"
:aria-selected="filters.mainStatus === tab.value"
:aria-selected="selectedStatus === tab.value"
aria-controls="unified-orders-results"
:tabindex="filters.mainStatus === tab.value ? 0 : -1"
:class="['status-tab', { 'is-active': filters.mainStatus === tab.value }]"
:tabindex="selectedStatus === tab.value ? 0 : -1"
:class="['status-tab', { 'is-active': selectedStatus === tab.value }]"
@click="selectStatus(tab.value)"
@keydown="handleTabKeydown($event, index)"
>{{ tab.label }}</button>
@@ -1,4 +1,4 @@
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessageBox } from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -9,6 +9,7 @@ import {
getOutboundDispatch,
getPreload,
smartSplit,
voidEvidence,
} from '../api'
vi.mock('../api', async (importOriginal) => {
@@ -19,6 +20,7 @@ vi.mock('../api', async (importOriginal) => {
getOutboundDispatch: vi.fn(),
getPreload: vi.fn(),
smartSplit: vi.fn(),
voidEvidence: vi.fn(),
}
})
@@ -50,6 +52,7 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
vi.mocked(getEvidence).mockReset().mockResolvedValue({ inboundId: '2086085512165634051', evidences: [] })
vi.mocked(getPreload).mockReset().mockResolvedValue(preload('REF-OLD'))
vi.mocked(smartSplit).mockReset()
vi.mocked(voidEvidence).mockReset()
})
it('renders the formal preload table and keeps the operation column first', async () => {
@@ -116,4 +119,41 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
expect(wrapper.text()).not.toContain('完成装柜')
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
})
it('uses a validated dialog when voiding evidence', async () => {
vi.mocked(getEvidence).mockResolvedValue({
inboundId: '2086085512165634051',
evidences: [
{ id: '1', inboundId: '2086085512165634051', tenantId: '2086085512165634052', evidenceType: 'PHOTO_STEP_1', fileId: '11', fileName: 'wrong.jpg', sealNo: null, remark: null, version: 2, status: 'ACTIVE', createdAt: null, updatedAt: null },
],
})
vi.mocked(voidEvidence).mockResolvedValue({} as never)
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue({ value: '照片上传错误' } as never)
const wrapper = mountPanel(['platform:firstleg:inbound:loading:view', 'platform:firstleg:inbound:loading:edit'], 'LOADING')
await flushPromises()
const voidButton = wrapper.findAll('button').find((button) => button.text() === '作废')
expect(voidButton).toBeTruthy()
await voidButton!.trigger('click')
await flushPromises()
expect(prompt).toHaveBeenCalledWith(
'作废后照片仍会保留在审计记录中。',
'作废证据照片',
expect.objectContaining({
inputType: 'textarea',
inputPlaceholder: '请输入作废原因',
type: 'warning',
}),
)
const options = prompt.mock.calls[0]?.[2]
expect(options?.inputValidator?.(' ')).toBe('作废原因不能为空')
expect(voidEvidence).toHaveBeenCalledWith(
'2086085512165634052',
'2086085512165634051',
'1',
{ expectedVersion: 2, reason: '照片上传错误' },
)
prompt.mockRestore()
})
})
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessageBox } from 'element-plus'
import { RefreshCw, Save, Check, Play, Camera, Eye, Plus, Pencil, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -345,8 +346,21 @@ async function uploadAndSaveEvidence(type: EvidenceType, event: Event) {
async function markEvidenceVoided(item: Evidence) {
if (!canEvidence.value || !item.id || String(item.id).startsWith('draft-')) return
const reason = window.prompt('请输入照片作废原因')?.trim()
if (!reason) return
let reason = ''
try {
const result = await ElMessageBox.prompt('作废后照片仍会保留在审计记录中。', '作废证据照片', {
inputType: 'textarea',
inputPlaceholder: '请输入作废原因',
inputValidator: (value) => Boolean(value?.trim()) || '作废原因不能为空',
confirmButtonText: '确认作废',
cancelButtonText: '取消',
type: 'warning',
})
reason = result.value.trim()
} catch (cause) {
if (cause === 'cancel' || cause === 'close') return
throw cause
}
const result = await run(`evidence-void-${item.id}`, () => voidEvidence(props.tenantId, props.inboundId, item.id, {
expectedVersion: item.version,
reason,