This commit is contained in:
2026-08-14 19:59:32 +08:00
parent f7594ebd39
commit 1540b80c89
29 changed files with 780 additions and 587 deletions
@@ -23,7 +23,6 @@ import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
type FilterModel = QueryModel & {
customerId: string
inboundCode: string
orderNo: string
warehouseId: string
expectedArrivalRange: string[]
}
@@ -36,7 +35,7 @@ const pageSize = ref(20)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', orderNo: '', warehouseId: '', expectedArrivalRange: [] })
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', warehouseId: '', expectedArrivalRange: [] })
const appliedFilters = ref<FilterModel>({ ...filters.value, expectedArrivalRange: [] })
const customers = ref<PlatformTenantResource[]>([])
const customerLoadError = ref<ApiErrorDetails | null>(null)
@@ -72,7 +71,6 @@ const queryFields = computed<QueryField[]>(() => [
},
{ key: 'warehouseId', type: 'select', label: '定位仓库', width: 220, placeholder: '全部仓库', options: warehouseOptions.value, props: { filterable: true } },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 220, placeholder: '请输入入仓码' },
{ key: 'orderNo', type: 'input', label: '订单号', width: 220, placeholder: '请输入订单号' },
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到达时间', width: 320, placeholder: '请选择日期范围' },
])
@@ -142,7 +140,6 @@ async function loadTasks() {
size: pageSize.value,
warehouseId: applied.warehouseId || undefined,
inboundCode: applied.inboundCode.trim() || undefined,
orderNo: applied.orderNo.trim() || undefined,
expectedArrivalFrom: dateParam(applied.expectedArrivalRange[0]),
expectedArrivalTo: dateParam(applied.expectedArrivalRange[1], true),
}))
@@ -163,7 +160,7 @@ function applyFilters() {
}
function resetFilters() {
filters.value = { customerId: '', inboundCode: '', orderNo: '', warehouseId: '', expectedArrivalRange: [] }
filters.value = { customerId: '', inboundCode: '', warehouseId: '', expectedArrivalRange: [] }
appliedFilters.value = { ...filters.value, expectedArrivalRange: [] }
if (page.value === 1) void loadTasks()
else page.value = 1
@@ -260,7 +257,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
<span v-if="!access.can(actionPermission) && (!access.can('platform:firstleg:inbound:view') || !(row as InboundOrderSummary).orderId)" class="muted-action">无操作权限</span>
</div>
</template>
<template #inboundCode="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
<template #inboundCode="{ row }"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong></template>
<template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template>
<template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template>
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
@@ -23,9 +23,9 @@ describe('InboundWorkflowWorkspace', () => {
})
it('opens measurement editing and review in prototype-sized centered dialogs', () => {
expect(source).toContain('title="编辑入库测量数据"')
expect(source).toContain(':title="`测量 · ${title}`"')
expect(source).toContain('width="min(1240px, calc(100vw - 32px))"')
expect(source).toContain('title="审核入库测量数据"')
expect(source).toContain(':title="`测量审核 · ${title}`"')
expect(source).toContain('width="min(1240px, calc(100vw - 32px))"')
expect(source).toContain('height: 92vh')
expect(source).toContain('max-height: 92vh')
@@ -194,7 +194,7 @@ onBeforeUnmount(() => {
<el-dialog
v-model="measurementDialogOpen"
class="inbound-operation-dialog inbound-measurement-dialog"
title="编辑入库测量数据"
:title="`测量 · ${title}`"
width="min(1240px, calc(100vw - 32px))"
top="4vh"
destroy-on-close
@@ -221,7 +221,7 @@ onBeforeUnmount(() => {
<el-dialog
v-model="reviewDialogOpen"
class="inbound-operation-dialog inbound-review-dialog"
title="审核入库测量数据"
:title="`测量审核 · ${title}`"
width="min(1240px, calc(100vw - 32px))"
top="4vh"
destroy-on-close
@@ -29,8 +29,8 @@ function label(value: string | null, labels: Record<string, string>) { return in
<div class="summary-heading">
<span class="summary-icon" aria-hidden="true"><Box :size="20" /></span>
<div class="summary-title">
<h3>入库码:{{ summary.inboundCode || `入库单 ${summary.id}` }}</h3>
<p class="summary-subtitle">订单 {{ value(summary.orderNo) }} · {{ value(summary.customerName) }}</p>
<h3>入仓码:{{ summary.inboundCode || '暂缺' }}</h3>
<p class="summary-subtitle">{{ value(summary.customerName) }}</p>
</div>
<el-tag :type="processType(summary.processStatus)" effect="light">{{ processLabel(summary.processStatus) }}</el-tag>
</div>
@@ -39,7 +39,6 @@ function label(value: string | null, labels: Record<string, string>) { return in
<section aria-labelledby="inbound-basic-heading">
<h4 id="inbound-basic-heading">基本信息</h4>
<dl class="summary-grid">
<div><dt>头程订单号</dt><dd>{{ value(summary.orderNo) }}</dd></div>
<div><dt>订单状态</dt><dd>{{ label(summary.orderStatusSnapshot, inboundOrderStatusLabels) }}</dd></div>
<div><dt>客户</dt><dd>{{ value(summary.customerName) }}</dd></div>
<div><dt>公司</dt><dd>{{ value(summary.companyName) }}</dd></div>
@@ -149,13 +149,13 @@ describe('InboundMeasurementPanel', () => {
const wrapper = await mountPanel()
expect(getInboundMeasurement).toHaveBeenCalledWith('10001', '7001')
expect((wrapper.find('input[readonly]').element as HTMLInputElement).value).toBe('RK202608040001')
expect(wrapper.text()).toContain('RK202608040001')
expect(wrapper.text()).toContain('周长(cm)')
expect(wrapper.find('.measurement-table').text()).toContain('190')
expect(wrapper.find('.measurement-table').text()).toContain('0.6')
expect(wrapper.find('.measurement-table').text()).not.toContain('周长')
await wrapper.find('.el-table__expand-icon').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('箱规周长')
expect(wrapper.text()).toContain('190 cm')
expect(wrapper.text()).toContain('MJ20210327')
expect(wrapper.text()).toContain('2026-08-04 10:30:00+08:00')
expect(wrapper.find('button[aria-label="预览测量图片"]').exists()).toBe(true)
@@ -189,27 +189,29 @@ describe('InboundMeasurementPanel', () => {
const wrapper = await mountPanel()
expect(wrapper.findAll('input[readonly]').some((input) => (input.element as HTMLInputElement).value === '未知来源')).toBe(true)
expect(wrapper.text()).toContain('未知来源')
expect(wrapper.text()).not.toContain('LEGACY_DEVICE')
})
it('follows the prototype grouping, labels, and editable field order', async () => {
it('keeps the first view focused on key totals and moves secondary fields behind disclosure', async () => {
const wrapper = await mountPanel()
const text = wrapper.text()
expect(text).toContain('原单数据与测量汇总')
expect(text).toContain('扫描箱测量数据明细')
expect(text).toContain('订单总箱数')
expect(text).toContain('确认箱数合计')
expect(text).toContain('货物总重量')
expect(text).toContain('实测总体积')
expect(text).toContain('实测总材积重')
expect(text).toContain('实测箱数')
expect(text).toContain('货物重量')
expect(text).toContain('总体积')
expect(text).toContain('材积重')
expect(text).toContain('申报信息')
expect(text).toContain('箱规明细')
expect(text).not.toContain('租户')
expect(text).not.toContain('数据版本')
expect(wrapper.find('.measurement-table').exists()).toBe(true)
expect(wrapper.find('.measurement-item').exists()).toBe(false)
expect(wrapper.find('.measurement-table').text()).toContain('周长(cm)')
expect(wrapper.find('.measurement-table').text()).not.toContain('周长')
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
expect(source).toContain('<el-collapse-item name="declaration" title="申报信息">')
expect(source).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
expect(source).toContain('.measurement-form-grid, .measurement-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));')
expect(source).toContain('.measurement-table { width: 100%; font-size: 12px; }')
expect(source).toContain('.measurement-data-grid :deep(.el-input__wrapper)')
@@ -224,7 +226,7 @@ describe('InboundMeasurementPanel', () => {
wrapper.vm.updateActualGrossWeight(125.75)
await flushPromises()
expect(wrapper.findAll('input[readonly]').some((input) => (input.element as HTMLInputElement).value === '125.75')).toBe(true)
expect(wrapper.find('.measurement-metrics').text()).toContain('125.75')
expect((countInput.element as HTMLInputElement).value).toBe('9')
})
@@ -232,8 +234,8 @@ describe('InboundMeasurementPanel', () => {
const wrapper = await mountPanel()
expect(wrapper.classes()).toContain('surface-panel')
expect(wrapper.find('.measurement-panel__heading').exists()).toBe(true)
expect(wrapper.findAll('input[readonly]').some((input) => (input.element as HTMLInputElement).value === '测量中')).toBe(true)
expect(wrapper.find('.measurement-summary-header').exists()).toBe(true)
expect(wrapper.find('.measurement-summary-status').text()).toContain('测量中')
})
it('saves only the changed confirmed package count with the current measurement version', async () => {
@@ -343,8 +345,9 @@ describe('InboundMeasurementPanel', () => {
await flushPromises()
expect(getInboundMeasurement).toHaveBeenLastCalledWith('10002', '7002')
expect(wrapper.findAll('input[readonly]').some((input) => (input.element as HTMLInputElement).value === 'RK202608040002')).toBe(true)
expect(wrapper.findAll('input[readonly]').some((input) => (input.element as HTMLInputElement).value === '测试企业')).toBe(false)
expect(wrapper.find('.measurement-summary-header').text()).toContain('RK202608040002')
expect(wrapper.find('.measurement-summary-header').text()).toContain('第二企业')
expect(wrapper.find('.measurement-summary-header').text()).not.toContain('测试企业')
})
it('renders a permission-specific read-only state without requesting data', async () => {
@@ -516,32 +516,45 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
<form class="measurement-form" @submit.prevent="saveDraft">
<fieldset :disabled="!mayMaintain || saving">
<legend class="sr-only">测量草稿</legend>
<section class="measurement-section">
<div class="measurement-section__heading measurement-panel__heading">
<div><h3>原单数据与测量汇总</h3></div>
</div>
<div class="measurement-data-grid">
<label><span>入仓号</span><el-input :model-value="detail.inboundCode || '—'" readonly /></label>
<label><span>公司</span><el-input :model-value="detail.companyName || '—'" readonly /></label>
<label><span>渠道</span><el-input :model-value="detail.channelName || detail.channelCode || '—'" readonly /></label>
<label><span>测量来源</span><el-input :model-value="sourceLabel" readonly /></label>
<label><span>当前状态</span><el-input :model-value="processStatusLabel(detail.processStatus)" readonly /></label>
<label><span>数据版本</span><el-input :model-value="String(form.version)" readonly /></label>
<label><span>确认箱数合计</span><el-input :model-value="formatNumber(confirmedPackageCount, 0)" readonly /></label>
<label><span>订单总箱数</span><el-input :model-value="formatNumber(expectedPackageCount, 0)" readonly /></label>
<label><span>货物总重量(kg)</span><el-input :model-value="formatNumber(detail.actualGrossWeightKg)" readonly /></label>
<label><span>原单总毛重(T.K.GW)</span><el-input-number v-model="form.originalGrossWeightKg" :min="0" :precision="3" controls-position="right" /></label>
<label><span>实测总体积(T.CBM)</span><el-input :model-value="formatNumber(detail.actualVolumeCbm, 4)" readonly /></label>
<label><span>原单总体积(T.CBM)</span><el-input-number v-model="form.originalVolumeCbm" :min="0" :precision="4" controls-position="right" /></label>
<label><span>实测总材积重(kg)</span><el-input :model-value="formatNumber(detail.volumeWeightKg)" readonly /></label>
<label><span>原单据号码(CKW)</span><el-input v-model="form.originalDocumentNo" placeholder="请输入原单据号码" maxlength="128" /></label>
<label><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="128" /></label>
</div>
<section class="measurement-section measurement-summary-section">
<header class="measurement-summary-header">
<div>
<span>入仓码</span>
<strong>{{ detail.inboundCode || '—' }}</strong>
<p>{{ detail.companyName || '客户未命名' }} · {{ detail.channelName || detail.channelCode || '渠道未填写' }}</p>
</div>
<div class="measurement-summary-status">
<el-tag effect="light">{{ processStatusLabel(detail.processStatus) }}</el-tag>
<span>{{ sourceLabel }}</span>
</div>
</header>
<dl class="measurement-metrics" aria-label="测量结果汇总">
<div :class="{ 'measurement-metric--warning': !packageCountMatched }">
<dt>实测箱数</dt>
<dd>{{ formatNumber(confirmedPackageCount, 0) }} <small>/ 申报 {{ formatNumber(expectedPackageCount, 0) }}</small></dd>
</div>
<div class="measurement-metric--primary"><dt>货物重量</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>kg</small></dd></div>
<div><dt>总体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, 4) }} <small>m³</small></dd></div>
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>kg</small></dd></div>
</dl>
<el-collapse class="measurement-more">
<el-collapse-item name="declaration" title="申报信息">
<div class="measurement-data-grid">
<label><span>申报箱数</span><el-input :model-value="formatNumber(expectedPackageCount, 0)" readonly /></label>
<label><span>申报毛重(kg)</span><el-input-number v-model="form.originalGrossWeightKg" :min="0" :precision="3" controls-position="right" /></label>
<label><span>申报体积(m³)</span><el-input-number v-model="form.originalVolumeCbm" :min="0" :precision="4" controls-position="right" /></label>
<label><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="128" /></label>
<label><span>原单据号码</span><el-input v-model="form.originalDocumentNo" placeholder="请输入原单据号码" maxlength="128" /></label>
</div>
</el-collapse-item>
</el-collapse>
</section>
<section class="measurement-section measurement-items-section">
<div class="measurement-section__heading">
<div><h3>扫描箱测量数据明细</h3><p>每条记录对应一个实际扫描箱;人工只确认该条记录代表的箱数,设备实测值不可在此修改。</p></div>
<div><h3>箱规明细</h3></div>
<el-button v-if="canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
</div>
@@ -559,7 +572,10 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
<dl class="measurement-row-details">
<div><dt>机器编号</dt><dd>{{ row.machineNo || '人工录入' }}</dd></div>
<div><dt>测量时间</dt><dd>{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}</dd></div>
<div><dt>每箱毛重</dt><dd>{{ formatNumber(row.unitWeightKg) }} kg</dd></div>
<div><dt>单箱体积</dt><dd>{{ formatNumber(row.serverValues.unitVolumeCbm, 4) }} m³</dd></div>
<div><dt>箱规周长</dt><dd>{{ formatNumber(row.serverValues.girthCm) }} cm</dd></div>
<div><dt>总材积重</dt><dd>{{ formatNumber(row.serverValues.volumeWeightKg) }} kg</dd></div>
<div><dt>备注</dt><dd>{{ row.remark || '—' }}</dd></div>
</dl>
</template>
@@ -585,24 +601,12 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
/>
</template>
</el-table-column>
<el-table-column label="每箱毛重(kg)" width="106" align="right">
<template #default="{ row }">{{ formatNumber(row.unitWeightKg) }}</template>
</el-table-column>
<el-table-column label="单箱体积(m³)" width="112" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.unitVolumeCbm, 4) }}</template>
</el-table-column>
<el-table-column label="周长(cm)" width="92" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.girthCm) }}</template>
</el-table-column>
<el-table-column label="总毛重(kg)" width="104" align="right">
<el-table-column label="总重量(kg)" width="112" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.totalWeightKg) }}</template>
</el-table-column>
<el-table-column label="总体积(m³)" width="108" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.totalVolumeCbm, 4) }}</template>
</el-table-column>
<el-table-column label="总材积重(kg)" width="116" align="right">
<template #default="{ row }">{{ formatNumber(row.serverValues.volumeWeightKg) }}</template>
</el-table-column>
<el-table-column label="图片" width="82" align="center">
<template #default="{ row }">
<el-tooltip :content="row.intelligentImageFileId ? '预览测量图片' : '暂无测量图片'">
@@ -620,7 +624,7 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
<footer v-if="canEdit || canSubmit" class="measurement-panel__footer">
<span v-if="isDirty"><AlertTriangle :size="15" />有尚未保存的修改</span>
<span v-else>当前显示版本 {{ form.version }}</span>
<span v-else>所有修改已保存</span>
<div>
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft"><Save :size="15" />保存测量数据</el-button>
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="!maySubmit" @click="submitForReview"><Send :size="15" />提交审核</el-button>
@@ -680,20 +684,33 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
.measurement-panel__actions, .measurement-panel__footer > div { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button) { margin-left: 0; }
.measurement-skeleton { padding: 24px; }
.measurement-context { padding: 16px 20px; border-bottom: 1px solid var(--cc-border); }
.measurement-context__identity { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.measurement-context__identity > div:first-child { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.measurement-context__identity strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 15px; }
.measurement-context__identity span { color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.measurement-tags { display: flex; flex: 0 0 auto; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 7px; }
.measurement-tags > span:last-child { color: var(--cc-muted); font-size: 11px; }
.measurement-metrics { display: none; }
.measurement-panel > :deep(.el-alert), .measurement-alert { margin: 16px 20px 0; width: auto; }
.measurement-alert p { margin: 5px 0 0; line-height: 1.55; }
.measurement-alert ul { margin: 7px 0 0; padding-left: 18px; font-size: 12px; line-height: 1.65; }
.measurement-alert .el-button { margin-top: 9px; }
.measurement-form fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
.measurement-section { padding: 18px 20px; border-top: 1px solid var(--cc-border); }
.measurement-summary-section { display: grid; gap: 16px; border-top: 0; }
.measurement-summary-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.measurement-summary-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
.measurement-summary-header span { color: var(--cc-muted); font-size: 11px; }
.measurement-summary-header strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 20px; line-height: 1.3; }
.measurement-summary-header p { margin: 0; color: var(--cc-muted); font-size: 12px; }
.measurement-summary-status { display: flex; flex: 0 0 auto; align-items: center; gap: 9px; }
.measurement-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
.measurement-metrics > div { display: grid; min-width: 0; min-height: 78px; align-content: center; gap: 6px; padding: 13px 16px; border-right: 1px solid var(--cc-border); }
.measurement-metrics > div:last-child { border-right: 0; }
.measurement-metrics dt { color: var(--cc-muted); font-size: 12px; }
.measurement-metrics dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 19px; font-weight: 700; overflow-wrap: anywhere; }
.measurement-metrics small { color: var(--cc-muted); font-size: 11px; font-weight: 500; }
.measurement-metric--primary { background: #f0f7ff; }
.measurement-metric--primary dd { color: var(--cc-primary); font-size: 23px; }
.measurement-metric--warning { background: var(--el-color-warning-light-9); }
.measurement-metric--warning dd { color: var(--el-color-warning-dark-2); }
.measurement-more { border-top: 0; border-bottom: 0; }
.measurement-more :deep(.el-collapse-item__header) { height: 38px; color: var(--cc-muted); font-size: 12px; }
.measurement-more :deep(.el-collapse-item__wrap) { border-bottom: 0; }
.measurement-more :deep(.el-collapse-item__content) { padding: 4px 0 2px; }
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
.measurement-section__heading > div { min-width: 0; }
.measurement-form-grid, .measurement-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; }
@@ -732,11 +749,11 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
}
@media (max-width: 720px) {
.measurement-panel__heading, .measurement-context__identity, .measurement-section__heading, .measurement-panel__footer { align-items: stretch; flex-direction: column; }
.measurement-panel__heading, .measurement-summary-header, .measurement-section__heading, .measurement-panel__footer { align-items: stretch; flex-direction: column; }
.measurement-panel__actions, .measurement-panel__actions :deep(.el-button), .measurement-panel__footer > div, .measurement-panel__footer :deep(.el-button) { width: 100%; }
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button), .measurement-section__heading :deep(.el-button) { min-height: 44px; }
.measurement-tags { justify-content: flex-start; }
.measurement-context, .measurement-section { padding-right: 14px; padding-left: 14px; }
.measurement-section { padding-right: 14px; padding-left: 14px; }
.measurement-panel > :deep(.el-alert), .measurement-alert { margin-right: 14px; margin-left: 14px; }
.measurement-form-grid, .measurement-data-grid { grid-template-columns: 1fr; }
.measurement-row-details { grid-template-columns: 1fr; padding-left: 54px; }
@@ -21,7 +21,6 @@ import { inboundFeeStatusLabels, inboundLoadingStatusLabels, inboundOrderStatusL
type FilterModel = QueryModel & {
customerId: string
inboundCode: string
orderNo: string
warehouseId: string
deliveryWarehouseId: string
processStatus: string
@@ -44,7 +43,7 @@ const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const printingInboundId = ref('')
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', orderNo: '', warehouseId: '', deliveryWarehouseId: '', processStatus: '', orderStatus: '', measureReviewStatus: '', customerName: '', destinationCode: '', feeStatus: '', feeReviewStatus: '', holdFlag: '', expectedArrivalRange: [] })
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', warehouseId: '', deliveryWarehouseId: '', processStatus: '', orderStatus: '', measureReviewStatus: '', customerName: '', destinationCode: '', feeStatus: '', feeReviewStatus: '', holdFlag: '', expectedArrivalRange: [] })
const appliedFilters = ref<FilterModel>({ ...filters.value, expectedArrivalRange: [] })
const selectedCustomerId = ref('')
const appliedCustomerId = ref('')
@@ -86,7 +85,6 @@ const allQueryFields = computed<QueryField[]>(() => [
{ key: 'warehouseId', type: 'select', width: 180, label: '定位仓库', placeholder: warehousesUnavailable.value ? '定位仓库加载失败' : '定位仓库', options: warehouseOptions.value, props: { 'aria-label': '定位仓库', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
{ key: 'deliveryWarehouseId', type: 'select', width: 180, label: '交货仓', placeholder: warehousesUnavailable.value ? '交货仓加载失败' : '交货仓', options: warehouseOptions.value, props: { 'aria-label': '交货仓', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
{ key: 'inboundCode', type: 'input', width: 220, label: '入仓码', placeholder: '入仓码' },
{ key: 'orderNo', type: 'input', width: 220, label: '头程订单号', placeholder: '头程订单号' },
{ key: 'processStatus', type: 'select', width: 160, label: '流程状态', placeholder: '流程状态', options: [
{ label: '待测量', value: 'WAIT_MEASURE' }, { label: '测量中', value: 'MEASURING' },
{ label: '待测量审核', value: 'MEASURE_PENDING' }, { label: '测量已驳回', value: 'MEASURE_REJECTED' },
@@ -251,7 +249,6 @@ async function loadInbounds() {
size: pageSize.value,
tab: tab.value || undefined,
inboundCode: queryValue(applied.inboundCode),
orderNo: queryValue(applied.orderNo),
warehouseId: queryValue(applied.warehouseId),
deliveryWarehouseId: queryValue(applied.deliveryWarehouseId),
processStatus: queryValue(applied.processStatus),
@@ -416,7 +413,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; customerRequestId +
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="inbound-orders-table" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
<template #pagination-left>当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</template>
<template #actions="{ row }"><div v-if="access.can('platform:firstleg:inbound:view')" class="row-actions"><el-button size="small" type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button size="small" plain type="primary" :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button size="small" plain type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div><span v-else class="muted-action">无查看权限</span></template>
<template #inbound="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
<template #inbound="{ row }"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong></template>
<template #processStatus="{ row }"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag></template>
<template #orderStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).orderStatusSnapshot, inboundOrderStatusLabels) }}</template>
<template #measureReviewStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).measureReviewStatus, measureReviewLabels) }}</template>
@@ -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>
@@ -1,11 +1,12 @@
import ElementPlus from 'element-plus'
import { ElMessageBox } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } 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 { ApiResponseError } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import {
confirmWeightResult,
correctWeightResult,
@@ -92,11 +93,9 @@ function mountPanel(permissions = allWeightPermissions) {
})
}
async function openWeightDialog(wrapper: VueWrapper, reviewEvidence = true) {
async function openWeightDialog(wrapper: VueWrapper) {
await wrapper.get('button[aria-label="查看称重图片 1"]').trigger('click')
await flushPromises()
const evidence = document.body.querySelector<HTMLInputElement>('.ocr-review-check input[type="checkbox"]')
if (reviewEvidence && evidence) await new DOMWrapper(evidence).setValue(true)
}
function dialogWeightInput() {
@@ -117,12 +116,6 @@ function dialogConfirmButton() {
return new DOMWrapper(button)
}
function dialogEvidenceCheckbox() {
const input = document.body.querySelector<HTMLInputElement>('.ocr-review-check input[type="checkbox"]')
if (!input) throw new Error('OCR evidence checkbox is missing')
return new DOMWrapper(input)
}
describe('WeightOcrPanel confirmation behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -178,7 +171,9 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(uploadWeightImage).toHaveBeenCalledWith('10001', '20001', file)
expect(createWeightResult).toHaveBeenCalledWith('10001', '20001', expect.objectContaining({ palletId: '50001' }))
expect(document.body.textContent).toContain('确认称重图片')
expect(document.body.textContent).toContain('识别重量 12.500 KG')
expect(document.body.textContent).toContain('当前托盘皮重')
expect(document.body.textContent).toContain('OCR 识别毛重')
expect(document.body.textContent).toContain('12.500 KG')
expect(document.body.querySelector('input[aria-label="当前图片确认重量"]')).not.toBeNull()
})
@@ -215,17 +210,21 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(deleteWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', 0)
})
it('requires image review before confirming even a high-confidence OCR value', async () => {
it('shows only the key weights and allows direct confirmation', async () => {
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper, false)
await openWeightDialog(wrapper)
expect(document.body.textContent).toContain('OCR 原文')
expect(document.body.textContent).toContain('285.5')
expect(document.body.textContent).toContain('98%')
expect(document.body.textContent).toContain('OCR 可能误读数字或小数点')
expect(dialogConfirmButton().attributes('disabled')).toBeDefined()
await dialogEvidenceCheckbox().setValue(true)
const dialogText = document.body.textContent || ''
expect(dialogText).toContain('当前托盘皮重')
expect(dialogText).toContain('12.000 KG')
expect(dialogText).toContain('OCR 识别毛重')
expect(dialogText).toContain('285.500 KG')
expect(dialogText).toContain('确认含托盘毛重')
for (const hiddenLabel of ['托盘编码', '托盘类型', '托盘容量', '识别状态', '置信度', '服务端毛重', '服务端净重', 'OCR 原文']) {
expect(dialogText).not.toContain(hiddenLabel)
}
expect(dialogConfirmButton().attributes('disabled')).toBeUndefined()
await dialogConfirmButton().trigger('click')
await flushPromises()
@@ -255,6 +254,18 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(wrapper.text()).toContain('285.500 KG')
})
it('closes the confirmation dialog immediately after a successful confirmation', async () => {
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true)
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(false)
})
it('requires a pallet selection before opening the file picker', async () => {
const wrapper = mountPanel()
await flushPromises()
@@ -292,22 +303,24 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(getWeightPalletOptions).not.toHaveBeenCalled()
})
it('requires explicit image review before confirming a low-confidence OCR value', async () => {
it('keeps a low-confidence OCR result focused on the editable weight', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue({
...workspace,
weightResults: [{ ...pendingWeight, ocrRawText: '5360', ocrWeightValue: 5360, confidence: 0.51863 }],
})
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper, false)
await openWeightDialog(wrapper)
expect(document.body.textContent).toContain('OCR 可能误读数字或小数点')
expect(document.body.textContent).toContain('OCR 原文')
expect(document.body.textContent).toContain('5360')
expect(document.body.textContent).toContain('52%')
expect(dialogConfirmButton().attributes('disabled')).toBeDefined()
const dialogText = document.body.textContent || ''
expect(dialogText).toContain('OCR 识别毛重')
expect(dialogText).toContain('5360.000 KG')
expect(dialogText).toContain('确认含托盘毛重')
expect(dialogText).not.toContain('置信度')
expect(dialogText).not.toContain('OCR 原文')
expect(dialogText).not.toContain('OCR 可能误读数字或小数点')
expect(dialogConfirmButton().attributes('disabled')).toBeUndefined()
await dialogEvidenceCheckbox().setValue(true)
await dialogConfirmButton().trigger('click')
await flushPromises()
@@ -338,16 +351,16 @@ describe('WeightOcrPanel confirmation behavior', () => {
await flushPromises()
await openWeightDialog(wrapper)
await dialogWeightInput().setValue('10.125')
await dialogWeightInput().setValue('30.125')
await dialogUnit(1).trigger('click')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
version: 0,
adjustedWeightValue: 10.125,
adjustedWeightValue: 30.125,
adjustedWeightUnit: 'LB',
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 10.125 LB',
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 30.125 LB',
})
expect(correctWeightResult).not.toHaveBeenCalled()
})
@@ -364,7 +377,8 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(confirmWeightResult).not.toHaveBeenCalled()
})
it('accepts the smallest LB value that remains representable in kilograms', async () => {
it('rejects a positive gross weight that does not exceed the pallet tare', async () => {
const warning = vi.spyOn(ElMessage, 'warning')
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
@@ -374,12 +388,8 @@ describe('WeightOcrPanel confirmation behavior', () => {
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
version: 0,
adjustedWeightValue: 0.002,
adjustedWeightUnit: 'LB',
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 0.002 LB',
})
expect(confirmWeightResult).not.toHaveBeenCalled()
expect(warning).toHaveBeenCalledWith('称重毛重必须大于托盘皮重 12.000 KG')
})
it('clamps a value outside the safe three-decimal JavaScript range', async () => {
@@ -407,16 +417,16 @@ describe('WeightOcrPanel confirmation behavior', () => {
await openWeightDialog(wrapper)
const input = dialogWeightInput()
await input.setValue('1.2345')
expect((input.element as HTMLInputElement).value).toBe('1.235')
await input.setValue('13.2345')
expect((input.element as HTMLInputElement).value).toBe('13.235')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
version: 0,
adjustedWeightValue: 1.235,
adjustedWeightValue: 13.235,
adjustedWeightUnit: 'KG',
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 1.235 KG',
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 13.235 KG',
})
})
@@ -487,16 +497,15 @@ describe('WeightOcrPanel confirmation behavior', () => {
await openWeightDialog(wrapper)
expect(dialogConfirmButton().attributes('disabled')).toBeDefined()
await dialogWeightInput().setValue('1.500')
await dialogEvidenceCheckbox().setValue(true)
await dialogWeightInput().setValue('13.500')
await dialogConfirmButton().trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
version: 0,
adjustedWeightValue: 1.5,
adjustedWeightValue: 13.5,
adjustedWeightUnit: 'KG',
adjustmentReason: 'OCR 原值 —,人工确认 1.500 KG',
adjustmentReason: 'OCR 原值 —,人工确认 13.500 KG',
})
})
@@ -556,7 +565,10 @@ describe('WeightOcrPanel confirmation behavior', () => {
})
it('keeps the edited draft available when confirmation fails', async () => {
vi.mocked(confirmWeightResult).mockRejectedValueOnce(new ApiResponseError('数据版本已变化,请刷新后重试', 'A0409', 409))
const errorMessage = vi.spyOn(ElMessage, 'error')
vi.mocked(confirmWeightResult).mockRejectedValueOnce(
new ApiResponseError('数据版本已变化,请刷新后重试', 'A0409', 409, undefined, 'request-1'),
)
const wrapper = mountPanel()
await flushPromises()
await openWeightDialog(wrapper)
@@ -567,7 +579,13 @@ describe('WeightOcrPanel confirmation behavior', () => {
await flushPromises()
expect((input.element as HTMLInputElement).value).toBe('286.125')
expect(wrapper.text()).toContain('数据版本已变化,请刷新后重试')
expect(wrapper.text()).not.toContain('操作未完成')
expect(errorMessage).toHaveBeenCalledWith(expect.objectContaining({
message: '数据版本已变化,请刷新后重试(请求 ID:request-1)',
duration: 5_000,
showClose: true,
}))
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true)
expect(confirmWeightResult).toHaveBeenCalledTimes(1)
})
})
@@ -20,12 +20,13 @@ describe('WeightOcrPanel contract', () => {
expect(source).toContain('palletId: selectedPalletId.value')
expect(source).toContain('downloadWeightImage')
expect(source).toContain('await openPreview(created, URL.createObjectURL(file))')
expect(source).toContain('previewShowsRecognition.value = Boolean(localUrl)')
expect(source).toContain('deleteWeightResult')
expect(source).toContain('confirmWeightResult')
expect(source).toContain('class="weight-card__value"')
expect(source).toContain('v-model="weightDrafts[previewResult.id].value"')
expect(source).toContain('识别重量')
expect(source).toContain('当前托盘皮重')
expect(source).toContain('OCR 识别毛重')
expect(source).toContain('确认含托盘毛重')
expect(source).toContain('payload.adjustedWeightValue = Number(draft.value)')
expect(source).toContain('payload.adjustmentReason = buildAdjustmentReason(row)')
expect(source).toContain('货物总重量')
@@ -38,11 +39,15 @@ describe('WeightOcrPanel contract', () => {
expect(source).toContain("['image/jpeg', 'image/png', 'image/bmp']")
expect(source).toContain('5 * 1024 * 1024')
expect(source).toContain('ocrRawText: getWeightOcrRawText(upload)')
const template = source.match(/<template>([\s\S]*?)<\/template>/)?.[1] || ''
expect(template).not.toContain('置信度')
expect(template).not.toContain('OCR 原文')
expect(template).not.toContain('提供方')
expect(template).not.toContain('文件 ID')
expect(source).not.toContain('ocr-review-check')
expect(source).not.toContain('OCR 可能误读数字或小数点')
expect(source).not.toContain('<span>置信度</span>')
expect(source).not.toContain('<span>OCR 原文</span>')
expect(source).not.toContain('<span>托盘编码</span>')
expect(source).not.toContain('<span>托盘类型</span>')
expect(source).not.toContain('<span>托盘容量</span>')
expect(source).not.toContain('<span>服务端毛重</span>')
expect(source).not.toContain('<span>服务端净重</span>')
})
it('gates every action with the implemented permissions and editable statuses', () => {
@@ -24,7 +24,7 @@ import {
} from '../api/weightOcr'
import type { WeightPalletOption, WeightResultResource, WeightWorkspaceResource } from '../types'
import WeightImageThumbnail from './WeightImageThumbnail.vue'
import { inboundProcessStatusLabels, inboundStatusLabel, weightRecognitionStatusLabels } from '../../status'
import { inboundProcessStatusLabels, inboundStatusLabel } from '../../status'
const props = defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()
const emit = defineEmits<{
@@ -42,16 +42,13 @@ const palletError = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
const uploading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const activeAction = ref('')
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewError = ref<ApiErrorDetails | null>(null)
const previewUrl = ref('')
const previewShowsRecognition = ref(false)
const previewResultId = ref('')
const previewSnapshot = ref<WeightResultResource | null>(null)
const previewEvidenceReviewed = ref(false)
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB' }>>({})
let loadSequence = 0
@@ -72,25 +69,12 @@ const previewConfirmDisabled = computed(() => {
return !row
|| !isWeightDraftValid(row)
|| Boolean(activeAction.value)
|| (requiresEvidenceReview(row) && !previewEvidenceReviewed.value)
})
function formatWeight(value: number | null, unit = 'KG') {
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
}
function formatConfidence(value: number | null) {
return value == null ? '未返回' : `${Math.round(value * 100)}%`
}
function recognitionLabel(status: string) {
return inboundStatusLabel(status, weightRecognitionStatusLabels, '未知状态')
}
function requiresEvidenceReview(row: WeightResultResource) {
return row.recognitionStatus !== 'CONFIRMED' && row.adjustedWeightValue == null
}
function actionKey(action: string, id: string) {
return `${action}:${id}`
}
@@ -130,6 +114,23 @@ function isWeightDraftValid(row: WeightResultResource) {
&& value <= maximumSafeWeight)
}
function draftGrossWeightKg(row: WeightResultResource) {
const draft = weightDrafts.value[row.id]
if (!draft || !Number.isFinite(Number(draft.value))) return null
const value = Number(draft.value)
return draft.unit === 'LB' ? value * 0.45359237 : value
}
function showActionError(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
const requestReference = details.requestId ? `(请求 ID:${details.requestId})` : ''
ElMessage.error({
message: `${details.message}${requestReference}`,
duration: 5_000,
showClose: true,
})
}
function cardWeight(row: WeightResultResource) {
if (row.recognitionStatus === 'CONFIRMED') return formatWeight(row.netWeightKg)
const draft = weightDrafts.value[row.id]
@@ -233,7 +234,6 @@ async function handleFileChange(event: Event) {
return
}
uploading.value = true
actionError.value = null
try {
const upload = await uploadWeightImage(customerId.value, props.inboundId, file)
const created = await createWeightResult(customerId.value, props.inboundId, {
@@ -251,7 +251,7 @@ async function handleFileChange(event: Event) {
ensureWeightDraft(created)
emitWorkspaceChanged()
} catch (error) {
actionError.value = getApiErrorDetails(error, '称重图片上传失败,请检查后重试')
showActionError(error, '称重图片上传失败,请检查后重试')
} finally {
uploading.value = false
}
@@ -263,13 +263,13 @@ async function confirmResult(row: WeightResultResource, closeAfterSuccess = fals
ElMessage.warning('请输入大于 0 的有效重量')
return
}
if (requiresEvidenceReview(row) && !previewEvidenceReviewed.value) {
ElMessage.warning('请先对照图片核对小数点和重量单位')
const grossWeightKg = draftGrossWeightKg(row)
if (grossWeightKg == null || grossWeightKg <= Number(row.palletTareWeightKg)) {
ElMessage.warning(`称重毛重必须大于托盘皮重 ${formatWeight(row.palletTareWeightKg)}`)
return
}
const key = actionKey('confirm', row.id)
activeAction.value = key
actionError.value = null
try {
const payload = { version: row.version } as Parameters<typeof confirmWeightResult>[3]
if (hasWeightDraftChanged(row)) {
@@ -282,13 +282,13 @@ async function confirmResult(row: WeightResultResource, closeAfterSuccess = fals
payload.adjustmentReason = buildAdjustmentReason(row)
}
await confirmWeightResult(customerId.value, props.inboundId, row.id, payload)
if (closeAfterSuccess) previewOpen.value = false
ElMessage.success('重量已确认')
await loadWorkspace()
if (closeAfterSuccess) previewOpen.value = false
emitWorkspaceChanged()
return true
} catch (error) {
actionError.value = getApiErrorDetails(error, '重量确认失败,请刷新后重试')
showActionError(error, '重量确认失败,请刷新后重试')
} finally {
activeAction.value = ''
}
@@ -304,8 +304,6 @@ async function openPreview(row: WeightResultResource, localUrl = '') {
releasePreview()
previewResultId.value = row.id
previewSnapshot.value = row
previewEvidenceReviewed.value = false
previewShowsRecognition.value = Boolean(localUrl)
previewError.value = null
previewOpen.value = true
if (localUrl) {
@@ -338,7 +336,6 @@ async function removeResult(row: WeightResultResource) {
}
const key = actionKey('delete', row.id)
activeAction.value = key
actionError.value = null
try {
await deleteWeightResult(customerId.value, props.inboundId, row.id, row.version)
if (previewResultId.value === row.id) previewOpen.value = false
@@ -346,7 +343,7 @@ async function removeResult(row: WeightResultResource) {
await loadWorkspace()
emitWorkspaceChanged()
} catch (error) {
actionError.value = getApiErrorDetails(error, '称重图片删除失败,请刷新后重试')
showActionError(error, '称重图片删除失败,请刷新后重试')
} finally {
activeAction.value = ''
}
@@ -362,7 +359,6 @@ watch(
palletError.value = null
weightDrafts.value = {}
loadError.value = null
actionError.value = null
previewOpen.value = false
releasePreview()
void loadWorkspace()
@@ -373,7 +369,6 @@ watch(
watch(previewOpen, (open) => {
if (!open) {
previewEvidenceReviewed.value = false
releasePreview()
}
})
@@ -387,7 +382,6 @@ defineExpose({ reload: loadWorkspace })
<header class="weight-heading">
<div>
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片</h3>
<p>先选择托盘,再上传图片核对含托盘毛重;皮重与货物净重由服务端计算。</p>
</div>
<div class="weight-heading__actions">
<div v-if="workspace" class="weight-summary" aria-live="polite">
@@ -472,21 +466,9 @@ defineExpose({ reload: loadWorkspace })
当前入库状态为 {{ inboundStatusLabel(workspace.processStatus, inboundProcessStatusLabels, '未知状态') }},称重数据仅供查看。
</el-alert>
<LoadErrorState
v-if="actionError"
title="操作未完成"
:message="actionError.message"
:show-retry="false"
compact
class="weight-action-error"
>
<template #details><ApiErrorMeta :details="actionError" /></template>
</LoadErrorState>
<div v-loading="loading" class="weight-list" aria-live="polite">
<div v-if="!loading && rows.length === 0" class="weight-empty">
<strong>尚未上传称重图片</strong>
<p>上传 JPG、PNG 或 BMP 图片,单张不超过 5MB。</p>
</div>
<div v-else class="weight-grid">
@@ -538,7 +520,7 @@ defineExpose({ reload: loadWorkspace })
:show-confirm="Boolean(previewResult && previewResult.recognitionStatus !== 'CONFIRMED' && canConfirm && editable)"
confirm-text="确认重量"
confirm-type="success"
width="min(680px, calc(100vw - 28px))"
width="min(640px, calc(100vw - 28px))"
@confirm="previewResult && confirmResult(previewResult, true)"
>
<LoadErrorState
@@ -552,35 +534,11 @@ defineExpose({ reload: loadWorkspace })
</LoadErrorState>
<template v-else-if="previewResult">
<img v-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
<dl class="ocr-evidence" aria-label="OCR 识别证据">
<div><dt>托盘编码</dt><dd>{{ previewResult.palletCode }}</dd></div>
<div><dt>托盘类型</dt><dd>{{ previewResult.palletType }}</dd></div>
<div><dt>托盘皮重</dt><dd>{{ formatWeight(previewResult.palletTareWeightKg) }}</dd></div>
<div><dt>托盘容量</dt><dd>{{ previewResult.palletCapacityCbm }} m³</dd></div>
<div><dt>识别状态</dt><dd>{{ recognitionLabel(previewResult.recognitionStatus) }}</dd></div>
<div><dt>置信度</dt><dd>{{ formatConfidence(previewResult.confidence) }}</dd></div>
<div><dt>服务端毛重</dt><dd>{{ formatWeight(previewResult.grossWeightKg) }}</dd></div>
<div><dt>服务端净重</dt><dd>{{ formatWeight(previewResult.netWeightKg) }}</dd></div>
<div class="ocr-evidence__raw"><dt>OCR 原文</dt><dd>{{ previewResult.ocrRawText || '未识别到有效重量' }}</dd></div>
<div v-if="previewResult.adjustmentReason" class="ocr-evidence__raw">
<dt>人工修正</dt><dd>{{ previewResult.adjustmentReason }}</dd>
</div>
</dl>
<el-alert
v-if="previewResult.recognitionStatus !== 'CONFIRMED' && requiresEvidenceReview(previewResult)"
class="ocr-review-alert"
type="warning"
:closable="false"
show-icon
title="OCR 可能误读数字或小数点,确认前请对照原图核对。"
/>
<div v-if="previewResult.recognitionStatus === 'CONFIRMED'" class="preview-weight is-confirmed">
<span>货物净重</span><strong>{{ formatWeight(previewResult.netWeightKg) }}</strong>
<div class="weight-key-facts" aria-label="称重关键数据">
<div><span>当前托盘皮重</span><strong>{{ formatWeight(previewResult.palletTareWeightKg) }}</strong></div>
<div><span>OCR 识别毛重</span><strong>{{ formatWeight(previewResult.ocrWeightValue, previewResult.ocrWeightUnit || 'KG') }}</strong></div>
</div>
<div v-else-if="weightDrafts[previewResult.id]" class="preview-weight">
<span v-if="previewShowsRecognition" class="preview-weight__recognized">
识别重量 {{ formatWeight(previewResult.ocrWeightValue, previewResult.ocrWeightUnit || '') }}
</span>
<div v-if="weightDrafts[previewResult.id]" class="preview-weight">
<label>
<span>确认含托盘毛重</span>
<el-input-number
@@ -590,21 +548,15 @@ defineExpose({ reload: loadWorkspace })
:max="maximumSafeWeight"
:precision="3"
:controls="false"
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
:disabled="previewResult.recognitionStatus === 'CONFIRMED' || Boolean(activeAction) || !editable || !canEdit || !canConfirm"
/>
</label>
<el-segmented
v-model="weightDrafts[previewResult.id].unit"
aria-label="当前图片重量单位"
:options="['KG', 'LB']"
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
:disabled="previewResult.recognitionStatus === 'CONFIRMED' || Boolean(activeAction) || !editable || !canEdit || !canConfirm"
/>
<el-checkbox
v-if="requiresEvidenceReview(previewResult)"
v-model="previewEvidenceReviewed"
class="ocr-review-check"
:disabled="Boolean(activeAction)"
>已对照图片核对小数点和重量单位</el-checkbox>
</div>
</template>
</AppDialog>
@@ -616,7 +568,6 @@ defineExpose({ reload: loadWorkspace })
.weight-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
.weight-heading h3 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 15px; }
.weight-heading h3 svg { color: var(--cc-primary); }
.weight-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
.weight-heading__actions { display: flex; flex: 0 0 auto; align-items: center; gap: 16px; }
.pallet-picker { display: grid; min-width: 260px; gap: 4px; }
.pallet-picker small { color: var(--cc-muted); font-size: 11px; line-height: 1.4; }
@@ -625,10 +576,9 @@ defineExpose({ reload: loadWorkspace })
.weight-summary strong { color: #067647; font-size: 18px; }
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.weight-state-note, .weight-action-error { margin-bottom: 14px; }
.weight-list { min-height: 120px; margin-top: 16px; }
.weight-empty { display: grid; min-height: 180px; place-items: center; align-content: center; gap: 8px; padding: 24px; color: var(--cc-muted); text-align: center; }
.weight-list { min-height: 0; margin-top: 14px; }
.weight-empty { display: flex; min-height: 0; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 4px; background: var(--cc-surface-muted); color: var(--cc-muted); }
.weight-empty strong { color: #344054; font-size: 14px; }
.weight-empty p { margin: 0; font-size: 12px; }
.weight-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; }
.weight-card { position: relative; min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
.weight-card__body { padding: 12px; text-align: center; }
@@ -640,22 +590,14 @@ defineExpose({ reload: loadWorkspace })
.weight-card__facts div { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; line-height: 1.4; }
.weight-card__facts dt { color: var(--cc-muted); }
.weight-card__facts dd { min-width: 0; margin: 0; color: #344054; overflow-wrap: anywhere; text-align: right; }
.weight-preview { display: block; width: 100%; max-height: min(58vh, 560px); object-fit: contain; border-radius: 6px; background: #f2f4f7; }
.ocr-evidence { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 18px; margin: 14px 0 0; padding: 12px 0; border-block: 1px solid var(--cc-border); }
.ocr-evidence div { min-width: 0; }
.ocr-evidence dt { color: var(--cc-muted); font-size: 12px; }
.ocr-evidence dd { margin: 4px 0 0; color: #344054; font-size: 13px; line-height: 1.5; overflow-wrap: anywhere; white-space: pre-wrap; }
.ocr-evidence__raw { grid-column: 1 / -1; }
.ocr-review-alert { margin-top: 12px; }
.weight-preview { display: block; width: 100%; max-height: min(50vh, 460px); object-fit: contain; border-radius: 6px; background: #f2f4f7; }
.weight-key-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; margin-top: 14px; padding: 14px 0; border-block: 1px solid var(--cc-border); }
.weight-key-facts div { display: grid; min-width: 0; gap: 4px; }
.weight-key-facts span { color: var(--cc-muted); font-size: 12px; }
.weight-key-facts strong { color: #101828; font-size: 18px; }
.preview-weight { display: grid; grid-template-columns: minmax(0, 1fr) 120px; align-items: end; gap: 12px; margin-top: 14px; }
.preview-weight__recognized { grid-column: 1 / -1; color: var(--cc-muted); font-size: 12px; }
.preview-weight label { display: grid; gap: 6px; color: #475467; font-size: 12px; }
.preview-weight :deep(.el-input-number), .preview-weight :deep(.el-segmented) { width: 100%; }
.ocr-review-check { grid-column: 1 / -1; white-space: normal; }
.ocr-review-check :deep(.el-checkbox__label) { line-height: 1.5; white-space: normal; }
.preview-weight.is-confirmed { display: flex; align-items: baseline; justify-content: space-between; padding: 12px 0 0; }
.preview-weight.is-confirmed span { color: var(--cc-muted); font-size: 12px; }
.preview-weight.is-confirmed strong { color: #067647; font-size: 20px; }
@media (max-width: 720px) {
.weight-heading { flex-direction: column; }
@@ -668,11 +610,10 @@ defineExpose({ reload: loadWorkspace })
}
@media (max-width: 360px) {
.weight-grid { grid-template-columns: 1fr; }
.weight-grid { grid-template-columns: 1fr; }
.preview-weight { grid-template-columns: 1fr; }
.preview-weight__recognized { grid-column: 1; }
.ocr-evidence { grid-template-columns: 1fr; }
.ocr-evidence__raw { grid-column: 1; }
.weight-key-facts { grid-template-columns: 1fr 1fr; gap: 12px; }
.weight-key-facts strong { font-size: 15px; }
.weight-card__facts div { align-items: flex-start; flex-direction: column; gap: 2px; }
.weight-card__facts dd { text-align: left; }
}