Files
admin-web/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
T

1001 lines
55 KiB
Vue
Raw Normal View History

2026-08-04 21:01:39 +08:00
<script setup lang="ts">
2026-09-14 15:33:35 +08:00
import '@/styles/adminDensity.css'
2026-08-07 15:53:58 +08:00
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
2026-08-12 14:08:33 +08:00
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
2026-08-13 17:43:04 +08:00
import { AlertTriangle, Image, Plus, Save, Send, Upload } from '@lucide/vue'
2026-08-04 21:01:39 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-09-03 10:46:07 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
2026-08-04 21:01:39 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
2026-08-11 19:35:28 +08:00
import { inboundProcessStatusLabels, inboundStatusLabel } from '../status'
2026-08-13 19:00:37 +08:00
import { createManualCartonScan, downloadInboundMeasurementImage, getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
2026-08-04 21:01:39 +08:00
import type {
InboundMeasurementDetail,
InboundMeasurementItem,
SaveInboundMeasurementItemRequest,
} from './types'
const props = defineProps<{
customerId?: string
tenantId?: string
inboundId: string
}>()
const emit = defineEmits<{
changed: []
2026-08-17 11:23:31 +08:00
updated: []
2026-08-17 17:49:10 +08:00
saved: []
submitted: []
2026-08-04 21:01:39 +08:00
}>()
2026-09-03 10:46:07 +08:00
type EditableMeasurementItem = Omit<SaveInboundMeasurementItemRequest, 'coefficientMode'> & {
2026-08-07 17:14:42 +08:00
itemId: string
2026-08-04 21:01:39 +08:00
clientKey: string
2026-09-08 17:38:09 +08:00
inboundCode: string
fbaShipmentNo: string | null
scannedShipmentId: string | null
2026-08-04 21:01:39 +08:00
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
2026-09-03 10:46:07 +08:00
chargeRuleId: string | null
coefficientMode: 'ACTUAL' | 'ADD' | 'MULTIPLY'
2026-09-11 16:17:21 +08:00
calculatedOuterLengthCm: number | null
calculatedOuterWidthCm: number | null
calculatedOuterHeightCm: number | null
calculationRuleVersion: string | null
2026-08-04 21:01:39 +08:00
}
interface MeasurementFormModel {
measurementId: string | null
version: number
actualGoodsName: string
expectedPalletCount: number | null
2026-08-04 21:01:39 +08:00
items: EditableMeasurementItem[]
}
2026-08-12 14:08:33 +08:00
interface ManualCartonFormModel {
boxCode: string
length: number | null
width: number | null
height: number | null
weight: number | null
scannedShipmentId: string
machineNo: string
measuredAt: string
}
2026-08-04 21:01:39 +08:00
const access = useAccessPreviewStore()
const loading = ref(false)
const saving = ref(false)
const submitting = ref(false)
2026-08-12 14:08:33 +08:00
const manualEntryOpen = ref(false)
const manualEntrySubmitting = ref(false)
const manualEntryImage = ref<File | null>(null)
const manualEntryErrors = ref<string[]>([])
2026-08-04 21:01:39 +08:00
const detail = ref<InboundMeasurementDetail | null>(null)
const loadError = ref<ApiErrorDetails | null>(null)
const operationError = ref<ApiErrorDetails | null>(null)
const conflictDetected = ref(false)
2026-08-07 15:53:58 +08:00
const imagePreviewOpen = ref(false)
const imagePreviewLoading = ref(false)
const imagePreviewUrl = ref('')
const imagePreviewError = ref<ApiErrorDetails | null>(null)
2026-08-04 21:01:39 +08:00
const validationErrors = ref<Record<string, string[]>>({})
const baseline = ref('')
2026-09-11 16:17:21 +08:00
const confirmedWeightAvailable = ref(false)
2026-08-04 21:01:39 +08:00
let requestSequence = 0
let itemSequence = 0
const form = reactive<MeasurementFormModel>({
measurementId: null,
version: 0,
actualGoodsName: '',
expectedPalletCount: null,
2026-08-04 21:01:39 +08:00
items: [],
})
2026-08-12 14:08:33 +08:00
const manualForm = reactive<ManualCartonFormModel>({
boxCode: '',
length: null,
width: null,
height: null,
weight: null,
scannedShipmentId: '',
machineNo: 'MANUAL-ADMIN',
measuredAt: '',
})
2026-08-04 21:01:39 +08:00
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const canEdit = computed(() => access.can('platform:firstleg:inbound:measure:edit'))
2026-08-12 14:57:02 +08:00
const canManualEntry = computed(() => access.can('platform:firstleg:inbound:measure:manual-entry'))
2026-08-04 21:01:39 +08:00
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))
2026-09-09 20:53:30 +08:00
const countEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const submitEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
2026-08-07 17:14:42 +08:00
const mayMaintain = computed(() => canEdit.value && countEditableProcess.value && !loading.value && !submitting.value)
2026-09-09 20:53:30 +08:00
const manualEntryEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
2026-08-12 14:57:02 +08:00
const mayCreateManualEntry = computed(() => canManualEntry.value && manualEntryEditableProcess.value && !loading.value && !saving.value && !submitting.value && !isDirty.value)
2026-08-07 15:53:58 +08:00
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)
2026-09-11 16:17:21 +08:00
const hasConfirmedWeightResult = computed(() => confirmedWeightAvailable.value)
const isDropship = computed(() => detail.value?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
const expectedPalletCountValid = computed(() => !isDropship.value || (Number.isInteger(form.expectedPalletCount) && Number(form.expectedPalletCount) >= 1))
const maySubmit = computed(() => canSubmit.value && submitEditableProcess.value && Boolean(form.measurementId) && packageCountMatched.value && hasConfirmedWeightResult.value && expectedPalletCountValid.value && !loading.value && !saving.value)
2026-09-11 16:17:21 +08:00
const submissionBlockReason = computed(() => {
if (!canSubmit.value || !submitEditableProcess.value || !form.measurementId) return ''
if (!hasConfirmedWeightResult.value) return '即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核'
if (!packageCountMatched.value) return '确认箱数合计必须与订单总箱数一致后才能提交审核'
if (!expectedPalletCountValid.value) return '请先填写并保存有效的预计托数后再提交审核'
if (isDirty.value) return '请先保存当前修改,再提交审核'
return ''
})
2026-08-04 21:01:39 +08:00
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)
2026-08-12 09:25:55 +08:00
const sourceLabel = computed(() => ({ THIRD_PARTY: '第三方设备', MANUAL: '平台人工', OPENAPI: '第三方设备' }[detail.value?.sourceType || ''] || (detail.value?.sourceType ? '未知来源' : '尚未测量')))
2026-08-04 21:01:39 +08:00
2026-09-03 11:13:28 +08:00
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
})
2026-08-04 21:01:39 +08:00
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'p2-inbound-measurement-editor',
2026-08-30 17:43:03 +08:00
label: computed(() => `入仓码 ${detail.value?.inboundCode || '待补充'} 的测量草稿`),
2026-08-04 21:01:39 +08:00
isOpen: registrationOpen,
model: form,
requiredPermission,
})
function nextItemKey() {
itemSequence += 1
return `measurement-item-${itemSequence}`
}
2026-08-12 14:08:33 +08:00
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()
2026-08-17 11:23:31 +08:00
emit('updated')
2026-08-12 14:08:33 +08:00
} catch (error) {
2026-08-18 09:09:34 +08:00
setOperationError(error, '手动箱规录入失败,请检查后重试')
2026-08-12 14:08:33 +08:00
} finally {
manualEntrySubmitting.value = false
}
}
2026-08-04 21:01:39 +08:00
function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
return {
2026-08-07 17:14:42 +08:00
itemId: String(item.id),
2026-09-03 10:46:07 +08:00
inboundDetailId: String(item.inboundDetailId || item.id),
2026-08-04 21:01:39 +08:00
clientKey: nextItemKey(),
2026-09-08 17:38:09 +08:00
inboundCode: item.inboundCode || '—',
fbaShipmentNo: item.fbaShipmentNo || null,
scannedShipmentId: item.scannedShipmentId || null,
2026-08-07 15:53:58 +08:00
boxCode: item.boxCode,
2026-08-04 21:01:39 +08:00
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),
2026-08-07 15:53:58 +08:00
machineNo: item.machineNo,
2026-08-18 16:09:43 +08:00
measuredAt: item.deviceMeasuredAt,
2026-08-04 21:01:39 +08:00
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,
},
2026-09-03 10:46:07 +08:00
chargeRuleId: item.chargeRuleId == null ? null : String(item.chargeRuleId),
coefficientMode: item.coefficientMode === 'MULTIPLY' ? 'MULTIPLY' : item.coefficientMode === 'ADD' ? 'ADD' : 'ACTUAL',
2026-09-11 16:17:21 +08:00
calculatedOuterLengthCm: item.calculatedOuterLengthCm ?? null,
calculatedOuterWidthCm: item.calculatedOuterWidthCm ?? null,
calculatedOuterHeightCm: item.calculatedOuterHeightCm ?? null,
calculationRuleVersion: item.calculationRuleVersion ?? null,
2026-08-04 21:01:39 +08:00
}
}
function clearForm() {
2026-09-11 16:17:21 +08:00
confirmedWeightAvailable.value = false
2026-08-04 21:01:39 +08:00
form.measurementId = null
form.version = 0
form.actualGoodsName = ''
form.expectedPalletCount = null
2026-08-04 21:01:39 +08:00
form.items.splice(0)
baseline.value = JSON.stringify(form)
captureBaseline()
}
function applyDetail(resource: InboundMeasurementDetail) {
detail.value = resource
2026-09-11 16:17:21 +08:00
confirmedWeightAvailable.value = resource.weightResults.some((item) => item.recognitionStatus === 'CONFIRMED')
2026-08-04 21:01:39 +08:00
form.measurementId = resource.measurementId == null ? null : String(resource.measurementId)
form.version = resource.version ?? 0
form.actualGoodsName = resource.actualGoodsName || ''
form.expectedPalletCount = resource.expectedPalletCount ?? null
2026-08-04 21:01:39 +08:00
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 {
2026-09-15 09:58:15 +08:00
await ElMessageBox.confirm('加载最新版本会丢弃当前尚未保存的修改。确定继续吗?', '重新加载测量数据', { customClass: 'admin-density-confirm',
2026-08-04 21:01:39 +08:00
type: 'warning',
confirmButtonText: '加载最新版本',
cancelButtonText: '继续编辑',
})
} catch {
return
}
}
await loadMeasurement()
}
function validateDraft() {
const errors: Record<string, string[]> = {}
const general: string[] = []
2026-08-07 15:53:58 +08:00
if (!form.items.length) general.push('请至少保留一条实际扫描箱测量记录')
if (isDropship.value && (!Number.isInteger(form.expectedPalletCount) || Number(form.expectedPalletCount) < 1 || Number(form.expectedPalletCount) > 1000)) general.push('一件代发预计托数必须是 1 至 1,000 的整数')
else if (isDropship.value && Number(form.expectedPalletCount) > confirmedPackageCount.value) general.push('一件代发预计托数不能超过实测箱数')
2026-08-07 15:53:58 +08:00
if (form.items.length > 500) general.push('单个测量批次最多包含 500 条实际扫描箱记录')
2026-08-04 21:01:39 +08:00
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 的整数')
2026-08-25 17:43:00 +08:00
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 位小数`)
}
2026-09-03 10:46:07 +08:00
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 位小数')
})
}
2026-08-04 21:01:39 +08:00
if (itemErrors.length) errors[item.clientKey] = itemErrors.map((message) => `第 ${index + 1} 条:${message}`)
})
if (general.length) errors.general = general
validationErrors.value = errors
2026-08-18 09:09:34 +08:00
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,
})
}
2026-08-04 21:01:39 +08:00
return Object.keys(errors).length === 0
}
function setOperationError(error: unknown, fallback: string) {
2026-08-25 17:43:00 +08:00
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
2026-08-18 09:09:34 +08:00
const conflict = details.message.includes('版本已变化')
2026-08-14 17:38:06 +08:00
|| details.message.includes('并发修改')
2026-09-10 10:22:48 +08:00
|| details.message.includes('已被其他人修改')
2026-08-18 09:09:34 +08:00
conflictDetected.value = conflict
operationError.value = conflict ? details : null
if (!conflict) {
ElMessage.error({ message: details.message, showClose: true, duration: 4500 })
}
2026-08-04 21:01:39 +08:00
return details
}
async function saveDraft() {
if (!mayMaintain.value || !validateDraft()) return
saving.value = true
operationError.value = null
conflictDetected.value = false
try {
2026-08-07 17:14:42 +08:00
const originalCounts = new Map((detail.value?.items || []).map((item) => [String(item.id), item.packageCount]))
2026-09-03 10:46:07 +08:00
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 || (detail.value.expectedPalletCount ?? null) !== form.expectedPalletCount || form.items.some((item) => originalDraftValues.get(item.itemId) !== JSON.stringify({
2026-09-03 10:46:07 +08:00
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,
}))))
2026-08-07 17:14:42 +08:00
const changedItems = form.items.filter((item) => originalCounts.get(item.itemId) !== item.packageCount)
let version = form.version
2026-08-13 19:00:37 +08:00
if (draftChanged) {
const saved = await saveInboundMeasurementDraft(customerId.value, props.inboundId, {
measurementId: form.measurementId,
version,
actualGoodsName: form.actualGoodsName.trim() || null,
expectedPalletCount: isDropship.value ? form.expectedPalletCount : null,
2026-09-03 10:46:07 +08:00
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,
})),
2026-08-13 19:00:37 +08:00
})
version = saved.version
}
2026-09-10 10:22:48 +08:00
if (!draftChanged) {
for (const item of changedItems) {
const saved = await updateConfirmedPackageCount(customerId.value, props.inboundId, item.itemId, {
measurementVersion: version,
confirmedPackageCount: item.packageCount,
})
version = saved.measurementVersion
}
2026-08-07 17:14:42 +08:00
}
form.version = version
ElMessage.success('确认箱数已保存,计算结果已由服务端更新')
2026-08-04 21:01:39 +08:00
await loadMeasurement()
2026-08-17 17:49:10 +08:00
emit('saved')
2026-08-04 21:01:39 +08:00
emit('changed')
} catch (error) {
2026-08-18 09:09:34 +08:00
setOperationError(error, '测量草稿保存失败,请检查后重试')
2026-08-04 21:01:39 +08:00
} finally {
saving.value = false
}
}
async function submitForReview() {
2026-09-11 16:17:21 +08:00
if (!hasConfirmedWeightResult.value) {
ElMessage.warning('即使只有一箱,也必须上传并确认至少一张称重图片后才能提交审核')
return
}
2026-08-04 21:01:39 +08:00
if (!maySubmit.value || !form.measurementId) return
2026-08-25 17:43:00 +08:00
if (!validateDraft()) return
2026-08-04 21:01:39 +08:00
if (isDirty.value) {
ElMessage.warning('请先保存当前修改,再提交审核')
return
}
try {
await ElMessageBox.confirm(
2026-09-15 11:54:19 +08:00
'提交成功后本批次不能继续编辑',
2026-08-04 21:01:39 +08:00
'提交测量审核',
2026-09-15 09:58:15 +08:00
{ customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '取消' },
2026-08-04 21:01:39 +08:00
)
} 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()
2026-08-17 17:49:10 +08:00
emit('submitted')
2026-08-04 21:01:39 +08:00
emit('changed')
} catch (error) {
2026-08-18 09:09:34 +08:00
setOperationError(error, '测量数据提交失败,请检查图片和称重结果后重试')
2026-08-04 21:01:39 +08:00
} 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)
}
2026-09-03 11:13:28 +08:00
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)}%`
}
2026-08-18 16:09:43 +08:00
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}`
}
2026-08-07 15:53:58 +08:00
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
}
}
2026-08-04 21:01:39 +08:00
function processStatusLabel(status: string | undefined) {
2026-08-11 19:35:28 +08:00
return inboundStatusLabel(status, inboundProcessStatusLabels)
2026-08-04 21:01:39 +08:00
}
2026-09-11 16:17:21 +08:00
function updateWeightEvidence(
actualGrossWeightKg: number | null,
actualNetWeightKg: number | null,
hasConfirmedWeight: boolean,
) {
2026-08-14 14:50:44 +08:00
if (!detail.value) return
2026-08-18 11:33:01 +08:00
detail.value = { ...detail.value, actualGrossWeightKg, actualNetWeightKg }
2026-09-11 16:17:21 +08:00
confirmedWeightAvailable.value = hasConfirmedWeight
}
function calculationModeLabel(value: EditableMeasurementItem['coefficientMode']) {
return value === 'ADD' ? '箱规尺寸' : value === 'MULTIPLY' ? '箱规系数' : '实测原尺寸'
2026-08-14 14:50:44 +08:00
}
2026-08-13 17:14:51 +08:00
function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
return validationErrors.value[row.clientKey]?.length ? 'measurement-table__row--error' : ''
}
2026-08-04 21:01:39 +08:00
watch(
() => [customerId.value, props.inboundId, canView.value] as const,
() => {
requestSequence += 1
detail.value = null
2026-09-11 16:17:21 +08:00
confirmedWeightAvailable.value = false
2026-08-04 21:01:39 +08:00
loadError.value = null
operationError.value = null
conflictDetected.value = false
validationErrors.value = {}
clearForm()
if (hasContext.value && canView.value) void loadMeasurement()
},
{ immediate: true },
)
2026-08-07 15:53:58 +08:00
onBeforeUnmount(releaseImagePreview)
2026-08-12 16:31:46 +08:00
2026-09-11 16:17:21 +08:00
defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlockReason, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })
2026-08-04 21:01:39 +08:00
</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">
2026-09-09 20:53:30 +08:00
<el-alert
v-if="detail.processStatus === 'PREPARING'"
type="warning"
:closable="false"
show-icon
class="measurement-alert"
title="订单尚待客服审核,可先完成到仓称重和测量"
>
已采集数据会保留并在订单审核通过后继续使用;订单审核前不能审核测量、计费或进入分柜。
</el-alert>
2026-08-07 17:14:42 +08:00
<el-alert v-if="!countEditableProcess" type="info" :closable="false" show-icon>
2026-08-04 21:01:39 +08:00
<template #title>当前状态为“{{ processStatusLabel(detail.processStatus) }}”,测量数据仅可查看</template>
2026-08-07 17:14:42 +08:00
确认箱数只允许在待测量、测量中、待审核或审核驳回状态维护。
2026-08-04 21:01:39 +08:00
</el-alert>
<el-alert v-else-if="!canEdit" type="info" :closable="false" show-icon title="当前账号仅可查看测量数据" />
2026-09-11 16:17:21 +08:00
<el-alert
v-if="submissionBlockReason && !hasConfirmedWeightResult"
type="warning"
:closable="false"
show-icon
class="measurement-alert"
:title="submissionBlockReason"
/>
2026-08-04 21:01:39 +08:00
<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>
2026-08-18 09:09:34 +08:00
<p v-if="Object.keys(validationErrors).length" class="sr-only" role="alert">
{{ Object.values(validationErrors).flat().join(';') }}
</p>
2026-08-04 21:01:39 +08:00
<form class="measurement-form" @submit.prevent="saveDraft">
<fieldset :disabled="!mayMaintain || saving">
<legend class="sr-only">测量草稿</legend>
2026-08-14 19:59:32 +08:00
<section class="measurement-section measurement-summary-section">
<header class="measurement-summary-header">
2026-08-18 11:33:01 +08:00
<div class="measurement-summary-context">
<p><span>客户</span>{{ detail.companyName || '客户未命名' }}</p>
<p><span>渠道</span>{{ detail.channelName || detail.channelCode || '渠道未填写' }}</p>
2026-08-14 19:59:32 +08:00
</div>
<div class="measurement-summary-status">
<el-tag effect="light">{{ processStatusLabel(detail.processStatus) }}</el-tag>
<span>{{ sourceLabel }}</span>
</div>
</header>
2026-09-03 11:13:28 +08:00
<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>
2026-08-18 16:09:43 +08:00
<h4 class="measurement-subheading">平台实测</h4>
<dl class="measurement-metrics" aria-label="平台实测结果汇总">
2026-08-14 19:59:32 +08:00
<div :class="{ 'measurement-metric--warning': !packageCountMatched }">
<dt>实测箱数</dt>
2026-08-18 16:09:43 +08:00
<dd>{{ formatNumber(confirmedPackageCount, 0) }} <small v-if="formatDifference(confirmedPackageCount, expectedPackageCount, 0)">/ {{ formatDifference(confirmedPackageCount, expectedPackageCount, 0) }}</small></dd>
2026-08-14 19:59:32 +08:00
</div>
2026-08-18 16:09:43 +08:00
<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>
2026-08-14 19:59:32 +08:00
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>kg</small></dd></div>
</dl>
2026-08-17 18:32:41 +08:00
<section class="measurement-declaration" aria-labelledby="measurement-declaration-title">
2026-08-18 16:09:43 +08:00
<h4 id="measurement-declaration-title">客户申报</h4>
2026-08-17 18:32:41 +08:00
<div class="measurement-data-grid">
2026-08-18 11:33:01 +08:00
<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>
<label v-if="isDropship && mayMaintain"><span>预计托数</span><el-input-number v-model="form.expectedPalletCount" placeholder="请输入预计托数" :min="1" :max="1000" :step="1" step-strictly controls-position="right" /></label>
<div v-else-if="isDropship" class="measurement-readonly-field"><span>预计托数</span><strong>{{ detail.expectedPalletCount ?? '—' }}</strong></div>
2026-08-18 11:33:01 +08:00
<div class="measurement-readonly-field"><span>原单据号码</span><strong>{{ detail.originalDocumentNo || '—' }}</strong></div>
2026-08-17 18:32:41 +08:00
</div>
2026-09-11 16:17:21 +08:00
<el-table
v-if="detail.latestDeclaration?.items.length"
:data="detail.latestDeclaration.items"
border
stripe
size="small"
class="measurement-declaration-table"
aria-label="客户申报箱规明细"
>
<el-table-column prop="itemNo" label="#" width="70" />
<el-table-column prop="cargoName" label="货物品名" min-width="150" show-overflow-tooltip />
<el-table-column prop="packageCount" label="申报箱数" width="100" />
<el-table-column label="申报尺寸 长×宽×高(cm)" min-width="190">
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
</el-table-column>
</el-table>
2026-08-17 18:32:41 +08:00
</section>
2026-08-04 21:01:39 +08:00
</section>
<section class="measurement-section measurement-items-section">
<div class="measurement-section__heading">
2026-08-14 19:59:32 +08:00
<div><h3>箱规明细</h3></div>
2026-09-03 10:46:07 +08:00
<div class="measurement-section-actions">
<el-button v-if="canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
</div>
2026-08-04 21:01:39 +08:00
</div>
2026-08-13 17:14:51 +08:00
<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">
2026-09-11 16:17:21 +08:00
<div><dt>箱规</dt><dd>{{ row.boxSpecification || '—' }}</dd></div>
2026-08-13 17:14:51 +08:00
<div><dt>机器编号</dt><dd>{{ row.machineNo || '人工录入' }}</dd></div>
<div><dt>测量时间</dt><dd>{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}</dd></div>
2026-09-11 16:17:21 +08:00
<div><dt>计算方式</dt><dd>{{ calculationModeLabel(row.coefficientMode) }}</dd></div>
<div><dt>计算后外箱</dt><dd>{{ formatNumber(row.calculatedOuterLengthCm) }} × {{ formatNumber(row.calculatedOuterWidthCm) }} × {{ formatNumber(row.calculatedOuterHeightCm) }} cm</dd></div>
2026-08-14 19:59:32 +08:00
<div><dt>单箱体积</dt><dd>{{ formatNumber(row.serverValues.unitVolumeCbm, 4) }} m³</dd></div>
2026-08-13 17:14:51 +08:00
<div><dt>箱规周长</dt><dd>{{ formatNumber(row.serverValues.girthCm) }} cm</dd></div>
2026-08-14 19:59:32 +08:00
<div><dt>总材积重</dt><dd>{{ formatNumber(row.serverValues.volumeWeightKg) }} kg</dd></div>
2026-09-11 16:17:21 +08:00
<div><dt>计算规则版本</dt><dd>{{ row.calculationRuleVersion || '—' }}</dd></div>
<div><dt>超限提醒</dt><dd>{{ row.overLimitWarning || '—' }}</dd></div>
2026-08-13 17:14:51 +08:00
<div><dt>备注</dt><dd>{{ row.remark || '—' }}</dd></div>
</dl>
</template>
</el-table-column>
2026-09-03 10:46:07 +08:00
<el-table-column prop="itemNo" label="#" width="70" align="center" />
2026-09-08 17:38:09 +08:00
<el-table-column label="入仓码" min-width="154" show-overflow-tooltip>
<template #default="{ row }">{{ row.inboundCode || '—' }}</template>
</el-table-column>
<el-table-column label="FBA 货件号" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.fbaShipmentNo || '—' }}</template>
</el-table-column>
<el-table-column label="设备实扫货件号" min-width="164" show-overflow-tooltip>
<template #default="{ row }">{{ row.scannedShipmentId || '—' }}</template>
</el-table-column>
2026-08-13 17:14:51 +08:00
<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>
2026-09-03 10:46:07 +08:00
<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>
2026-08-13 17:14:51 +08:00
<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
2026-09-03 19:29:38 +08:00
controls-position="right" placeholder="请输入箱数" />
2026-08-13 17:14:51 +08:00
</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="暂无扫描箱测量数据" />
2026-08-04 21:01:39 +08:00
</section>
</fieldset>
</form>
<footer v-if="canEdit || canSubmit" class="measurement-panel__footer">
2026-09-10 17:13:08 +08:00
<span v-if="!hasConfirmedWeightResult"><AlertTriangle :size="15" />至少上传并确认一张称重图片后才能提交审核</span>
<span v-else-if="isDirty"><AlertTriangle :size="15" />有尚未保存的修改</span>
2026-08-14 19:59:32 +08:00
<span v-else>所有修改已保存</span>
2026-08-04 21:01:39 +08:00
<div>
2026-08-13 19:00:37 +08:00
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft"><Save :size="15" />保存测量数据</el-button>
2026-08-04 21:01:39 +08:00
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="!maySubmit" @click="submitForReview"><Send :size="15" />提交审核</el-button>
</div>
</footer>
2026-08-07 15:53:58 +08:00
2026-09-14 15:33:35 +08:00
<el-dialog class="admin-density-dialog" v-model="imagePreviewOpen" title="当前有效测量图片" width="min(720px, 92vw)" destroy-on-close @closed="releaseImagePreview">
2026-08-07 15:53:58 +08:00
<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>
2026-08-12 14:08:33 +08:00
2026-09-14 15:33:35 +08:00
<el-dialog class="admin-density-dialog" v-model="manualEntryOpen" title="手动录入箱规" width="min(680px, 92vw)" destroy-on-close>
2026-08-12 14:08:33 +08:00
<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>
2026-09-03 19:29:38 +08:00
<el-form-item label="测量长(cm) *"><el-input-number v-model="manualForm.length" :min="0.001" :precision="3" controls-position="right" placeholder="请输入测量长" /></el-form-item>
<el-form-item label="测量宽(cm) *"><el-input-number v-model="manualForm.width" :min="0.001" :precision="3" controls-position="right" placeholder="请输入测量宽" /></el-form-item>
<el-form-item label="测量高(cm) *"><el-input-number v-model="manualForm.height" :min="0.001" :precision="3" controls-position="right" placeholder="请输入测量高" /></el-form-item>
<el-form-item label="单箱毛重(kg) *"><el-input-number v-model="manualForm.weight" :min="0.001" :precision="3" controls-position="right" placeholder="请输入单箱毛重" /></el-form-item>
2026-08-12 14:08:33 +08:00
<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>
2026-09-03 19:29:38 +08:00
<el-form-item label="设备/来源编号"><el-input v-model="manualForm.machineNo" maxlength="64" placeholder="请输入设备编号(选填)" /></el-form-item>
<el-form-item label="Shipment ID"><el-input v-model="manualForm.scannedShipmentId" maxlength="64" placeholder="请输入扫描运单号(选填)" /></el-form-item>
2026-08-12 14:08:33 +08:00
</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>
2026-08-04 21:01:39 +08:00
</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); }
2026-09-14 17:54:21 +08:00
.measurement-panel h2 { font-size: var(--admin-font-title, 16px); }
.measurement-panel h3 { font-size: var(--admin-font-section, 14px); }
.measurement-panel__heading p, .measurement-section__heading p { margin: var(--admin-space-1, 4px) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 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: var(--admin-space-3, 8px); }
.measurement-declaration-table { margin-top: var(--admin-space-5, 12px); }
2026-08-04 21:01:39 +08:00
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button) { margin-left: 0; }
2026-09-14 17:54:21 +08:00
.measurement-skeleton { padding: var(--admin-space-7, 24px); }
.measurement-panel > :deep(.el-alert), .measurement-alert { margin: var(--admin-space-6, 16px) 20px 0; width: auto; }
2026-08-04 21:01:39 +08:00
.measurement-alert p { margin: 5px 0 0; line-height: 1.55; }
2026-09-14 17:54:21 +08:00
.measurement-alert ul { margin: 7px 0 0; padding-left: 18px; font-size: var(--admin-font-caption, 12px); line-height: 1.65; }
2026-08-04 21:01:39 +08:00
.measurement-alert .el-button { margin-top: 9px; }
.measurement-form fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
2026-09-14 17:54:21 +08:00
.measurement-section { padding: var(--admin-space-6, 16px) 0px; border-top: 1px solid var(--cc-border); }
.measurement-summary-section { display: grid; gap: var(--admin-space-5, 12px); border-top: 0; }
2026-08-14 19:59:32 +08:00
.measurement-summary-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
2026-09-14 17:54:21 +08:00
.measurement-summary-context { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: var(--admin-space-3, 8px) 20px; }
.measurement-summary-header span { color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); }
2026-09-03 11:13:28 +08:00
.measurement-deviation-alert { margin: 0; width: auto; }
.measurement-deviation-alert p { margin: 3px 0 0; line-height: 1.55; }
2026-09-14 17:54:21 +08:00
.measurement-summary-context p { display: inline-flex; min-width: 0; align-items: baseline; gap: var(--admin-space-2, 6px); margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: var(--admin-font-caption, 12px); font-weight: 600; }
2026-08-14 19:59:32 +08:00
.measurement-summary-status { display: flex; flex: 0 0 auto; align-items: center; gap: 9px; }
2026-08-17 18:32:41 +08:00
.measurement-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 2px 0; }
2026-09-14 17:54:21 +08:00
.measurement-subheading { margin: 0; color: #344054; font-size: var(--admin-font-caption, 12px); font-weight: 700; }
.measurement-metrics > div { display: grid; min-width: 0; min-height: 58px; align-content: center; gap: var(--admin-space-1, 4px); padding: 5px 18px; border-left: 1px solid var(--cc-border); }
2026-08-17 18:32:41 +08:00
.measurement-metrics > div:first-child { padding-left: 0; border-left: 0; }
2026-09-14 17:54:21 +08:00
.measurement-metrics dt { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
2026-08-17 18:32:41 +08:00
.measurement-metrics dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 18px; font-weight: 700; overflow-wrap: anywhere; }
2026-09-14 17:54:21 +08:00
.measurement-metrics small { color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); font-weight: 500; }
2026-08-17 18:32:41 +08:00
.measurement-metric--primary dd { color: var(--cc-primary); font-size: 20px; }
2026-08-14 19:59:32 +08:00
.measurement-metric--warning dd { color: var(--el-color-warning-dark-2); }
2026-09-14 17:54:21 +08:00
.measurement-declaration { padding-top: var(--admin-space-5, 12px); border-top: 1px solid var(--cc-border); }
.measurement-declaration h4 { margin: 0 0 var(--admin-space-4, 10px); color: #344054; font-size: var(--admin-font-caption, 12px); font-weight: 700; }
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: var(--admin-space-6, 16px); }
2026-08-04 21:01:39 +08:00
.measurement-section__heading > div { min-width: 0; }
2026-09-14 17:54:21 +08:00
.measurement-section-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--admin-space-3, 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 var(--admin-space-6, 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: var(--admin-space-1, 4px) 0 0; border: 1px solid var(--cc-border); border-radius: 6px; }.suggestion-preview div { padding: var(--admin-space-5, 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: var(--admin-font-caption, 11px); }.suggestion-preview dd { margin: 5px 0 0; color: var(--cc-text); font-size: var(--admin-font-body, 13px); font-weight: 650; }
.measurement-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6, 16px); }
.measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--admin-space-5, 12px); }
.measurement-form label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #344054; font-size: var(--admin-font-caption, 12px); font-weight: 650; }
2026-08-04 21:01:39 +08:00
.measurement-form :deep(.el-input-number), .measurement-form :deep(.el-input) { width: 100%; }
2026-09-14 17:54:21 +08:00
.measurement-data-grid label { display: flex; min-width: 0; flex-direction: column; gap: 5px; color: #344054; font-size: var(--admin-font-caption, 12px); font-weight: 600; }
2026-08-18 11:33:01 +08:00
.measurement-data-grid :deep(.el-input__wrapper) { border-radius: 3px; }
.measurement-readonly-field { display: grid; min-width: 0; align-content: start; gap: 7px; }
2026-09-14 17:54:21 +08:00
.measurement-readonly-field span { color: #344054; font-size: var(--admin-font-caption, 12px); font-weight: 600; }
.measurement-readonly-field strong { min-height: var(--admin-control-height, 32px); padding: 7px 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: var(--admin-font-body, 13px); line-height: 18px; }
2026-08-07 16:17:45 +08:00
.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; }
2026-09-14 17:54:21 +08:00
.manual-entry-alert { margin-bottom: var(--admin-space-6, 16px); }
.manual-entry-alert ul { margin: var(--admin-space-2, 6px) 0 0; padding-left: 18px; }
2026-08-12 14:08:33 +08:00
.manual-entry-form :deep(.el-input-number), .manual-entry-form :deep(.el-date-editor) { width: 100%; }
.manual-entry-upload { width: 100%; }
2026-09-14 17:54:21 +08:00
.measurement-items-section { background: var(--admin-surface, #fff); }
.measurement-table { width: 100%; font-size: var(--admin-font-caption, 12px); }
.measurement-table :deep(.el-table__header th) { padding: var(--admin-space-3, 8px) 0; background: #f6f8fa; color: #475467; font-size: var(--admin-font-caption, 11px); font-weight: 700; }
2026-08-13 17:14:51 +08:00
.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); }
2026-09-14 17:54:21 +08:00
.warehouse-suggestion-table { display: grid; gap: var(--admin-space-4, 10px); margin-top: 18px; }.warehouse-suggestion-table h3 { margin: 0; color: var(--cc-text); font-size: var(--admin-font-section, 14px); }
2026-08-13 17:14:51 +08:00
.measurement-box-code { font-weight: 650; }
.measurement-count-editor { width: 104px !important; }
2026-09-14 17:54:21 +08:00
.measurement-row-details { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-3, 8px) 20px; margin: 0; padding: var(--admin-space-1, 4px) 18px var(--admin-space-3, 8px) 62px; }
.measurement-row-details dt { color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); }
.measurement-row-details dd { margin: 3px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: var(--admin-font-caption, 12px); font-weight: 600; }
.measurement-panel__footer { position: sticky; bottom: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6, 16px); padding: var(--admin-space-5, 12px) 20px; border-top: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }
.measurement-panel__footer > span { display: inline-flex; align-items: center; gap: var(--admin-space-2, 6px); color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
2026-08-04 21:01:39 +08:00
.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) {
2026-08-13 17:14:51 +08:00
.measurement-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2026-08-17 18:32:41 +08:00
.measurement-data-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
2026-08-13 17:43:04 +08:00
.measurement-row-details { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2026-08-04 21:01:39 +08:00
.measurement-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2026-08-17 18:32:41 +08:00
.measurement-metrics > div:nth-child(3) { padding-left: 0; border-left: 0; }
2026-08-04 21:01:39 +08:00
}
@media (max-width: 720px) {
2026-08-14 19:59:32 +08:00
.measurement-panel__heading, .measurement-summary-header, .measurement-section__heading, .measurement-panel__footer { align-items: stretch; flex-direction: column; }
2026-08-04 21:01:39 +08:00
.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; }
2026-08-14 19:59:32 +08:00
.measurement-section { padding-right: 14px; padding-left: 14px; }
2026-08-04 21:01:39 +08:00
.measurement-panel > :deep(.el-alert), .measurement-alert { margin-right: 14px; margin-left: 14px; }
2026-08-13 17:43:04 +08:00
.measurement-form-grid, .measurement-data-grid { grid-template-columns: 1fr; }
2026-08-13 17:14:51 +08:00
.measurement-row-details { grid-template-columns: 1fr; padding-left: 54px; }
2026-08-04 21:01:39 +08:00
}
@media (max-width: 420px) {
}
</style>