Files
admin-web/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
T
2026-09-03 17:49:01 +08:00

896 lines
47 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, 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 AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { inboundProcessStatusLabels, inboundStatusLabel } from '../status'
import { createManualCartonScan, downloadInboundMeasurementImage, getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
import type {
InboundMeasurementDetail,
InboundMeasurementItem,
SaveInboundMeasurementItemRequest,
} from './types'
const props = defineProps<{
customerId?: string
tenantId?: string
inboundId: string
}>()
const emit = defineEmits<{
changed: []
updated: []
saved: []
submitted: []
}>()
type EditableMeasurementItem = Omit<SaveInboundMeasurementItemRequest, 'coefficientMode'> & {
itemId: string
clientKey: string
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
chargeRuleId: string | null
coefficientMode: 'ACTUAL' | 'ADD' | 'MULTIPLY'
}
interface MeasurementFormModel {
measurementId: string | null
version: number
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,
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 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 ? '未知来源' : '尚未测量')))
interface MeasurementDeviationWarning {
key: string
text: string
}
const measurementDeviationWarnings = computed<MeasurementDeviationWarning[]>(() => {
const resource = detail.value
if (!resource) return []
const warnings: MeasurementDeviationWarning[] = []
const declaredItems = resource.latestDeclaration?.items || []
const dimensions = [
{ key: 'lengthCm', label: '长' },
{ key: 'widthCm', label: '宽' },
{ key: 'heightCm', label: '高' },
] as const
for (const dimension of dimensions) {
const differences = resource.items.flatMap((measuredItem) => {
const declaredItem = declaredItems.find((candidate) => (
measuredItem.inboundDetailId != null
&& candidate.inboundDetailId != null
&& String(candidate.inboundDetailId) === String(measuredItem.inboundDetailId)
)) || declaredItems.find((candidate) => candidate.itemNo != null && Number(candidate.itemNo) === Number(measuredItem.itemNo))
if (!declaredItem) return []
const declaredValue = declaredItem[dimension.key]
const measuredValue = measuredItem[dimension.key]
const ratio = deviationRatio(measuredValue, declaredValue)
if (ratio == null || ratio < 0.1 - 1e-9) return []
return [{
measuredItem,
declaredValue,
measuredValue,
ratio,
}]
}).sort((left, right) => right.ratio - left.ratio)
const largest = differences[0]
if (!largest) continue
const boxLabel = largest.measuredItem.boxCode || `第 ${largest.measuredItem.itemNo} 条箱规`
const countSuffix = differences.length > 1 ? `,共 ${differences.length} 条箱规` : ''
warnings.push({
key: dimension.key,
text: `${dimension.label}偏差 ${formatPercentage(largest.ratio)}(${boxLabel}:下单 ${formatNumber(largest.declaredValue)} cm → 实测 ${formatNumber(largest.measuredValue)} cm${countSuffix})`,
})
}
const declaredGrossWeight = resource.latestDeclaration?.grossWeightKg ?? resource.originalGrossWeightKg
const weightRatio = deviationRatio(resource.actualGrossWeightKg, declaredGrossWeight)
if (weightRatio != null && weightRatio >= 0.1 - 1e-9) {
warnings.push({
key: 'grossWeightKg',
text: `重量偏差 ${formatPercentage(weightRatio)}(下单 ${formatNumber(declaredGrossWeight)} kg → 实测 ${formatNumber(resource.actualGrossWeightKg)} kg)`,
})
}
return warnings
})
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'p2-inbound-measurement-editor',
label: computed(() => `入仓码 ${detail.value?.inboundCode || '待补充'} 的测量草稿`),
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('updated')
} catch (error) {
setOperationError(error, '手动箱规录入失败,请检查后重试')
} finally {
manualEntrySubmitting.value = false
}
}
function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
return {
itemId: String(item.id),
inboundDetailId: String(item.inboundDetailId || 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.deviceMeasuredAt,
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,
},
chargeRuleId: item.chargeRuleId == null ? null : String(item.chargeRuleId),
coefficientMode: item.coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : item.coefficientMode === 'ADD' ? 'ADD' : 'ACTUAL',
}
}
function clearForm() {
form.measurementId = null
form.version = 0
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.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 的整数')
const dimensions = [
['长', item.lengthCm],
['宽', item.widthCm],
['高', item.heightCm],
] as const
for (const [label, value] of dimensions) {
if (!Number.isFinite(value) || value <= 0) itemErrors.push(`${label}必须是大于 0 的有效数值`)
else if (value > 999_999_999.999) itemErrors.push(`${label}超出系统允许上限 999,999,999.999 cm`)
else if (Math.abs(value * 1000 - Math.round(value * 1000)) > 1e-6) itemErrors.push(`${label}最多保留 3 位小数`)
}
const outerDimensions = [item.outerLengthCm, item.outerWidthCm, item.outerHeightCm]
if (outerDimensions.some((value) => value != null)) {
outerDimensions.forEach((value) => {
if (value != null && (!Number.isFinite(value) || value <= 0)) itemErrors.push('仓库修改外箱尺寸必须大于 0')
else if (value != null && Math.abs(value * 1000 - Math.round(value * 1000)) > 1e-6) itemErrors.push('仓库修改外箱尺寸最多保留 3 位小数')
})
}
if (itemErrors.length) errors[item.clientKey] = itemErrors.map((message) => `第 ${index + 1} 条:${message}`)
})
if (general.length) errors.general = general
validationErrors.value = errors
const messages = Object.values(errors).flat()
if (messages.length) {
ElMessage.warning({
message: messages.length === 1 ? messages[0] : `${messages[0]}(另有 ${messages.length - 1} 项)`,
showClose: true,
duration: 4500,
})
}
return Object.keys(errors).length === 0
}
function setOperationError(error: unknown, fallback: string) {
const source = getApiErrorDetails(error, fallback)
const numericOverflow = /data truncation|out of range|numeric value|decimal|数值.*(过大|超出)|尺寸.*(过大|超限)/i.test(source.message)
const largestItem = numericOverflow
? [...form.items].sort((left, right) => Math.max(right.lengthCm, right.widthCm, right.heightCm) - Math.max(left.lengthCm, left.widthCm, left.heightCm))[0]
: undefined
const details = numericOverflow ? {
...source,
message: largestItem
? `箱规尺寸或箱数超出系统允许范围,请检查箱号 ${largestItem.boxCode || largestItem.itemNo}(${formatNumber(largestItem.lengthCm)} × ${formatNumber(largestItem.widthCm)} × ${formatNumber(largestItem.heightCm)} cm)后重试`
: '箱规尺寸或箱数超出系统允许范围,请检查长、宽、高和箱数后重试',
} : source
const conflict = details.message.includes('版本已变化')
|| details.message.includes('并发修改')
conflictDetected.value = conflict
operationError.value = conflict ? details : null
if (!conflict) {
ElMessage.error({ message: details.message, showClose: true, duration: 4500 })
}
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 originalDraftValues = new Map((detail.value?.items || []).map((item) => [String(item.id), JSON.stringify({
lengthCm: item.lengthCm, widthCm: item.widthCm, heightCm: item.heightCm,
outerLengthCm: item.outerLengthCm, outerWidthCm: item.outerWidthCm, outerHeightCm: item.outerHeightCm,
chargeRuleId: item.chargeRuleId || null, coefficientMode: item.coefficientMode || 'ACTUAL', remark: item.remark || null,
})]))
const draftChanged = Boolean(detail.value && ((detail.value.actualGoodsName || '') !== form.actualGoodsName || form.items.some((item) => originalDraftValues.get(item.itemId) !== JSON.stringify({
lengthCm: item.lengthCm, widthCm: item.widthCm, heightCm: item.heightCm,
outerLengthCm: item.outerLengthCm, outerWidthCm: item.outerWidthCm, outerHeightCm: item.outerHeightCm,
chargeRuleId: item.chargeRuleId || null, coefficientMode: item.coefficientMode, remark: item.remark || null,
}))))
const changedItems = form.items.filter((item) => originalCounts.get(item.itemId) !== item.packageCount)
let version = form.version
if (draftChanged) {
const saved = await saveInboundMeasurementDraft(customerId.value, props.inboundId, {
measurementId: form.measurementId,
version,
actualGoodsName: form.actualGoodsName.trim() || null,
items: form.items.map((item) => ({
inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification,
packageCount: item.packageCount, lengthCm: item.lengthCm, widthCm: item.widthCm, heightCm: item.heightCm,
intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning,
outerLengthCm: item.outerLengthCm, outerWidthCm: item.outerWidthCm, outerHeightCm: item.outerHeightCm,
chargeRuleId: item.coefficientMode === 'MULTIPLY' ? item.chargeRuleId : null,
coefficientMode: item.coefficientMode === 'ACTUAL' ? null : item.coefficientMode, remark: item.remark,
})),
})
version = saved.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('saved')
emit('changed')
} catch (error) {
setOperationError(error, '测量草稿保存失败,请检查后重试')
} finally {
saving.value = false
}
}
async function submitForReview() {
if (!maySubmit.value || !form.measurementId) return
if (!validateDraft()) 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('submitted')
emit('changed')
} catch (error) {
setOperationError(error, '测量数据提交失败,请检查图片和称重结果后重试')
} 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 deviationRatio(actual: number | null | undefined, declared: number | null | undefined) {
if (actual == null || declared == null || !Number.isFinite(actual) || !Number.isFinite(declared) || declared <= 0) return null
return Math.abs(actual - declared) / Math.abs(declared)
}
function formatPercentage(ratio: number) {
return `${formatNumber(ratio * 100, 1)}%`
}
function formatDifference(actual: number | null | undefined, declared: number | null | undefined, maximumFractionDigits = 3, unit = '') {
if (actual == null || declared == null || !Number.isFinite(actual) || !Number.isFinite(declared)) return null
const difference = actual - declared
if (difference === 0) return null
const sign = difference > 0 ? '+' : ''
return `差异 ${sign}${formatNumber(difference, maximumFractionDigits)}${unit}`
}
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 updateActualWeights(actualGrossWeightKg: number | null, actualNetWeightKg: number | null) {
if (!detail.value) return
detail.value = { ...detail.value, actualGrossWeightKg, actualNetWeightKg }
}
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, updateActualWeights, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })
</script>
<template>
<section class="surface-panel measurement-panel" aria-label="入库测量草稿">
<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">
<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>
<p v-if="Object.keys(validationErrors).length" class="sr-only" role="alert">
{{ Object.values(validationErrors).flat().join(';') }}
</p>
<form class="measurement-form" @submit.prevent="saveDraft">
<fieldset :disabled="!mayMaintain || saving">
<legend class="sr-only">测量草稿</legend>
<section class="measurement-section measurement-summary-section">
<header class="measurement-summary-header">
<div class="measurement-summary-context">
<p><span>客户</span>{{ detail.companyName || '客户未命名' }}</p>
<p><span>渠道</span>{{ detail.channelName || detail.channelCode || '渠道未填写' }}</p>
</div>
<div class="measurement-summary-status">
<el-tag effect="light">{{ processStatusLabel(detail.processStatus) }}</el-tag>
<span>{{ sourceLabel }}</span>
</div>
</header>
<el-alert
v-if="measurementDeviationWarnings.length"
class="measurement-deviation-alert"
type="warning"
:closable="false"
show-icon
title="下单箱规与测量数据差异达到 10%"
>
<p v-for="warning in measurementDeviationWarnings" :key="warning.key">{{ warning.text }}</p>
</el-alert>
<h4 class="measurement-subheading">平台实测</h4>
<dl class="measurement-metrics" aria-label="平台实测结果汇总">
<div :class="{ 'measurement-metric--warning': !packageCountMatched }">
<dt>实测箱数</dt>
<dd>{{ formatNumber(confirmedPackageCount, 0) }} <small v-if="formatDifference(confirmedPackageCount, expectedPackageCount, 0)">/ {{ formatDifference(confirmedPackageCount, expectedPackageCount, 0) }}</small></dd>
</div>
<div><dt>实测毛重</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>kg<span v-if="formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg')"> · {{ formatDifference(detail.actualGrossWeightKg, detail.originalGrossWeightKg, 3, ' kg') }}</span></small></dd></div>
<div class="measurement-metric--primary"><dt>货物净重</dt><dd>{{ formatNumber(detail.actualNetWeightKg) }} <small>kg</small></dd></div>
<div><dt>总体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, 4) }} <small>m³<span v-if="formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 4, ' m³')"> · {{ formatDifference(detail.actualVolumeCbm, detail.originalVolumeCbm, 4, ' m³') }}</span></small></dd></div>
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>kg</small></dd></div>
</dl>
<section class="measurement-declaration" aria-labelledby="measurement-declaration-title">
<h4 id="measurement-declaration-title">客户申报</h4>
<div class="measurement-data-grid">
<div class="measurement-readonly-field"><span>申报箱数</span><strong>{{ formatNumber(expectedPackageCount, 0) }}</strong></div>
<div class="measurement-readonly-field"><span>申报毛重(kg)</span><strong>{{ formatNumber(detail.originalGrossWeightKg) }}</strong></div>
<div class="measurement-readonly-field"><span>申报体积(m³)</span><strong>{{ formatNumber(detail.originalVolumeCbm, 4) }}</strong></div>
<label v-if="mayMaintain" class="measurement-goods-name"><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="255" /></label>
<div v-else class="measurement-readonly-field measurement-goods-name"><span>实际品名</span><strong>{{ detail.actualGoodsName || '—' }}</strong></div>
<div class="measurement-readonly-field"><span>原单据号码</span><strong>{{ detail.originalDocumentNo || '—' }}</strong></div>
</div>
</section>
</section>
<section class="measurement-section measurement-items-section">
<div class="measurement-section__heading">
<div><h3>箱规明细</h3></div>
<div class="measurement-section-actions">
<el-button v-if="canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
</div>
</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.unitVolumeCbm, 4) }} m³</dd></div>
<div><dt>箱规周长</dt><dd>{{ formatNumber(row.serverValues.girthCm) }} cm</dd></div>
<div><dt>总材积重</dt><dd>{{ formatNumber(row.serverValues.volumeWeightKg) }} kg</dd></div>
<div><dt>备注</dt><dd>{{ row.remark || '—' }}</dd></div>
</dl>
</template>
</el-table-column>
<el-table-column prop="itemNo" label="#" width="70" 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="165" align="center">
<template #default="{ row }"><el-input-number v-model="row.outerLengthCm" aria-label="仓库修改外箱长" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></template>
</el-table-column>
<el-table-column label="仓库修改外箱(宽)" width="165" align="center">
<template #default="{ row }"><el-input-number v-model="row.outerWidthCm" aria-label="仓库修改外箱宽" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></template>
</el-table-column>
<el-table-column label="仓库修改外箱(高)" width="165" align="center">
<template #default="{ row }"><el-input-number v-model="row.outerHeightCm" aria-label="仓库修改外箱高" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></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="总体积(m³)" width="108" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.totalVolumeCbm, 4) }}</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>所有修改已保存</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-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: 16px 20px; border-top: 1px solid var(--cc-border); }
.measurement-summary-section { display: grid; gap: 12px; border-top: 0; }
.measurement-summary-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.measurement-summary-context { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 8px 20px; }
.measurement-summary-header span { color: var(--cc-muted); font-size: 11px; }
.measurement-deviation-alert { margin: 0; width: auto; }
.measurement-deviation-alert p { margin: 3px 0 0; line-height: 1.55; }
.measurement-summary-context p { display: inline-flex; min-width: 0; align-items: baseline; gap: 6px; margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 12px; font-weight: 600; }
.measurement-summary-status { display: flex; flex: 0 0 auto; align-items: center; gap: 9px; }
.measurement-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 2px 0; }
.measurement-subheading { margin: 0; color: #344054; font-size: 12px; font-weight: 700; }
.measurement-metrics > div { display: grid; min-width: 0; min-height: 58px; align-content: center; gap: 4px; padding: 5px 18px; border-left: 1px solid var(--cc-border); }
.measurement-metrics > div:first-child { padding-left: 0; border-left: 0; }
.measurement-metrics dt { color: var(--cc-muted); font-size: 12px; }
.measurement-metrics dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 18px; font-weight: 700; overflow-wrap: anywhere; }
.measurement-metrics small { color: var(--cc-muted); font-size: 11px; font-weight: 500; }
.measurement-metric--primary dd { color: var(--cc-primary); font-size: 20px; }
.measurement-metric--warning dd { color: var(--el-color-warning-dark-2); }
.measurement-declaration { padding-top: 12px; border-top: 1px solid var(--cc-border); }
.measurement-declaration h4 { margin: 0 0 10px; color: #344054; font-size: 12px; font-weight: 700; }
.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-section-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }.measurement-section-actions :deep(.el-button) { margin-left: 0; }
.warehouse-suggestion-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }.warehouse-suggestion-form :deep(.el-select),.warehouse-suggestion-form :deep(.el-input-number) { width: 100%; }.warehouse-suggestion-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }.suggestion-preview { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 4px 0 0; border: 1px solid var(--cc-border); border-radius: 6px; }.suggestion-preview div { padding: 12px; border-right: 1px solid var(--cc-border); }.suggestion-preview div:last-child { border-right: 0; }.suggestion-preview dt { color: var(--cc-muted); font-size: 11px; }.suggestion-preview dd { margin: 5px 0 0; color: var(--cc-text); font-size: 13px; font-weight: 650; }
.measurement-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; }
.measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
.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-data-grid label { display: flex; min-width: 0; flex-direction: column; gap: 5px; color: #344054; font-size: 12px; font-weight: 600; }
.measurement-data-grid :deep(.el-input__wrapper) { border-radius: 3px; }
.measurement-readonly-field { display: grid; min-width: 0; align-content: start; gap: 7px; }
.measurement-readonly-field span { color: #344054; font-size: 12px; font-weight: 600; }
.measurement-readonly-field strong { min-height: 32px; padding: 7px 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; line-height: 18px; }
.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: #fff; }
.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); }
.warehouse-suggestion-table { display: grid; gap: 10px; margin-top: 18px; }.warehouse-suggestion-table h3 { margin: 0; color: var(--cc-text); font-size: 14px; }
.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: 12px 20px; border-top: 1px solid var(--cc-border); background: #fff; }
.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-data-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.measurement-row-details { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.measurement-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.measurement-metrics > div:nth-child(3) { padding-left: 0; border-left: 0; }
}
@media (max-width: 720px) {
.measurement-panel__heading, .measurement-summary-header, .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-section { padding-right: 14px; padding-left: 14px; }
.measurement-panel > :deep(.el-alert), .measurement-alert { margin-right: 14px; margin-left: 14px; }
.measurement-form-grid, .measurement-data-grid { grid-template-columns: 1fr; }
.measurement-row-details { grid-template-columns: 1fr; padding-left: 54px; }
}
@media (max-width: 420px) {
}
</style>