Files
admin-web/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
T
2026-08-13 17:24:43 +08:00

782 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { AlertTriangle, Image, Plus, RefreshCw, Ruler, Save, Send, Upload } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { inboundProcessStatusLabels, inboundStatusLabel } from '../status'
import { createManualCartonScan, downloadInboundMeasurementImage, getInboundMeasurement, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
import type {
InboundMeasurementDetail,
InboundMeasurementItem,
SaveInboundMeasurementItemRequest,
} from './types'
const props = defineProps<{
customerId?: string
tenantId?: string
inboundId: string
}>()
const emit = defineEmits<{
changed: []
}>()
interface EditableMeasurementItem extends SaveInboundMeasurementItemRequest {
itemId: string
clientKey: string
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
}
interface MeasurementFormModel {
measurementId: string | null
version: number
originalDocumentNo: string
originalGrossWeightKg: number | null
originalVolumeCbm: number | null
actualGoodsName: string
items: EditableMeasurementItem[]
}
interface ManualCartonFormModel {
boxCode: string
length: number | null
width: number | null
height: number | null
weight: number | null
scannedShipmentId: string
machineNo: string
measuredAt: string
}
const access = useAccessPreviewStore()
const loading = ref(false)
const saving = ref(false)
const submitting = ref(false)
const manualEntryOpen = ref(false)
const manualEntrySubmitting = ref(false)
const manualEntryImage = ref<File | null>(null)
const manualEntryErrors = ref<string[]>([])
const detail = ref<InboundMeasurementDetail | null>(null)
const loadError = ref<ApiErrorDetails | null>(null)
const operationError = ref<ApiErrorDetails | null>(null)
const conflictDetected = ref(false)
const imagePreviewOpen = ref(false)
const imagePreviewLoading = ref(false)
const imagePreviewUrl = ref('')
const imagePreviewError = ref<ApiErrorDetails | null>(null)
const validationErrors = ref<Record<string, string[]>>({})
const baseline = ref('')
let requestSequence = 0
let itemSequence = 0
const form = reactive<MeasurementFormModel>({
measurementId: null,
version: 0,
originalDocumentNo: '',
originalGrossWeightKg: null,
originalVolumeCbm: null,
actualGoodsName: '',
items: [],
})
const manualForm = reactive<ManualCartonFormModel>({
boxCode: '',
length: null,
width: null,
height: null,
weight: null,
scannedShipmentId: '',
machineNo: 'MANUAL-ADMIN',
measuredAt: '',
})
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const canEdit = computed(() => access.can('platform:firstleg:inbound:measure:edit'))
const canManualEntry = computed(() => access.can('platform:firstleg:inbound:measure:manual-entry'))
const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit'))
const customerId = computed(() => props.customerId || props.tenantId || '')
const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
const countEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const submitEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const mayMaintain = computed(() => canEdit.value && countEditableProcess.value && !loading.value && !submitting.value)
const manualEntryEditableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const mayCreateManualEntry = computed(() => canManualEntry.value && manualEntryEditableProcess.value && !loading.value && !saving.value && !submitting.value && !isDirty.value)
const confirmedPackageCount = computed(() => form.items.reduce((sum, item) => sum + (Number.isFinite(item.packageCount) ? item.packageCount : 0), 0))
const expectedPackageCount = computed(() => detail.value?.originalPackageCount ?? null)
const packageCountDifference = computed(() => expectedPackageCount.value == null ? null : confirmedPackageCount.value - expectedPackageCount.value)
const packageCountMatched = computed(() => packageCountDifference.value === null || packageCountDifference.value === 0)
const packageCountDifferenceLabel = computed(() => {
if (packageCountDifference.value == null) return ''
return packageCountDifference.value > 0
? `超出 ${packageCountDifference.value} 箱`
: `还差 ${Math.abs(packageCountDifference.value)} 箱`
})
const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && !loading.value && !saving.value)
const snapshot = computed(() => JSON.stringify(form))
const isDirty = computed(() => Boolean(detail.value) && snapshot.value !== baseline.value)
const registrationOpen = computed(() => hasContext.value && canView.value && Boolean(detail.value))
const requiredPermission = computed(() => canEdit.value ? 'platform:firstleg:inbound:measure:edit' : null)
const sourceLabel = computed(() => ({ THIRD_PARTY: '第三方设备', MANUAL: '平台人工', OPENAPI: '第三方设备' }[detail.value?.sourceType || ''] || (detail.value?.sourceType ? '未知来源' : '尚未测量')))
const sourceTagType = computed(() => detail.value?.sourceType === 'MANUAL' ? 'info' : 'primary')
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'p2-inbound-measurement-editor',
label: computed(() => `入库单 ${detail.value?.inboundCode || props.inboundId} 的测量草稿`),
isOpen: registrationOpen,
model: form,
requiredPermission,
})
function nextItemKey() {
itemSequence += 1
return `measurement-item-${itemSequence}`
}
function localDateTimeNow() {
const now = new Date()
const pad = (value: number) => String(value).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
}
function resetManualEntry() {
manualForm.boxCode = ''
manualForm.length = null
manualForm.width = null
manualForm.height = null
manualForm.weight = null
manualForm.scannedShipmentId = ''
manualForm.machineNo = 'MANUAL-ADMIN'
manualForm.measuredAt = localDateTimeNow()
manualEntryImage.value = null
manualEntryErrors.value = []
}
function openManualEntry() {
if (!mayCreateManualEntry.value) {
if (isDirty.value) ElMessage.warning('请先保存当前确认箱数,再录入新的箱规')
return
}
resetManualEntry()
manualEntryOpen.value = true
}
function selectManualImage(uploadFile: UploadFile) {
const file = uploadFile.raw
manualEntryImage.value = file || null
}
function validateManualEntry() {
const errors: string[] = []
if (!manualForm.boxCode.trim()) errors.push('请输入箱号')
if (manualForm.boxCode.trim().length > 128) errors.push('箱号不能超过 128 个字符')
if (!(manualForm.length != null && manualForm.length > 0)) errors.push('测量长必须大于 0')
if (!(manualForm.width != null && manualForm.width > 0)) errors.push('测量宽必须大于 0')
if (!(manualForm.height != null && manualForm.height > 0)) errors.push('测量高必须大于 0')
if (!(manualForm.weight != null && manualForm.weight > 0)) errors.push('单箱毛重必须大于 0')
if (!manualForm.measuredAt) errors.push('请选择测量时间')
if (!manualEntryImage.value) errors.push('请上传当前箱测量图片')
if (manualEntryImage.value && manualEntryImage.value.size > 5 * 1024 * 1024) errors.push('测量图片不能超过 5 MB')
manualEntryErrors.value = errors
return errors.length === 0
}
async function submitManualEntry() {
if (!mayCreateManualEntry.value || !validateManualEntry() || manualForm.weight == null) return
manualEntrySubmitting.value = true
operationError.value = null
try {
await createManualCartonScan(customerId.value, props.inboundId, {
boxCode: manualForm.boxCode.trim(),
length: manualForm.length!,
width: manualForm.width!,
height: manualForm.height!,
weight: manualForm.weight,
scannedShipmentId: manualForm.scannedShipmentId.trim() || null,
machineNo: manualForm.machineNo.trim() || 'MANUAL-ADMIN',
measuredAt: manualForm.measuredAt,
}, manualEntryImage.value!)
manualEntryOpen.value = false
ElMessage.success('箱规已录入,服务端计算结果已更新')
await loadMeasurement()
emit('changed')
} catch (error) {
const details = setOperationError(error, '手动箱规录入失败,请检查后重试')
ElMessage.error(details.message)
} finally {
manualEntrySubmitting.value = false
}
}
function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
return {
itemId: String(item.id),
clientKey: nextItemKey(),
boxCode: item.boxCode,
itemNo: item.itemNo,
boxSpecification: item.boxSpecification,
packageCount: item.packageCount,
lengthCm: item.lengthCm,
widthCm: item.widthCm,
heightCm: item.heightCm,
unitWeightKg: item.unitWeightKg,
intelligentImageFileId: item.intelligentImageFileId == null ? null : String(item.intelligentImageFileId),
machineNo: item.machineNo,
measuredAt: item.measuredAt,
overLimitWarning: item.overLimitWarning,
outerLengthCm: item.outerLengthCm,
outerWidthCm: item.outerWidthCm,
outerHeightCm: item.outerHeightCm,
remark: item.remark,
serverValues: {
girthCm: item.girthCm,
unitVolumeCbm: item.unitVolumeCbm,
totalWeightKg: item.totalWeightKg,
totalVolumeCbm: item.totalVolumeCbm,
volumeWeightKg: item.volumeWeightKg,
},
}
}
function clearForm() {
form.measurementId = null
form.version = 0
form.originalDocumentNo = ''
form.originalGrossWeightKg = null
form.originalVolumeCbm = null
form.actualGoodsName = ''
form.items.splice(0)
baseline.value = JSON.stringify(form)
captureBaseline()
}
function applyDetail(resource: InboundMeasurementDetail) {
detail.value = resource
form.measurementId = resource.measurementId == null ? null : String(resource.measurementId)
form.version = resource.version ?? 0
form.originalDocumentNo = resource.originalDocumentNo || ''
form.originalGrossWeightKg = resource.originalGrossWeightKg
form.originalVolumeCbm = resource.originalVolumeCbm
form.actualGoodsName = resource.actualGoodsName || ''
form.items.splice(0, form.items.length, ...resource.items.map(editableItem))
baseline.value = JSON.stringify(form)
captureBaseline()
validationErrors.value = {}
conflictDetected.value = false
}
async function loadMeasurement() {
if (!hasContext.value || !canView.value) return
const sequence = ++requestSequence
loading.value = true
loadError.value = null
operationError.value = null
try {
const resource = await getInboundMeasurement(customerId.value, props.inboundId)
if (sequence !== requestSequence) return
applyDetail(resource)
} catch (error) {
if (sequence !== requestSequence) return
detail.value = null
clearForm()
loadError.value = getApiErrorDetails(error, '测量数据加载失败,请稍后重试')
} finally {
if (sequence === requestSequence) loading.value = false
}
}
async function reloadLatest() {
if (isDirty.value) {
try {
await ElMessageBox.confirm('加载最新版本会丢弃当前尚未保存的修改。确定继续吗?', '重新加载测量数据', {
type: 'warning',
confirmButtonText: '加载最新版本',
cancelButtonText: '继续编辑',
})
} catch {
return
}
}
await loadMeasurement()
}
function validateDraft() {
const errors: Record<string, string[]> = {}
const general: string[] = []
if (!form.items.length) general.push('请至少保留一条实际扫描箱测量记录')
if (form.items.length > 500) general.push('单个测量批次最多包含 500 条实际扫描箱记录')
form.items.forEach((item, index) => {
const itemErrors: string[] = []
if (!Number.isInteger(item.packageCount) || item.packageCount < 1 || item.packageCount > 1_000_000) itemErrors.push('箱数必须是 1 至 1,000,000 的整数')
if (itemErrors.length) errors[item.clientKey] = itemErrors.map((message) => `第 ${index + 1} 条:${message}`)
})
if (general.length) errors.general = general
validationErrors.value = errors
return Object.keys(errors).length === 0
}
function setOperationError(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
operationError.value = details
conflictDetected.value = details.code === 'A0409'
|| details.code === 'CONFLICT'
|| details.code === 'HTTP_409'
|| details.message.includes('版本')
|| details.message.includes('并发')
return details
}
async function saveDraft() {
if (!mayMaintain.value || !validateDraft()) return
saving.value = true
operationError.value = null
conflictDetected.value = false
try {
const originalCounts = new Map((detail.value?.items || []).map((item) => [String(item.id), item.packageCount]))
const changedItems = form.items.filter((item) => originalCounts.get(item.itemId) !== item.packageCount)
let version = form.version
for (const item of changedItems) {
const saved = await updateConfirmedPackageCount(customerId.value, props.inboundId, item.itemId, {
measurementVersion: version,
confirmedPackageCount: item.packageCount,
})
version = saved.measurementVersion
}
form.version = version
ElMessage.success('确认箱数已保存,计算结果已由服务端更新')
await loadMeasurement()
emit('changed')
} catch (error) {
const details = setOperationError(error, '测量草稿保存失败,请检查后重试')
if (!conflictDetected.value) ElMessage.error(details.message)
} finally {
saving.value = false
}
}
async function submitForReview() {
if (!maySubmit.value || !form.measurementId) return
if (isDirty.value) {
ElMessage.warning('请先保存当前修改,再提交审核')
return
}
try {
await ElMessageBox.confirm(
'提交后将校验测量图片和全部称重结果,并由后端准备托盘与库位占用。提交成功后本批次不能继续编辑,设备也不能再覆盖已提交数据。',
'提交测量审核',
{ type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '取消' },
)
} catch {
return
}
submitting.value = true
operationError.value = null
conflictDetected.value = false
try {
await submitInboundMeasurement(customerId.value, props.inboundId, {
measurementId: form.measurementId,
version: form.version,
})
ElMessage.success('测量数据已提交审核')
await loadMeasurement()
emit('changed')
} catch (error) {
const details = setOperationError(error, '测量数据提交失败,请检查图片和称重结果后重试')
if (!conflictDetected.value) ElMessage.error(details.message)
} finally {
submitting.value = false
}
}
function formatNumber(value: number | null | undefined, maximumFractionDigits = 3) {
if (value == null || !Number.isFinite(value)) return '—'
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value)
}
function releaseImagePreview() {
if (imagePreviewUrl.value) URL.revokeObjectURL(imagePreviewUrl.value)
imagePreviewUrl.value = ''
imagePreviewError.value = null
}
async function previewImage(fileId: string | null) {
if (!fileId) return
releaseImagePreview()
imagePreviewOpen.value = true
imagePreviewLoading.value = true
try {
const blob = await downloadInboundMeasurementImage(customerId.value, props.inboundId, fileId)
imagePreviewUrl.value = URL.createObjectURL(blob)
} catch (error) {
imagePreviewError.value = getApiErrorDetails(error, '测量图片加载失败')
} finally {
imagePreviewLoading.value = false
}
}
function processStatusLabel(status: string | undefined) {
return inboundStatusLabel(status, inboundProcessStatusLabels)
}
function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
return validationErrors.value[row.clientKey]?.length ? 'measurement-table__row--error' : ''
}
watch(
() => [customerId.value, props.inboundId, canView.value] as const,
() => {
requestSequence += 1
detail.value = null
loadError.value = null
operationError.value = null
conflictDetected.value = false
validationErrors.value = {}
clearForm()
if (hasContext.value && canView.value) void loadMeasurement()
},
{ immediate: true },
)
onBeforeUnmount(releaseImagePreview)
defineExpose({ saveDraft, submitForReview, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit })
</script>
<template>
<section class="surface-panel measurement-panel" aria-label="入库测量草稿">
<header class="panel-heading measurement-panel__heading">
<div>
<span class="measurement-panel__icon"><Ruler :size="18" aria-hidden="true" /></span>
<div>
<h2>测量与箱规</h2>
<p>维护箱规原始值;体积、材积重和汇总结果均由服务端计算。</p>
</div>
</div>
<div class="measurement-panel__actions">
<el-button :loading="loading" :disabled="!hasContext || !canView || saving || submitting" @click="reloadLatest">
<RefreshCw :size="15" />刷新
</el-button>
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft">
<Save :size="15" />保存确认箱数
</el-button>
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="!maySubmit" @click="submitForReview">
<Send :size="15" />提交审核
</el-button>
</div>
</header>
<el-empty v-if="!hasContext" description="请选择所属企业和入库单后维护测量数据" />
<LoadErrorState
v-else-if="!canView"
title="无测量查看权限"
message="当前账号缺少 platform:firstleg:inbound:measure:view 权限。"
:show-retry="false"
/>
<div v-else-if="loading && !detail" class="measurement-skeleton" aria-label="正在加载测量数据">
<el-skeleton :rows="8" animated />
</div>
<LoadErrorState
v-else-if="loadError && !detail"
title="测量数据加载失败"
:message="loadError.message"
:loading="loading"
@retry="loadMeasurement"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<template v-else-if="detail">
<div class="measurement-context">
<div class="measurement-context__identity">
<div>
<strong>{{ detail.inboundCode }}</strong>
<span>{{ detail.companyName }} · {{ detail.channelName || detail.channelCode || '未设置渠道' }}</span>
</div>
<div class="measurement-tags">
<el-tag :type="sourceTagType" effect="light">{{ sourceLabel }}</el-tag>
<StatusBadge :status="processStatusLabel(detail.processStatus)" />
<span>版本 {{ form.version }}</span>
</div>
</div>
<dl class="measurement-metrics">
<div><dt>订单总箱数</dt><dd>{{ formatNumber(expectedPackageCount, 0) }}</dd></div>
<div><dt>确认箱数合计</dt><dd>{{ formatNumber(confirmedPackageCount, 0) }}</dd></div>
<div><dt>实测总毛重</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>T.K.GW</small></dd></div>
<div><dt>实测总体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, 4) }} <small>T.CBM</small></dd></div>
<div><dt>实测总材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>KG</small></dd></div>
</dl>
</div>
<el-alert v-if="!countEditableProcess" type="info" :closable="false" show-icon>
<template #title>当前状态为“{{ processStatusLabel(detail.processStatus) }}”,测量数据仅可查看</template>
确认箱数只允许在待测量、测量中、待审核或审核驳回状态维护。
</el-alert>
<el-alert v-else-if="!canEdit" type="info" :closable="false" show-icon title="当前账号仅可查看测量数据" />
<el-alert v-if="conflictDetected" type="warning" :closable="false" show-icon class="measurement-alert">
<template #title>数据版本已变化,当前修改尚未丢失</template>
<p>{{ operationError?.message }}</p>
<ApiErrorMeta v-if="operationError" :details="operationError" />
<el-button size="small" @click="reloadLatest">加载最新版本</el-button>
</el-alert>
<el-alert v-else-if="operationError" type="error" :closable="false" show-icon class="measurement-alert">
<template #title>操作未完成</template>
<p>{{ operationError.message }}</p>
<ApiErrorMeta :details="operationError" />
</el-alert>
<el-alert v-if="Object.keys(validationErrors).length" type="error" :closable="false" show-icon class="measurement-alert">
<template #title>请修正测量草稿中的问题</template>
<ul>
<li v-for="message in Object.values(validationErrors).flat()" :key="message">{{ message }}</li>
</ul>
</el-alert>
<el-alert v-if="expectedPackageCount !== null && !packageCountMatched" type="warning" :closable="false" show-icon class="measurement-alert">
<template #title>确认箱数尚未与订单总箱数匹配</template>
当前已确认 {{ confirmedPackageCount }} 箱,订单总箱数 {{ expectedPackageCount }} 箱,{{ packageCountDifferenceLabel }}。
</el-alert>
<form class="measurement-form" @submit.prevent="saveDraft">
<fieldset :disabled="!mayMaintain || saving">
<legend class="sr-only">测量草稿</legend>
<section class="measurement-section">
<div class="measurement-section__heading">
<div><h3>原单数据</h3><p>原单值仅用于审核对比,不参与前端计算。</p></div>
</div>
<dl class="measurement-order-summary">
<div><dt>原单据号码(CKW)</dt><dd>{{ form.originalDocumentNo || '—' }}</dd></div>
<div><dt>原单据毛重(T.K.GW)</dt><dd>{{ formatNumber(form.originalGrossWeightKg) }}</dd></div>
<div><dt>原单据总体积(T.CBM)</dt><dd>{{ formatNumber(form.originalVolumeCbm, 4) }}</dd></div>
<div><dt>实际品名</dt><dd>{{ form.actualGoodsName || '—' }}</dd></div>
</dl>
</section>
<section class="measurement-section measurement-items-section">
<div class="measurement-section__heading">
<div><h3>扫描箱测量数据明细</h3><p>每条记录对应一个实际扫描箱;人工只确认该条记录代表的箱数,设备实测值不可在此修改。</p></div>
<el-button v-if="canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
</div>
<el-table
v-if="form.items.length"
:data="form.items"
border
stripe
table-layout="fixed"
class="measurement-table"
:row-class-name="measurementRowClassName"
>
<el-table-column type="expand" width="42">
<template #default="{ row }">
<dl class="measurement-row-details">
<div><dt>机器编号</dt><dd>{{ row.machineNo || '人工录入' }}</dd></div>
<div><dt>测量时间</dt><dd>{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}</dd></div>
<div><dt>箱规周长</dt><dd>{{ formatNumber(row.serverValues.girthCm) }} cm</dd></div>
<div><dt>备注</dt><dd>{{ row.remark || '—' }}</dd></div>
</dl>
</template>
</el-table-column>
<el-table-column type="index" label="#" width="48" align="center" />
<el-table-column label="箱号" min-width="156" show-overflow-tooltip>
<template #default="{ row }"><span class="measurement-box-code">{{ row.boxCode || '—' }}</span></template>
</el-table-column>
<el-table-column label="尺寸 长×宽×高(cm)" width="166" align="center">
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
</el-table-column>
<el-table-column label="确认箱数" width="132" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.packageCount"
class="measurement-count-editor"
aria-label="确认箱数"
:min="1"
:max="1000000"
:step="1"
step-strictly
controls-position="right"
/>
</template>
</el-table-column>
<el-table-column label="每箱毛重(kg)" width="106" align="right">
<template #default="{ row }">{{ formatNumber(row.unitWeightKg) }}</template>
</el-table-column>
<el-table-column label="单箱体积(m³)" width="112" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.unitVolumeCbm, 4) }}</template>
</el-table-column>
<el-table-column label="周长(cm)" width="92" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.girthCm) }}</template>
</el-table-column>
<el-table-column label="总毛重(kg)" width="104" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.totalWeightKg) }}</template>
</el-table-column>
<el-table-column label="总体积(m³)" width="108" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.totalVolumeCbm, 4) }}</template>
</el-table-column>
<el-table-column label="总材积重(kg)" width="116" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.volumeWeightKg) }}</template>
</el-table-column>
<el-table-column label="图片" width="82" align="center">
<template #default="{ row }">
<el-tooltip :content="row.intelligentImageFileId ? '预览测量图片' : '暂无测量图片'">
<el-button text type="primary" :disabled="!row.intelligentImageFileId" aria-label="预览测量图片" @click="previewImage(row.intelligentImageFileId)">
<Image :size="16" />
</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无扫描箱测量数据" />
</section>
</fieldset>
</form>
<footer v-if="canEdit || canSubmit" class="measurement-panel__footer">
<span v-if="isDirty"><AlertTriangle :size="15" />有尚未保存的修改</span>
<span v-else>当前显示版本 {{ form.version }}</span>
<div>
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft"><Save :size="15" />保存确认箱数</el-button>
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="!maySubmit" @click="submitForReview"><Send :size="15" />提交审核</el-button>
</div>
</footer>
<el-dialog v-model="imagePreviewOpen" title="当前有效测量图片" width="min(720px, 92vw)" destroy-on-close @closed="releaseImagePreview">
<div class="measurement-image-preview">
<el-skeleton v-if="imagePreviewLoading" :rows="5" animated />
<LoadErrorState v-else-if="imagePreviewError" title="图片加载失败" :message="imagePreviewError.message" :show-retry="false">
<template #details><ApiErrorMeta :details="imagePreviewError" /></template>
</LoadErrorState>
<img v-else-if="imagePreviewUrl" :src="imagePreviewUrl" alt="当前扫描箱测量图片" />
<el-empty v-else description="暂无可预览图片" />
</div>
</el-dialog>
<el-dialog v-model="manualEntryOpen" title="手动录入箱规" width="min(680px, 92vw)" destroy-on-close>
<el-alert v-if="manualEntryErrors.length" type="error" :closable="false" show-icon class="manual-entry-alert" title="请修正以下问题">
<ul><li v-for="error in manualEntryErrors" :key="error">{{ error }}</li></ul>
</el-alert>
<el-form label-position="top" class="manual-entry-form" @submit.prevent="submitManualEntry">
<div class="measurement-form-grid">
<el-form-item label="箱号 *"><el-input v-model="manualForm.boxCode" placeholder="请输入已打印箱唛上的箱号" /></el-form-item>
<el-form-item label="测量长(cm) *"><el-input-number v-model="manualForm.length" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="测量宽(cm) *"><el-input-number v-model="manualForm.width" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="测量高(cm) *"><el-input-number v-model="manualForm.height" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="单箱毛重(kg) *"><el-input-number v-model="manualForm.weight" :min="0.001" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="测量时间 *"><el-date-picker v-model="manualForm.measuredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm:ss" /></el-form-item>
<el-form-item label="设备/来源编号"><el-input v-model="manualForm.machineNo" maxlength="64" /></el-form-item>
<el-form-item label="Shipment ID"><el-input v-model="manualForm.scannedShipmentId" maxlength="64" /></el-form-item>
</div>
<el-form-item label="当前箱测量图片 *">
<el-upload class="manual-entry-upload" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectManualImage" :on-remove="() => { manualEntryImage = null }">
<el-button><Upload :size="15" />选择图片</el-button>
<template #tip><div class="el-upload__tip">支持图片格式,大小不超过 5 MB</div></template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="manualEntrySubmitting" @click="manualEntryOpen = false">取消</el-button>
<el-button type="primary" :loading="manualEntrySubmitting" @click="submitManualEntry">保存箱规</el-button>
</template>
</el-dialog>
</template>
</section>
</template>
<style scoped>
.measurement-panel { overflow: hidden; }
.measurement-panel__heading > div:first-child { display: flex; min-width: 0; align-items: flex-start; gap: 11px; }
.measurement-panel__icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 8px; background: #eef4ff; color: var(--cc-primary); }
.measurement-panel h2, .measurement-panel h3 { margin: 0; color: var(--cc-text); }
.measurement-panel h2 { font-size: 16px; }
.measurement-panel h3 { font-size: 14px; }
.measurement-panel__heading p, .measurement-section__heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.measurement-panel__actions, .measurement-panel__footer > div { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button) { margin-left: 0; }
.measurement-skeleton { padding: 24px; }
.measurement-context { padding: 18px 20px; background: #fbfcfe; }
.measurement-context__identity { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.measurement-context__identity > div:first-child { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.measurement-context__identity strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 15px; }
.measurement-context__identity span { color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.measurement-tags { display: flex; flex: 0 0 auto; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 7px; }
.measurement-tags > span:last-child { color: var(--cc-muted); font-size: 11px; }
.measurement-metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 16px 0 0; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.measurement-metrics > div { min-width: 0; padding: 13px 15px; border-right: 1px solid var(--cc-border); }
.measurement-metrics > div:last-child { border-right: 0; }
.measurement-metrics dt { color: var(--cc-muted); font-size: 11px; }
.measurement-metrics dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 17px; font-weight: 700; }
.measurement-metrics small { color: var(--cc-muted); font-size: 10px; font-weight: 500; }
.measurement-panel > :deep(.el-alert), .measurement-alert { margin: 16px 20px 0; width: auto; }
.measurement-alert p { margin: 5px 0 0; line-height: 1.55; }
.measurement-alert ul { margin: 7px 0 0; padding-left: 18px; font-size: 12px; line-height: 1.65; }
.measurement-alert .el-button { margin-top: 9px; }
.measurement-form fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
.measurement-section { padding: 22px 20px; border-top: 1px solid var(--cc-border); }
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
.measurement-section__heading > div { min-width: 0; }
.measurement-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; }
.measurement-form label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #344054; font-size: 12px; font-weight: 650; }
.measurement-form :deep(.el-input-number), .measurement-form :deep(.el-input) { width: 100%; }
.measurement-order-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
.measurement-order-summary > div { min-width: 0; padding: 11px 13px; border-right: 1px solid var(--cc-border); }
.measurement-order-summary > div:last-child { border-right: 0; }
.measurement-order-summary dt { color: var(--cc-muted); font-size: 11px; }
.measurement-order-summary dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; font-weight: 650; }
.measurement-image-preview { display: grid; min-height: 240px; place-items: center; }
.measurement-image-preview img { display: block; width: auto; max-width: 100%; max-height: 68vh; object-fit: contain; }
.manual-entry-alert { margin-bottom: 16px; }
.manual-entry-alert ul { margin: 6px 0 0; padding-left: 18px; }
.manual-entry-form :deep(.el-input-number), .manual-entry-form :deep(.el-date-editor) { width: 100%; }
.manual-entry-upload { width: 100%; }
.measurement-items-section { background: #fafbfc; }
.measurement-table { width: 100%; font-size: 12px; }
.measurement-table :deep(.el-table__header th) { padding: 8px 0; background: #f6f8fa; color: #475467; font-size: 11px; font-weight: 700; }
.measurement-table :deep(.el-table__cell) { padding: 7px 0; }
.measurement-table :deep(.cell) { line-height: 1.35; }
.measurement-table :deep(.measurement-table__row--error > td.el-table__cell) { background: var(--el-color-danger-light-9); }
.measurement-box-code { font-weight: 650; }
.measurement-count-editor { width: 104px !important; }
.measurement-row-details { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px 20px; margin: 0; padding: 4px 18px 8px 62px; }
.measurement-row-details dt { color: var(--cc-muted); font-size: 11px; }
.measurement-row-details dd { margin: 3px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 12px; font-weight: 600; }
.measurement-panel__footer { position: sticky; bottom: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 20px; border-top: 1px solid var(--cc-border); background: rgba(255, 255, 255, .97); box-shadow: 0 -8px 22px rgba(15, 23, 42, .05); }
.measurement-panel__footer > span { display: inline-flex; align-items: center; gap: 6px; color: var(--cc-muted); font-size: 12px; }
.measurement-panel__footer > span:has(svg) { color: #b54708; font-weight: 650; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
@media (max-width: 960px) {
.measurement-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.measurement-order-summary, .measurement-row-details { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.measurement-order-summary > div:nth-child(2) { border-right: 0; }
.measurement-order-summary > div:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); }
.measurement-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.measurement-metrics > div:nth-child(2) { border-right: 0; }
.measurement-metrics > div:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); }
}
@media (max-width: 720px) {
.measurement-panel__heading, .measurement-context__identity, .measurement-section__heading, .measurement-panel__footer { align-items: stretch; flex-direction: column; }
.measurement-panel__actions, .measurement-panel__actions :deep(.el-button), .measurement-panel__footer > div, .measurement-panel__footer :deep(.el-button) { width: 100%; }
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button), .measurement-section__heading :deep(.el-button) { min-height: 44px; }
.measurement-tags { justify-content: flex-start; }
.measurement-context, .measurement-section { padding-right: 14px; padding-left: 14px; }
.measurement-panel > :deep(.el-alert), .measurement-alert { margin-right: 14px; margin-left: 14px; }
.measurement-form-grid { grid-template-columns: 1fr; }
.measurement-row-details { grid-template-columns: 1fr; padding-left: 54px; }
}
@media (max-width: 420px) {
.measurement-metrics, .measurement-order-summary { grid-template-columns: 1fr; }
.measurement-metrics > div, .measurement-order-summary > div { border-right: 0; border-bottom: 1px solid var(--cc-border); }
.measurement-metrics > div:last-child, .measurement-order-summary > div:last-child { border-bottom: 0; }
}
</style>