diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 0f6b2895..9fa07303 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -33,6 +33,7 @@ describe('adminNavigation', () => { '订单测量', '测量数据审核', '库位管理', + '托盘管理', '整柜业务', ]) expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访']) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 957847b9..4b756375 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -68,6 +68,7 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/inbounds/measurements', permission: 'platform:firstleg:inbound:measure:view' }, { key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/inbounds/measurement-reviews', permission: 'firstleg:inbound:measurement-review' }, { key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' }, + { key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' }, { key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' }, ], }, diff --git a/src/modules/inbounds/weight/api/weightOcr.test.ts b/src/modules/inbounds/weight/api/weightOcr.test.ts index 53829841..e7ed3816 100644 --- a/src/modules/inbounds/weight/api/weightOcr.test.ts +++ b/src/modules/inbounds/weight/api/weightOcr.test.ts @@ -93,6 +93,18 @@ describe('inbound weight OCR API', () => { ) }) + it('passes an abort signal without changing the frozen pallet endpoint parameters', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] }) + const controller = new AbortController() + + await getWeightPalletOptions('10001', '20001', controller.signal) + + expect(http.get).toHaveBeenCalledWith( + '/platform/v1/first-leg/inbounds/20001/weight-pallet-options', + { params: { tenantId: '10001' }, signal: controller.signal }, + ) + }) + it('registers, corrects, and confirms a weight result using the implemented contracts', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1' } }) vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', version: 1 } }) diff --git a/src/modules/inbounds/weight/api/weightOcr.ts b/src/modules/inbounds/weight/api/weightOcr.ts index 8caafc52..72324d19 100644 --- a/src/modules/inbounds/weight/api/weightOcr.ts +++ b/src/modules/inbounds/weight/api/weightOcr.ts @@ -60,10 +60,10 @@ export async function getWeightWorkspace(tenantId: string, inboundId: string) { return unwrapApiResponse(response) } -export async function getWeightPalletOptions(tenantId: string, inboundId: string) { +export async function getWeightPalletOptions(tenantId: string, inboundId: string, signal?: AbortSignal) { const response = await http.get, ApiEnvelope>( `${measurementBasePath}/${segment(inboundId)}/weight-pallet-options`, - { params: { tenantId } }, + { params: { tenantId }, ...(signal ? { signal } : {}) }, ) return unwrapApiResponse(response) } diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts b/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts index 2b97ff99..78013526 100644 --- a/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts @@ -352,6 +352,64 @@ describe('WeightOcrPanel confirmation behavior', () => { expect(getWeightPalletOptions).not.toHaveBeenCalled() }) + it('keeps historical pallet snapshots visible without requesting candidates for a read-only operator', async () => { + const wrapper = mountPanel(['platform:firstleg:inbound:measure:view']) + await flushPromises() + + expect(getWeightWorkspace).toHaveBeenCalledOnce() + expect(getWeightPalletOptions).not.toHaveBeenCalled() + expect(wrapper.find('.pallet-picker .el-select').exists()).toBe(false) + expect(wrapper.text()).toContain('木托盘') + expect(wrapper.text()).toContain('12.000 KG') + }) + + it('does not request pallet candidates when the inbound status is not editable', async () => { + vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, processStatus: 'MEASURE_PENDING' }) + + mountPanel() + await flushPromises() + + expect(getWeightPalletOptions).not.toHaveBeenCalled() + }) + + it('virtualizes one thousand pallet candidates instead of rendering one thousand option nodes', async () => { + vi.mocked(getWeightPalletOptions).mockResolvedValue(Array.from({ length: 1_000 }, (_, index) => ({ + palletId: String(60_000 + index), + palletCode: `TP-${index}`, + palletType: `托盘类型 ${index}`, + capacityCbm: 1.2, + tareWeightKg: 12, + }))) + const wrapper = mountPanel() + await flushPromises() + + await wrapper.find('.pallet-picker .el-select').trigger('click') + await flushPromises() + + const renderedOptions = document.body.querySelectorAll('.el-select-dropdown__item') + expect(renderedOptions.length).toBeGreaterThan(0) + expect(renderedOptions.length).toBeLessThan(100) + }) + + it('aborts an in-flight pallet request and ignores its late result when the panel closes', async () => { + let resolveOptions: ((value: Awaited>) => void) | undefined + vi.mocked(getWeightPalletOptions).mockImplementationOnce(() => new Promise((resolve) => { + resolveOptions = resolve + })) + const wrapper = mountPanel() + await flushPromises() + const signal = vi.mocked(getWeightPalletOptions).mock.calls[0]?.[2] + + wrapper.unmount() + resolveOptions?.([{ + palletId: 'late', palletCode: 'TP-LATE', palletType: '过期托盘', capacityCbm: 1, tareWeightKg: 10, + }]) + await flushPromises() + + expect(signal?.aborted).toBe(true) + expect(document.body.textContent).not.toContain('过期托盘') + }) + it('keeps a low-confidence OCR result focused on the editable weight', async () => { vi.mocked(getWeightWorkspace).mockResolvedValue({ ...workspace, diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.test.ts b/src/modules/inbounds/weight/components/WeightOcrPanel.test.ts index 527c16c4..24903aa8 100644 --- a/src/modules/inbounds/weight/components/WeightOcrPanel.test.ts +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.test.ts @@ -55,8 +55,10 @@ describe('WeightOcrPanel contract', () => { expect(source).toContain("option.palletType || '未记录类型'") }) - it('keeps pallet type, tare weight, and capacity in one select line', () => { - expect(source).toContain(':label="palletOptionLabel(option)"') + it('keeps pallet type, tare weight, and capacity in one virtualized select line', () => { + expect(source).toContain('') expect(source).toContain('width: clamp(360px, 36vw, 520px)') @@ -67,6 +69,9 @@ describe('WeightOcrPanel contract', () => { expect(source).toContain("platform:firstleg:inbound:weight:edit") expect(source).toContain("platform:firstleg:inbound:weight:confirm") expect(source).toContain("new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])") + expect(source).toContain('&& canEdit.value') + expect(source).toContain('&& editable.value') + expect(source).toContain('cancelPalletOptionsLoad()') expect(source).toContain("workspace.value?.processStatus === 'MEASURE_PENDING'") expect(source).toContain('v-if="canEdit && !reviewMode"') expect(source).toContain('v-if="canEdit && editable && !reviewMode"') diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.vue b/src/modules/inbounds/weight/components/WeightOcrPanel.vue index e8db56cb..732198be 100644 --- a/src/modules/inbounds/weight/components/WeightOcrPanel.vue +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.vue @@ -59,6 +59,8 @@ const previewResultId = ref('') const previewSnapshot = ref(null) const weightDrafts = ref>({}) let loadSequence = 0 +let palletLoadSequence = 0 +let palletAbortController: AbortController | null = null const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED']) const minimumWeight = { KG: 0.001, LB: 0.002 } as const @@ -68,6 +70,18 @@ const canEdit = computed(() => access.can('platform:firstleg:inbound:weight:edit const canConfirm = computed(() => access.can('platform:firstleg:inbound:weight:confirm')) const canView = computed(() => access.can('platform:firstleg:inbound:measure:view')) const editable = computed(() => Boolean(workspace.value && editableStatuses.has(workspace.value.processStatus))) +const canLoadPalletOptions = computed(() => Boolean( + !props.reviewMode + && customerId.value + && props.inboundId + && canView.value + && canEdit.value + && editable.value, +)) +const virtualPalletOptions = computed(() => palletOptions.value.map((option) => ({ + label: palletOptionLabel(option), + value: option.palletId, +}))) const reviewCorrectionEnabled = computed(() => Boolean( props.reviewMode && props.correctionMode @@ -212,32 +226,44 @@ async function loadWorkspace() { } async function loadPalletOptions() { - if (props.reviewMode) { + if (!canLoadPalletOptions.value) { + cancelPalletOptionsLoad() palletOptions.value = [] selectedPalletId.value = '' palletError.value = null return } - if (!customerId.value || !props.inboundId || !canView.value) { - palletOptions.value = [] - selectedPalletId.value = '' - return - } + cancelPalletOptionsLoad() + const sequence = ++palletLoadSequence + const controller = new AbortController() + palletAbortController = controller palletLoading.value = true palletError.value = null try { - const result = await getWeightPalletOptions(customerId.value, props.inboundId) + const result = await getWeightPalletOptions(customerId.value, props.inboundId, controller.signal) + if (sequence !== palletLoadSequence || controller.signal.aborted) return palletOptions.value = result if (!result.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = '' } catch (error) { + if (sequence !== palletLoadSequence || controller.signal.aborted) return palletOptions.value = [] selectedPalletId.value = '' palletError.value = getApiErrorDetails(error, '可选托盘加载失败,请稍后重试') } finally { - palletLoading.value = false + if (sequence === palletLoadSequence) { + palletLoading.value = false + if (palletAbortController === controller) palletAbortController = null + } } } +function cancelPalletOptionsLoad() { + ++palletLoadSequence + palletAbortController?.abort() + palletAbortController = null + palletLoading.value = false +} + function chooseFile() { if (!editable.value) { ElMessage.warning('当前入库状态不允许上传称重图片') @@ -397,17 +423,32 @@ watch( previewOpen.value = false releasePreview() void loadWorkspace() - void loadPalletOptions() }, { immediate: true }, ) +watch( + () => [customerId.value, props.inboundId, canLoadPalletOptions.value] as const, + () => { + cancelPalletOptionsLoad() + palletOptions.value = [] + selectedPalletId.value = '' + palletError.value = null + if (canLoadPalletOptions.value) void loadPalletOptions() + }, +) + watch(previewOpen, (open) => { if (!open) { releasePreview() } }) -onBeforeUnmount(releasePreview) +onBeforeUnmount(() => { + cancelPalletOptionsLoad() + palletOptions.value = [] + selectedPalletId.value = '' + releasePreview() +}) defineExpose({ reload: loadWorkspace }) @@ -424,22 +465,16 @@ defineExpose({ reload: loadWorkspace })
货物重量{{ formatWeight(workspace.actualNetWeightKg) }}
- - - + />