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

622 lines
25 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, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ImagePlus,
RefreshCw,
Scale,
Trash2,
} 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,
createWeightResult,
deleteWeightResult,
downloadWeightImage,
getWeightOcrRawText,
getWeightPalletOptions,
getWeightWorkspace,
uploadWeightImage,
} from '../api/weightOcr'
import type { WeightPalletOption, WeightResultResource, WeightWorkspaceResource } from '../types'
import WeightImageThumbnail from './WeightImageThumbnail.vue'
import { inboundProcessStatusLabels, inboundStatusLabel } from '../../status'
const props = defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()
const emit = defineEmits<{
changed: []
summaryChanged: [actualGrossWeightKg: number | null]
}>()
const access = useAccessPreviewStore()
const fileInput = ref<HTMLInputElement>()
const workspace = ref<WeightWorkspaceResource | null>(null)
const palletOptions = ref<WeightPalletOption[]>([])
const selectedPalletId = ref('')
const palletLoading = ref(false)
const palletError = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
const uploading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const activeAction = ref('')
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewError = ref<ApiErrorDetails | null>(null)
const previewUrl = ref('')
const previewResultId = ref('')
const previewSnapshot = ref<WeightResultResource | null>(null)
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB' }>>({})
let loadSequence = 0
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
const minimumWeight = { KG: 0.001, LB: 0.002 } as const
const maximumSafeWeight = 9_000_000_000_000
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 selectedPallet = computed(() => palletOptions.value.find((option) => option.palletId === selectedPalletId.value) ?? null)
const previewResult = computed(() => rows.value.find((row) => row.id === previewResultId.value)
|| previewSnapshot.value)
const previewConfirmDisabled = computed(() => {
const row = previewResult.value
return !row
|| !isWeightDraftValid(row)
|| Boolean(activeAction.value)
})
function formatWeight(value: number | null, unit = 'KG') {
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
}
function actionKey(action: string, id: string) {
return `${action}:${id}`
}
function isActionLoading(action: string, id: string) {
return activeAction.value === actionKey(action, id)
}
function normalizeUnit(value: string | null) {
return value?.trim().toUpperCase().startsWith('LB') ? 'LB' as const : 'KG' as const
}
function isSupportedUnit(value: string | null) {
return ['KG', 'KGS', 'LB', 'LBS'].includes(value?.trim().toUpperCase() || '')
}
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 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)
return Boolean(draft
&& Number.isFinite(value)
&& value >= minimumWeight[draft.unit]
&& value <= maximumSafeWeight)
}
function draftGrossWeightKg(row: WeightResultResource) {
const draft = weightDrafts.value[row.id]
if (!draft || !Number.isFinite(Number(draft.value))) return null
const value = Number(draft.value)
return draft.unit === 'LB' ? value * 0.45359237 : value
}
function showActionError(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
const requestReference = details.requestId ? `(请求 ID:${details.requestId})` : ''
ElMessage.error({
message: `${details.message}${requestReference}`,
duration: 5_000,
showClose: true,
})
}
function cardWeight(row: WeightResultResource) {
if (row.recognitionStatus === 'CONFIRMED') return formatWeight(row.netWeightKg)
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
const savedValue = row.adjustedWeightValue ?? row.ocrWeightValue
const rawSavedUnit = row.adjustedWeightUnit ?? row.ocrWeightUnit
const savedUnit = normalizeUnit(rawSavedUnit)
if (!isSupportedUnit(rawSavedUnit)) return true
return Number(draft.value) !== Number(savedValue) || draft.unit !== savedUnit
}
function buildAdjustmentReason(row: WeightResultResource) {
const draft = weightDrafts.value[row.id]
const ocrUnit = row.ocrWeightUnit?.trim().toUpperCase()
const ocrValue = row.ocrWeightValue == null
? '—'
: `${Number(row.ocrWeightValue).toFixed(3)}${ocrUnit ? ` ${ocrUnit}` : '(单位缺失)'}`
return `OCR 原值 ${ocrValue},人工确认 ${formatWeight(Number(draft.value), draft.unit)}`
}
function emitWorkspaceChanged() {
emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null)
emit('changed')
}
async function loadWorkspace() {
if (!customerId.value || !props.inboundId || !canView.value) {
workspace.value = null
weightDrafts.value = {}
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
syncWeightDrafts(result.weightResults)
}
} catch (error) {
if (sequence !== loadSequence) return
loadError.value = getApiErrorDetails(error, '称重与 OCR 数据加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
async function loadPalletOptions() {
if (!customerId.value || !props.inboundId || !canView.value) {
palletOptions.value = []
selectedPalletId.value = ''
return
}
palletLoading.value = true
palletError.value = null
try {
const result = await getWeightPalletOptions(customerId.value, props.inboundId)
palletOptions.value = result
if (!result.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = ''
} catch (error) {
palletOptions.value = []
selectedPalletId.value = ''
palletError.value = getApiErrorDetails(error, '可选托盘加载失败,请稍后重试')
} finally {
palletLoading.value = false
}
}
function chooseFile() {
if (!editable.value) {
ElMessage.warning('当前入库状态不允许上传称重图片')
return
}
if (!selectedPalletId.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
try {
const upload = await uploadWeightImage(customerId.value, props.inboundId, file)
const created = await createWeightResult(customerId.value, props.inboundId, {
fileId: String(upload.fileId),
palletId: selectedPalletId.value,
ocrProvider: 'FILE_WEIGHT_OCR',
ocrRawText: getWeightOcrRawText(upload),
ocrWeightValue: upload.weightValue,
ocrWeightUnit: upload.weightUnit,
confidence: upload.confidence,
})
ensureWeightDraft(created)
await openPreview(created, URL.createObjectURL(file))
await loadWorkspace()
ensureWeightDraft(created)
emitWorkspaceChanged()
} catch (error) {
showActionError(error, '称重图片上传失败,请检查后重试')
} finally {
uploading.value = false
}
}
async function confirmResult(row: WeightResultResource, closeAfterSuccess = false) {
const draft = weightDrafts.value[row.id]
if (!draft || !isWeightDraftValid(row)) {
ElMessage.warning('请输入大于 0 的有效重量')
return
}
const grossWeightKg = draftGrossWeightKg(row)
if (grossWeightKg == null || grossWeightKg <= Number(row.palletTareWeightKg)) {
ElMessage.warning(`称重毛重必须大于托盘皮重 ${formatWeight(row.palletTareWeightKg)}`)
return
}
const key = actionKey('confirm', row.id)
activeAction.value = key
try {
const payload = { version: row.version } as Parameters<typeof confirmWeightResult>[3]
if (hasWeightDraftChanged(row)) {
if (!canEdit.value) {
ElMessage.warning('当前账号缺少重量修改权限')
return
}
payload.adjustedWeightValue = Number(draft.value)
payload.adjustedWeightUnit = draft.unit
payload.adjustmentReason = buildAdjustmentReason(row)
}
await confirmWeightResult(customerId.value, props.inboundId, row.id, payload)
if (closeAfterSuccess) previewOpen.value = false
ElMessage.success('重量已确认')
await loadWorkspace()
emitWorkspaceChanged()
return true
} catch (error) {
showActionError(error, '重量确认失败,请刷新后重试')
} finally {
activeAction.value = ''
}
return false
}
function releasePreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
async function openPreview(row: WeightResultResource, localUrl = '') {
releasePreview()
previewResultId.value = row.id
previewSnapshot.value = row
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)
previewUrl.value = URL.createObjectURL(blob)
} catch (error) {
previewError.value = getApiErrorDetails(error, '称重图片读取失败,请稍后重试')
} finally {
previewLoading.value = false
}
}
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
try {
await deleteWeightResult(customerId.value, props.inboundId, row.id, row.version)
if (previewResultId.value === row.id) previewOpen.value = false
ElMessage.success('称重图片已删除')
await loadWorkspace()
emitWorkspaceChanged()
} catch (error) {
showActionError(error, '称重图片删除失败,请刷新后重试')
} finally {
activeAction.value = ''
}
}
watch(
() => [customerId.value, props.inboundId, canView.value] as const,
() => {
++loadSequence
workspace.value = null
palletOptions.value = []
selectedPalletId.value = ''
palletError.value = null
weightDrafts.value = {}
loadError.value = null
previewOpen.value = false
releasePreview()
void loadWorkspace()
void loadPalletOptions()
},
{ 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" />称重图片</h3>
</div>
<div class="weight-heading__actions">
<div v-if="workspace" class="weight-summary" aria-live="polite">
<span>货物总重量</span>
<strong>{{ formatWeight(workspace.actualGrossWeightKg) }}</strong>
</div>
<div class="pallet-picker">
<el-select
v-model="selectedPalletId"
aria-label="选择称重托盘"
placeholder="选择称重托盘"
filterable
:loading="palletLoading"
:disabled="loading || uploading || !editable || !canEdit || Boolean(palletError)"
>
<el-option
v-for="option in palletOptions"
:key="option.palletId"
:label="option.palletType || '未记录类型'"
:value="option.palletId"
/>
</el-select>
<small v-if="selectedPallet">皮重 {{ formatWeight(selectedPallet.tareWeightKg) }} · 容量 {{ selectedPallet.capacityCbm }} m³</small>
</div>
<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 || palletLoading || !editable || !selectedPalletId || Boolean(palletError)"
@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>
<LoadErrorState
v-if="palletError"
title="可选托盘加载失败"
:message="palletError.message"
:loading="palletLoading"
compact
class="weight-action-error"
@retry="loadPalletOptions"
>
<template #details><ApiErrorMeta :details="palletError" /></template>
</LoadErrorState>
<el-alert
v-else-if="!palletLoading && palletOptions.length === 0 && editable && canEdit"
type="warning"
:closable="false"
show-icon
class="weight-action-error"
title="当前仓库暂无可选托盘,暂时不能上传称重图片。"
>
<el-button size="small" @click="loadPalletOptions"><RefreshCw :size="14" />重新加载</el-button>
</el-alert>
<el-alert v-if="workspace && !editable" type="info" :closable="false" show-icon class="weight-state-note">
当前入库状态为 {{ inboundStatusLabel(workspace.processStatus, inboundProcessStatusLabels, '未知状态') }},称重数据仅供查看。
</el-alert>
<div v-loading="loading" class="weight-list" aria-live="polite">
<div v-if="!loading && rows.length === 0" class="weight-empty">
<strong>尚未上传称重图片</strong>
</div>
<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
plain
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>
<dl class="weight-card__facts">
<div><dt>托盘类型</dt><dd>{{ row.palletType || '未记录类型' }}</dd></div>
<div><dt>服务端确认毛重</dt><dd>{{ formatWeight(row.grossWeightKg) }}</dd></div>
<div><dt>托盘皮重</dt><dd>{{ formatWeight(row.palletTareWeightKg) }}</dd></div>
<div><dt>货物重量</dt><dd>{{ formatWeight(row.netWeightKg) }}</dd></div>
</dl>
</div>
</article>
</div>
</div>
</template>
</template>
<AppDialog
v-model="previewOpen"
title="确认称重图片"
:loading="previewLoading"
:confirm-loading="Boolean(previewResult && isActionLoading('confirm', previewResult.id))"
:confirm-disabled="previewConfirmDisabled"
:show-confirm="Boolean(previewResult && previewResult.recognitionStatus !== 'CONFIRMED' && canConfirm && editable)"
confirm-text="确认重量"
confirm-type="success"
width="min(640px, calc(100vw - 28px))"
@confirm="previewResult && confirmResult(previewResult, true)"
>
<LoadErrorState
v-if="previewError"
title="图片读取失败"
:message="previewError.message"
:show-retry="false"
compact
>
<template #details><ApiErrorMeta :details="previewError" /></template>
</LoadErrorState>
<template v-else-if="previewResult">
<img v-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
<div class="weight-key-facts" aria-label="称重关键数据">
<div><span>托盘类型</span><strong>{{ previewResult.palletType || '未记录类型' }}</strong></div>
<div><span>当前托盘皮重</span><strong>{{ formatWeight(previewResult.palletTareWeightKg) }}</strong></div>
<div><span>OCR 识别毛重</span><strong>{{ formatWeight(previewResult.ocrWeightValue, previewResult.ocrWeightUnit || 'KG') }}</strong></div>
</div>
<div v-if="weightDrafts[previewResult.id]" class="preview-weight">
<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="previewResult.recognitionStatus === 'CONFIRMED' || Boolean(activeAction) || !editable || !canEdit || !canConfirm"
/>
</label>
<el-segmented
v-model="weightDrafts[previewResult.id].unit"
aria-label="当前图片重量单位"
:options="['KG', 'LB']"
:disabled="previewResult.recognitionStatus === 'CONFIRMED' || Boolean(activeAction) || !editable || !canEdit || !canConfirm"
/>
</div>
</template>
</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__actions { display: flex; flex: 0 0 auto; align-items: center; gap: 16px; }
.pallet-picker { display: grid; min-width: 260px; gap: 4px; }
.pallet-picker small { color: var(--cc-muted); font-size: 11px; line-height: 1.4; }
.weight-summary { display: grid; gap: 2px; text-align: right; }
.weight-summary span { color: var(--cc-muted); font-size: 12px; }
.weight-summary strong { color: #067647; font-size: 18px; }
.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-state-note, .weight-action-error { margin-bottom: 14px; }
.weight-list { min-height: 0; margin-top: 14px; }
.weight-empty { display: flex; min-height: 0; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 4px; background: var(--cc-surface-muted); color: var(--cc-muted); }
.weight-empty strong { color: #344054; font-size: 14px; }
.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-card__facts { display: grid; gap: 6px; margin: 10px 0 0; padding-top: 10px; border-top: 1px solid var(--cc-border); }
.weight-card__facts div { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; line-height: 1.4; }
.weight-card__facts dt { color: var(--cc-muted); }
.weight-card__facts dd { min-width: 0; margin: 0; color: #344054; overflow-wrap: anywhere; text-align: right; }
.weight-preview { display: block; width: 100%; max-height: min(50vh, 460px); object-fit: contain; border-radius: 6px; background: #f2f4f7; }
.weight-key-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; margin-top: 14px; padding: 14px 0; border-block: 1px solid var(--cc-border); }
.weight-key-facts div { display: grid; min-width: 0; gap: 4px; }
.weight-key-facts span { color: var(--cc-muted); font-size: 12px; }
.weight-key-facts strong { color: #101828; font-size: 18px; }
.preview-weight { display: grid; grid-template-columns: minmax(0, 1fr) 120px; align-items: end; gap: 12px; margin-top: 14px; }
.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%; }
@media (max-width: 720px) {
.weight-heading { flex-direction: column; }
.weight-heading__actions { width: 100%; align-items: stretch; flex-direction: column; }
.pallet-picker { width: 100%; min-width: 0; }
.pallet-picker :deep(.el-select) { width: 100%; }
.weight-heading__actions :deep(.el-button) { width: 100%; min-height: 44px; }
.weight-summary { text-align: left; }
.weight-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 360px) {
.weight-grid { grid-template-columns: 1fr; }
.preview-weight { grid-template-columns: 1fr; }
.weight-key-facts { grid-template-columns: 1fr 1fr; gap: 12px; }
.weight-key-facts strong { font-size: 15px; }
.weight-card__facts div { align-items: flex-start; flex-direction: column; gap: 2px; }
.weight-card__facts dd { text-align: left; }
}
</style>