This commit is contained in:
2026-08-07 20:07:37 +08:00
parent 28d0b81c5a
commit 99413ee155
27 changed files with 1065 additions and 595 deletions
@@ -4,7 +4,9 @@ import {
confirmWeightResult,
correctWeightResult,
createWeightResult,
deleteWeightResult,
downloadWeightImage,
downloadWeightThumbnail,
getWeightOcrRawText,
getWeightWorkspace,
normalizeWeightConfidence,
@@ -13,7 +15,7 @@ import {
} from './weightOcr'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
http: { delete: vi.fn(), get: vi.fn(), post: vi.fn(), put: vi.fn() },
ApiResponseError: class ApiResponseError extends Error {},
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
@@ -21,6 +23,7 @@ vi.mock('@/shared/api/http', () => ({
describe('inbound weight OCR API', () => {
beforeEach(() => {
vi.mocked(http.get).mockReset()
vi.mocked(http.delete).mockReset()
vi.mocked(http.post).mockReset()
vi.mocked(http.put).mockReset()
})
@@ -136,4 +139,22 @@ describe('inbound weight OCR API', () => {
{ responseType: 'blob' },
)
})
it('downloads a thumbnail and deletes a result without converting long IDs', async () => {
const image = new Blob(['jpg'], { type: 'image/jpeg' })
vi.mocked(http.get).mockResolvedValue(image)
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await expect(downloadWeightThumbnail('10001', '20001', '9007199254740995')).resolves.toBe(image)
expect(http.get).toHaveBeenCalledWith(
'/platform/v1/files/measurements/10001/20001/9007199254740995/thumbnail',
{ responseType: 'blob' },
)
await deleteWeightResult('10001', '20001', '9007199254740997', 3)
expect(http.delete).toHaveBeenCalledWith(
'/platform/v1/first-leg/inbounds/20001/weight-results/9007199254740997',
{ params: { tenantId: '10001', version: 3 } },
)
})
})
@@ -94,6 +94,24 @@ export async function downloadWeightImage(tenantId: string, inboundId: string, f
}
}
export async function downloadWeightThumbnail(tenantId: string, inboundId: string, fileId: string) {
try {
const blob = await http.get<Blob, Blob>(
`${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}/${segment(fileId)}/thumbnail`,
{ responseType: 'blob' },
)
const responseError = await parseBlobError(blob, 200)
if (responseError) throw responseError
return blob
} catch (error) {
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
const responseError = await parseBlobError(error.response.data, error.response.status)
if (responseError) throw responseError
}
throw error
}
}
export async function createWeightResult(
tenantId: string,
inboundId: string,
@@ -138,3 +156,16 @@ export async function confirmWeightResult(
)
return unwrapApiResponse(response)
}
export async function deleteWeightResult(
tenantId: string,
inboundId: string,
resultId: string,
version: number,
) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(
`${measurementBasePath}/${segment(inboundId)}/weight-results/${segment(resultId)}`,
{ params: { tenantId, version } },
)
unwrapApiResponse(response)
}
@@ -0,0 +1,44 @@
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { downloadWeightThumbnail } from '../api/weightOcr'
import WeightImageThumbnail from './WeightImageThumbnail.vue'
vi.mock('../api/weightOcr', () => ({
downloadWeightThumbnail: vi.fn(),
}))
enableAutoUnmount(afterEach)
describe('WeightImageThumbnail', () => {
const createObjectURL = vi.fn(() => 'blob:thumbnail')
const revokeObjectURL = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
})
it('loads an authenticated thumbnail and releases its object URL', async () => {
vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' }))
const wrapper = mount(WeightImageThumbnail, {
props: { customerId: '10001', inboundId: '20001', fileId: '30001', alt: '称重图片 1' },
})
await flushPromises()
expect(downloadWeightThumbnail).toHaveBeenCalledWith('10001', '20001', '30001')
expect(wrapper.get('img').attributes('src')).toBe('blob:thumbnail')
await wrapper.unmount()
expect(revokeObjectURL).toHaveBeenCalledWith('blob:thumbnail')
})
it('shows a stable failure state when the thumbnail cannot be read', async () => {
vi.mocked(downloadWeightThumbnail).mockRejectedValue(new Error('network'))
const wrapper = mount(WeightImageThumbnail, {
props: { customerId: '10001', inboundId: '20001', fileId: '30001', alt: '称重图片 1' },
})
await flushPromises()
expect(wrapper.text()).toContain('图片加载失败')
expect(wrapper.find('img').exists()).toBe(false)
})
})
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { ImageOff } from '@lucide/vue'
import { downloadWeightThumbnail } from '../api/weightOcr'
const props = defineProps<{
customerId: string
inboundId: string
fileId: string
alt: string
}>()
defineEmits<{ open: [] }>()
const imageUrl = ref('')
const loading = ref(false)
const failed = ref(false)
let requestId = 0
function releaseImage() {
if (imageUrl.value) URL.revokeObjectURL(imageUrl.value)
imageUrl.value = ''
}
async function load() {
const currentRequest = ++requestId
releaseImage()
failed.value = false
loading.value = true
try {
const blob = await downloadWeightThumbnail(props.customerId, props.inboundId, props.fileId)
if (currentRequest !== requestId) return
imageUrl.value = URL.createObjectURL(blob)
} catch {
if (currentRequest === requestId) failed.value = true
} finally {
if (currentRequest === requestId) loading.value = false
}
}
watch(() => [props.customerId, props.inboundId, props.fileId], load, { immediate: true })
onBeforeUnmount(() => {
requestId += 1
releaseImage()
})
</script>
<template>
<button class="weight-thumb" type="button" :aria-label="`查看${alt}`" @click="$emit('open')">
<img v-if="imageUrl" :src="imageUrl" :alt="alt" loading="lazy" />
<span v-else class="weight-thumb__state" :class="{ 'is-loading': loading }">
<ImageOff v-if="failed" :size="22" aria-hidden="true" />
<span>{{ failed ? '图片加载失败' : '图片加载中' }}</span>
</span>
</button>
</template>
<style scoped>
.weight-thumb { display: block; width: 100%; aspect-ratio: 4 / 3; overflow: hidden; padding: 0; border: 0; border-radius: 6px 6px 0 0; background: #f2f4f7; cursor: zoom-in; }
.weight-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
.weight-thumb__state { display: grid; width: 100%; height: 100%; place-items: center; align-content: center; gap: 7px; color: #667085; font-size: 12px; }
.weight-thumb__state.is-loading { color: #98a2b3; }
.weight-thumb:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
</style>
@@ -1,13 +1,19 @@
import ElementPlus from 'element-plus'
import { ElMessageBox } from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import {
confirmWeightResult,
correctWeightResult,
createWeightResult,
deleteWeightResult,
downloadWeightImage,
downloadWeightThumbnail,
getWeightWorkspace,
uploadWeightImage,
} from '../api/weightOcr'
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
import WeightOcrPanel from './WeightOcrPanel.vue'
@@ -18,7 +24,12 @@ vi.mock('../api/weightOcr', async () => {
...actual,
confirmWeightResult: vi.fn(),
correctWeightResult: vi.fn(),
createWeightResult: vi.fn(),
deleteWeightResult: vi.fn(),
downloadWeightImage: vi.fn(),
downloadWeightThumbnail: vi.fn(),
getWeightWorkspace: vi.fn(),
uploadWeightImage: vi.fn(),
}
})
@@ -72,11 +83,40 @@ function mountPanel(permissions = allWeightPermissions) {
})
}
async function openWeightDialog(wrapper: VueWrapper) {
await wrapper.get('button[aria-label="查看称重图片 1"]').trigger('click')
await flushPromises()
}
function dialogWeightInput() {
const input = document.body.querySelector<HTMLInputElement>('input[aria-label="当前图片确认重量"]')
if (!input) throw new Error('weight confirmation input is missing')
return new DOMWrapper(input)
}
function dialogUnit(index: number) {
const option = document.body.querySelectorAll<HTMLElement>('.el-dialog .el-segmented__item').item(index)
return new DOMWrapper(option)
}
function dialogConfirmButton() {
const button = [...document.body.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')]
.find((candidate) => candidate.textContent?.trim() === '确认重量')
if (!button) throw new Error('weight confirmation button is missing')
return new DOMWrapper(button)
}
describe('WeightOcrPanel confirmation behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
vi.mocked(getWeightWorkspace).mockResolvedValue(workspace)
vi.mocked(downloadWeightImage).mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' }))
vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' }))
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:test-image'),
revokeObjectURL: vi.fn(),
})
vi.mocked(confirmWeightResult).mockResolvedValue({
...pendingWeight,
recognitionStatus: 'CONFIRMED',
@@ -85,11 +125,79 @@ describe('WeightOcrPanel confirmation behavior', () => {
})
})
it('confirms an unchanged OCR value with one request', async () => {
it('opens the editable confirmation dialog immediately after upload', async () => {
vi.mocked(uploadWeightImage).mockResolvedValue({
fileId: '40002',
fileName: 'weight.jpg',
contentType: 'image/jpeg',
sizeBytes: 100,
recognitionStatus: 'SUCCESS',
weightValue: 12.5,
weightUnit: 'KG',
confidence: 0.98,
error: null,
})
vi.mocked(createWeightResult).mockResolvedValue({
...pendingWeight,
id: '30002',
fileId: '40002',
ocrWeightValue: 12.5,
})
const wrapper = mountPanel()
await flushPromises()
const input = wrapper.get('input[type="file"]')
const file = new File(['image'], 'weight.jpg', { type: 'image/jpeg' })
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
await input.trigger('change')
await flushPromises()
expect(uploadWeightImage).toHaveBeenCalledWith('10001', '20001', file)
expect(document.body.textContent).toContain('确认称重图片')
expect(document.body.textContent).toContain('识别重量 12.500 KG')
expect(document.body.querySelector('input[aria-label="当前图片确认重量"]')).not.toBeNull()
})
it('renders thirty compact image cards without technical OCR details', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({
...workspace,
weightResults: Array.from({ length: 30 }, (_, index) => ({
...pendingWeight,
id: String(30001 + index),
fileId: String(40001 + index),
ocrRawText: `raw-${index}`,
})),
})
const wrapper = mountPanel()
await flushPromises()
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
expect(wrapper.findAll('.weight-card')).toHaveLength(30)
expect(wrapper.findAll('.weight-thumb')).toHaveLength(30)
expect(wrapper.text()).not.toContain('置信度')
expect(wrapper.text()).not.toContain('OCR 原文')
expect(wrapper.text()).not.toContain('FILE_WEIGHT_OCR')
expect(wrapper.text()).not.toContain('raw-0')
})
it('deletes a pending image with its optimistic-lock version', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
vi.mocked(deleteWeightResult).mockResolvedValue(undefined)
const wrapper = mountPanel()
await flushPromises()
await wrapper.get('button[aria-label="删除称重图片 1"]').trigger('click')
await flushPromises()
expect(deleteWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', 0)
})
it('confirms an unchanged OCR value with one request', async () => {
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
expect(document.body.textContent).not.toContain('识别重量 285.500 KG')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledTimes(1)
@@ -100,9 +208,10 @@ describe('WeightOcrPanel confirmation behavior', () => {
it('sends the adjusted value and a specific audit reason in the same confirmation request', async () => {
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').setValue('286.125')
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
await dialogWeightInput().setValue('286.125')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledTimes(1)
@@ -118,10 +227,11 @@ describe('WeightOcrPanel confirmation behavior', () => {
it('sends an LB adjustment atomically after switching units', async () => {
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').setValue('10.125')
await wrapper.findAll('.el-segmented__item').at(1)?.trigger('click')
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
await dialogWeightInput().setValue('10.125')
await dialogUnit(1).trigger('click')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
@@ -136,21 +246,23 @@ describe('WeightOcrPanel confirmation behavior', () => {
it('rejects an LB value that would round to zero kilograms', async () => {
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').setValue('0.001')
await wrapper.findAll('.el-segmented__item').at(1)?.trigger('click')
await dialogWeightInput().setValue('0.001')
await dialogUnit(1).trigger('click')
expect(wrapper.get('.weight-row__editor .el-button--success').attributes('disabled')).toBeDefined()
expect(dialogConfirmButton().attributes('disabled')).toBeDefined()
expect(confirmWeightResult).not.toHaveBeenCalled()
})
it('accepts the smallest LB value that remains representable in kilograms', async () => {
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').setValue('0.002')
await wrapper.findAll('.el-segmented__item').at(1)?.trigger('click')
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
await dialogWeightInput().setValue('0.002')
await dialogUnit(1).trigger('click')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
@@ -164,11 +276,12 @@ describe('WeightOcrPanel confirmation behavior', () => {
it('clamps a value outside the safe three-decimal JavaScript range', async () => {
const wrapper = mountPanel()
await flushPromises()
const input = wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]')
await openWeightDialog(wrapper)
const input = dialogWeightInput()
await input.setValue('9000000000001')
expect((input.element as HTMLInputElement).value).toBe('9000000000000.000')
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
@@ -182,11 +295,12 @@ describe('WeightOcrPanel confirmation behavior', () => {
it('rounds an input with more than three decimals before confirmation', async () => {
const wrapper = mountPanel()
await flushPromises()
const input = wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]')
await openWeightDialog(wrapper)
const input = dialogWeightInput()
await input.setValue('1.2345')
expect((input.element as HTMLInputElement).value).toBe('1.235')
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
@@ -210,8 +324,9 @@ describe('WeightOcrPanel confirmation behavior', () => {
})
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 1 })
@@ -224,9 +339,10 @@ describe('WeightOcrPanel confirmation behavior', () => {
'platform:firstleg:inbound:weight:confirm',
])
await flushPromises()
await openWeightDialog(wrapper)
expect(wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').attributes('disabled')).toBeDefined()
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
expect(dialogWeightInput().attributes('disabled')).toBeDefined()
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 })
@@ -238,8 +354,11 @@ describe('WeightOcrPanel confirmation behavior', () => {
'platform:firstleg:inbound:weight:edit',
])
await flushPromises()
await openWeightDialog(wrapper)
expect(wrapper.find('.weight-row__editor .el-button--success').exists()).toBe(false)
const confirmButtons = [...document.body.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')]
.filter((button) => button.textContent?.trim() === '确认重量')
expect(confirmButtons).toHaveLength(0)
expect(confirmWeightResult).not.toHaveBeenCalled()
})
@@ -256,10 +375,11 @@ describe('WeightOcrPanel confirmation behavior', () => {
})
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
expect(wrapper.get('.weight-row__editor .el-button--success').attributes('disabled')).toBeDefined()
await wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').setValue('1.500')
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
expect(dialogConfirmButton().attributes('disabled')).toBeDefined()
await dialogWeightInput().setValue('1.500')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
@@ -277,8 +397,9 @@ describe('WeightOcrPanel confirmation behavior', () => {
})
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 })
@@ -291,8 +412,9 @@ describe('WeightOcrPanel confirmation behavior', () => {
})
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
@@ -310,8 +432,9 @@ describe('WeightOcrPanel confirmation behavior', () => {
})
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
@@ -326,10 +449,11 @@ describe('WeightOcrPanel confirmation behavior', () => {
vi.mocked(confirmWeightResult).mockRejectedValueOnce(new Error('version conflict'))
const wrapper = mountPanel()
await flushPromises()
const input = wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]')
await openWeightDialog(wrapper)
const input = dialogWeightInput()
await input.setValue('286.125')
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect((input.element as HTMLInputElement).value).toBe('286.125')
@@ -14,29 +14,29 @@ describe('WeightOcrPanel contract', () => {
expect(template).not.toContain('租户')
})
it('covers upload, preview, OCR retry, inline correction, confirmation, and server totals', () => {
it('covers upload, immediate preview, inline correction, deletion, confirmation, and server totals', () => {
expect(source).toContain('uploadWeightImage')
expect(source).toContain('downloadWeightImage')
expect(source).toContain('retryWeightRecognition')
expect(source).toContain('correctWeightResult')
expect(source).toContain('await openPreview(created, URL.createObjectURL(file))')
expect(source).toContain('previewShowsRecognition.value = Boolean(localUrl)')
expect(source).toContain('deleteWeightResult')
expect(source).toContain('confirmWeightResult')
expect(source).toContain('实际总毛重(服务端汇总)')
expect(source).toContain('称重图片序号 #{{ index + 1 }}')
expect(source).toContain('workspace.value?.actualGrossWeightKg')
expect(source).toContain('v-model="weightDrafts[row.id].value"')
expect(source).toContain('识别值已自动填入,可修改后直接确认保存。')
expect(source).toContain('class="weight-card__value"')
expect(source).toContain('v-model="weightDrafts[previewResult.id].value"')
expect(source).toContain('识别重量')
expect(source).toContain('payload.adjustedWeightValue = Number(draft.value)')
expect(source).toContain('payload.adjustmentReason = buildAdjustmentReason(row)')
expect(source).not.toContain('version = corrected.version')
expect(source).not.toContain('title="人工修正重量"')
})
it('matches backend image limits and preserves missing OCR text honestly', () => {
it('matches backend image limits and keeps technical OCR data out of the list', () => {
expect(source).toContain("['image/jpeg', 'image/png', 'image/bmp']")
expect(source).toContain('5 * 1024 * 1024')
expect(source).toContain('图片服务未返回 OCR 原文')
expect(source).toContain('ocrRawText: getWeightOcrRawText(upload)')
expect(source).toContain("adjustmentReason: 'OCR 重试识别结果'")
const template = source.match(/<template>([\s\S]*?)<\/template>/)?.[1] || ''
expect(template).not.toContain('置信度')
expect(template).not.toContain('OCR 原文')
expect(template).not.toContain('提供方')
expect(template).not.toContain('文件 ID')
})
it('gates every action with the implemented permissions and editable statuses', () => {
@@ -1,13 +1,10 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
CheckCircle2,
Eye,
FileImage,
ImagePlus,
RefreshCw,
Scale,
Trash2,
} from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -16,15 +13,15 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import {
confirmWeightResult,
correctWeightResult,
createWeightResult,
deleteWeightResult,
downloadWeightImage,
getWeightOcrRawText,
getWeightWorkspace,
retryWeightRecognition,
uploadWeightImage,
} from '../api/weightOcr'
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
import WeightImageThumbnail from './WeightImageThumbnail.vue'
const props = defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()
const emit = defineEmits<{ changed: [] }>()
@@ -41,6 +38,9 @@ const previewOpen = ref(false)
const previewLoading = ref(false)
const previewError = ref<ApiErrorDetails | null>(null)
const previewUrl = ref('')
const previewShowsRecognition = ref(false)
const previewResultId = ref('')
const previewSnapshot = ref<WeightResultResource | null>(null)
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB' }>>({})
let loadSequence = 0
@@ -53,29 +53,13 @@ const canConfirm = computed(() => access.can('platform:firstleg:inbound:weight:c
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const editable = computed(() => Boolean(workspace.value && editableStatuses.has(workspace.value.processStatus)))
const rows = computed(() => workspace.value?.weightResults ?? [])
const confirmedCount = computed(() => rows.value.filter((row) => row.recognitionStatus === 'CONFIRMED').length)
const pendingCount = computed(() => rows.value.length - confirmedCount.value)
const confirmedTotal = computed(() => workspace.value?.actualGrossWeightKg ?? null)
const previewResult = computed(() => rows.value.find((row) => row.id === previewResultId.value)
|| previewSnapshot.value)
function formatWeight(value: number | null, unit = 'KG') {
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
}
function formatConfidence(value: number | null) {
return value == null ? '未返回' : `${(Number(value) * 100).toFixed(1)}%`
}
function formatTime(value: string | null) {
return value?.replace('T', ' ').slice(0, 16) || '—'
}
function statusMeta(status: string) {
if (status === 'CONFIRMED') return { label: '已确认', type: 'success' as const }
if (status === 'PENDING_CONFIRM') return { label: '待确认', type: 'warning' as const }
if (status === 'NEEDS_MANUAL') return { label: '需人工处理', type: 'danger' as const }
return { label: status || '未知', type: 'info' as const }
}
function actionKey(action: string, id: string) {
return `${action}:${id}`
}
@@ -99,6 +83,13 @@ function syncWeightDrafts(results: WeightResultResource[]) {
}]))
}
function ensureWeightDraft(row: WeightResultResource) {
weightDrafts.value[row.id] ??= {
value: row.adjustedWeightValue ?? row.ocrWeightValue,
unit: normalizeUnit(row.adjustedWeightUnit ?? row.ocrWeightUnit),
}
}
function isWeightDraftValid(row: WeightResultResource) {
const draft = weightDrafts.value[row.id]
const value = Number(draft?.value)
@@ -108,6 +99,12 @@ function isWeightDraftValid(row: WeightResultResource) {
&& value <= maximumSafeWeight)
}
function cardWeight(row: WeightResultResource) {
if (row.recognitionStatus === 'CONFIRMED') return formatWeight(row.finalWeightKg)
const draft = weightDrafts.value[row.id]
return formatWeight(draft?.value ?? null, draft?.unit ?? 'KG')
}
function hasWeightDraftChanged(row: WeightResultResource) {
const draft = weightDrafts.value[row.id]
if (!draft) return false
@@ -144,7 +141,6 @@ async function loadWorkspace() {
}
} catch (error) {
if (sequence !== loadSequence) return
workspace.value = null
loadError.value = getApiErrorDetails(error, '称重与 OCR 数据加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
@@ -179,7 +175,7 @@ async function handleFileChange(event: Event) {
actionError.value = null
try {
const upload = await uploadWeightImage(customerId.value, props.inboundId, file)
await createWeightResult(customerId.value, props.inboundId, {
const created = await createWeightResult(customerId.value, props.inboundId, {
fileId: String(upload.fileId),
ocrProvider: 'FILE_WEIGHT_OCR',
ocrRawText: getWeightOcrRawText(upload),
@@ -187,10 +183,10 @@ async function handleFileChange(event: Event) {
ocrWeightUnit: upload.weightUnit,
confidence: upload.confidence,
})
ElMessage.success(upload.recognitionStatus === 'FAILED'
? '图片已保存,OCR 未识别出重量,请人工修正或重试'
: '称重图片已上传并完成识别')
ensureWeightDraft(created)
await openPreview(created, URL.createObjectURL(file))
await loadWorkspace()
ensureWeightDraft(created)
emit('changed')
} catch (error) {
actionError.value = getApiErrorDetails(error, '称重图片上传失败,请检查后重试')
@@ -199,7 +195,7 @@ async function handleFileChange(event: Event) {
}
}
async function confirmResult(row: WeightResultResource) {
async function confirmResult(row: WeightResultResource, closeAfterSuccess = false) {
const draft = weightDrafts.value[row.id]
if (!draft || !isWeightDraftValid(row)) {
ElMessage.warning('请输入大于 0 的有效重量')
@@ -220,39 +216,17 @@ async function confirmResult(row: WeightResultResource) {
payload.adjustmentReason = buildAdjustmentReason(row)
}
await confirmWeightResult(customerId.value, props.inboundId, row.id, payload)
ElMessage.success('重量已确认并计入实际总重量')
ElMessage.success('重量已确认')
await loadWorkspace()
if (closeAfterSuccess) previewOpen.value = false
emit('changed')
return true
} catch (error) {
actionError.value = getApiErrorDetails(error, '重量确认失败,请刷新后重试')
} finally {
activeAction.value = ''
}
}
async function retryOcr(row: WeightResultResource) {
const key = actionKey('retry', row.id)
activeAction.value = key
actionError.value = null
try {
const result = await retryWeightRecognition(customerId.value, props.inboundId, row.fileId)
if (result.weightValue == null || !result.weightUnit) {
throw new Error(result.error || 'OCR 仍未识别出有效重量,可改用人工修正')
}
await correctWeightResult(customerId.value, props.inboundId, row.id, {
version: row.version,
adjustedWeightValue: result.weightValue,
adjustedWeightUnit: result.weightUnit.toUpperCase().startsWith('LB') ? 'LB' : 'KG',
adjustmentReason: 'OCR 重试识别结果',
})
ElMessage.success('OCR 重试成功,请核对并确认重量')
await loadWorkspace()
emit('changed')
} catch (error) {
actionError.value = getApiErrorDetails(error, 'OCR 重试失败,可稍后重试或人工修正')
} finally {
activeAction.value = ''
}
return false
}
function releasePreview() {
@@ -260,10 +234,18 @@ function releasePreview() {
previewUrl.value = ''
}
async function openPreview(row: WeightResultResource) {
async function openPreview(row: WeightResultResource, localUrl = '') {
releasePreview()
previewResultId.value = row.id
previewSnapshot.value = row
previewShowsRecognition.value = Boolean(localUrl)
previewError.value = null
previewOpen.value = true
if (localUrl) {
previewUrl.value = localUrl
previewLoading.value = false
return
}
previewLoading.value = true
try {
const blob = await downloadWeightImage(customerId.value, props.inboundId, row.fileId)
@@ -275,6 +257,34 @@ async function openPreview(row: WeightResultResource) {
}
}
async function removeResult(row: WeightResultResource) {
try {
await ElMessageBox.confirm(
row.recognitionStatus === 'CONFIRMED'
? '删除后将重新计算实际总毛重,确认删除这张称重图片吗?'
: '确认删除这张称重图片吗?',
'删除称重图片',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch {
return
}
const key = actionKey('delete', row.id)
activeAction.value = key
actionError.value = null
try {
await deleteWeightResult(customerId.value, props.inboundId, row.id, row.version)
if (previewResultId.value === row.id) previewOpen.value = false
ElMessage.success('称重图片已删除')
await loadWorkspace()
emit('changed')
} catch (error) {
actionError.value = getApiErrorDetails(error, '称重图片删除失败,请刷新后重试')
} finally {
activeAction.value = ''
}
}
watch(
() => [customerId.value, props.inboundId, canView.value] as const,
() => {
@@ -300,8 +310,8 @@ defineExpose({ reload: loadWorkspace })
<section class="weight-workspace" aria-labelledby="weight-ocr-title">
<header class="weight-heading">
<div>
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片与 OCR</h3>
<p>逐张核对识别值并确认,服务端汇总所有已确认图片的实际总重量。</p>
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片</h3>
<p>上传后直接核对重量,确认结果自动计入实际总毛重。</p>
</div>
<div class="weight-heading__actions">
<input
@@ -339,12 +349,6 @@ defineExpose({ reload: loadWorkspace })
</LoadErrorState>
<template v-else>
<div class="weight-metrics" aria-live="polite" :aria-busy="loading">
<div><span>实际总毛重(服务端汇总)</span><strong>{{ formatWeight(confirmedTotal) }}</strong></div>
<div><span>已确认图片</span><strong>{{ confirmedCount }}</strong></div>
<div><span>待处理图片</span><strong :class="{ 'is-warning': pendingCount > 0 }">{{ pendingCount }}</strong></div>
</div>
<el-alert v-if="workspace && !editable" type="info" :closable="false" show-icon class="weight-state-note">
当前入库状态为 {{ workspace.processStatus }},称重数据仅供查看。
</el-alert>
@@ -362,97 +366,55 @@ defineExpose({ reload: loadWorkspace })
<div v-loading="loading" class="weight-list" aria-live="polite">
<div v-if="!loading && rows.length === 0" class="weight-empty">
<FileImage :size="28" aria-hidden="true" />
<strong>尚未上传称重图片</strong>
<p>上传 JPG、PNG 或 BMP 图片,单张不超过 5MB。</p>
</div>
<article v-for="(row, index) in rows" :key="row.id" class="weight-row">
<div class="weight-row__identity">
<span class="weight-row__icon"><FileImage :size="18" aria-hidden="true" /></span>
<div>
<strong>称重图片序号 #{{ index + 1 }}</strong>
<small>文件 ID {{ row.fileId }} · {{ row.ocrProvider || 'OCR 服务未标识' }}</small>
<div v-else class="weight-grid">
<article v-for="(row, index) in rows" :key="row.id" class="weight-card">
<WeightImageThumbnail
:customer-id="customerId"
:inbound-id="inboundId"
:file-id="row.fileId"
:alt="`称重图片 ${index + 1}`"
@open="openPreview(row)"
/>
<el-tooltip v-if="canEdit && editable" content="删除图片" placement="top">
<el-button
text
type="danger"
class="weight-delete"
:aria-label="`删除称重图片 ${index + 1}`"
:loading="isActionLoading('delete', row.id)"
:disabled="Boolean(activeAction)"
@click="removeResult(row)"
><Trash2 :size="16" /></el-button>
</el-tooltip>
<div class="weight-card__body">
<button
type="button"
class="weight-card__value"
:aria-label="`查看称重图片 ${index + 1},重量 ${cardWeight(row)}`"
@click="openPreview(row)"
><strong>{{ cardWeight(row) }}</strong></button>
</div>
<el-tag :type="statusMeta(row.recognitionStatus).type" effect="light">
{{ statusMeta(row.recognitionStatus).label }}
</el-tag>
</div>
<dl class="weight-row__facts">
<div><dt>OCR 原值</dt><dd>{{ formatWeight(row.ocrWeightValue, row.ocrWeightUnit || '') }}</dd></div>
<div><dt>置信度</dt><dd>{{ formatConfidence(row.confidence) }}</dd></div>
<div><dt>人工修正</dt><dd>{{ formatWeight(row.adjustedWeightValue, row.adjustedWeightUnit || '') }}</dd></div>
<div><dt>最终计入</dt><dd class="weight-final">{{ formatWeight(row.finalWeightKg) }}</dd></div>
</dl>
<div class="weight-row__raw">
<span>OCR 原文</span>
<p>{{ row.ocrRawText || '图片服务未返回 OCR 原文' }}</p>
</div>
<div v-if="row.adjustmentReason || row.confirmedAt" class="weight-row__audit">
<span v-if="row.adjustmentReason">修正原因:{{ row.adjustmentReason }}</span>
<span v-if="row.confirmedAt">确认时间:{{ formatTime(row.confirmedAt) }}</span>
</div>
<div
v-if="row.recognitionStatus !== 'CONFIRMED' && weightDrafts[row.id]"
class="weight-row__editor"
>
<label>
<span>确认重量</span>
<el-input-number
v-model="weightDrafts[row.id].value"
:aria-label="`称重图片序号 ${index + 1} 的确认重量`"
:min="minimumWeight[weightDrafts[row.id].unit]"
:max="maximumSafeWeight"
:precision="3"
:controls="false"
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
/>
</label>
<label>
<span>单位</span>
<el-segmented
v-model="weightDrafts[row.id].unit"
:aria-label="`称重图片序号 ${index + 1} 的重量单位`"
:options="['KG', 'LB']"
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
/>
</label>
<el-button
v-if="canConfirm && editable"
type="success"
:loading="isActionLoading('confirm', row.id)"
:disabled="Boolean(activeAction) || !isWeightDraftValid(row) || (hasWeightDraftChanged(row) && !canEdit)"
@click="confirmResult(row)"
><CheckCircle2 :size="16" />确认并保存</el-button>
<small>识别值已自动填入,可修改后直接确认保存。</small>
</div>
<footer class="weight-row__actions">
<el-button link type="primary" @click="openPreview(row)"><Eye :size="15" />查看图片</el-button>
<el-button
v-if="canEdit && editable && row.recognitionStatus !== 'CONFIRMED'"
link
type="primary"
:loading="isActionLoading('retry', row.id)"
:disabled="Boolean(activeAction)"
@click="retryOcr(row)"
><RefreshCw :size="15" />重试 OCR</el-button>
</footer>
</article>
</article>
</div>
</div>
</template>
</template>
<AppDialog
v-model="previewOpen"
title="称重图片"
title="确认称重图片"
:loading="previewLoading"
:show-footer="false"
width="min(760px, calc(100vw - 28px))"
:confirm-loading="Boolean(previewResult && isActionLoading('confirm', previewResult.id))"
:confirm-disabled="!previewResult || !isWeightDraftValid(previewResult) || Boolean(activeAction)"
:show-confirm="Boolean(previewResult && previewResult.recognitionStatus !== 'CONFIRMED' && canConfirm && editable)"
confirm-text="确认重量"
confirm-type="success"
width="min(680px, calc(100vw - 28px))"
@confirm="previewResult && confirmResult(previewResult, true)"
>
<LoadErrorState
v-if="previewError"
@@ -463,7 +425,35 @@ defineExpose({ reload: loadWorkspace })
>
<template #details><ApiErrorMeta :details="previewError" /></template>
</LoadErrorState>
<img v-else-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
<template v-else-if="previewResult">
<img v-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
<div v-if="previewResult.recognitionStatus === 'CONFIRMED'" class="preview-weight is-confirmed">
<span>确认重量</span><strong>{{ formatWeight(previewResult.finalWeightKg) }}</strong>
</div>
<div v-else-if="weightDrafts[previewResult.id]" class="preview-weight">
<span v-if="previewShowsRecognition" class="preview-weight__recognized">
识别重量 {{ formatWeight(previewResult.ocrWeightValue, previewResult.ocrWeightUnit || '') }}
</span>
<label>
<span>确认重量</span>
<el-input-number
v-model="weightDrafts[previewResult.id].value"
aria-label="当前图片确认重量"
:min="minimumWeight[weightDrafts[previewResult.id].unit]"
:max="maximumSafeWeight"
:precision="3"
:controls="false"
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
/>
</label>
<el-segmented
v-model="weightDrafts[previewResult.id].unit"
aria-label="当前图片重量单位"
:options="['KG', 'LB']"
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
/>
</div>
</template>
</AppDialog>
</section>
</template>
@@ -476,61 +466,36 @@ defineExpose({ reload: loadWorkspace })
.weight-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
.weight-heading__actions { flex: 0 0 auto; }
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.weight-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 18px 0; border: 1px solid var(--cc-border); border-radius: 8px; background: #f8fafc; }
.weight-metrics > div { min-width: 0; padding: 15px 18px; border-right: 1px solid var(--cc-border); }
.weight-metrics > div:last-child { border-right: 0; }
.weight-metrics span { display: block; margin-bottom: 5px; color: var(--cc-muted); font-size: 11px; }
.weight-metrics strong { font-size: 20px; line-height: 1.2; }
.weight-metrics strong.is-warning { color: #b54708; }
.weight-state-note, .weight-action-error { margin-bottom: 14px; }
.weight-list { min-height: 120px; }
.weight-state-note, .weight-action-error { margin-bottom: 14px; }
.weight-list { min-height: 120px; margin-top: 16px; }
.weight-empty { display: grid; min-height: 180px; place-items: center; align-content: center; gap: 8px; padding: 24px; color: var(--cc-muted); text-align: center; }
.weight-empty svg { color: #98a2b3; }
.weight-empty strong { color: #344054; font-size: 14px; }
.weight-empty p { margin: 0; font-size: 12px; }
.weight-row { padding: 18px 0; border-bottom: 1px solid var(--cc-border); }
.weight-row:last-child { border-bottom: 0; }
.weight-row__identity { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; align-items: center; gap: 10px; }
.weight-row__icon { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 7px; background: #eef4ff; color: var(--cc-primary); }
.weight-row__identity strong, .weight-row__identity small { display: block; min-width: 0; overflow-wrap: anywhere; }
.weight-row__identity strong { font-size: 13px; }
.weight-row__identity small { margin-top: 3px; color: var(--cc-muted); font-size: 11px; }
.weight-row__facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 14px 0 0; background: var(--cc-border); }
.weight-row__facts > div { min-width: 0; padding: 11px 12px; background: #f8fafc; }
.weight-row__facts dt { color: var(--cc-muted); font-size: 10px; }
.weight-row__facts dd { margin: 4px 0 0; font-size: 12px; font-weight: 650; overflow-wrap: anywhere; }
.weight-row__facts .weight-final { color: #067647; }
.weight-row__raw { display: grid; grid-template-columns: 78px minmax(0, 1fr); gap: 10px; margin-top: 12px; font-size: 11px; line-height: 1.55; }
.weight-row__raw span { color: var(--cc-muted); }
.weight-row__raw p { margin: 0; color: #475467; overflow-wrap: anywhere; white-space: pre-wrap; }
.weight-row__audit { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; color: var(--cc-muted); font-size: 11px; line-height: 1.5; }
.weight-row__editor { display: grid; grid-template-columns: minmax(180px, 1fr) 150px auto; align-items: end; gap: 12px; margin-top: 14px; padding: 14px; border: 1px solid #b7dfca; border-radius: 7px; background: #f2fbf6; }
.weight-row__editor label { display: grid; min-width: 0; gap: 6px; color: #475467; font-size: 11px; }
.weight-row__editor :deep(.el-input-number), .weight-row__editor :deep(.el-segmented) { width: 100%; }
.weight-row__editor > small { grid-column: 1 / -1; color: var(--cc-muted); font-size: 11px; line-height: 1.5; }
.weight-row__actions { display: flex; flex-wrap: wrap; gap: 2px 12px; margin-top: 12px; }
.weight-row__actions :deep(.el-button) { margin-left: 0; }
.weight-preview { display: block; max-width: 100%; max-height: min(68vh, 720px); margin: 0 auto; object-fit: contain; }
.weight-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; }
.weight-card { position: relative; min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
.weight-card__body { padding: 12px; text-align: center; }
.weight-delete { position: absolute; z-index: 2; top: 8px; right: 8px; width: 30px; height: 30px; padding: 0; border: 1px solid rgb(255 255 255 / 82%); background: rgb(255 255 255 / 92%); box-shadow: 0 1px 3px rgb(16 24 40 / 16%); }
.weight-card__value { width: 100%; padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; text-align: center; }
.weight-card__value strong { color: #101828; font-size: 17px; }
.weight-card__value:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 4px; }
.weight-preview { display: block; width: 100%; max-height: min(58vh, 560px); object-fit: contain; border-radius: 6px; background: #f2f4f7; }
.preview-weight { display: grid; grid-template-columns: minmax(0, 1fr) 120px; align-items: end; gap: 12px; margin-top: 14px; }
.preview-weight__recognized { grid-column: 1 / -1; color: var(--cc-muted); font-size: 12px; }
.preview-weight label { display: grid; gap: 6px; color: #475467; font-size: 12px; }
.preview-weight :deep(.el-input-number), .preview-weight :deep(.el-segmented) { width: 100%; }
.preview-weight.is-confirmed { display: flex; align-items: baseline; justify-content: space-between; padding: 12px 0 0; }
.preview-weight.is-confirmed span { color: var(--cc-muted); font-size: 12px; }
.preview-weight.is-confirmed strong { color: #067647; font-size: 20px; }
@media (max-width: 720px) {
.weight-heading { flex-direction: column; }
.weight-heading__actions, .weight-heading__actions :deep(.el-button) { width: 100%; }
.weight-heading__actions :deep(.el-button), .weight-row__actions :deep(.el-button) { min-height: 44px; }
.weight-metrics { grid-template-columns: 1fr; }
.weight-metrics > div { border-right: 0; border-bottom: 1px solid var(--cc-border); }
.weight-metrics > div:last-child { border-bottom: 0; }
.weight-row__facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.weight-row__raw { grid-template-columns: 1fr; gap: 4px; }
.weight-row__editor { grid-template-columns: minmax(0, 1fr) 132px; }
.weight-row__editor > .el-button { grid-column: 1 / -1; width: 100%; }
.weight-row__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.weight-row__actions :deep(.el-button) { width: 100%; justify-content: flex-start; }
.weight-heading__actions, .weight-heading__actions :deep(.el-button) { width: 100%; min-height: 44px; }
.weight-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 360px) {
.weight-row__identity { grid-template-columns: 32px minmax(0, 1fr); }
.weight-row__identity > .el-tag { grid-column: 2; justify-self: start; }
.weight-row__facts, .weight-row__editor, .weight-row__actions { grid-template-columns: 1fr; }
.weight-row__editor > .el-button { grid-column: 1; }
.weight-grid { grid-template-columns: 1fr; }
.preview-weight { grid-template-columns: 1fr; }
.preview-weight__recognized { grid-column: 1; }
}
</style>