优化
This commit is contained in:
@@ -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.lengthCm" :min="0.001" :precision="3" :controls="false" /></label>
|
||||
<label><span>箱规宽(cm)*</span><el-input-number v-model="item.widthCm" :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.widthCm" :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>每箱毛重(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>
|
||||
|
||||
@@ -11,6 +11,10 @@ function measurementPath(inboundId: string) {
|
||||
return `/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/measurement`
|
||||
}
|
||||
|
||||
function segment(value: string) {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
export async function getInboundMeasurement(customerId: string, inboundId: string) {
|
||||
const response = await http.get<ApiEnvelope<InboundMeasurementDetail>, ApiEnvelope<InboundMeasurementDetail>>(
|
||||
measurementPath(inboundId),
|
||||
@@ -44,3 +48,10 @@ export async function submitInboundMeasurement(
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export function downloadInboundMeasurementImage(customerId: string, inboundId: string, fileId: string) {
|
||||
return http.get<Blob, Blob>(
|
||||
`/platform/v1/files/measurements/${segment(customerId)}/${segment(inboundId)}/${segment(fileId)}`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type InboundMeasurementProcessStatus =
|
||||
|
||||
export interface InboundMeasurementItem {
|
||||
id: MeasurementIdentifier
|
||||
boxCode: string | null
|
||||
itemNo: string
|
||||
boxSpecification: string
|
||||
packageCount: number
|
||||
@@ -26,6 +27,8 @@ export interface InboundMeasurementItem {
|
||||
totalVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
intelligentImageFileId: MeasurementIdentifier | null
|
||||
machineNo: string | null
|
||||
measuredAt: string | null
|
||||
overLimitWarning: string | null
|
||||
outerLengthCm: number | null
|
||||
outerWidthCm: number | null
|
||||
@@ -72,6 +75,7 @@ export interface InboundMeasurementDetail {
|
||||
originalDocumentNo: string | null
|
||||
originalGrossWeightKg: number | null
|
||||
originalVolumeCbm: number | null
|
||||
originalPackageCount: number | null
|
||||
actualPackageCount: number | null
|
||||
actualGrossWeightKg: number | null
|
||||
actualVolumeCbm: number | null
|
||||
@@ -82,6 +86,7 @@ export interface InboundMeasurementDetail {
|
||||
}
|
||||
|
||||
export interface SaveInboundMeasurementItemRequest {
|
||||
boxCode: string | null
|
||||
itemNo: string
|
||||
boxSpecification: string
|
||||
packageCount: number
|
||||
@@ -90,6 +95,8 @@ export interface SaveInboundMeasurementItemRequest {
|
||||
heightCm: number
|
||||
unitWeightKg: number
|
||||
intelligentImageFileId: MeasurementIdentifier | null
|
||||
machineNo: string | null
|
||||
measuredAt: string | null
|
||||
overLimitWarning: string | null
|
||||
outerLengthCm: number | null
|
||||
outerWidthCm: number | null
|
||||
@@ -128,4 +135,3 @@ export interface InboundMeasurementSubmitted {
|
||||
feeNo: string | null
|
||||
feeStatus: string | null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user