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

778 lines
33 KiB
Vue
Raw Normal View History

2026-08-04 21:01:39 +08:00
<script setup lang="ts">
2026-09-14 15:33:35 +08:00
import '@/styles/adminDensity.css'
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
2026-08-07 18:24:05 +08:00
import { computed, onBeforeUnmount, ref, watch } from 'vue'
2026-08-07 20:07:37 +08:00
import { ElMessage, ElMessageBox } from 'element-plus'
2026-08-04 21:01:39 +08:00
import {
ImagePlus,
2026-08-14 17:38:06 +08:00
RefreshCw,
2026-08-04 21:01:39 +08:00
Scale,
2026-08-07 20:07:37 +08:00
Trash2,
2026-08-04 21:01:39 +08:00
} 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,
2026-08-07 20:07:37 +08:00
deleteWeightResult,
2026-08-04 21:01:39 +08:00
downloadWeightImage,
2026-08-07 17:46:27 +08:00
getWeightOcrRawText,
2026-08-14 17:38:06 +08:00
getWeightPalletOptions,
2026-08-04 21:01:39 +08:00
getWeightWorkspace,
uploadWeightImage,
} from '../api/weightOcr'
2026-09-04 12:18:39 +08:00
import type {
WeightPalletOption,
WeightResultResource,
WeightWorkspaceResource,
} from '../types'
2026-08-07 20:07:37 +08:00
import WeightImageThumbnail from './WeightImageThumbnail.vue'
2026-08-04 21:01:39 +08:00
2026-08-18 11:33:01 +08:00
const props = withDefaults(defineProps<{
customerId?: string
tenantId?: string
inboundId: string
reviewMode?: boolean
correctionMode?: boolean
2026-09-23 17:47:54 +08:00
tableMode?: boolean
2026-08-18 11:33:01 +08:00
}>(), {
reviewMode: false,
correctionMode: false,
2026-09-23 17:47:54 +08:00
tableMode: false,
2026-08-18 11:33:01 +08:00
})
2026-08-14 14:50:44 +08:00
const emit = defineEmits<{
changed: []
2026-09-11 16:17:21 +08:00
summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null, hasConfirmedWeight: boolean]
2026-08-14 14:50:44 +08:00
}>()
2026-08-04 21:01:39 +08:00
const access = useAccessPreviewStore()
const fileInput = ref<HTMLInputElement>()
const workspace = ref<WeightWorkspaceResource | null>(null)
2026-08-14 17:38:06 +08:00
const palletOptions = ref<WeightPalletOption[]>([])
const selectedPalletId = ref('')
const palletKeyword = ref('')
2026-08-14 17:38:06 +08:00
const palletLoading = ref(false)
const palletError = ref<ApiErrorDetails | null>(null)
2026-08-04 21:01:39 +08:00
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('')
2026-08-07 20:07:37 +08:00
const previewResultId = ref('')
const previewSnapshot = ref<WeightResultResource | null>(null)
2026-08-18 11:33:01 +08:00
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB'; reason: string }>>({})
2026-08-04 21:01:39 +08:00
let loadSequence = 0
2026-08-27 11:08:03 +08:00
let palletLoadSequence = 0
let palletAbortController: AbortController | null = null
const PALLET_OPTION_PAGE_SIZE = 50
2026-08-04 21:01:39 +08:00
2026-09-09 20:53:30 +08:00
const editableStatuses = new Set(['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
2026-08-07 19:23:43 +08:00
const minimumWeight = { KG: 0.001, LB: 0.002 } as const
const maximumSafeWeight = 9_000_000_000_000
2026-08-04 21:01:39 +08:00
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)))
2026-08-27 11:08:03 +08:00
const canLoadPalletOptions = computed(() => Boolean(
!props.reviewMode
&& customerId.value
&& props.inboundId
&& canView.value
&& canEdit.value
&& editable.value,
))
const virtualPalletOptions = computed(() => palletOptions.value.map((option) => ({
label: palletOptionLabel(option),
value: option.palletId,
})))
2026-08-18 11:33:01 +08:00
const reviewCorrectionEnabled = computed(() => Boolean(
props.reviewMode
&& props.correctionMode
&& workspace.value?.processStatus === 'MEASURE_PENDING',
))
2026-08-04 21:01:39 +08:00
const rows = computed(() => workspace.value?.weightResults ?? [])
2026-08-07 20:07:37 +08:00
const previewResult = computed(() => rows.value.find((row) => row.id === previewResultId.value)
|| previewSnapshot.value)
2026-08-08 11:05:45 +08:00
const previewConfirmDisabled = computed(() => {
const row = previewResult.value
return !row
|| !isWeightDraftValid(row)
2026-08-18 11:33:01 +08:00
|| (row.recognitionStatus === 'CONFIRMED' && reviewCorrectionEnabled.value
&& (!hasWeightDraftChanged(row) || !weightDrafts.value[row.id]?.reason.trim()))
2026-08-08 11:05:45 +08:00
|| Boolean(activeAction.value)
})
2026-08-18 11:33:01 +08:00
const previewCanConfirm = computed(() => Boolean(previewResult.value && canConfirm.value
&& (editable.value || (reviewCorrectionEnabled.value && canEdit.value))))
const previewInputEditable = computed(() => Boolean(previewResult.value && canEdit.value && canConfirm.value
&& (editable.value || reviewCorrectionEnabled.value)))
const previewConfirmText = computed(() => previewResult.value?.recognitionStatus === 'CONFIRMED'
? '保存重量修正'
: '确认重量')
2026-08-04 21:01:39 +08:00
function formatWeight(value: number | null, unit = 'KG') {
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
}
2026-08-17 18:32:41 +08:00
function palletOptionLabel(option: WeightPalletOption) {
return `${option.palletType || '未记录类型'} · 皮重 ${formatWeight(option.tareWeightKg)} · 容量 ${option.capacityCbm} m³`
}
2026-08-04 21:01:39 +08:00
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) {
2026-08-07 19:23:43 +08:00
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() || '')
2026-08-07 18:24:05 +08:00
}
function syncWeightDrafts(results: WeightResultResource[]) {
weightDrafts.value = Object.fromEntries(results.map((row) => [row.id, {
value: row.adjustedWeightValue ?? row.ocrWeightValue,
unit: normalizeUnit(row.adjustedWeightUnit ?? row.ocrWeightUnit),
2026-08-18 11:33:01 +08:00
reason: '',
2026-08-07 18:24:05 +08:00
}]))
}
2026-08-07 20:07:37 +08:00
function ensureWeightDraft(row: WeightResultResource) {
weightDrafts.value[row.id] ??= {
value: row.adjustedWeightValue ?? row.ocrWeightValue,
unit: normalizeUnit(row.adjustedWeightUnit ?? row.ocrWeightUnit),
2026-08-18 11:33:01 +08:00
reason: '',
2026-08-07 20:07:37 +08:00
}
}
2026-08-07 18:24:05 +08:00
function isWeightDraftValid(row: WeightResultResource) {
const draft = weightDrafts.value[row.id]
2026-08-07 19:23:43 +08:00
const value = Number(draft?.value)
return Boolean(draft
&& Number.isFinite(value)
&& value >= minimumWeight[draft.unit]
&& value <= maximumSafeWeight)
2026-08-07 18:24:05 +08:00
}
2026-08-14 19:59:32 +08:00
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,
})
}
2026-08-07 20:07:37 +08:00
function cardWeight(row: WeightResultResource) {
2026-08-14 17:38:06 +08:00
if (row.recognitionStatus === 'CONFIRMED') return formatWeight(row.netWeightKg)
2026-08-07 20:07:37 +08:00
const draft = weightDrafts.value[row.id]
return formatWeight(draft?.value ?? null, draft?.unit ?? 'KG')
}
2026-09-04 12:18:39 +08:00
function putawayStatusLabel(status: WeightResultResource['slotStatus']) {
return ({
RESERVED_EMPTY: '预留空位',
BOUND: '待上架',
TRANSFERRED: '已上架',
RELEASED: '已释放',
} as const)[status as Exclude<WeightResultResource['slotStatus'], null>] || '未分配'
}
2026-08-07 18:24:05 +08:00
function hasWeightDraftChanged(row: WeightResultResource) {
const draft = weightDrafts.value[row.id]
if (!draft) return false
const savedValue = row.adjustedWeightValue ?? row.ocrWeightValue
2026-08-07 19:23:43 +08:00
const rawSavedUnit = row.adjustedWeightUnit ?? row.ocrWeightUnit
const savedUnit = normalizeUnit(rawSavedUnit)
if (!isSupportedUnit(rawSavedUnit)) return true
2026-08-07 18:24:05 +08:00
return Number(draft.value) !== Number(savedValue) || draft.unit !== savedUnit
}
2026-08-07 18:44:55 +08:00
function buildAdjustmentReason(row: WeightResultResource) {
const draft = weightDrafts.value[row.id]
2026-08-07 19:23:43 +08:00
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)}`
2026-08-07 18:44:55 +08:00
}
2026-08-14 14:50:44 +08:00
function emitWorkspaceChanged() {
2026-09-11 16:17:21 +08:00
emit(
'summaryChanged',
workspace.value?.actualGrossWeightKg ?? null,
workspace.value?.actualNetWeightKg ?? null,
workspace.value?.weightResults.some((item) => item.recognitionStatus === 'CONFIRMED') === true,
)
2026-08-14 14:50:44 +08:00
emit('changed')
}
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
loadError.value = getApiErrorDetails(error, '称重与 OCR 数据加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
async function loadPalletOptions(keyword = palletKeyword.value) {
2026-08-27 11:08:03 +08:00
if (!canLoadPalletOptions.value) {
cancelPalletOptionsLoad()
2026-08-18 11:33:01 +08:00
palletOptions.value = []
selectedPalletId.value = ''
palletError.value = null
return
}
2026-08-27 11:08:03 +08:00
cancelPalletOptionsLoad()
const sequence = ++palletLoadSequence
const controller = new AbortController()
palletAbortController = controller
palletKeyword.value = keyword.trim()
2026-08-14 17:38:06 +08:00
palletLoading.value = true
palletError.value = null
try {
const result = await getWeightPalletOptions(customerId.value, props.inboundId, {
page: 1,
size: PALLET_OPTION_PAGE_SIZE,
keyword: palletKeyword.value || undefined,
signal: controller.signal,
})
2026-08-27 11:08:03 +08:00
if (sequence !== palletLoadSequence || controller.signal.aborted) return
2026-08-27 13:50:41 +08:00
palletOptions.value = result.records
if (!result.records.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = ''
2026-08-14 17:38:06 +08:00
} catch (error) {
2026-08-27 11:08:03 +08:00
if (sequence !== palletLoadSequence || controller.signal.aborted) return
2026-08-14 17:38:06 +08:00
palletOptions.value = []
selectedPalletId.value = ''
palletError.value = getApiErrorDetails(error, '可选托盘加载失败,请稍后重试')
} finally {
2026-08-27 11:08:03 +08:00
if (sequence === palletLoadSequence) {
palletLoading.value = false
if (palletAbortController === controller) palletAbortController = null
}
2026-08-14 17:38:06 +08:00
}
}
function searchPalletOptions(keyword: string) {
void loadPalletOptions(keyword)
}
function reloadPalletOptions() {
void loadPalletOptions()
}
2026-08-27 11:08:03 +08:00
function cancelPalletOptionsLoad() {
++palletLoadSequence
palletAbortController?.abort()
palletAbortController = null
palletLoading.value = false
}
2026-08-04 21:01:39 +08:00
function chooseFile() {
if (!editable.value) {
ElMessage.warning('当前入库状态不允许上传称重图片')
return
}
2026-08-14 17:38:06 +08:00
if (!selectedPalletId.value) {
ElMessage.warning('请先选择本次称重使用的托盘')
return
}
2026-08-04 21:01:39 +08:00
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)
2026-08-07 20:07:37 +08:00
const created = await createWeightResult(customerId.value, props.inboundId, {
2026-08-04 21:01:39 +08:00
fileId: String(upload.fileId),
2026-08-14 17:38:06 +08:00
palletId: selectedPalletId.value,
2026-08-04 21:01:39 +08:00
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,
})
2026-08-07 20:07:37 +08:00
ensureWeightDraft(created)
await openPreview(created, URL.createObjectURL(file))
2026-08-04 21:01:39 +08:00
await loadWorkspace()
2026-08-07 20:07:37 +08:00
ensureWeightDraft(created)
2026-08-14 14:50:44 +08:00
emitWorkspaceChanged()
2026-08-04 21:01:39 +08:00
} catch (error) {
2026-08-14 19:59:32 +08:00
showActionError(error, '称重图片上传失败,请检查后重试')
2026-08-04 21:01:39 +08:00
} finally {
uploading.value = false
}
}
2026-08-07 20:07:37 +08:00
async function confirmResult(row: WeightResultResource, closeAfterSuccess = false) {
2026-08-07 18:24:05 +08:00
const draft = weightDrafts.value[row.id]
if (!draft || !isWeightDraftValid(row)) {
ElMessage.warning('请输入大于 0 的有效重量')
return
}
2026-08-14 19:59:32 +08:00
const grossWeightKg = draftGrossWeightKg(row)
if (grossWeightKg == null || grossWeightKg <= Number(row.palletTareWeightKg)) {
ElMessage.warning(`称重毛重必须大于托盘皮重 ${formatWeight(row.palletTareWeightKg)}`)
2026-08-08 11:05:45 +08:00
return
}
2026-08-04 21:01:39 +08:00
const key = actionKey('confirm', row.id)
activeAction.value = key
try {
2026-08-07 18:44:55 +08:00
const payload = { version: row.version } as Parameters<typeof confirmWeightResult>[3]
2026-08-07 18:24:05 +08:00
if (hasWeightDraftChanged(row)) {
if (!canEdit.value) {
ElMessage.warning('当前账号缺少重量修改权限')
return
}
2026-08-07 18:44:55 +08:00
payload.adjustedWeightValue = Number(draft.value)
payload.adjustedWeightUnit = draft.unit
2026-08-18 11:33:01 +08:00
payload.adjustmentReason = row.recognitionStatus === 'CONFIRMED' && reviewCorrectionEnabled.value
? draft.reason.trim()
: buildAdjustmentReason(row)
2026-08-07 18:24:05 +08:00
}
2026-08-07 18:44:55 +08:00
await confirmWeightResult(customerId.value, props.inboundId, row.id, payload)
2026-08-14 19:59:32 +08:00
if (closeAfterSuccess) previewOpen.value = false
2026-08-18 11:33:01 +08:00
ElMessage.success(row.recognitionStatus === 'CONFIRMED' ? '含托盘毛重已修正,货物重量和合计已由服务端重算' : '重量已确认')
2026-08-04 21:01:39 +08:00
await loadWorkspace()
2026-08-14 14:50:44 +08:00
emitWorkspaceChanged()
2026-08-07 20:07:37 +08:00
return true
2026-08-04 21:01:39 +08:00
} catch (error) {
2026-08-14 19:59:32 +08:00
showActionError(error, '重量确认失败,请刷新后重试')
2026-08-04 21:01:39 +08:00
} finally {
activeAction.value = ''
}
2026-08-07 20:07:37 +08:00
return false
2026-08-04 21:01:39 +08:00
}
function releasePreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
2026-08-07 20:07:37 +08:00
async function openPreview(row: WeightResultResource, localUrl = '') {
2026-08-04 21:01:39 +08:00
releasePreview()
2026-08-07 20:07:37 +08:00
previewResultId.value = row.id
previewSnapshot.value = row
2026-08-04 21:01:39 +08:00
previewError.value = null
previewOpen.value = true
2026-08-07 20:07:37 +08:00
if (localUrl) {
previewUrl.value = localUrl
previewLoading.value = false
return
}
2026-08-04 21:01:39 +08:00
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
}
}
2026-08-07 20:07:37 +08:00
async function removeResult(row: WeightResultResource) {
try {
await ElMessageBox.confirm(
row.recognitionStatus === 'CONFIRMED'
2026-09-04 12:18:39 +08:00
? '删除后将重新计算货物总重量;对应库位仍为本票保留,不会释放给其他订单。确认删除这张称重图片吗?'
: '对应库位仍为本票保留,不会释放给其他订单。确认删除这张称重图片吗?',
2026-08-07 20:07:37 +08:00
'删除称重图片',
2026-09-15 09:58:15 +08:00
{ customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
2026-08-07 20:07:37 +08:00
)
} 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()
2026-08-14 14:50:44 +08:00
emitWorkspaceChanged()
2026-08-07 20:07:37 +08:00
} catch (error) {
2026-08-14 19:59:32 +08:00
showActionError(error, '称重图片删除失败,请刷新后重试')
2026-08-07 20:07:37 +08:00
} finally {
activeAction.value = ''
}
}
2026-08-04 21:01:39 +08:00
watch(
() => [customerId.value, props.inboundId, canView.value] as const,
() => {
++loadSequence
workspace.value = null
2026-08-14 17:38:06 +08:00
palletOptions.value = []
selectedPalletId.value = ''
palletKeyword.value = ''
2026-08-14 17:38:06 +08:00
palletError.value = null
2026-08-07 18:24:05 +08:00
weightDrafts.value = {}
2026-08-04 21:01:39 +08:00
loadError.value = null
previewOpen.value = false
releasePreview()
void loadWorkspace()
},
{ immediate: true },
)
2026-08-27 11:08:03 +08:00
watch(
() => [customerId.value, props.inboundId, canLoadPalletOptions.value] as const,
() => {
cancelPalletOptionsLoad()
palletOptions.value = []
selectedPalletId.value = ''
palletKeyword.value = ''
2026-08-27 11:08:03 +08:00
palletError.value = null
if (canLoadPalletOptions.value) void loadPalletOptions()
},
)
2026-08-08 11:05:45 +08:00
watch(previewOpen, (open) => {
if (!open) {
releasePreview()
}
})
2026-08-27 11:08:03 +08:00
onBeforeUnmount(() => {
cancelPalletOptionsLoad()
palletOptions.value = []
selectedPalletId.value = ''
releasePreview()
})
2026-08-04 21:01:39 +08:00
defineExpose({ reload: loadWorkspace })
</script>
<template>
2026-10-20 23:34:11 +08:00
<section class="weight-workspace admin-workbench" aria-labelledby="weight-ocr-title">
2026-08-04 21:01:39 +08:00
<header class="weight-heading">
<div>
2026-08-07 20:07:37 +08:00
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片</h3>
2026-08-04 21:01:39 +08:00
</div>
<div class="weight-heading__actions">
2026-08-18 11:33:01 +08:00
<div v-if="workspace" class="weight-summary-group" aria-live="polite">
<div class="weight-summary"><span>实测毛重</span><strong>{{ formatWeight(workspace.actualGrossWeightKg) }}</strong></div>
<div class="weight-summary"><span>货物重量</span><strong>{{ formatWeight(workspace.actualNetWeightKg) }}</strong></div>
2026-08-14 14:50:44 +08:00
</div>
2026-08-14 17:38:06 +08:00
<div class="pallet-picker">
2026-08-27 11:08:03 +08:00
<el-select-v2
v-if="canEdit && !reviewMode"
2026-08-14 17:38:06 +08:00
v-model="selectedPalletId"
2026-08-27 11:08:03 +08:00
:options="virtualPalletOptions"
2026-08-14 17:38:06 +08:00
aria-label="选择称重托盘"
placeholder="选择称重托盘"
filterable
remote
:remote-method="searchPalletOptions"
2026-08-14 17:38:06 +08:00
:loading="palletLoading"
:disabled="loading || uploading || !editable || !canEdit || Boolean(palletError)"
2026-08-27 11:08:03 +08:00
/>
2026-08-14 17:38:06 +08:00
</div>
2026-08-04 21:01:39 +08:00
<input
2026-08-18 11:33:01 +08:00
v-if="!reviewMode"
2026-08-04 21:01:39 +08:00
ref="fileInput"
class="visually-hidden-input"
type="file"
accept="image/jpeg,image/png,image/bmp"
aria-label="选择称重图片"
@change="handleFileChange"
/>
<el-button
2026-08-18 11:33:01 +08:00
v-if="canEdit && !reviewMode"
2026-08-04 21:01:39 +08:00
type="primary"
:loading="uploading"
2026-08-14 17:38:06 +08:00
:disabled="loading || palletLoading || !editable || !selectedPalletId || Boolean(palletError)"
2026-08-04 21:01:39 +08:00
@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>
2026-08-14 17:38:06 +08:00
<LoadErrorState
v-if="palletError"
title="可选托盘加载失败"
:message="palletError.message"
:loading="palletLoading"
compact
class="weight-action-error"
@retry="reloadPalletOptions"
2026-08-14 17:38:06 +08:00
>
<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="reloadPalletOptions"><RefreshCw :size="14" />重新加载</el-button>
2026-08-14 17:38:06 +08:00
</el-alert>
2026-08-04 21:01:39 +08:00
<div v-loading="loading" class="weight-list" aria-live="polite">
<div v-if="!loading && rows.length === 0" class="weight-empty">
<strong>尚未上传称重图片</strong>
</div>
2026-09-23 17:47:54 +08:00
<div v-else-if="tableMode" class="weight-table-wrap">
2026-10-20 23:34:11 +08:00
<div class="workbench-evidence"><table class="weight-data-table">
2026-09-23 17:47:54 +08:00
<thead><tr><th>#</th><th>称重图片</th><th>托盘编码</th><th>托盘类型</th><th>库位</th><th>实测毛重(KG)</th><th>服务端确认毛重(KG)</th><th>托盘皮重(KG)</th><th>货物重量(KG)</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="(row, index) in rows" :key="row.id">
<td>{{ index + 1 }}</td>
<td><WeightImageThumbnail :customer-id="customerId" :inbound-id="inboundId" :file-id="row.fileId" :alt="`称重图片 ${index + 1}`" @open="openPreview(row)" /></td>
<td>{{ row.palletCode || '—' }}</td>
<td>{{ row.palletType || '—' }}</td>
<td><span class="weight-location">{{ row.locationCode || '未分配' }}<template v-if="row.locationCode"> · {{ putawayStatusLabel(row.slotStatus) }}</template></span></td>
<td class="weight-gross-value">{{ formatWeight(row.adjustedWeightValue ?? row.ocrWeightValue, row.adjustedWeightUnit || row.ocrWeightUnit || 'KG') }}</td>
<td>{{ formatWeight(row.grossWeightKg) }}</td>
<td>{{ formatWeight(row.palletTareWeightKg) }}</td>
<td class="weight-net-value">{{ formatWeight(row.netWeightKg) }}</td>
<td><el-tooltip v-if="canEdit && editable && !reviewMode" content="删除图片" placement="top"><el-button text type="danger" :aria-label="`删除称重图片 ${index + 1}`" :loading="isActionLoading('delete', row.id)" :disabled="Boolean(activeAction)" @click="removeResult(row)"><Trash2 :size="16" /></el-button></el-tooltip><span v-else>—</span></td>
</tr>
</tbody>
2026-10-20 23:34:11 +08:00
</table></div>
2026-09-23 17:47:54 +08:00
</div>
2026-08-07 20:07:37 +08:00
<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)"
/>
2026-08-18 11:33:01 +08:00
<el-tooltip v-if="canEdit && editable && !reviewMode" content="删除图片" placement="top">
2026-08-07 20:07:37 +08:00
<el-button
2026-08-12 18:00:26 +08:00
plain
2026-08-07 20:07:37 +08:00
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>
2026-08-14 17:38:06 +08:00
<dl class="weight-card__facts">
<div><dt>托盘编码</dt><dd :title="row.palletCode">{{ row.palletCode || '未记录编码' }}</dd></div>
2026-08-17 18:00:42 +08:00
<div><dt>托盘类型</dt><dd>{{ row.palletType || '未记录类型' }}</dd></div>
2026-09-04 14:04:57 +08:00
<div><dt>库位</dt><dd :title="row.locationCode || '未分配'">{{ row.locationCode || '未分配' }}<template v-if="row.locationCode"> · {{ putawayStatusLabel(row.slotStatus) }}</template><template v-if="row.sharedOverflow"> · A0 临时溢出位</template></dd></div>
2026-08-17 18:00:42 +08:00
<div><dt>服务端确认毛重</dt><dd>{{ formatWeight(row.grossWeightKg) }}</dd></div>
2026-08-14 17:38:06 +08:00
<div><dt>托盘皮重</dt><dd>{{ formatWeight(row.palletTareWeightKg) }}</dd></div>
2026-08-17 18:00:42 +08:00
<div><dt>货物重量</dt><dd>{{ formatWeight(row.netWeightKg) }}</dd></div>
2026-08-14 17:38:06 +08:00
</dl>
2026-08-18 11:33:01 +08:00
<el-button
v-if="reviewCorrectionEnabled && row.recognitionStatus === 'CONFIRMED' && canEdit && canConfirm"
class="weight-card__correct"
type="primary"
plain
@click="openPreview(row)"
>修改含托盘毛重</el-button>
2026-08-04 21:01:39 +08:00
</div>
2026-08-07 20:07:37 +08:00
</article>
</div>
2026-08-04 21:01:39 +08:00
</div>
</template>
</template>
2026-10-20 23:34:11 +08:00
<AppDialog class="admin-density-dialog admin-workbench is-dialog"
2026-08-04 21:01:39 +08:00
v-model="previewOpen"
:title="workspace?.inboundCode ? `确认称重图片 · ${workspace.inboundCode}` : '确认称重图片'"
2026-08-04 21:01:39 +08:00
:loading="previewLoading"
2026-08-07 20:07:37 +08:00
:confirm-loading="Boolean(previewResult && isActionLoading('confirm', previewResult.id))"
2026-08-08 11:05:45 +08:00
:confirm-disabled="previewConfirmDisabled"
2026-08-18 11:33:01 +08:00
:show-confirm="Boolean(previewResult && previewCanConfirm && (previewResult.recognitionStatus !== 'CONFIRMED' || reviewCorrectionEnabled))"
:confirm-text="previewConfirmText"
2026-08-07 20:07:37 +08:00
confirm-type="success"
width="min(1080px, calc(100vw - 28px))"
append-to-body
fullscreenable
2026-08-07 20:07:37 +08:00
@confirm="previewResult && confirmResult(previewResult, true)"
2026-08-04 21:01:39 +08:00
>
<LoadErrorState
v-if="previewError"
title="图片读取失败"
:message="previewError.message"
:show-retry="false"
compact
>
<template #details><ApiErrorMeta :details="previewError" /></template>
</LoadErrorState>
<div v-else-if="previewResult" class="weight-preview-layout">
2026-08-07 20:07:37 +08:00
<img v-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
<section class="density-dialog-section" data-tone="work">
<h3 class="density-dialog-section__title">称重数据</h3>
2026-10-20 23:34:11 +08:00
<div class="workbench-evidence"><table class="density-table density-table--editable weight-confirm-table" aria-label="称重关键数据">
<colgroup><col class="weight-confirm-label-column" /><col /></colgroup>
<tbody>
<tr><th scope="row"><span>托盘类型</span></th><td>{{ previewResult.palletType || '未记录类型' }}</td></tr>
<tr><th scope="row">当前托盘皮重</th><td>{{ formatWeight(previewResult.palletTareWeightKg) }}</td></tr>
<tr><th scope="row">OCR 识别毛重</th><td>{{ formatWeight(previewResult.ocrWeightValue, previewResult.ocrWeightUnit || 'KG') }}</td></tr>
<template v-if="weightDrafts[previewResult.id]">
<tr><th scope="row"><label for="weight-confirm-value">确认含托盘毛重</label></th><td>
<el-input-number
id="weight-confirm-value"
v-model="weightDrafts[previewResult.id].value"
aria-label="当前图片确认重量"
:min="minimumWeight[weightDrafts[previewResult.id].unit]"
:max="maximumSafeWeight"
:precision="3"
:controls="false"
:disabled="Boolean(activeAction) || !previewInputEditable" placeholder="请输入确认重量" />
</td></tr>
<tr><th scope="row">重量单位</th><td>
<el-segmented
v-model="weightDrafts[previewResult.id].unit"
aria-label="当前图片重量单位"
:options="['KG', 'LB']"
:disabled="Boolean(activeAction) || !previewInputEditable"
/>
</td></tr>
<tr v-if="reviewCorrectionEnabled && previewResult.recognitionStatus === 'CONFIRMED'"><th scope="row"><label for="weight-correction-reason">修改原因 *</label></th><td>
<el-input id="weight-correction-reason" v-model="weightDrafts[previewResult.id].reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写本次毛重修正原因" />
</td></tr>
</template>
</tbody>
2026-10-20 23:34:11 +08:00
</table></div>
</section>
</div>
2026-08-04 21:01:39 +08:00
</AppDialog>
</section>
</template>
<style scoped>
.weight-workspace { min-width: 0; color: var(--el-text-color-primary); }
2026-10-20 23:34:11 +08:00
.weight-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding-bottom: var(--admin-space-4, 10px); border-bottom: 1px solid var(--workbench-border); }
2026-09-14 17:54:21 +08:00
.weight-heading h3 { display: flex; align-items: center; gap: var(--admin-space-3, 8px); margin: 0; font-size: 15px; }
2026-10-20 23:34:11 +08:00
.weight-heading h3 svg { color: var(--workbench-primary); }
2026-09-14 17:54:21 +08:00
.weight-heading__actions { display: flex; flex: 0 0 auto; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: var(--admin-space-6, 16px); }
2026-08-17 18:32:41 +08:00
.pallet-picker { width: clamp(360px, 36vw, 520px); min-width: 0; }
.pallet-picker :deep(.el-select) { width: 100%; }
2026-09-14 17:54:21 +08:00
.weight-summary { display: flex; align-items: baseline; gap: var(--admin-space-2, 6px); white-space: nowrap; }
2026-08-18 16:09:43 +08:00
.weight-summary-group { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; }
2026-10-20 23:34:11 +08:00
.weight-summary span { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
2026-09-14 17:54:21 +08:00
.weight-summary strong { color: #067647; font-size: var(--admin-font-title, 16px); }
2026-08-04 21:01:39 +08:00
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
2026-09-04 12:18:39 +08:00
.weight-action-error { margin-bottom: 14px; }
2026-08-14 19:59:32 +08:00
.weight-list { min-height: 0; margin-top: 14px; }
2026-10-20 23:34:11 +08:00
.weight-empty { display: flex; min-height: 0; align-items: center; gap: var(--admin-space-4, 10px); padding: var(--admin-space-1, 4px) 0 0; color: var(--workbench-muted); }
2026-09-14 17:54:21 +08:00
.weight-empty strong { color: #344054; font-size: var(--admin-font-section, 14px); }
2026-08-07 20:07:37 +08:00
.weight-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; }
2026-10-20 23:34:11 +08:00
.weight-card { position: relative; min-width: 0; overflow: hidden; border: 1px solid var(--workbench-border); border-radius: 6px; background: var(--admin-surface, #fff); }
2026-09-14 17:54:21 +08:00
.weight-card__body { padding: var(--admin-space-5, 12px); text-align: center; }
2026-08-07 20:07:37 +08:00
.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; }
2026-10-20 23:34:11 +08:00
.weight-card__value:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: 4px; }
.weight-card__facts { display: grid; gap: var(--admin-space-2, 6px); margin: var(--admin-space-4, 10px) 0 0; padding-top: var(--admin-space-4, 10px); border-top: 1px solid var(--workbench-border); }
2026-09-14 17:54:21 +08:00
.weight-card__facts div { display: flex; justify-content: space-between; gap: var(--admin-space-3, 8px); font-size: var(--admin-font-caption, 11px); line-height: 1.4; }
2026-10-20 23:34:11 +08:00
.weight-card__facts dt { color: var(--workbench-muted); }
2026-08-14 17:38:06 +08:00
.weight-card__facts dd { min-width: 0; margin: 0; color: #344054; overflow-wrap: anywhere; text-align: right; }
2026-09-14 17:54:21 +08:00
.weight-card__correct { width: 100%; margin: var(--admin-space-5, 12px) 0 0; }
2026-10-20 23:34:11 +08:00
.weight-table-wrap { overflow-x: auto; border: 1px solid var(--workbench-border); border-radius: 8px; background: #fff; }
2026-09-23 17:47:54 +08:00
.weight-data-table { width: 100%; min-width: 1180px; border-collapse: separate; border-spacing: 0; table-layout: fixed; font-size: var(--admin-font-caption, 12px); }
2026-10-21 13:22:42 +08:00
.weight-data-table th { height: 42px; padding: 0 8px; border-bottom: 1px solid var(--workbench-border); background: var(--cc-list-table-header-bg); color: var(--cc-list-table-header-text); font-weight: 700; white-space: nowrap; }
2026-10-20 23:34:11 +08:00
.weight-data-table td { height: 76px; padding: 6px 8px; border-top: 1px solid var(--workbench-border); color: var(--workbench-text); text-align: center; vertical-align: middle; white-space: nowrap; }
.weight-data-table th + th, .weight-data-table td + td { border-left: 1px solid var(--workbench-border); }
.weight-data-table tbody tr:nth-child(even) td { background: var(--workbench-surface-soft); }
.weight-data-table tbody tr:hover td { background: var(--workbench-accent-soft); }
2026-09-23 17:47:54 +08:00
.weight-data-table :deep(.weight-thumb) { width: 90px; height: 60px; margin: auto; border-radius: 8px; }
.weight-data-table .weight-gross-value { color: #33425f; font-weight: 750; }
.weight-data-table .weight-net-value { color: #16875b; font-weight: 750; }
.weight-location { display: inline-block; padding: 3px 7px; border: 1px solid #e2e8f2; border-radius: 5px; background: #f3f6fb; color: #53627a; }
2026-08-14 19:59:32 +08:00
.weight-preview { display: block; width: 100%; max-height: min(50vh, 460px); object-fit: contain; border-radius: 6px; background: #f2f4f7; }
.weight-preview-layout { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(320px, 1fr); align-items: start; gap: var(--admin-space-5, 12px); }
.weight-confirm-table { table-layout: fixed; }
.weight-confirm-label-column { width: 42%; }
2026-08-04 21:01:39 +08:00
@media (max-width: 720px) {
.weight-heading { flex-direction: column; }
2026-08-14 14:50:44 +08:00
.weight-heading__actions { width: 100%; align-items: stretch; flex-direction: column; }
2026-08-14 17:38:06 +08:00
.pallet-picker { width: 100%; min-width: 0; }
.pallet-picker :deep(.el-select) { width: 100%; }
2026-08-14 14:50:44 +08:00
.weight-heading__actions :deep(.el-button) { width: 100%; min-height: 44px; }
2026-08-18 11:33:01 +08:00
.weight-summary-group { width: 100%; justify-content: space-between; }
2026-08-07 20:07:37 +08:00
.weight-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.weight-preview-layout { grid-template-columns: 1fr; }
2026-08-04 21:01:39 +08:00
}
@media (max-width: 360px) {
2026-08-14 19:59:32 +08:00
.weight-grid { grid-template-columns: 1fr; }
2026-08-14 17:38:06 +08:00
.weight-card__facts div { align-items: flex-start; flex-direction: column; gap: 2px; }
.weight-card__facts dd { text-align: left; }
2026-08-04 21:01:39 +08:00
}
</style>