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

787 lines
34 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 '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
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'
const props = withDefaults(defineProps<{
customerId?: string
tenantId?: string
inboundId: string
reviewMode?: boolean
correctionMode?: boolean
tableMode?: boolean
}>(), {
reviewMode: false,
correctionMode: false,
tableMode: false,
})
const emit = defineEmits<{
changed: []
summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null, hasConfirmedWeight: boolean]
}>()
const access = useAccessPreviewStore()
const fileInput = ref<HTMLInputElement>()
const workspace = ref<WeightWorkspaceResource | null>(null)
const palletOptions = ref<WeightPalletOption[]>([])
const selectedPalletId = ref('')
const palletKeyword = 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'; reason: string }>>({})
let loadSequence = 0
let palletLoadSequence = 0
let palletAbortController: AbortController | null = null
const PALLET_OPTION_PAGE_SIZE = 50
const editableStatuses = new Set(['PREPARING', '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 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,
})))
const reviewCorrectionEnabled = computed(() => Boolean(
props.reviewMode
&& props.correctionMode
&& workspace.value?.processStatus === 'MEASURE_PENDING',
))
const rows = computed(() => workspace.value?.weightResults ?? [])
const previewResult = computed(() => rows.value.find((row) => row.id === previewResultId.value)
|| previewSnapshot.value)
const previewConfirmDisabled = computed(() => {
const row = previewResult.value
return !row
|| !isWeightDraftValid(row)
|| (row.recognitionStatus === 'CONFIRMED' && reviewCorrectionEnabled.value
&& (!hasWeightDraftChanged(row) || !weightDrafts.value[row.id]?.reason.trim()))
|| Boolean(activeAction.value)
})
const previewCanConfirm = computed(() => Boolean(previewResult.value && canConfirm.value
&& ((!props.reviewMode && editable.value) || (reviewCorrectionEnabled.value && canEdit.value))))
const previewInputEditable = computed(() => Boolean(previewResult.value && canEdit.value && canConfirm.value
&& ((!props.reviewMode && editable.value) || reviewCorrectionEnabled.value)))
const previewConfirmText = computed(() => previewResult.value?.recognitionStatus === 'CONFIRMED'
? '保存重量修正'
: '确认重量')
function formatWeight(value: number | null, unit = 'KG') {
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
}
function palletOptionLabel(option: WeightPalletOption) {
return `${option.palletType || '未记录类型'} · 皮重 ${formatWeight(option.tareWeightKg)} · 容量 ${option.capacityCbm} m³`
}
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),
reason: '',
}]))
}
function ensureWeightDraft(row: WeightResultResource) {
weightDrafts.value[row.id] ??= {
value: row.adjustedWeightValue ?? row.ocrWeightValue,
unit: normalizeUnit(row.adjustedWeightUnit ?? row.ocrWeightUnit),
reason: '',
}
}
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 putawayStatusLabel(status: WeightResultResource['slotStatus']) {
return ({
RESERVED_EMPTY: '预留空位',
BOUND: '待上架',
TRANSFERRED: '已上架',
RELEASED: '已释放',
} as const)[status as Exclude<WeightResultResource['slotStatus'], null>] || '未分配'
}
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,
workspace.value?.actualNetWeightKg ?? null,
workspace.value?.weightResults.some((item) => item.recognitionStatus === 'CONFIRMED') === true,
)
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(keyword = palletKeyword.value) {
if (!canLoadPalletOptions.value) {
cancelPalletOptionsLoad()
palletOptions.value = []
selectedPalletId.value = ''
palletError.value = null
return
}
cancelPalletOptionsLoad()
const sequence = ++palletLoadSequence
const controller = new AbortController()
palletAbortController = controller
palletKeyword.value = keyword.trim()
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,
})
if (sequence !== palletLoadSequence || controller.signal.aborted) return
palletOptions.value = result.records
if (!result.records.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = ''
} catch (error) {
if (sequence !== palletLoadSequence || controller.signal.aborted) return
palletOptions.value = []
selectedPalletId.value = ''
palletError.value = getApiErrorDetails(error, '可选托盘加载失败,请稍后重试')
} finally {
if (sequence === palletLoadSequence) {
palletLoading.value = false
if (palletAbortController === controller) palletAbortController = null
}
}
}
function searchPalletOptions(keyword: string) {
void loadPalletOptions(keyword)
}
function reloadPalletOptions() {
void loadPalletOptions()
}
function cancelPalletOptionsLoad() {
++palletLoadSequence
palletAbortController?.abort()
palletAbortController = null
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 = row.recognitionStatus === 'CONFIRMED' && reviewCorrectionEnabled.value
? draft.reason.trim()
: buildAdjustmentReason(row)
}
await confirmWeightResult(customerId.value, props.inboundId, row.id, payload)
if (closeAfterSuccess) previewOpen.value = false
ElMessage.success(row.recognitionStatus === 'CONFIRMED' ? '含托盘毛重已修正,货物重量和合计已由服务端重算' : '重量已确认')
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'
? '删除后将重新计算货物总重量;对应库位仍为本票保留,不会释放给其他订单。确认删除这张称重图片吗?'
: '对应库位仍为本票保留,不会释放给其他订单。确认删除这张称重图片吗?',
'删除称重图片',
{ customClass: 'admin-density-confirm', 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 = ''
palletKeyword.value = ''
palletError.value = null
weightDrafts.value = {}
loadError.value = null
previewOpen.value = false
releasePreview()
void loadWorkspace()
},
{ immediate: true },
)
watch(
() => [customerId.value, props.inboundId, canLoadPalletOptions.value] as const,
() => {
cancelPalletOptionsLoad()
palletOptions.value = []
selectedPalletId.value = ''
palletKeyword.value = ''
palletError.value = null
if (canLoadPalletOptions.value) void loadPalletOptions()
},
)
watch(previewOpen, (open) => {
if (!open) {
releasePreview()
}
})
onBeforeUnmount(() => {
cancelPalletOptionsLoad()
palletOptions.value = []
selectedPalletId.value = ''
releasePreview()
})
defineExpose({ reload: loadWorkspace })
</script>
<template>
<section class="weight-workspace admin-workbench" aria-labelledby="weight-ocr-title">
<slot name="heading">
<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-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>
</div>
<div v-if="canEdit && !reviewMode" class="pallet-picker">
<el-select-v2
v-model="selectedPalletId"
:options="virtualPalletOptions"
aria-label="选择称重托盘"
placeholder="选择称重托盘"
filterable
remote
:remote-method="searchPalletOptions"
:loading="palletLoading"
:disabled="loading || uploading || !editable || !canEdit || Boolean(palletError)"
/>
</div>
<input
v-if="!reviewMode"
ref="fileInput"
class="visually-hidden-input"
type="file"
accept="image/jpeg,image/png,image/bmp"
aria-label="选择称重图片"
@change="handleFileChange"
/>
<el-button
v-if="canEdit && !reviewMode"
type="primary"
:loading="uploading"
:disabled="loading || palletLoading || !editable || !selectedPalletId || Boolean(palletError)"
@click="chooseFile"
><ImagePlus :size="16" />上传称重图片</el-button>
</div>
</header>
</slot>
<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="reloadPalletOptions"
>
<template #details><ApiErrorMeta :details="palletError" /></template>
</LoadErrorState>
<el-alert
v-else-if="!reviewMode && !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>
</el-alert>
<div v-loading="loading" class="weight-list" aria-live="polite">
<slot name="results" :rows="rows" :open-preview="openPreview" :can-correct="reviewCorrectionEnabled && canEdit && canConfirm" :busy="loading || Boolean(activeAction)">
<div v-if="!loading && rows.length === 0" class="weight-empty">
<strong>尚未上传称重图片</strong>
</div>
<div v-else-if="tableMode" class="weight-table-wrap">
<div class="workbench-evidence"><table class="weight-data-table">
<colgroup v-if="reviewMode"><col /><col class="weight-index-column" /><col span="8" /></colgroup>
<thead><tr><th>操作</th><th>#</th><th>称重图片</th><th>托盘编码</th><th>托盘类型</th><th>库位</th><th>实测毛重(KG)</th><th>服务端确认毛重(KG)</th><th>托盘皮重(KG)</th><th>货物重量(KG)</th></tr></thead>
<tbody>
<tr v-for="(row, index) in rows" :key="row.id">
<td>
<el-button v-if="reviewCorrectionEnabled && row.recognitionStatus === 'CONFIRMED' && canEdit && canConfirm" link type="primary" :aria-label="`修改重量 ${index + 1}`" :disabled="Boolean(activeAction)" @click="openPreview(row)">修改重量</el-button>
<el-tooltip v-else-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>
<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>
</tr>
</tbody>
</table></div>
</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 && !reviewMode" 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 :title="row.palletCode">{{ row.palletCode || '未记录编码' }}</dd></div>
<div><dt>托盘类型</dt><dd>{{ row.palletType || '未记录类型' }}</dd></div>
<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>
<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>
<el-button
v-if="reviewCorrectionEnabled && row.recognitionStatus === 'CONFIRMED' && canEdit && canConfirm"
class="weight-card__correct"
type="primary"
plain
@click="openPreview(row)"
>修改含托盘毛重</el-button>
</div>
</article>
</div>
</slot>
</div>
</template>
</template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog"
v-model="previewOpen"
:title="workspace?.inboundCode ? `确认称重图片 · ${workspace.inboundCode}` : '确认称重图片'"
:loading="previewLoading"
:confirm-loading="Boolean(previewResult && isActionLoading('confirm', previewResult.id))"
:confirm-disabled="previewConfirmDisabled"
:show-confirm="Boolean(previewResult && previewCanConfirm && (previewResult.recognitionStatus !== 'CONFIRMED' || reviewCorrectionEnabled))"
:confirm-text="previewConfirmText"
confirm-type="success"
width="min(1080px, calc(100vw - 28px))"
append-to-body
fullscreenable
@confirm="previewResult && confirmResult(previewResult, true)"
>
<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">
<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>
<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>
</table></div>
</section>
</div>
</AppDialog>
</section>
</template>
<style scoped>
.weight-workspace { min-width: 0; color: var(--el-text-color-primary); }
.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); }
.weight-heading h3 { display: flex; align-items: center; gap: var(--admin-space-3, 8px); margin: 0; font-size: 15px; }
.weight-heading h3 svg { color: var(--workbench-primary); }
.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); }
.pallet-picker { width: clamp(360px, 36vw, 520px); min-width: 0; }
.pallet-picker :deep(.el-select) { width: 100%; }
.weight-summary { display: flex; align-items: baseline; gap: var(--admin-space-2, 6px); white-space: nowrap; }
.weight-summary-group { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; }
.weight-summary span { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
.weight-summary strong { color: #067647; font-size: var(--admin-font-title, 16px); }
.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-action-error { margin-bottom: 14px; }
.weight-list { min-height: 0; margin-top: 14px; }
.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); }
.weight-empty strong { color: #344054; font-size: var(--admin-font-section, 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(--workbench-border); border-radius: 6px; background: var(--admin-surface, #fff); }
.weight-card__body { padding: var(--admin-space-5, 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(--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); }
.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; }
.weight-card__facts dt { color: var(--workbench-muted); }
.weight-card__facts dd { min-width: 0; margin: 0; color: #344054; overflow-wrap: anywhere; text-align: right; }
.weight-card__correct { width: 100%; margin: var(--admin-space-5, 12px) 0 0; }
.weight-table-wrap { overflow-x: auto; border: 1px solid var(--workbench-border); border-radius: 8px; background: #fff; }
.weight-data-table { width: 100%; min-width: 1180px; border-collapse: separate; border-spacing: 0; table-layout: fixed; font-size: var(--admin-font-caption, 12px); }
.weight-data-table .weight-index-column { width: 44px; }
.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; }
.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); }
.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; }
.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%; }
@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-group { width: 100%; justify-content: space-between; }
.weight-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.weight-preview-layout { grid-template-columns: 1fr; }
}
@media (max-width: 360px) {
.weight-grid { grid-template-columns: 1fr; }
.weight-card__facts div { align-items: flex-start; flex-direction: column; gap: 2px; }
.weight-card__facts dd { text-align: left; }
}
</style>