优化
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
confirmWeightResult,
|
||||
correctWeightResult,
|
||||
createWeightResult,
|
||||
downloadWeightImage,
|
||||
getWeightWorkspace,
|
||||
retryWeightRecognition,
|
||||
uploadWeightImage,
|
||||
} from './weightOcr'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
|
||||
ApiResponseError: class ApiResponseError extends Error {},
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
describe('inbound weight OCR API', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
vi.mocked(http.put).mockReset()
|
||||
})
|
||||
|
||||
it('loads the measurement workspace using the real firstleg path', async () => {
|
||||
const detail = { inboundId: '9007199254740993', weightResults: [] }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
|
||||
await expect(getWeightWorkspace('10001', '9007199254740993')).resolves.toEqual(detail)
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/9007199254740993/measurement',
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads and retries against the file service without converting long IDs', async () => {
|
||||
const result = { fileId: '9007199254740995', recognitionStatus: 'SUCCESS' }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: result })
|
||||
const file = new File(['image'], 'weight.png', { type: 'image/png' })
|
||||
|
||||
await expect(uploadWeightImage('10001', '9007199254740993', file)).resolves.toEqual(result)
|
||||
const [uploadPath, body] = vi.mocked(http.post).mock.calls[0]!
|
||||
expect(uploadPath).toBe('/platform/v1/files/measurements/10001/9007199254740993')
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
expect((body as FormData).get('file')).toBe(file)
|
||||
|
||||
await retryWeightRecognition('10001', '9007199254740993', '9007199254740995')
|
||||
expect(http.post).toHaveBeenLastCalledWith(
|
||||
'/platform/v1/files/measurements/10001/9007199254740993/9007199254740995/recognize',
|
||||
)
|
||||
})
|
||||
|
||||
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 } })
|
||||
|
||||
const createPayload = {
|
||||
fileId: '9007199254740995',
|
||||
ocrProvider: 'FILE_WEIGHT_OCR',
|
||||
ocrRawText: null,
|
||||
ocrWeightValue: 12.5,
|
||||
ocrWeightUnit: 'KG',
|
||||
confidence: 0.98,
|
||||
}
|
||||
await createWeightResult('10001', '20001', createPayload)
|
||||
expect(http.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/platform/v1/first-leg/inbounds/20001/weight-results',
|
||||
createPayload,
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
|
||||
const correction = { version: 0, adjustedWeightValue: 12.8, adjustedWeightUnit: 'KG' as const, adjustmentReason: '读数模糊' }
|
||||
await correctWeightResult('10001', '20001', '30001', correction)
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/20001/weight-results/30001',
|
||||
correction,
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
|
||||
await confirmWeightResult('10001', '20001', '30001', { version: 1 })
|
||||
expect(http.post).toHaveBeenLastCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/20001/weight-results/30001/confirm',
|
||||
{ version: 1 },
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('downloads an authenticated image as a blob', async () => {
|
||||
const image = new Blob(['png'], { type: 'image/png' })
|
||||
vi.mocked(http.get).mockResolvedValue(image)
|
||||
await expect(downloadWeightImage('10001', '20001', '40001')).resolves.toBe(image)
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
'/platform/v1/files/measurements/10001/20001/40001',
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import axios from 'axios'
|
||||
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
ConfirmWeightResultRequest,
|
||||
CorrectWeightResultRequest,
|
||||
CreateWeightResultRequest,
|
||||
WeightImageUploadResult,
|
||||
WeightResultResource,
|
||||
WeightWorkspaceResource,
|
||||
} from '../types'
|
||||
|
||||
const measurementBasePath = '/platform/v1/first-leg/inbounds'
|
||||
const imageBasePath = '/platform/v1/files/measurements'
|
||||
const maxErrorEnvelopeBytes = 1024 * 1024
|
||||
|
||||
function segment(value: string) {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
async function parseBlobError(blob: Blob, status?: number) {
|
||||
if (!blob.type.toLowerCase().includes('json')) return null
|
||||
if (blob.size > maxErrorEnvelopeBytes) {
|
||||
return new ApiResponseError('图片接口返回了超出限制的 JSON 响应', 'INVALID_IMAGE_RESPONSE', status)
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(await blob.text()) as Partial<ApiEnvelope<unknown>>
|
||||
return new ApiResponseError(
|
||||
payload.code === '200' ? '图片接口未返回图片内容' : payload.message || '称重图片读取失败',
|
||||
payload.code === '200' ? 'INVALID_IMAGE_RESPONSE' : payload.code || (status ? `HTTP_${status}` : 'IMAGE_FAILED'),
|
||||
status,
|
||||
payload.data,
|
||||
payload.requestId,
|
||||
payload.traceId,
|
||||
)
|
||||
} catch {
|
||||
return new ApiResponseError('图片接口返回了无法识别的响应', 'INVALID_IMAGE_RESPONSE', status)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWeightWorkspace(tenantId: string, inboundId: string) {
|
||||
const response = await http.get<ApiEnvelope<WeightWorkspaceResource>, ApiEnvelope<WeightWorkspaceResource>>(
|
||||
`${measurementBasePath}/${segment(inboundId)}/measurement`,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function uploadWeightImage(tenantId: string, inboundId: string, file: File) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
const response = await http.post<ApiEnvelope<WeightImageUploadResult>, ApiEnvelope<WeightImageUploadResult>>(
|
||||
`${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}`,
|
||||
body,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function retryWeightRecognition(tenantId: string, inboundId: string, fileId: string) {
|
||||
const response = await http.post<ApiEnvelope<WeightImageUploadResult>, ApiEnvelope<WeightImageUploadResult>>(
|
||||
`${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}/${segment(fileId)}/recognize`,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function downloadWeightImage(tenantId: string, inboundId: string, fileId: string) {
|
||||
try {
|
||||
const blob = await http.get<Blob, Blob>(
|
||||
`${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}/${segment(fileId)}`,
|
||||
{ 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,
|
||||
payload: CreateWeightResultRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<WeightResultResource>, ApiEnvelope<WeightResultResource>>(
|
||||
`${measurementBasePath}/${segment(inboundId)}/weight-results`,
|
||||
payload,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function correctWeightResult(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
resultId: string,
|
||||
payload: CorrectWeightResultRequest,
|
||||
) {
|
||||
const response = await http.put<ApiEnvelope<WeightResultResource>, ApiEnvelope<WeightResultResource>>(
|
||||
`${measurementBasePath}/${segment(inboundId)}/weight-results/${segment(resultId)}`,
|
||||
payload,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function confirmWeightResult(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
resultId: string,
|
||||
payload: ConfirmWeightResultRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<WeightResultResource>, ApiEnvelope<WeightResultResource>>(
|
||||
`${measurementBasePath}/${segment(inboundId)}/weight-results/${segment(resultId)}/confirm`,
|
||||
payload,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/weight/components/WeightOcrPanel.vue'), 'utf8')
|
||||
|
||||
describe('WeightOcrPanel contract', () => {
|
||||
it('uses customer language at the page boundary while retaining a compatibility input', () => {
|
||||
expect(source).toContain("defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()")
|
||||
expect(source).toContain("const customerId = computed(() => props.customerId || props.tenantId || '')")
|
||||
expect(source).toContain("defineEmits<{ changed: [] }>()")
|
||||
expect(source).toContain("defineExpose({ reload: loadWorkspace })")
|
||||
const template = source.match(/<template>([\s\S]*?)<\/template>/)?.[1] || ''
|
||||
expect(template).not.toContain('租户')
|
||||
})
|
||||
|
||||
it('covers upload, preview, OCR retry, correction, confirmation, and server totals', () => {
|
||||
expect(source).toContain('uploadWeightImage')
|
||||
expect(source).toContain('downloadWeightImage')
|
||||
expect(source).toContain('retryWeightRecognition')
|
||||
expect(source).toContain('correctWeightResult')
|
||||
expect(source).toContain('confirmWeightResult')
|
||||
expect(source).toContain('实际总毛重(服务端汇总)')
|
||||
expect(source).toContain('称重图片序号 #{{ index + 1 }}')
|
||||
expect(source).toContain('workspace.value?.actualGrossWeightKg')
|
||||
})
|
||||
|
||||
it('matches backend image limits and preserves missing OCR text honestly', () => {
|
||||
expect(source).toContain("['image/jpeg', 'image/png', 'image/bmp']")
|
||||
expect(source).toContain('5 * 1024 * 1024')
|
||||
expect(source).toContain('图片服务未返回 OCR 原文')
|
||||
expect(source).toContain("adjustmentReason: 'OCR 重试识别结果'")
|
||||
})
|
||||
|
||||
it('gates every action with the implemented permissions and editable statuses', () => {
|
||||
expect(source).toContain("platform:firstleg:inbound:measure:view")
|
||||
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'])")
|
||||
})
|
||||
|
||||
it('provides error tracking, empty, loading, and narrow-screen states', () => {
|
||||
expect(source).toContain('<ApiErrorMeta')
|
||||
expect(source).toContain('<LoadErrorState')
|
||||
expect(source).toContain('尚未上传称重图片')
|
||||
expect(source).toContain('v-loading="loading"')
|
||||
expect(source).toContain('@media (max-width: 360px)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,530 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
FileImage,
|
||||
ImagePlus,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Scale,
|
||||
} from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import {
|
||||
confirmWeightResult,
|
||||
correctWeightResult,
|
||||
createWeightResult,
|
||||
downloadWeightImage,
|
||||
getWeightWorkspace,
|
||||
retryWeightRecognition,
|
||||
uploadWeightImage,
|
||||
} from '../api/weightOcr'
|
||||
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
|
||||
|
||||
const props = defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const correctionFormRef = ref<FormInstance>()
|
||||
const workspace = ref<WeightWorkspaceResource | null>(null)
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const activeAction = ref('')
|
||||
const correctionOpen = ref(false)
|
||||
const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref<ApiErrorDetails | null>(null)
|
||||
const previewUrl = ref('')
|
||||
const correction = reactive({
|
||||
resultId: '',
|
||||
version: 0,
|
||||
value: null as number | null,
|
||||
unit: 'KG' as 'KG' | 'LB',
|
||||
reason: '',
|
||||
})
|
||||
let loadSequence = 0
|
||||
|
||||
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
|
||||
const customerId = computed(() => props.customerId || props.tenantId || '')
|
||||
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 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 correctionRules: FormRules<typeof correction> = {
|
||||
value: [
|
||||
{ required: true, message: '请输入有效重量', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => Number(value) > 0 ? callback() : callback(new Error('重量必须大于 0')),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
unit: [{ required: true, message: '请选择重量单位', trigger: 'change' }],
|
||||
reason: [{ required: true, message: '请填写人工修正原因', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
function isActionLoading(action: string, id: string) {
|
||||
return activeAction.value === actionKey(action, id)
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
if (!customerId.value || !props.inboundId || !canView.value) {
|
||||
workspace.value = null
|
||||
return
|
||||
}
|
||||
const sequence = ++loadSequence
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const result = await getWeightWorkspace(customerId.value, props.inboundId)
|
||||
if (sequence === loadSequence) workspace.value = result
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return
|
||||
workspace.value = null
|
||||
loadError.value = getApiErrorDetails(error, '称重与 OCR 数据加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function chooseFile() {
|
||||
if (!editable.value) {
|
||||
ElMessage.warning('当前入库状态不允许上传称重图片')
|
||||
return
|
||||
}
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function validateImage(file: File) {
|
||||
if (!['image/jpeg', 'image/png', 'image/bmp'].includes(file.type)) return '称重图片仅支持 JPG、PNG、BMP'
|
||||
if (file.size > 5 * 1024 * 1024) return '称重图片不能超过 5MB'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file || uploading.value) return
|
||||
const validationError = validateImage(file)
|
||||
if (validationError) {
|
||||
ElMessage.warning(validationError)
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
actionError.value = null
|
||||
try {
|
||||
const upload = await uploadWeightImage(customerId.value, props.inboundId, file)
|
||||
await createWeightResult(customerId.value, props.inboundId, {
|
||||
fileId: String(upload.fileId),
|
||||
ocrProvider: 'FILE_WEIGHT_OCR',
|
||||
ocrRawText: null,
|
||||
ocrWeightValue: upload.weightValue,
|
||||
ocrWeightUnit: upload.weightUnit,
|
||||
confidence: upload.confidence,
|
||||
})
|
||||
ElMessage.success(upload.recognitionStatus === 'FAILED'
|
||||
? '图片已保存,OCR 未识别出重量,请人工修正或重试'
|
||||
: '称重图片已上传并完成识别')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '称重图片上传失败,请检查后重试')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCorrection(row: WeightResultResource) {
|
||||
correction.resultId = row.id
|
||||
correction.version = row.version
|
||||
correction.value = row.adjustedWeightValue ?? row.ocrWeightValue
|
||||
correction.unit = (row.adjustedWeightUnit ?? row.ocrWeightUnit) === 'LB' ? 'LB' : 'KG'
|
||||
correction.reason = ''
|
||||
correctionOpen.value = true
|
||||
void correctionFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function submitCorrection() {
|
||||
if (!await correctionFormRef.value?.validate().catch(() => false) || correction.value == null) return
|
||||
const key = actionKey('correct', correction.resultId)
|
||||
activeAction.value = key
|
||||
actionError.value = null
|
||||
try {
|
||||
await correctWeightResult(customerId.value, props.inboundId, correction.resultId, {
|
||||
version: correction.version,
|
||||
adjustedWeightValue: correction.value,
|
||||
adjustedWeightUnit: correction.unit,
|
||||
adjustmentReason: correction.reason.trim(),
|
||||
})
|
||||
correctionOpen.value = false
|
||||
ElMessage.success('重量修正已保存,请确认后计入总重量')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '重量修正失败,请刷新后重试')
|
||||
} finally {
|
||||
activeAction.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmResult(row: WeightResultResource) {
|
||||
const key = actionKey('confirm', row.id)
|
||||
activeAction.value = key
|
||||
actionError.value = null
|
||||
try {
|
||||
await confirmWeightResult(customerId.value, props.inboundId, row.id, { version: row.version })
|
||||
ElMessage.success('重量已确认并计入实际总重量')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
} 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 = ''
|
||||
}
|
||||
}
|
||||
|
||||
function releasePreview() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
|
||||
async function openPreview(row: WeightResultResource) {
|
||||
releasePreview()
|
||||
previewError.value = null
|
||||
previewOpen.value = true
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const blob = await downloadWeightImage(customerId.value, props.inboundId, row.fileId)
|
||||
previewUrl.value = URL.createObjectURL(blob)
|
||||
} catch (error) {
|
||||
previewError.value = getApiErrorDetails(error, '称重图片读取失败,请稍后重试')
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [customerId.value, props.inboundId, canView.value] as const,
|
||||
() => {
|
||||
++loadSequence
|
||||
workspace.value = null
|
||||
loadError.value = null
|
||||
actionError.value = null
|
||||
correctionOpen.value = false
|
||||
previewOpen.value = false
|
||||
releasePreview()
|
||||
void loadWorkspace()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(previewOpen, (open) => { if (!open) releasePreview() })
|
||||
onBeforeUnmount(releasePreview)
|
||||
|
||||
defineExpose({ reload: loadWorkspace })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
<div class="weight-heading__actions">
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="visually-hidden-input"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/bmp"
|
||||
aria-label="选择称重图片"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<el-button
|
||||
v-if="canEdit"
|
||||
type="primary"
|
||||
:loading="uploading"
|
||||
:disabled="loading || !editable"
|
||||
@click="chooseFile"
|
||||
><ImagePlus :size="16" />上传称重图片</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
|
||||
当前账号缺少入库测量查看权限。
|
||||
</el-alert>
|
||||
|
||||
<template v-else>
|
||||
<LoadErrorState
|
||||
v-if="loadError"
|
||||
title="称重数据加载失败"
|
||||
:message="loadError.message"
|
||||
:loading="loading"
|
||||
compact
|
||||
@retry="loadWorkspace"
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</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>
|
||||
|
||||
<LoadErrorState
|
||||
v-if="actionError"
|
||||
title="操作未完成"
|
||||
:message="actionError.message"
|
||||
:show-retry="false"
|
||||
compact
|
||||
class="weight-action-error"
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="actionError" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<el-button
|
||||
v-if="canEdit && editable"
|
||||
link
|
||||
type="primary"
|
||||
:disabled="Boolean(activeAction)"
|
||||
@click="openCorrection(row)"
|
||||
><Pencil :size="15" />人工修正</el-button>
|
||||
<el-button
|
||||
v-if="canConfirm && editable && row.recognitionStatus !== 'CONFIRMED'"
|
||||
link
|
||||
type="success"
|
||||
:loading="isActionLoading('confirm', row.id)"
|
||||
:disabled="Boolean(activeAction) || row.recognitionStatus === 'NEEDS_MANUAL'"
|
||||
@click="confirmResult(row)"
|
||||
><CheckCircle2 :size="15" />确认重量</el-button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<AppDialog
|
||||
v-model="correctionOpen"
|
||||
title="人工修正重量"
|
||||
confirm-text="保存修正"
|
||||
:confirm-loading="activeAction.startsWith('correct:')"
|
||||
@confirm="submitCorrection"
|
||||
>
|
||||
<el-form ref="correctionFormRef" :model="correction" :rules="correctionRules" label-position="top">
|
||||
<div class="correction-grid">
|
||||
<el-form-item label="修正重量" prop="value">
|
||||
<el-input-number v-model="correction.value" :min="0.001" :precision="3" :controls="false" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单位" prop="unit">
|
||||
<el-segmented v-model="correction.unit" :options="['KG', 'LB']" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="修正原因" prop="reason">
|
||||
<el-input
|
||||
v-model="correction.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="说明读数模糊、单位识别错误等具体原因"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
v-model="previewOpen"
|
||||
title="称重图片"
|
||||
:loading="previewLoading"
|
||||
:show-footer="false"
|
||||
width="min(760px, calc(100vw - 28px))"
|
||||
>
|
||||
<LoadErrorState
|
||||
v-if="previewError"
|
||||
title="图片读取失败"
|
||||
:message="previewError.message"
|
||||
:show-retry="false"
|
||||
compact
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="previewError" /></template>
|
||||
</LoadErrorState>
|
||||
<img v-else-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.weight-workspace { min-width: 0; color: var(--el-text-color-primary); }
|
||||
.weight-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
|
||||
.weight-heading h3 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 15px; }
|
||||
.weight-heading h3 svg { color: var(--cc-primary); }
|
||||
.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-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__actions { display: flex; flex-wrap: wrap; gap: 2px 12px; margin-top: 12px; }
|
||||
.weight-row__actions :deep(.el-button) { margin-left: 0; }
|
||||
.correction-grid { display: grid; grid-template-columns: minmax(0, 1fr) 150px; gap: 16px; }
|
||||
.correction-grid :deep(.el-input-number), .correction-grid :deep(.el-segmented) { width: 100%; }
|
||||
.weight-preview { display: block; max-width: 100%; max-height: min(68vh, 720px); margin: 0 auto; object-fit: contain; }
|
||||
|
||||
@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__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.weight-row__actions :deep(.el-button) { width: 100%; justify-content: flex-start; }
|
||||
.correction-grid { grid-template-columns: 1fr; gap: 0; }
|
||||
}
|
||||
|
||||
@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__actions { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as WeightOcrPanel } from './components/WeightOcrPanel.vue'
|
||||
export type { WeightResultResource, WeightWorkspaceResource } from './types'
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export type WeightRecognitionStatus = 'PENDING_CONFIRM' | 'NEEDS_MANUAL' | 'CONFIRMED' | string
|
||||
|
||||
export interface WeightResultResource {
|
||||
id: string
|
||||
fileId: string
|
||||
ocrProvider: string | null
|
||||
ocrRawText: string | null
|
||||
ocrWeightValue: number | null
|
||||
ocrWeightUnit: string | null
|
||||
confidence: number | null
|
||||
adjustedWeightValue: number | null
|
||||
adjustedWeightUnit: string | null
|
||||
finalWeightKg: number | null
|
||||
recognitionStatus: WeightRecognitionStatus
|
||||
adjustmentReason: string | null
|
||||
adjustedBy: string | null
|
||||
adjustedAt: string | null
|
||||
confirmedBy: string | null
|
||||
confirmedAt: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface WeightWorkspaceResource {
|
||||
inboundId: string
|
||||
inboundCode: string
|
||||
processStatus: string
|
||||
actualGrossWeightKg: number | null
|
||||
weightResults: WeightResultResource[]
|
||||
}
|
||||
|
||||
export interface WeightImageUploadResult {
|
||||
fileId: string
|
||||
fileName: string
|
||||
contentType: string
|
||||
sizeBytes: number
|
||||
recognitionStatus: string
|
||||
weightValue: number | null
|
||||
weightUnit: string | null
|
||||
confidence: number | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface CreateWeightResultRequest {
|
||||
fileId: string
|
||||
ocrProvider: string | null
|
||||
ocrRawText: string | null
|
||||
ocrWeightValue: number | null
|
||||
ocrWeightUnit: string | null
|
||||
confidence: number | null
|
||||
}
|
||||
|
||||
export interface CorrectWeightResultRequest {
|
||||
version: number
|
||||
adjustedWeightValue: number
|
||||
adjustedWeightUnit: 'KG' | 'LB'
|
||||
adjustmentReason: string
|
||||
}
|
||||
|
||||
export interface ConfirmWeightResultRequest {
|
||||
version: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user