770 lines
38 KiB
Vue
770 lines
38 KiB
Vue
<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>
|