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

357 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Pencil, RotateCcw } from '@lucide/vue'
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'
import { downloadInboundMeasurementImage } from '../measurement/api'
import MeasurementEvidenceThumbnail from '../measurement/components/MeasurementEvidenceThumbnail.vue'
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
import { approveMeasurement, getMeasurementReviewDetail } from './api'
import type { MeasurementReviewDetail } from './types'
const props = defineProps<{
customerId?: string
/** @deprecated 仅用于兼容尚未完成 customerId 映射的上层集成。 */
tenantId?: string
inboundId: string
fulfillmentType?: string | null
customerName?: string | null
channelName?: string | null
channelCode?: string | null
postApproveLoading?: boolean
approvalCompleted?: boolean
}>()
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const detail = ref<MeasurementReviewDetail | null>(null)
const loading = ref(false)
const actionLoading = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const correctionMode = ref(false)
const requestSerial = ref(0)
const imagePreviewOpen = ref(false)
const imagePreviewTitle = ref('测量图片')
const imagePreviewUrl = ref('')
const imagePreviewLoading = ref(false)
const imagePreviewError = ref<ApiErrorDetails | null>(null)
let imagePreviewRequestSerial = 0
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 approvalTargetLabel = computed(() => props.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '待一件代发组托' : '待分柜')
const approvalButtonLabel = computed(() => `通过并进入${approvalTargetLabel.value}`)
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
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() || '渠道未填写')
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}`
}
function formatDate(value: string | null | undefined) {
return value ? value.replace('T', ' ') : '—'
}
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
}
}
function isConflict(error: unknown) {
const details = getApiErrorDetails(error)
return details.message.includes('版本已变化') || details.message.includes('并发修改')
}
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() {
await loadReview()
}
async function handleWeightChanged() {
await loadReview()
}
async function confirmApprove() {
if (!detail.value || !isPending.value || !canReview.value || actionLoading.value || props.postApproveLoading || props.approvalCompleted) return
try {
await ElMessageBox.confirm(
`审核通过后,入库单将进入${approvalTargetLabel.value}。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?`,
'确认测量审核通过',
{ confirmButtonText: approvalButtonLabel.value, cancelButtonText: '返回', type: 'warning' },
)
} catch {
return
}
actionLoading.value = 'approve'
try {
const result = await approveMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.inboundVersion,
})
const resultStatusLabel = inboundStatusLabel(result.processStatus, inboundProcessStatusLabels)
if (result.initialPricingSucceeded) ElMessage.success(`审核已通过,入库单已进入${resultStatusLabel},费用已完成初算`)
else ElMessage.warning(`审核已通过,入库单已进入${resultStatusLabel};费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`)
emit('changed')
} catch (error) {
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重新审核' : getApiErrorDetails(error, '审核通过失败').message)
if (isConflict(error)) await reload()
} finally {
actionLoading.value = ''
}
}
watch(() => [customerId.value, props.inboundId], () => {
correctionMode.value = false
if (customerId.value && props.inboundId) void reload()
}, { immediate: true })
onMounted(() => {
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
})
onBeforeUnmount(releaseImagePreview)
defineExpose({ confirmApprove, isPending, canReview, actionLoading })
</script>
<template>
<section class="review-panel" aria-labelledby="measurement-review-title">
<h2 id="measurement-review-title" class="sr-only">测量审核内容</h2>
<div class="review-toolbar" aria-label="测量审核操作">
<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' || postApproveLoading" :disabled="approvalCompleted" @click="confirmApprove"><Check :size="16" />{{ approvalCompleted ? '审核已通过' : approvalButtonLabel }}</el-button>
<el-button :loading="loading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
</div>
</div>
<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">
<el-alert v-if="detail.reviewReason" type="warning" :closable="false" show-icon :title="`最近审核说明:${detail.reviewReason}`" />
<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 || '箱规编码待补充'} 的测量图片`"
empty-text="无图片"
@open="openImagePreview(row.intelligentImageFileId, row.boxCode || row.itemNo || '箱规编码待补充')"
/>
</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>
<WeightOcrPanel
review-mode
:correction-mode="correctionMode"
:customer-id="customerId"
:inbound-id="inboundId"
@changed="handleWeightChanged"
/>
</template>
<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>
</section>
</template>
<style scoped>
.review-panel { display: grid; gap: 16px; color: var(--cc-text); }
.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; }
.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; }
.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; }
.review-alert-title { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.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); }
@media (max-width: 720px) {
.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; }
.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; }
.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)); }
}
@media (max-width: 360px) {
.review-toolbar { margin: -10px -10px 0; padding: 10px; }
.review-comparison__row { grid-template-columns: 1fr; gap: 6px; }
.review-metrics, .review-declaration { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 10px; }
}
</style>