Files
admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue
T
2026-08-07 17:46:27 +08:00

532 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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,
getWeightOcrRawText,
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: getWeightOcrRawText(upload),
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>