优化
This commit is contained in:
@@ -4,7 +4,8 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import { downloadWeightImage, downloadWeightThumbnail } from '../weight/api/weightOcr'
|
||||
import WeightImageThumbnail from '../weight/components/WeightImageThumbnail.vue'
|
||||
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory, rejectMeasurement } from './api'
|
||||
import MeasurementReviewPanel from './MeasurementReviewPanel.vue'
|
||||
|
||||
@@ -16,6 +17,11 @@ vi.mock('./api', () => ({
|
||||
releaseRejectedMeasurement: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../weight/api/weightOcr', () => ({
|
||||
downloadWeightImage: vi.fn(),
|
||||
downloadWeightThumbnail: vi.fn(),
|
||||
}))
|
||||
|
||||
const detail = {
|
||||
inboundId: '90001', inboundCode: 'RK90001', companyName: '柏悦客户', channelCode: 'SEA', channelName: '海运',
|
||||
processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: 'STANDARD', reviewReason: null, reviewerId: null, reviewerName: null,
|
||||
@@ -31,6 +37,11 @@ enableAutoUnmount(afterEach)
|
||||
describe('MeasurementReviewPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn((blob: Blob) => blob.size > 5 ? 'blob:weight-original' : 'blob:weight-thumbnail'),
|
||||
})
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
@@ -38,31 +49,84 @@ describe('MeasurementReviewPanel', () => {
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(getMeasurementReviewDetail).mockResolvedValue(detail)
|
||||
vi.mocked(listMeasurementReviewHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' }))
|
||||
vi.mocked(downloadWeightImage).mockResolvedValue(new Blob(['original-image'], { type: 'image/jpeg' }))
|
||||
})
|
||||
|
||||
it('loads and compares original, device and OCR data without exposing a normal fee action', async () => {
|
||||
it('keeps the review focused on weight evidence and omits carton measurement details', async () => {
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('原单申报')
|
||||
expect(wrapper.text()).toContain('设备实测')
|
||||
expect(wrapper.text()).toContain('设备采集')
|
||||
expect(wrapper.text()).not.toContain('DEVICE')
|
||||
expect(wrapper.text()).toContain('货物总重量')
|
||||
expect(wrapper.text()).toContain('实测长 × 宽 × 高(cm)')
|
||||
expect(wrapper.text()).toContain('周长超过渠道限制')
|
||||
expect(wrapper.text()).toContain('OCR 原文:设备屏幕原始识别内容')
|
||||
expect(wrapper.text()).toContain('TP-001')
|
||||
expect(wrapper.text()).toContain('托盘皮重')
|
||||
expect(wrapper.text()).toContain('入仓码')
|
||||
expect(wrapper.text()).toContain('RK90001')
|
||||
expect(wrapper.text()).toContain('柏悦客户 · 海运')
|
||||
expect(wrapper.text()).toContain('合计货物重量')
|
||||
expect(wrapper.text()).toContain('全部称重图片')
|
||||
expect(wrapper.text()).toContain('称重图片')
|
||||
expect(wrapper.text()).toContain('货物重量')
|
||||
expect(wrapper.text()).toContain('调整原因:修正 OCR 毛重读数')
|
||||
expect(wrapper.findComponent(DataTable).exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('110 kg')
|
||||
expect(wrapper.text()).not.toContain('申报毛重')
|
||||
expect(wrapper.text()).not.toContain('托盘皮重')
|
||||
expect(wrapper.text()).not.toContain('OCR 原文')
|
||||
expect(wrapper.text()).not.toContain('设备屏幕原始识别内容')
|
||||
expect(wrapper.text()).not.toContain('置信度')
|
||||
expect(wrapper.text()).not.toContain('调整原因')
|
||||
expect(wrapper.findAllComponents(WeightImageThumbnail)).toHaveLength(1)
|
||||
expect(wrapper.text()).not.toContain('测量明细')
|
||||
expect(wrapper.text()).not.toContain('实测长 × 宽 × 高(cm)')
|
||||
expect(wrapper.text()).not.toContain('设备实测体积')
|
||||
expect(wrapper.text()).not.toContain('材积重')
|
||||
expect(wrapper.text()).not.toContain('租户')
|
||||
expect(wrapper.text()).toContain('通过并进入待分柜')
|
||||
expect(wrapper.text()).not.toContain('确认费用')
|
||||
expect(getMeasurementReviewDetail).toHaveBeenCalledWith('10001', '90001')
|
||||
expect(downloadWeightThumbnail).toHaveBeenCalledWith('10001', '90001', 'file-1')
|
||||
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
|
||||
expect(source).toContain('defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canReview, canRelease, actionLoading })')
|
||||
expect(source).not.toContain('measurementColumns')
|
||||
expect(source.indexOf('全部称重图片')).toBeLessThan(source.indexOf('审核结论'))
|
||||
expect(source).not.toContain('<DataTable')
|
||||
expect(source).toContain('<el-collapse class="review-history">')
|
||||
})
|
||||
|
||||
it('shows every weight image at once with its cargo weight below', async () => {
|
||||
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({
|
||||
...detail,
|
||||
actualGrossWeightKg: 330,
|
||||
weightResults: [
|
||||
detail.weightResults[0],
|
||||
{ ...detail.weightResults[0], id: '3', fileId: 'file-2', netWeightKg: 100 },
|
||||
{ ...detail.weightResults[0], id: '4', fileId: 'file-3', netWeightKg: 120 },
|
||||
],
|
||||
})
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.weight-evidence-card')).toHaveLength(3)
|
||||
expect(wrapper.findAllComponents(WeightImageThumbnail)).toHaveLength(3)
|
||||
expect(wrapper.findAll('.weight-evidence-card__weight').map((card) => card.text())).toEqual([
|
||||
'货物重量110 kg',
|
||||
'货物重量100 kg',
|
||||
'货物重量120 kg',
|
||||
])
|
||||
expect(wrapper.find('.weight-overview').text()).toContain('330 kg')
|
||||
expect(downloadWeightThumbnail).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('opens the authenticated original image with the reviewed weight facts', async () => {
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.weight-evidence-card .weight-thumb').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(downloadWeightImage).toHaveBeenCalledWith('10001', '90001', 'file-1')
|
||||
const preview = document.body.querySelector('.weight-preview')
|
||||
expect(preview?.textContent).toContain('TP-001 · 木托盘')
|
||||
expect(preview?.textContent).toContain('120 kg')
|
||||
expect(preview?.textContent).toContain('10 kg')
|
||||
expect(preview?.textContent).toContain('110 kg')
|
||||
expect(preview?.querySelector('img')?.getAttribute('src')).toBe('blob:weight-original')
|
||||
})
|
||||
|
||||
it('shows the rejected release action only when the permission and rejected state exist', async () => {
|
||||
@@ -107,8 +171,9 @@ describe('MeasurementReviewPanel', () => {
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(approveMeasurement).toHaveBeenCalledOnce()
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('初算失败不阻断审核')
|
||||
expect(wrapper.text()).not.toContain('初算失败不阻断审核')
|
||||
})
|
||||
|
||||
it('rejects with the inbound optimistic-lock version instead of the measurement version', async () => {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Check, CircleAlert, History, RotateCcw, ShieldAlert, X } from '@lucide/vue'
|
||||
import { Check, CircleAlert, RotateCcw, ShieldAlert, X } 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 DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { inboundReviewStatusLabels, inboundStatusLabel, weightRecognitionStatusLabels } from '../status'
|
||||
import { inboundReviewStatusLabels, inboundStatusLabel } from '../status'
|
||||
import { downloadWeightImage } from '../weight/api/weightOcr'
|
||||
import WeightImageThumbnail from '../weight/components/WeightImageThumbnail.vue'
|
||||
import {
|
||||
approveMeasurement,
|
||||
getMeasurementReviewDetail,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
rejectMeasurement,
|
||||
releaseRejectedMeasurement,
|
||||
} from './api'
|
||||
import type { MeasurementReviewDetail, MeasurementReviewItem, MeasurementReviewRecord } from './types'
|
||||
import type { MeasurementReviewDetail, MeasurementReviewRecord } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId?: string
|
||||
@@ -39,6 +40,13 @@ const historyError = ref<ApiErrorDetails | null>(null)
|
||||
const reasonDialog = ref<'reject' | 'release' | null>(null)
|
||||
const reason = ref('')
|
||||
const requestSerial = ref(0)
|
||||
const previewOpen = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref<ApiErrorDetails | null>(null)
|
||||
const previewResult = ref<MeasurementReviewDetail['weightResults'][number] | null>(null)
|
||||
const previewIndex = ref(0)
|
||||
let previewRequestSerial = 0
|
||||
const reasonDialogOpen = computed({
|
||||
get: () => reasonDialog.value !== null,
|
||||
set: (open: boolean) => { if (!open) reasonDialog.value = null },
|
||||
@@ -52,34 +60,6 @@ const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTE
|
||||
const confirmedWeights = computed(() => detail.value?.weightResults.filter((item) => item.confirmedAt && item.netWeightKg !== null).length ?? 0)
|
||||
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
|
||||
|
||||
const measurementColumns: DataTableColumn[] = [
|
||||
{ key: 'boxCode', label: '扫描箱号', minWidth: 190, slotName: 'boxCode' },
|
||||
{ key: 'packageCount', label: '确认箱数', width: 90, slotName: 'packageCount' },
|
||||
{ key: 'dimensions', label: '实测长 × 宽 × 高(cm)', minWidth: 190, slotName: 'dimensions' },
|
||||
{ key: 'unitWeightKg', label: '实测重量(kg)', minWidth: 115, slotName: 'unitWeightKg' },
|
||||
{ key: 'machineNo', label: '机器编号', minWidth: 130, slotName: 'machineNo' },
|
||||
{ key: 'measuredAt', label: '测量时间', minWidth: 150, slotName: 'measuredAt' },
|
||||
{ key: 'intelligentImageFileId', label: '当前图片', minWidth: 110, slotName: 'image' },
|
||||
{ key: 'unitVolumeCbm', label: '每箱体积(m³)', minWidth: 125, slotName: 'unitVolumeCbm' },
|
||||
{ key: 'totalWeightKg', label: '总毛重(kg)', minWidth: 110, slotName: 'totalWeightKg' },
|
||||
{ key: 'totalVolumeCbm', label: '总体积(m³)', minWidth: 110, slotName: 'totalVolumeCbm' },
|
||||
{ key: 'volumeWeightKg', label: '材积重(kg)', minWidth: 110, slotName: 'volumeWeightKg' },
|
||||
{ key: 'note', label: '超限提示 / 备注', minWidth: 200, slotName: 'note' },
|
||||
]
|
||||
|
||||
const weightColumns: DataTableColumn[] = [
|
||||
{ key: 'file', label: '称重文件 / OCR 原文', minWidth: 260, slotName: 'file' },
|
||||
{ key: 'pallet', label: '托盘', minWidth: 170, slotName: 'pallet' },
|
||||
{ key: 'status', label: '识别状态', width: 100, slotName: 'status' },
|
||||
{ key: 'ocrWeight', label: 'OCR 含托盘毛重', minWidth: 140, slotName: 'ocrWeight' },
|
||||
{ key: 'adjustedWeight', label: '人工修正毛重', minWidth: 130, slotName: 'adjustedWeight' },
|
||||
{ key: 'grossWeight', label: '服务端毛重', minWidth: 115, slotName: 'grossWeight' },
|
||||
{ key: 'tareWeight', label: '托盘皮重', minWidth: 110, slotName: 'tareWeight' },
|
||||
{ key: 'netWeight', label: '货物重量', minWidth: 110, slotName: 'netWeight' },
|
||||
{ key: 'confidence', label: '置信度', width: 90, slotName: 'confidence' },
|
||||
{ key: 'reason', label: '调整原因', minWidth: 180, slotName: 'reason' },
|
||||
]
|
||||
|
||||
function formatNumber(value: number | null | undefined, unit = '') {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return '-'
|
||||
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })}${unit}`
|
||||
@@ -95,13 +75,33 @@ function resultLabel(value: string | null | undefined) {
|
||||
return '未知结果'
|
||||
}
|
||||
|
||||
function recognitionStatusLabel(value: string | null | undefined) {
|
||||
return inboundStatusLabel(value, weightRecognitionStatusLabels, '待确认')
|
||||
function releasePreview() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
|
||||
function measurementSourceLabel(value: string | null | undefined) {
|
||||
if (!value) return '-'
|
||||
return ({ DEVICE: '设备采集', THIRD_PARTY: '第三方设备', MANUAL: '人工录入' } as Record<string, string>)[value] || '其他来源'
|
||||
async function openWeightImage(row: unknown, index: number) {
|
||||
previewResult.value = row as MeasurementReviewDetail['weightResults'][number]
|
||||
previewIndex.value = index
|
||||
previewOpen.value = true
|
||||
await loadPreviewImage()
|
||||
}
|
||||
|
||||
async function loadPreviewImage() {
|
||||
const row = previewResult.value
|
||||
if (!row?.fileId) return
|
||||
const serial = ++previewRequestSerial
|
||||
releasePreview()
|
||||
previewError.value = null
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const blob = await downloadWeightImage(customerId.value, props.inboundId, row.fileId)
|
||||
if (serial === previewRequestSerial) previewUrl.value = URL.createObjectURL(blob)
|
||||
} catch (error) {
|
||||
if (serial === previewRequestSerial) previewError.value = getApiErrorDetails(error, '称重原图读取失败,请重新加载')
|
||||
} finally {
|
||||
if (serial === previewRequestSerial) previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isConflict(error: unknown) {
|
||||
@@ -225,13 +225,27 @@ onMounted(() => {
|
||||
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
|
||||
})
|
||||
|
||||
watch(previewOpen, (open) => {
|
||||
if (!open) {
|
||||
previewRequestSerial += 1
|
||||
previewResult.value = null
|
||||
previewError.value = null
|
||||
releasePreview()
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(releasePreview)
|
||||
|
||||
defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canReview, canRelease, actionLoading })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="review-panel" aria-labelledby="measurement-review-title">
|
||||
<header class="review-header">
|
||||
<h2 id="measurement-review-title">测量信息</h2>
|
||||
<div>
|
||||
<span>入仓码</span>
|
||||
<h2 id="measurement-review-title">{{ detail?.inboundCode || '-' }}</h2>
|
||||
<p>{{ detail ? `${detail.companyName || '客户未命名'} · ${detail.channelName || detail.channelCode || '渠道未填写'}` : '称重结果' }}</p>
|
||||
</div>
|
||||
<div class="review-header-status">
|
||||
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
|
||||
<el-button text :loading="loading || historyLoading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
|
||||
@@ -246,66 +260,41 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
|
||||
<el-skeleton v-if="loading && !detail" :rows="8" animated />
|
||||
|
||||
<template v-else-if="detail">
|
||||
<dl class="review-fields">
|
||||
<div><dt>入库单号</dt><dd>{{ detail.inboundCode || '-' }}</dd></div>
|
||||
<div><dt>客户名称</dt><dd>{{ detail.companyName || '-' }}</dd></div>
|
||||
<div><dt>原单申报</dt><dd>{{ detail.originalDocumentNo || '-' }}</dd></div>
|
||||
<div><dt>渠道</dt><dd>{{ detail.channelName || detail.channelCode || '-' }}</dd></div>
|
||||
<div><dt>申报箱数</dt><dd>{{ formatNumber(detail.originalPackageCount, ' 箱') }}</dd></div>
|
||||
<div><dt>实测箱数</dt><dd>{{ formatNumber(detail.actualPackageCount, ' 箱') }}</dd></div>
|
||||
<div><dt>申报毛重</dt><dd>{{ formatNumber(detail.originalGrossWeightKg, ' kg') }}</dd></div>
|
||||
<div><dt>货物总重量</dt><dd>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</dd></div>
|
||||
<div><dt>申报体积</dt><dd>{{ formatNumber(detail.originalVolumeCbm, ' CBM') }}</dd></div>
|
||||
<div><dt>设备实测体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, ' CBM') }}</dd></div>
|
||||
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg, ' kg') }}</dd></div>
|
||||
<div><dt>测量来源</dt><dd>{{ measurementSourceLabel(detail.sourceType) }}</dd></div>
|
||||
<div><dt>已确认称重图片</dt><dd>{{ confirmedWeights }} / {{ detail.weightResults.length }}</dd></div>
|
||||
<div><dt>审核状态</dt><dd><StatusBadge :status="reviewStatusLabel" /></dd></div>
|
||||
<dl class="weight-overview" aria-label="重量审核摘要">
|
||||
<div class="weight-overview__primary"><dt>合计货物重量</dt><dd>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</dd></div>
|
||||
<div><dt>称重图片</dt><dd>{{ confirmedWeights }} / {{ detail.weightResults.length }} 张已确认</dd></div>
|
||||
</dl>
|
||||
|
||||
<div v-if="detail.reviewReason" class="review-reason">
|
||||
<CircleAlert :size="17" /><span><strong>最近审核说明:</strong>{{ detail.reviewReason }}</span>
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<div class="section-heading"><h3>测量明细</h3><span class="section-count">{{ detail.items.length }} 条</span></div>
|
||||
<DataTable :data="detail.items" :columns="measurementColumns" empty-text="暂无设备测量明细" border stripe>
|
||||
<template #boxCode="{ row }">{{ (row as MeasurementReviewItem).boxCode || (row as MeasurementReviewItem).itemNo || '-' }}</template>
|
||||
<template #packageCount="{ row }">{{ (row as MeasurementReviewItem).packageCount ?? '-' }}</template>
|
||||
<template #dimensions="{ row }">{{ [(row as MeasurementReviewItem).lengthCm, (row as MeasurementReviewItem).widthCm, (row as MeasurementReviewItem).heightCm].map((value) => formatNumber(value)).join(' × ') }}</template>
|
||||
<template #outerDimensions="{ row }">{{ [(row as MeasurementReviewItem).outerLengthCm, (row as MeasurementReviewItem).outerWidthCm, (row as MeasurementReviewItem).outerHeightCm].map((value) => formatNumber(value)).join(' × ') }}</template>
|
||||
<template #girthCm="{ row }">{{ formatNumber((row as MeasurementReviewItem).girthCm) }}</template>
|
||||
<template #unitWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).unitWeightKg) }}</template>
|
||||
<template #machineNo="{ row }">{{ (row as MeasurementReviewItem).machineNo || '-' }}</template>
|
||||
<template #measuredAt="{ row }">{{ formatDate((row as MeasurementReviewItem).measuredAt) }}</template>
|
||||
<template #image="{ row }">{{ (row as MeasurementReviewItem).intelligentImageFileId ? '已关联' : '暂无' }}</template>
|
||||
<template #unitVolumeCbm="{ row }">{{ formatNumber((row as MeasurementReviewItem).unitVolumeCbm) }}</template>
|
||||
<template #totalWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).totalWeightKg) }}</template>
|
||||
<template #totalVolumeCbm="{ row }">{{ formatNumber((row as MeasurementReviewItem).totalVolumeCbm) }}</template>
|
||||
<template #volumeWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).volumeWeightKg) }}</template>
|
||||
<template #note="{ row }"><div class="measurement-note"><strong v-if="(row as MeasurementReviewItem).overLimitWarning">{{ (row as MeasurementReviewItem).overLimitWarning }}</strong><span>{{ (row as MeasurementReviewItem).remark || ((row as MeasurementReviewItem).overLimitWarning ? '' : '-') }}</span></div></template>
|
||||
<template #empty><el-empty description="暂无设备测量明细" :image-size="48" /></template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<div class="section-heading"><h3>称重图片识别结果</h3><span class="section-count">{{ detail.weightResults.length }} 张</span></div>
|
||||
<DataTable :data="detail.weightResults" :columns="weightColumns" empty-text="暂无称重图片识别结果" border stripe>
|
||||
<template #file="{ row }"><div class="table-wrap"><strong>{{ row.fileId || '未关联文件' }}</strong><span v-if="row.ocrRawText">OCR 原文:{{ row.ocrRawText }}</span></div></template>
|
||||
<template #pallet="{ row }"><div class="table-wrap"><strong>{{ row.palletCode }}</strong><span>{{ row.palletType }} · {{ formatNumber(row.palletCapacityCbm, ' m³') }}</span></div></template>
|
||||
<template #status="{ row }">{{ recognitionStatusLabel(row.recognitionStatus) }}</template>
|
||||
<template #ocrWeight="{ row }">{{ formatNumber(row.ocrWeightValue, ` ${row.ocrWeightUnit || ''}`) }}</template>
|
||||
<template #adjustedWeight="{ row }">{{ formatNumber(row.adjustedWeightValue, ` ${row.adjustedWeightUnit || ''}`) }}</template>
|
||||
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg, ' kg') }}</template>
|
||||
<template #tareWeight="{ row }">{{ formatNumber(row.palletTareWeightKg, ' kg') }}</template>
|
||||
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg, ' kg') }}</template>
|
||||
<template #confidence="{ row }">{{ row.confidence === null ? '-' : `${Math.round(row.confidence * 100)}%` }}</template>
|
||||
<template #reason="{ row }">{{ row.adjustmentReason ? `调整原因:${row.adjustmentReason}` : '-' }}</template>
|
||||
</DataTable>
|
||||
<div class="review-section weight-evidence-section">
|
||||
<div class="section-heading"><h3>全部称重图片</h3><span class="section-count">{{ detail.weightResults.length }} 张</span></div>
|
||||
<div v-if="detail.weightResults.length" class="weight-evidence-grid">
|
||||
<article v-for="(row, index) in detail.weightResults" :key="row.id" class="weight-evidence-card">
|
||||
<div class="weight-evidence-card__image">
|
||||
<WeightImageThumbnail
|
||||
v-if="row.fileId"
|
||||
:customer-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
:file-id="row.fileId"
|
||||
:alt="`称重图片 ${index + 1}`"
|
||||
@open="openWeightImage(row, index)"
|
||||
/>
|
||||
<span v-else class="missing-evidence">未关联图片</span>
|
||||
</div>
|
||||
<div class="weight-evidence-card__weight">
|
||||
<span>货物重量</span>
|
||||
<strong>{{ formatNumber(row.netWeightKg, ' kg') }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty v-else description="暂无称重图片" :image-size="56" />
|
||||
</div>
|
||||
|
||||
<div class="review-actions" :class="{ 'review-actions--disabled': !isPending && !isRejected }">
|
||||
<div><strong>审核结论</strong><span v-if="isPending">通过后进入待分柜;费用仅做初算,初算失败不阻断审核。</span><span v-else-if="isRejected">当前测量已驳回,可修正后重新提交,或按权限申请异常放行。</span><span v-else>当前记录不在可操作的测量审核状态。</span></div>
|
||||
<div><strong>审核结论</strong><span v-if="isPending">通过后进入待分柜</span><span v-else-if="isRejected">已驳回,可修正后重新提交</span><span v-else>当前记录不可审核</span></div>
|
||||
<div class="action-buttons">
|
||||
<el-button v-if="isPending && canReview" type="danger" plain :loading="actionLoading === 'reject'" @click="openReasonDialog('reject')"><X :size="16" />驳回</el-button>
|
||||
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并进入待分柜</el-button>
|
||||
@@ -313,20 +302,39 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-section history-section">
|
||||
<div class="section-heading"><div><h3><History :size="17" />审核历史</h3><p>每次审核均保留操作人、原因和放行开关快照。</p></div></div>
|
||||
<el-alert v-if="historyError" type="warning" :closable="false" show-icon><template #title><div class="review-alert-title"><span>{{ historyError.message }}</span><ApiErrorMeta :details="historyError" /></div></template><el-button link type="warning" @click="loadHistory">重试</el-button></el-alert>
|
||||
<el-skeleton v-else-if="historyLoading" :rows="3" animated />
|
||||
<el-empty v-else-if="!history.length" description="暂无审核历史" :image-size="48" />
|
||||
<ol v-else class="history-list">
|
||||
<li v-for="item in history" :key="item.id">
|
||||
<div class="history-marker" :class="{ 'history-marker--success': item.reviewResult === 'APPROVED', 'history-marker--danger': item.reviewResult === 'REJECTED' }" />
|
||||
<div class="history-content"><div><strong>{{ resultLabel(item.reviewResult) }}{{ item.exceptionRelease ? ' · 异常放行' : '' }}</strong><time>{{ formatDate(item.reviewedAt) }}</time></div><p>{{ item.releaseReason || item.reviewReason || '未填写说明' }}</p><span>{{ item.reviewerName || '系统' }} · {{ item.allowRejectedConfig ? '放行开关已开启' : '放行开关未开启' }}</span></div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<el-collapse class="review-history">
|
||||
<el-collapse-item name="history" :title="`审核历史(${history.length})`">
|
||||
<el-alert v-if="historyError" type="warning" :closable="false" show-icon><template #title><div class="review-alert-title"><span>{{ historyError.message }}</span><ApiErrorMeta :details="historyError" /></div></template><el-button link type="warning" @click="loadHistory">重试</el-button></el-alert>
|
||||
<el-skeleton v-else-if="historyLoading" :rows="3" animated />
|
||||
<el-empty v-else-if="!history.length" description="暂无审核历史" :image-size="48" />
|
||||
<ol v-else class="history-list">
|
||||
<li v-for="item in history" :key="item.id">
|
||||
<div class="history-marker" :class="{ 'history-marker--success': item.reviewResult === 'APPROVED', 'history-marker--danger': item.reviewResult === 'REJECTED' }" />
|
||||
<div class="history-content"><div><strong>{{ resultLabel(item.reviewResult) }}{{ item.exceptionRelease ? ' · 异常放行' : '' }}</strong><time>{{ formatDate(item.reviewedAt) }}</time></div><p>{{ item.releaseReason || item.reviewReason || '未填写说明' }}</p><span>{{ item.reviewerName || '系统' }}</span></div>
|
||||
</li>
|
||||
</ol>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="previewOpen" :title="`称重原图 ${previewIndex + 1}`" width="min(880px, calc(100vw - 28px))" destroy-on-close append-to-body>
|
||||
<div v-if="previewResult" class="weight-preview">
|
||||
<div v-loading="previewLoading" class="weight-preview__canvas">
|
||||
<img v-if="previewUrl" :src="previewUrl" :alt="`称重原图 ${previewIndex + 1}`" />
|
||||
<el-alert v-else-if="previewError" type="error" :closable="false" show-icon>
|
||||
<template #title><div class="review-alert-title"><span>{{ previewError.message }}</span><ApiErrorMeta :details="previewError" /></div></template>
|
||||
<el-button link type="danger" :loading="previewLoading" @click="loadPreviewImage">重新加载原图</el-button>
|
||||
</el-alert>
|
||||
</div>
|
||||
<dl class="weight-preview__facts">
|
||||
<div><dt>托盘</dt><dd>{{ previewResult.palletCode }} · {{ previewResult.palletType }}</dd></div>
|
||||
<div><dt>含托盘重量</dt><dd>{{ formatNumber(previewResult.grossWeightKg, ' kg') }}</dd></div>
|
||||
<div><dt>托盘皮重</dt><dd>{{ formatNumber(previewResult.palletTareWeightKg, ' kg') }}</dd></div>
|
||||
<div><dt>货物重量</dt><dd>{{ formatNumber(previewResult.netWeightKg, ' kg') }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="reasonDialogOpen" :title="reasonDialog === 'reject' ? '填写驳回原因' : '填写异常放行原因'" width="min(520px, calc(100vw - 28px))" destroy-on-close>
|
||||
<el-alert v-if="reasonDialog === 'release'" title="异常放行不会抹去驳回记录;请说明为什么允许该测量继续进入自动计费入库。" type="warning" :closable="false" show-icon />
|
||||
<el-input v-model="reason" type="textarea" :rows="5" maxlength="1000" show-word-limit :placeholder="reasonDialog === 'reject' ? '请说明需要修正的测量问题' : '请填写异常放行的业务原因'" />
|
||||
@@ -336,22 +344,35 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.review-panel { display: grid; gap: 20px; color: var(--cc-text); }
|
||||
.review-panel { display: grid; gap: 16px; color: var(--cc-text); }
|
||||
.review-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; padding-bottom: 4px; }
|
||||
.review-header h2 { margin: 0 0 5px; font-size: 22px; line-height: 1.25; }
|
||||
.review-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
|
||||
.review-header > div:first-child > span { color: var(--cc-muted); font-size: 11px; }
|
||||
.review-header h2 { margin: 0; overflow-wrap: anywhere; font-size: 20px; line-height: 1.25; }
|
||||
.review-header p, .section-heading p { margin: 0; color: var(--cc-muted); font-size: 13px; line-height: 1.55; }
|
||||
.review-header-status { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
|
||||
.review-alert-title { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.review-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 32px; margin: 0; border-top: 1px solid var(--cc-border); }
|
||||
.review-fields > div { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; min-width: 0; padding: 10px 0; border-bottom: 1px solid var(--cc-border); font-size: 13px; }
|
||||
.review-fields dt { color: var(--cc-muted); }.review-fields dd { min-width: 0; margin: 0; color: var(--cc-text); font-weight: 600; overflow-wrap: anywhere; }
|
||||
.weight-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); }
|
||||
.weight-overview > div { display: grid; align-content: center; min-width: 0; min-height: 82px; gap: 7px; padding: 14px 18px; border-right: 1px solid var(--cc-border); }
|
||||
.weight-overview > div:last-child { border-right: 0; }.weight-overview dt { color: var(--cc-muted); font-size: 12px; }.weight-overview dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 17px; font-weight: 700; overflow-wrap: anywhere; }.weight-overview__primary { background: #f0f7ff; }.weight-overview__primary dd { color: var(--cc-primary); font-size: 24px; }
|
||||
.review-reason { display: flex; gap: 8px; align-items: flex-start; padding: 12px 14px; border: 1px solid #f3d19e; border-radius: 8px; background: #fdf6ec; color: #8a5a00; font-size: 13px; line-height: 1.55; }
|
||||
.review-section { display: grid; gap: 13px; padding-top: 2px; }.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }.section-heading h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 4px; font-size: 16px; }.section-count { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
||||
.measurement-note { overflow-wrap: anywhere; }.measurement-note strong, .measurement-note span { display: block; }.measurement-note strong { color: var(--el-color-warning-dark-2); }.measurement-note strong + span { margin-top: 4px; color: var(--cc-muted); }
|
||||
.table-wrap { display: grid; gap: 4px; min-width: 0; }.table-wrap span { color: var(--cc-muted); font-size: 12px; line-height: 1.45; overflow-wrap: anywhere; }
|
||||
.weight-evidence-section { min-width: 0; }
|
||||
.weight-evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
|
||||
.weight-evidence-card { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
|
||||
.weight-evidence-card__image { display: grid; aspect-ratio: 4 / 3; overflow: hidden; place-items: center; background: #f2f4f7; }
|
||||
.weight-evidence-card__image :deep(.weight-thumb) { height: 100%; border-radius: 0; }
|
||||
.weight-evidence-card__weight { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; }
|
||||
.weight-evidence-card__weight span { color: var(--cc-muted); font-size: 12px; }
|
||||
.weight-evidence-card__weight strong { color: #067647; font-size: 20px; overflow-wrap: anywhere; text-align: right; }
|
||||
.missing-evidence { color: var(--cc-muted); font-size: 12px; }
|
||||
.weight-preview { display: grid; gap: 14px; }.weight-preview__canvas { display: grid; min-height: 320px; max-height: 62vh; overflow: hidden; place-items: center; border: 1px solid var(--cc-border); border-radius: 6px; background: #f2f4f7; }.weight-preview__canvas img { display: block; max-width: 100%; max-height: 62vh; object-fit: contain; }.weight-preview__canvas .el-alert { width: min(520px, calc(100% - 24px)); }.weight-preview__facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; }.weight-preview__facts > div { display: grid; min-width: 0; gap: 5px; padding: 12px 14px; border-right: 1px solid var(--cc-border); }.weight-preview__facts > div:last-child { border-right: 0; }.weight-preview__facts dt { color: var(--cc-muted); font-size: 12px; }.weight-preview__facts dd { min-width: 0; margin: 0; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.review-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 16px; border: 1px solid #b3d8ff; border-radius: 10px; background: #ecf5ff; }.review-actions > div:first-child { display: grid; gap: 4px; min-width: 0; }.review-actions span { color: #606266; font-size: 12px; line-height: 1.5; }.review-actions--disabled { border-color: var(--cc-border); background: var(--cc-surface-muted); }.action-buttons { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.history-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }.history-list li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 10px; position: relative; padding-bottom: 16px; }.history-list li:not(:last-child)::after { content: ''; position: absolute; top: 16px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); }.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 2px; border: 3px solid #909399; border-radius: 50%; background: var(--cc-surface); }.history-marker--success { border-color: #67c23a; }.history-marker--danger { border-color: #f56c6c; }.history-content { min-width: 0; }.history-content > div { display: flex; justify-content: space-between; gap: 12px; }.history-content time, .history-content > span { color: var(--cc-muted); font-size: 11px; }.history-content p { margin: 5px 0 4px; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
|
||||
.review-history { border-top: 1px solid var(--cc-border); border-bottom: 0; }
|
||||
.review-history :deep(.el-collapse-item__header) { height: 42px; color: var(--cc-muted); font-size: 12px; }
|
||||
.review-history :deep(.el-collapse-item__wrap) { border-bottom: 0; }
|
||||
.el-button { min-height: 36px; }.el-button :deep(svg) { flex: 0 0 auto; }.review-panel :deep(.el-dialog__body) { display: grid; gap: 14px; }
|
||||
@media (max-width: 720px) { .review-header, .section-heading, .review-actions { align-items: flex-start; flex-direction: column; }.review-header-status, .action-buttons { width: 100%; justify-content: space-between; }.review-fields { grid-template-columns: 1fr; gap: 0; }.review-actions .action-buttons .el-button { flex: 1 1 140px; }.review-actions .action-buttons { justify-content: stretch; }.review-actions .action-buttons :deep(.el-button) { width: 100%; } }
|
||||
@media (max-width: 380px) { .review-header h2 { font-size: 19px; }.review-header p { font-size: 12px; }.review-fields > div { grid-template-columns: 100px minmax(0, 1fr); }.review-actions { padding: 13px; } }
|
||||
@media (max-width: 720px) { .review-header, .section-heading, .review-actions { align-items: flex-start; flex-direction: column; }.review-header-status, .action-buttons { width: 100%; justify-content: space-between; }.weight-overview { grid-template-columns: 1fr; }.weight-overview > div { min-height: 68px; border-right: 0; border-bottom: 1px solid var(--cc-border); }.weight-overview > div:last-child { border-bottom: 0; }.weight-evidence-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.weight-preview__facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }.weight-preview__facts > div:nth-child(2) { border-right: 0; }.weight-preview__facts > div:nth-child(-n + 2) { border-bottom: 1px solid var(--cc-border); }.review-actions .action-buttons .el-button { flex: 1 1 140px; }.review-actions .action-buttons { justify-content: stretch; }.review-actions .action-buttons :deep(.el-button) { width: 100%; } }
|
||||
@media (max-width: 380px) { .review-header h2 { font-size: 19px; }.review-header p { font-size: 12px; }.weight-overview { grid-template-columns: 1fr; }.weight-overview > div { min-height: 68px; border-right: 0; border-bottom: 1px solid var(--cc-border); }.weight-overview > div:last-child { border-bottom: 0; }.weight-overview__primary dd { font-size: 21px; }.weight-evidence-grid { grid-template-columns: 1fr; }.review-actions { padding: 13px; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user