Files
admin-web/src/modules/inbounds/review/MeasurementReviewPanel.vue
T

352 lines
19 KiB
Vue
Raw Normal View History

2026-08-04 21:01:39 +08:00
<script setup lang="ts">
2026-08-18 16:09:43 +08:00
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
2026-08-04 21:01:39 +08:00
import { ElMessage, ElMessageBox } from 'element-plus'
2026-08-18 11:33:01 +08:00
import { Check, Pencil, RotateCcw } from '@lucide/vue'
2026-08-04 21:01:39 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
2026-08-18 16:09:43 +08:00
import { downloadInboundMeasurementImage } from '../measurement/api'
import MeasurementEvidenceThumbnail from '../measurement/components/MeasurementEvidenceThumbnail.vue'
2026-08-14 19:59:32 +08:00
import { inboundReviewStatusLabels, inboundStatusLabel } from '../status'
2026-08-18 11:33:01 +08:00
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
2026-08-18 16:09:43 +08:00
import { approveMeasurement, getMeasurementReviewDetail } from './api'
import type { MeasurementReviewDetail } from './types'
2026-08-04 21:01:39 +08:00
const props = defineProps<{
customerId?: string
/** @deprecated 仅用于兼容尚未完成 customerId 映射的上层集成。 */
tenantId?: string
inboundId: string
2026-08-18 11:41:14 +08:00
customerName?: string | null
channelName?: string | null
channelCode?: string | null
2026-08-04 21:01:39 +08:00
}>()
2026-08-18 11:33:01 +08:00
const emit = defineEmits<{ changed: [] }>()
2026-08-04 21:01:39 +08:00
const access = useAccessPreviewStore()
const detail = ref<MeasurementReviewDetail | null>(null)
const loading = ref(false)
const actionLoading = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
2026-08-18 11:33:01 +08:00
const correctionMode = ref(false)
2026-08-04 21:01:39 +08:00
const requestSerial = ref(0)
2026-08-18 16:09:43 +08:00
const imagePreviewOpen = ref(false)
const imagePreviewTitle = ref('测量图片')
const imagePreviewUrl = ref('')
const imagePreviewLoading = ref(false)
const imagePreviewError = ref<ApiErrorDetails | null>(null)
let imagePreviewRequestSerial = 0
2026-08-04 21:01:39 +08:00
const canReview = computed(() => access.can('firstleg:inbound:measurement-review'))
const customerId = computed(() => props.customerId || props.tenantId || '')
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
2026-08-11 19:35:28 +08:00
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
2026-08-18 11:41:14 +08:00
const displayCustomerName = computed(() => detail.value?.companyName?.trim() || props.customerName?.trim() || '客户未命名')
const displayChannelName = computed(() => detail.value?.channelName?.trim() || detail.value?.channelCode?.trim() || props.channelName?.trim() || props.channelCode?.trim() || '渠道未填写')
2026-08-18 16:09:43 +08:00
const sourceLabels: Record<string, string> = {
THIRD_PARTY: '第三方设备',
DEVICE: '第三方设备',
OPENAPI: '第三方设备',
MANUAL: '平台人工',
}
const sourceLabel = computed(() => sourceLabels[detail.value?.sourceType || ''] || (detail.value?.sourceType ? '未知来源' : '尚未记录'))
function formatNumber(value: number | null | undefined, maximumFractionDigits = 3) {
if (value === null || value === undefined || !Number.isFinite(value)) return '—'
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits, minimumFractionDigits: 0 }).format(value)
}
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-04 21:01:39 +08:00
function formatDate(value: string | null | undefined) {
2026-08-18 11:33:01 +08:00
return value ? value.replace('T', ' ') : '—'
2026-08-04 21:01:39 +08:00
}
2026-08-18 16:09:43 +08:00
function releaseImagePreview() {
imagePreviewRequestSerial += 1
if (imagePreviewUrl.value) URL.revokeObjectURL(imagePreviewUrl.value)
imagePreviewUrl.value = ''
imagePreviewLoading.value = false
imagePreviewError.value = null
}
async function openImagePreview(fileId: string | null, itemLabel: string) {
if (!fileId) return
releaseImagePreview()
const serial = ++imagePreviewRequestSerial
imagePreviewTitle.value = `测量图片 · ${itemLabel}`
imagePreviewOpen.value = true
imagePreviewLoading.value = true
try {
const blob = await downloadInboundMeasurementImage(customerId.value, props.inboundId, fileId)
if (serial !== imagePreviewRequestSerial) return
imagePreviewUrl.value = URL.createObjectURL(blob)
} catch (error) {
if (serial !== imagePreviewRequestSerial) return
imagePreviewError.value = getApiErrorDetails(error, '测量原图读取失败,请稍后重试')
} finally {
if (serial === imagePreviewRequestSerial) imagePreviewLoading.value = false
}
2026-08-11 19:35:28 +08:00
}
2026-08-04 21:01:39 +08:00
function isConflict(error: unknown) {
const details = getApiErrorDetails(error)
2026-08-14 17:38:06 +08:00
return details.message.includes('版本已变化') || details.message.includes('并发修改')
2026-08-04 21:01:39 +08:00
}
async function loadReview() {
const serial = ++requestSerial.value
loadError.value = null
loading.value = true
try {
const result = await getMeasurementReviewDetail(customerId.value, props.inboundId)
if (serial === requestSerial.value) detail.value = result
} catch (error) {
if (serial === requestSerial.value) {
detail.value = null
loadError.value = getApiErrorDetails(error, '测量审核数据加载失败')
}
} finally {
if (serial === requestSerial.value) loading.value = false
}
}
async function reload() {
2026-08-18 16:09:43 +08:00
await loadReview()
2026-08-04 21:01:39 +08:00
}
2026-08-18 11:33:01 +08:00
async function handleWeightChanged() {
await loadReview()
}
2026-08-04 21:01:39 +08:00
async function confirmApprove() {
if (!detail.value || !isPending.value || !canReview.value) return
try {
await ElMessageBox.confirm(
2026-08-14 14:27:03 +08:00
'审核通过后,入库单将进入待分柜。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?',
2026-08-04 21:01:39 +08:00
'确认测量审核通过',
2026-08-14 14:27:03 +08:00
{ confirmButtonText: '通过并进入待分柜', cancelButtonText: '返回', type: 'warning' },
2026-08-04 21:01:39 +08:00
)
} catch {
return
}
actionLoading.value = 'approve'
try {
2026-08-14 14:27:03 +08:00
const result = await approveMeasurement(customerId.value, props.inboundId, {
2026-08-04 21:01:39 +08:00
measurementId: detail.value.measurementId,
2026-08-13 20:00:38 +08:00
expectedVersion: detail.value.inboundVersion,
2026-08-04 21:01:39 +08:00
})
2026-08-18 11:33:01 +08:00
if (result.initialPricingSucceeded) ElMessage.success('审核已通过,入库单已进入待分柜,费用已完成初算')
else ElMessage.warning(`审核已通过,入库单已进入待分柜;费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`)
2026-08-04 21:01:39 +08:00
await reload()
emit('changed')
} catch (error) {
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重新审核' : getApiErrorDetails(error, '审核通过失败').message)
if (isConflict(error)) await reload()
} finally {
actionLoading.value = ''
}
}
watch(() => [customerId.value, props.inboundId], () => {
2026-08-18 11:33:01 +08:00
correctionMode.value = false
2026-08-04 21:01:39 +08:00
if (customerId.value && props.inboundId) void reload()
}, { immediate: true })
onMounted(() => {
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
})
2026-08-18 16:09:43 +08:00
onBeforeUnmount(releaseImagePreview)
2026-08-12 16:31:46 +08:00
2026-08-18 11:33:01 +08:00
defineExpose({ confirmApprove, isPending, canReview, actionLoading })
2026-08-04 21:01:39 +08:00
</script>
<template>
<section class="review-panel" aria-labelledby="measurement-review-title">
2026-08-18 11:33:01 +08:00
<h2 id="measurement-review-title" class="sr-only">测量审核内容</h2>
<div class="review-toolbar" aria-label="测量审核操作">
2026-08-18 11:41:14 +08:00
<div class="review-toolbar__status">
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
<span v-if="isPending">通过后进入待分柜</span>
<span v-else-if="isRejected">历史记录为审核驳回,仅供查看</span>
<span v-else>当前记录不可审核</span>
<span v-if="detail" class="review-toolbar__context">客户:{{ displayCustomerName }} · 渠道:{{ displayChannelName }}</span>
</div>
<div class="review-toolbar__actions">
<el-button v-if="isPending && canReview" type="primary" plain @click="correctionMode = true"><Pencil :size="16" />修改重量</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并进入待分柜</el-button>
2026-08-18 16:09:43 +08:00
<el-button :loading="loading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
2026-08-14 19:59:32 +08:00
</div>
2026-08-18 11:33:01 +08:00
</div>
2026-08-04 21:01:39 +08:00
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title><div class="review-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
<el-button link type="danger" @click="reload">重新加载</el-button>
</el-alert>
<el-skeleton v-if="loading && !detail" :rows="8" animated />
<template v-else-if="detail">
2026-08-18 11:33:01 +08:00
<el-alert v-if="detail.reviewReason" type="warning" :closable="false" show-icon :title="`最近审核说明:${detail.reviewReason}`" />
2026-08-04 21:01:39 +08:00
2026-08-18 16:09:43 +08:00
<section class="review-data" aria-labelledby="review-data-title">
<header class="review-section-heading">
<div>
<h3 id="review-data-title">审核数据总览</h3>
</div>
<el-tag effect="plain">{{ sourceLabel }}</el-tag>
</header>
<div class="review-comparison">
<div class="review-comparison__row review-comparison__row--declared">
<strong>客户申报</strong>
<dl class="review-declaration" aria-label="客户申报信息">
<div><dt>箱数</dt><dd>{{ formatNumber(detail.originalPackageCount, 0) }}</dd></div>
<div><dt>毛重</dt><dd>{{ formatNumber(detail.originalGrossWeightKg) }} <small>kg</small></dd></div>
<div><dt>体积</dt><dd>{{ formatNumber(detail.originalVolumeCbm, 4) }} <small>m³</small></dd></div>
</dl>
</div>
<div class="review-comparison__row">
<strong>平台实测</strong>
<dl class="review-metrics" aria-label="平台实测结果汇总">
<div><dt>箱数</dt><dd>{{ formatNumber(detail.actualPackageCount, 0) }} <small v-if="formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0)">{{ formatDifference(detail.actualPackageCount, detail.originalPackageCount, 0) }}</small></dd></div>
<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="review-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>
<div><dt>体积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>kg</small></dd></div>
</dl>
</div>
</div>
</section>
<section class="review-items" aria-labelledby="review-items-title">
<header class="review-section-heading">
<div>
<h3 id="review-items-title">箱规测量明细</h3>
</div>
<span>{{ detail.items.length }} 条</span>
</header>
<div v-if="detail.items.length" class="review-table-scroll">
<el-table :data="detail.items" border stripe table-layout="fixed" class="review-table">
<el-table-column type="index" label="#" width="44" align="center" />
<el-table-column label="箱号" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.boxCode || row.itemNo || '—' }}</template>
</el-table-column>
<el-table-column label="测量图片" width="98" align="center">
<template #default="{ row }">
<MeasurementEvidenceThumbnail
:customer-id="customerId"
:inbound-id="inboundId"
:file-id="row.intelligentImageFileId"
:alt="`箱规 ${row.boxCode || row.itemNo || row.id} 的测量图片`"
empty-text="无图片"
@open="openImagePreview(row.intelligentImageFileId, row.boxCode || row.itemNo || row.id)"
/>
</template>
</el-table-column>
<el-table-column label="尺寸 长×宽×高(cm)" width="132" align="center">
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
</el-table-column>
<el-table-column prop="packageCount" label="箱数" width="54" align="right">
<template #default="{ row }">{{ formatNumber(row.packageCount, 0) }}</template>
</el-table-column>
<el-table-column label="单箱体积(m³)" width="106" align="right">
<template #default="{ row }">{{ formatNumber(row.unitVolumeCbm, 4) }}</template>
</el-table-column>
<el-table-column label="总体积(m³)" width="96" align="right">
<template #default="{ row }">{{ formatNumber(row.totalVolumeCbm, 4) }}</template>
</el-table-column>
<el-table-column label="体积重(kg)" width="96" align="right">
<template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template>
</el-table-column>
<el-table-column label="测量时间" width="152" class-name="review-time-cell">
<template #default="{ row }">{{ formatDate(row.deviceMeasuredAt) }}</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else description="暂无箱规测量明细" :image-size="48" />
</section>
2026-08-18 11:33:01 +08:00
<WeightOcrPanel
review-mode
:correction-mode="correctionMode"
:customer-id="customerId"
:inbound-id="inboundId"
@changed="handleWeightChanged"
/>
2026-08-04 21:01:39 +08:00
</template>
2026-08-18 16:09:43 +08:00
<el-dialog v-model="imagePreviewOpen" :title="imagePreviewTitle" width="min(760px, 92vw)" destroy-on-close append-to-body @closed="releaseImagePreview">
<div class="review-image-preview">
<el-skeleton v-if="imagePreviewLoading" :rows="5" animated />
<div v-else-if="imagePreviewError" class="review-image-preview__error">
<span>{{ imagePreviewError.message }}</span>
<ApiErrorMeta :details="imagePreviewError" />
</div>
<img v-else-if="imagePreviewUrl" :src="imagePreviewUrl" alt="当前箱规测量原图" />
<el-empty v-else description="暂无可预览图片" />
</div>
</el-dialog>
2026-08-04 21:01:39 +08:00
</section>
</template>
<style scoped>
2026-08-14 19:59:32 +08:00
.review-panel { display: grid; gap: 16px; color: var(--cc-text); }
2026-08-18 11:41:14 +08:00
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin: -20px -24px 0; padding: 14px 24px; border-bottom: 1px solid var(--cc-border); background: #fff; }
2026-08-18 11:33:01 +08:00
.review-toolbar__status, .review-toolbar__actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 8px; }
.review-toolbar__status > span { color: var(--cc-muted); font-size: 12px; }
.review-toolbar__actions { justify-content: flex-end; }
.review-toolbar__actions :deep(.el-button) { margin-left: 0; }
2026-08-18 11:41:14 +08:00
.review-toolbar__status > .review-toolbar__context { padding-left: 8px; border-left: 1px solid var(--cc-border); font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
2026-08-04 21:01:39 +08:00
.review-alert-title { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
2026-08-18 16:09:43 +08:00
.review-data, .review-items { min-width: 0; padding: 0 0 12px; border-bottom: 1px solid var(--cc-border); }
.review-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 8px; }
.review-section-heading > div { min-width: 0; }
.review-section-heading h3 { margin: 0; color: var(--cc-text); font-size: 14px; line-height: 1.35; }
.review-section-heading > span { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.review-comparison { border-top: 1px solid var(--cc-border); }
.review-comparison__row { display: grid; grid-template-columns: 78px minmax(0, 1fr); align-items: center; min-width: 0; padding: 8px 0; }
.review-comparison__row + .review-comparison__row { border-top: 1px dashed var(--cc-border); }
.review-comparison__row > strong { color: #344054; font-size: 12px; }
.review-metrics, .review-declaration { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px 16px; min-width: 0; margin: 0; }
.review-declaration { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.review-metrics > div, .review-declaration > div { display: flex; min-width: 0; align-items: baseline; gap: 6px; }
.review-metrics dt, .review-declaration dt { color: var(--cc-muted); font-size: 11px; line-height: 1.4; }
.review-metrics dd { margin: 0; overflow-wrap: anywhere; color: #172033; font-size: 15px; font-weight: 700; line-height: 1.35; }
.review-metrics dd small, .review-declaration dd small { color: var(--cc-muted); font-size: 11px; font-weight: 500; }
.review-metrics .review-metric--primary dd { color: #067647; }
.review-declaration dd { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; font-weight: 650; line-height: 1.4; }
.review-table-scroll { width: 100%; min-width: 0; overflow: hidden; }
.review-table { width: 100%; font-size: 12px; }
.review-table :deep(.el-table__header th) { padding: 8px 0; background: #f6f8fa; color: #475467; font-size: 11px; font-weight: 700; }
.review-table :deep(.el-table__header .cell) { white-space: nowrap; }
.review-table :deep(.el-table__cell) { padding: 7px 0; }
.review-table :deep(.review-time-cell .cell) { white-space: nowrap; }
.review-table :deep(.measurement-evidence-thumb) { min-width: 72px; border-radius: 4px; }
.review-image-preview { display: grid; min-height: 240px; place-items: center; }
.review-image-preview img { display: block; width: auto; max-width: 100%; max-height: 70vh; object-fit: contain; }
.review-image-preview__error { display: grid; justify-items: center; gap: 8px; color: var(--el-color-danger); }
2026-08-18 11:33:01 +08:00
@media (max-width: 720px) {
2026-08-18 11:41:14 +08:00
.review-toolbar { align-items: stretch; flex-direction: column; margin: -14px -14px 0; padding: 12px 14px; }
.review-toolbar__status > .review-toolbar__context { width: 100%; padding-top: 8px; padding-left: 0; border-top: 1px solid var(--cc-border); border-left: 0; }
2026-08-18 11:33:01 +08:00
.review-toolbar__actions { display: grid; width: 100%; grid-template-columns: 1fr; justify-content: stretch; }
.review-toolbar__actions :deep(.el-button) { width: 100%; min-height: 44px; }
2026-08-18 16:09:43 +08:00
.review-section-heading { align-items: stretch; flex-direction: column; }
.review-comparison__row { grid-template-columns: 78px minmax(0, 1fr); align-items: start; }
.review-metrics, .review-declaration { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2026-08-18 11:33:01 +08:00
}
@media (max-width: 360px) {
.review-toolbar { margin: -10px -10px 0; padding: 10px; }
2026-08-18 16:09:43 +08:00
.review-comparison__row { grid-template-columns: 1fr; gap: 6px; }
.review-metrics, .review-declaration { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 10px; }
2026-08-18 11:33:01 +08:00
}
2026-08-04 21:01:39 +08:00
</style>