Files
admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue
T

519 lines
22 KiB
Vue
Raw Normal View History

2026-08-04 21:01:39 +08:00
<script setup lang="ts">
2026-08-07 18:24:05 +08:00
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
2026-08-04 21:01:39 +08:00
import {
CheckCircle2,
Eye,
FileImage,
ImagePlus,
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,
2026-08-07 17:46:27 +08:00
getWeightOcrRawText,
2026-08-04 21:01:39 +08:00
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 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 previewOpen = ref(false)
const previewLoading = ref(false)
const previewError = ref<ApiErrorDetails | null>(null)
const previewUrl = ref('')
2026-08-07 18:24:05 +08:00
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB' }>>({})
2026-08-04 21:01:39 +08:00
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)
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)
}
2026-08-07 18:24:05 +08:00
function normalizeUnit(value: string | null) {
return value?.toUpperCase().startsWith('LB') ? 'LB' as const : 'KG' as const
}
function syncWeightDrafts(results: WeightResultResource[]) {
weightDrafts.value = Object.fromEntries(results.map((row) => [row.id, {
value: row.adjustedWeightValue ?? row.ocrWeightValue,
unit: normalizeUnit(row.adjustedWeightUnit ?? row.ocrWeightUnit),
}]))
}
function isWeightDraftValid(row: WeightResultResource) {
const draft = weightDrafts.value[row.id]
return Boolean(draft && Number.isFinite(Number(draft.value)) && Number(draft.value) > 0)
}
function hasWeightDraftChanged(row: WeightResultResource) {
const draft = weightDrafts.value[row.id]
if (!draft) return false
const savedValue = row.adjustedWeightValue ?? row.ocrWeightValue
const savedUnit = normalizeUnit(row.adjustedWeightUnit ?? row.ocrWeightUnit)
return Number(draft.value) !== Number(savedValue) || draft.unit !== savedUnit
}
2026-08-04 21:01:39 +08:00
async function loadWorkspace() {
if (!customerId.value || !props.inboundId || !canView.value) {
workspace.value = null
2026-08-07 18:24:05 +08:00
weightDrafts.value = {}
2026-08-04 21:01:39 +08:00
return
}
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
const result = await getWeightWorkspace(customerId.value, props.inboundId)
2026-08-07 18:24:05 +08:00
if (sequence === loadSequence) {
workspace.value = result
syncWeightDrafts(result.weightResults)
}
2026-08-04 21:01:39 +08:00
} 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',
2026-08-07 17:46:27 +08:00
ocrRawText: getWeightOcrRawText(upload),
2026-08-04 21:01:39 +08:00
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
}
}
async function confirmResult(row: WeightResultResource) {
2026-08-07 18:24:05 +08:00
const draft = weightDrafts.value[row.id]
if (!draft || !isWeightDraftValid(row)) {
ElMessage.warning('请输入大于 0 的有效重量')
return
}
2026-08-04 21:01:39 +08:00
const key = actionKey('confirm', row.id)
activeAction.value = key
actionError.value = null
try {
2026-08-07 18:24:05 +08:00
let version = row.version
if (hasWeightDraftChanged(row)) {
if (!canEdit.value) {
ElMessage.warning('当前账号缺少重量修改权限')
return
}
const corrected = await correctWeightResult(customerId.value, props.inboundId, row.id, {
version,
adjustedWeightValue: Number(draft.value),
adjustedWeightUnit: draft.unit,
adjustmentReason: '人工核对后修改识别重量',
})
version = corrected.version
}
await confirmWeightResult(customerId.value, props.inboundId, row.id, { version })
2026-08-04 21:01:39 +08:00
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
2026-08-07 18:24:05 +08:00
weightDrafts.value = {}
2026-08-04 21:01:39 +08:00
loadError.value = null
actionError.value = null
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>
2026-08-07 18:24:05 +08:00
<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="0.001"
:max="999999999999999.999"
:precision="3"
:controls="false"
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
/>
</label>
<label>
<span>单位</span>
<el-segmented
v-model="weightDrafts[row.id].unit"
: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>
2026-08-04 21:01:39 +08:00
<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>
</div>
</template>
</template>
<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; }
2026-08-07 18:24:05 +08:00
.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; }
2026-08-04 21:01:39 +08:00
.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; }
@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; }
2026-08-07 18:24:05 +08:00
.weight-row__editor { grid-template-columns: minmax(0, 1fr) 132px; }
.weight-row__editor > .el-button { grid-column: 1 / -1; width: 100%; }
2026-08-04 21:01:39 +08:00
.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; }
}
@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; }
2026-08-07 18:24:05 +08:00
.weight-row__facts, .weight-row__editor, .weight-row__actions { grid-template-columns: 1fr; }
.weight-row__editor > .el-button { grid-column: 1; }
2026-08-04 21:01:39 +08:00
}
</style>