@@ -1,14 +1,14 @@
< script setup lang = "ts" >
import { computed , reactive , ref , watch } from 'vue'
import { computed , onBeforeUnmount , reactive , ref , watch } from 'vue'
import { ElMessage , ElMessageBox } from 'element-plus'
import { AlertTriangle , Plus , RefreshCw , Ruler , Save , Send , Trash2 } from '@lucide/vue'
import { AlertTriangle , Image , Plus , RefreshCw , Ruler , Save , Send , Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import { getInboundMeasurement , saveInboundMeasurementDraft , submitInboundMeasurement } from './api'
import { downloadInboundMeasurementImage , getInboundMeasurement , saveInboundMeasurementDraft , submitInboundMeasurement } from './api'
import type {
InboundMeasurementDetail ,
InboundMeasurementItem ,
@@ -49,6 +49,10 @@ 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
@@ -71,7 +75,11 @@ const customerId = computed(() => props.customerId || props.tenantId || '')
const hasContext = computed ( ( ) => Boolean ( customerId . value && props . inboundId ) )
const editableProcess = computed ( ( ) => [ 'WAIT_MEASURE' , 'MEASURING' , 'MEASURE_REJECTED' ] . includes ( detail . value ? . processStatus || '' ) )
const mayMaintain = computed ( ( ) => canEdit . value && editableProcess . value && ! loading . value && ! submitting . value )
const maySubmit = computed ( ( ) => canSubmit . value && editableProcess . value && Boolean ( form . measurementId ) && ! loading . value && ! saving . value )
const maySubmit = computed ( ( ) => canSubmit . value && editableProcess . value && Boolean ( form . measurementId ) && packageCountMatched . value && ! loading . value && ! saving . 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 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 ) )
@@ -100,6 +108,7 @@ function nextItemKey() {
function emptyItem ( ) : EditableMeasurementItem {
return {
clientKey : nextItemKey ( ) ,
boxCode : null ,
itemNo : '' ,
boxSpecification : '' ,
packageCount : 1 ,
@@ -108,6 +117,8 @@ function emptyItem(): EditableMeasurementItem {
heightCm : 0 ,
unitWeightKg : 0 ,
intelligentImageFileId : null ,
machineNo : null ,
measuredAt : null ,
overLimitWarning : null ,
outerLengthCm : null ,
outerWidthCm : null ,
@@ -120,6 +131,7 @@ function emptyItem(): EditableMeasurementItem {
function editableItem ( item : InboundMeasurementItem ) : EditableMeasurementItem {
return {
clientKey : nextItemKey ( ) ,
boxCode : item . boxCode ,
itemNo : item . itemNo ,
boxSpecification : item . boxSpecification ,
packageCount : item . packageCount ,
@@ -128,6 +140,8 @@ function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
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 ,
@@ -233,15 +247,16 @@ function isNonNegative(value: number | null) {
function validateDraft ( ) {
const errors : Record < string , string [ ] > = { }
const general : string [ ] = [ ]
if ( ! form . items . length ) general . push ( '请至少添加一条箱规 ' )
if ( form . items . length > 500 ) general . push ( '单个测量批次最多包含 500 条箱规 ' )
if ( ! form . items . length ) general . push ( '请至少保留一条实际扫描箱测量记录 ' )
if ( form . items . length > 500 ) general . push ( '单个测量批次最多包含 500 条实际扫描箱记录 ' )
if ( form . originalDocumentNo . trim ( ) . length > 64 ) general . push ( '原始单据号不能超过 64 个字符' )
if ( form . actualGoodsName . trim ( ) . length > 255 ) general . push ( '实际品名不能超过 255 个字符' )
if ( ! isNonNegative ( form . originalGrossWeightKg ) ) general . push ( '申报毛重不能小于 0' )
if ( ! isNonNegative ( form . originalVolumeCbm ) ) general . push ( '申报体积不能小于 0' )
form . items . forEach ( ( item , index ) => {
const itemErrors : string [ ] = [ ]
if ( ! item . itemNo . trim ( ) ) itemErrors . push ( '请输入箱规序号 ')
if ( item . boxCode && item . boxCode . length > 128 ) itemErrors . push ( '箱号不能超过 128 个字符 ')
if ( ! item . itemNo . trim ( ) ) itemErrors . push ( '请输入测量记录序号' )
else if ( item . itemNo . trim ( ) . length > 32 ) itemErrors . push ( '箱规序号不能超过 32 个字符' )
if ( ! item . boxSpecification . trim ( ) ) itemErrors . push ( '请输入箱规描述' )
else if ( item . boxSpecification . trim ( ) . length > 64 ) itemErrors . push ( '箱规描述不能超过 64 个字符' )
@@ -267,6 +282,7 @@ function buildPayload(): SaveInboundMeasurementDraftRequest {
originalVolumeCbm : form . originalVolumeCbm ,
actualGoodsName : optionalText ( form . actualGoodsName ) ,
items : form . items . map ( ( item ) => ( {
boxCode : optionalText ( item . boxCode || '' ) ,
itemNo : item . itemNo . trim ( ) ,
boxSpecification : item . boxSpecification . trim ( ) ,
packageCount : item . packageCount ,
@@ -275,6 +291,8 @@ function buildPayload(): SaveInboundMeasurementDraftRequest {
heightCm : item . heightCm ,
unitWeightKg : item . unitWeightKg ,
intelligentImageFileId : item . intelligentImageFileId || null ,
machineNo : optionalText ( item . machineNo || '' ) ,
measuredAt : item . measuredAt || null ,
overLimitWarning : optionalText ( item . overLimitWarning || '' ) ,
outerLengthCm : item . outerLengthCm ,
outerWidthCm : item . outerWidthCm ,
@@ -354,6 +372,27 @@ function formatNumber(value: number | null | undefined, maximumFractionDigits =
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 ( {
WAIT _MEASURE : '待测量' , MEASURING : '测量中' , MEASURE _PENDING : '待审核' , MEASURE _REJECTED : '审核驳回' ,
@@ -375,6 +414,8 @@ watch(
} ,
{ immediate : true } ,
)
onBeforeUnmount ( releaseImagePreview )
< / script >
< template >
@@ -437,7 +478,8 @@ watch(
< / div >
< / div >
< dl class = "measurement-metrics" >
< div > < dt > 实测 总箱数< / dt > < dd > { { formatNumber ( detail . actualPackageCount , 0 ) } } < / dd > < / div >
< div > < dt > 订单 总箱数< / dt > < dd > { { formatNumber ( expectedPackageCount , 0 ) } } < / dd > < / div >
< div > < dt > 确认箱数合计 < / dt > < dd > { { formatNumber ( confirmedPackageCount , 0 ) } } < / dd > < / div >
< div > < dt > 实测总毛重 < / dt > < dd > { { formatNumber ( detail . actualGrossWeightKg ) } } < small > T . K . GW < / small > < / dd > < / div >
< div > < dt > 实测总体积 < / dt > < dd > { { formatNumber ( detail . actualVolumeCbm , 4 ) } } < small > T . CBM < / small > < / dd > < / div >
< div > < dt > 实测总材积重 < / dt > < dd > { { formatNumber ( detail . volumeWeightKg ) } } < small > KG < / small > < / dd > < / div >
@@ -470,6 +512,11 @@ watch(
< / ul >
< / el -alert >
< el-alert v-if = "expectedPackageCount !== null && !packageCountMatched" type="warning" :closable="false" show-icon class="measurement-alert" >
< template # title > 确认箱数尚未与订单总箱数匹配 < / template >
当前已确认 { { confirmedPackageCount } } 箱 , 订单总箱数 { { expectedPackageCount } } 箱 , 还差 { { Math . abs ( packageCountDifference || 0 ) } } 箱 。
< / el-alert >
< form class = "measurement-form" @submit.prevent ="saveDraft" >
< fieldset : disabled = "!mayMaintain || saving" >
< legend class = "sr-only" > 测量草稿 < / legend >
@@ -497,18 +544,19 @@ watch(
< article v-for = "(item, index) in form.items" :key="item.clientKey" class="measurement-item" :class="{ 'has-error': validationErrors[item.clientKey]?.length }" >
< header >
< div > < strong > 箱规 { { index + 1 } } < / strong > < span > { { item . itemNo || '待填写序 号' } } · { { item . boxSpecification || '待填写箱规 ' } } < / span > < / div >
< div > < strong > 扫描 箱 { { index + 1 } } < / strong > < span > { { item . boxCode || '未关联箱 号' } } · { { item . machineNo || '人工录入 ' } } < / span > < / div >
< el-button v-if = "canEdit" link type="danger" :disabled="!mayMaintain || form.items.length === 1" @click="removeItem(index)"><Trash2 :size="15" / > 删除 < / el-button >
< / header >
< div class = "measurement-item__grid" >
< label > < span > 明细编号 * < / span > < el-input v-model = "item.itemNo" maxlength="32" placeholder="例如 001" / > < / label >
< label > < span > 箱规 * < / span > < el-input v-model = "item.boxSpecification" maxlength="64" placeholder="例如 50× 40× 30cm " / > < / label >
< label > < span > 箱规长 ( cm ) * < / span > < el-input-number v-model = "item.leng thCm" :min="0.001" :precision="3" :controls="false" / > < / label >
< label > < span > 箱规宽 ( cm ) * < / span > < el-input-number v-model = "item.width Cm" :min="0.001" :precision="3" :controls="false" / > < / label >
< label > < span > 箱规高 ( cm ) * < / span > < el-input-number v-model = "item.heightCm" :min="0. 001 " :precision="3" :controls="false " / > < / label >
< label > < span > 箱数 * < / span > < el-input-number v-model = "item.packageCount" :min="1" :max="1000000" :step="1" step-strictly controls-position="right" / > < / label >
< label > < span > 箱号 < / span > < el-input : model -value = " item.boxCode | | ' — ' " readonly / > < / label >
< label > < span > 测量长 ( cm ) * < / span > < el-input-number v-model = "item.lengthCm" :min="0.001" :precision="3" :controls="false " / > < / label >
< label > < span > 测量宽 ( cm ) * < / span > < el-input-number v-model = "item.wid thCm" :min="0.001" :precision="3" :controls="false" / > < / label >
< label > < span > 测量高 ( cm ) * < / span > < el-input-number v-model = "item.height Cm" :min="0.001" :precision="3" :controls="false" / > < / label >
< label > < span > 确认箱数 * < / span > < el-input-number v-model = "item.packageCount" :min="1" :max="1000 000 " :step="1" step-strictly controls-position="right " / > < / label >
< label > < span > 每箱毛重 ( kg ) * < / span > < el-input-number v-model = "item.unitWeightKg" :min="0.001" :precision="3" :controls="false" / > < / label >
< label > < span > 智能图片文件 ID < / span > < el-input v-model = "item.intelligentImageFileId" inputmode="numeric" placeholder="文件服务返回的 ID" / > < / label >
< label > < span > 机器编号 < / span > < el-input : model -value = " item.machineNo | | ' — ' " readonly / > < / label >
< label > < span > 测量时间 < / span > < el-input : model -value = " item.measuredAt ? item.measuredAt.replace ( ' T ' , ' ' ) : ' — ' " readonly / > < / label >
< label class = "measurement-image-field" > < span > 当前有效图片 < / span > < el-button :disabled = "!item.intelligentImageFileId" @click ="previewImage(item.intelligentImageFileId)" > < Image :size = "15" / > { { item . intelligentImageFileId ? '预览图片' : '暂无图片' } } < / el-button > < / label >
< / div >
< details class = "measurement-item__advanced" >
< summary > 外包装尺寸与补充说明 < / summary >
@@ -540,6 +588,17 @@ watch(
< 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 >
< / template >
< / section >
< / template >