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

770 lines
38 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 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: []
}>()
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 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 { 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('updated')
} 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.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 originalDraft = detail.value
? { originalDocumentNo: detail.value.originalDocumentNo || '', originalGrossWeightKg: detail.value.originalGrossWeightKg, originalVolumeCbm: detail.value.originalVolumeCbm, actualGoodsName: detail.value.actualGoodsName || '' }
: null
const draftChanged = Boolean(originalDraft && (
originalDraft.originalDocumentNo !== form.originalDocumentNo
|| originalDraft.originalGrossWeightKg !== form.originalGrossWeightKg
|| originalDraft.originalVolumeCbm !== form.originalVolumeCbm
|| originalDraft.actualGoodsName !== form.actualGoodsName
))
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,
originalDocumentNo: form.originalDocumentNo.trim() || null,
originalGrossWeightKg: form.originalGrossWeightKg,
originalVolumeCbm: form.originalVolumeCbm,
actualGoodsName: form.actualGoodsName.trim() || null,
items: form.items.map(({ itemId: _itemId, clientKey: _clientKey, serverValues: _serverValues, ...item }) => item),
})
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) {
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('submitted')
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 updateActualGrossWeight(actualGrossWeightKg: number | null) {
if (!detail.value) return
detail.value = { ...detail.value, actualGrossWeightKg }
}
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, updateActualGrossWeight, 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>
<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>
<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>
<span>入仓码</span>
<strong>{{ detail.inboundCode || '—' }}</strong>
<p>{{ detail.companyName || '客户未命名' }} · {{ 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>
<dl class="measurement-metrics" aria-label="测量结果汇总">
<div :class="{ 'measurement-metric--warning': !packageCountMatched }">
<dt>实测箱数</dt>
<dd>{{ formatNumber(confirmedPackageCount, 0) }} <small>/ 申报 {{ formatNumber(expectedPackageCount, 0) }}</small></dd>
</div>
<div class="measurement-metric--primary"><dt>货物重量</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>kg</small></dd></div>
<div><dt>总体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, 4) }} <small>m³</small></dd></div>
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>kg</small></dd></div>
</dl>
<el-collapse class="measurement-more">
<el-collapse-item name="declaration" title="申报信息">
<div class="measurement-data-grid">
<label><span>申报箱数</span><el-input :model-value="formatNumber(expectedPackageCount, 0)" readonly /></label>
<label><span>申报毛重(kg)</span><el-input-number v-model="form.originalGrossWeightKg" :min="0" :precision="3" controls-position="right" /></label>
<label><span>申报体积(m³)</span><el-input-number v-model="form.originalVolumeCbm" :min="0" :precision="4" controls-position="right" /></label>
<label><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="128" /></label>
<label><span>原单据号码</span><el-input v-model="form.originalDocumentNo" placeholder="请输入原单据号码" maxlength="128" /></label>
</div>
</el-collapse-item>
</el-collapse>
</section>
<section class="measurement-section measurement-items-section">
<div class="measurement-section__heading">
<div><h3>箱规明细</h3></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.unitWeightKg) }} kg</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 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="112" 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="图片" 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: 18px 20px; border-top: 1px solid var(--cc-border); }
.measurement-summary-section { display: grid; gap: 16px; border-top: 0; }
.measurement-summary-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.measurement-summary-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
.measurement-summary-header span { color: var(--cc-muted); font-size: 11px; }
.measurement-summary-header strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 20px; line-height: 1.3; }
.measurement-summary-header p { margin: 0; color: var(--cc-muted); font-size: 12px; }
.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)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
.measurement-metrics > div { display: grid; min-width: 0; min-height: 78px; align-content: center; gap: 6px; padding: 13px 16px; border-right: 1px solid var(--cc-border); }
.measurement-metrics > div:last-child { border-right: 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: 19px; font-weight: 700; overflow-wrap: anywhere; }
.measurement-metrics small { color: var(--cc-muted); font-size: 11px; font-weight: 500; }
.measurement-metric--primary { background: #f0f7ff; }
.measurement-metric--primary dd { color: var(--cc-primary); font-size: 23px; }
.measurement-metric--warning { background: var(--el-color-warning-light-9); }
.measurement-metric--warning dd { color: var(--el-color-warning-dark-2); }
.measurement-more { border-top: 0; border-bottom: 0; }
.measurement-more :deep(.el-collapse-item__header) { height: 38px; color: var(--cc-muted); font-size: 12px; }
.measurement-more :deep(.el-collapse-item__wrap) { border-bottom: 0; }
.measurement-more :deep(.el-collapse-item__content) { padding: 4px 0 2px; }
.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, .measurement-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; }
.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; background: #f5f5f5; box-shadow: 0 0 0 1px #e5e7eb inset; }
.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); }
.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-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(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-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>