feat: 支持OCR重量直接修改确认
This commit is contained in:
@@ -14,7 +14,7 @@ describe('WeightOcrPanel contract', () => {
|
||||
expect(template).not.toContain('租户')
|
||||
})
|
||||
|
||||
it('covers upload, preview, OCR retry, correction, confirmation, and server totals', () => {
|
||||
it('covers upload, preview, OCR retry, inline correction, confirmation, and server totals', () => {
|
||||
expect(source).toContain('uploadWeightImage')
|
||||
expect(source).toContain('downloadWeightImage')
|
||||
expect(source).toContain('retryWeightRecognition')
|
||||
@@ -23,6 +23,11 @@ describe('WeightOcrPanel contract', () => {
|
||||
expect(source).toContain('实际总毛重(服务端汇总)')
|
||||
expect(source).toContain('称重图片序号 #{{ index + 1 }}')
|
||||
expect(source).toContain('workspace.value?.actualGrossWeightKg')
|
||||
expect(source).toContain('v-model="weightDrafts[row.id].value"')
|
||||
expect(source).toContain('识别值已自动填入,可修改后直接确认保存。')
|
||||
expect(source).toContain("adjustmentReason: '人工核对后修改识别重量'")
|
||||
expect(source).toContain('version = corrected.version')
|
||||
expect(source).not.toContain('title="人工修正重量"')
|
||||
})
|
||||
|
||||
it('matches backend image limits and preserves missing OCR text honestly', () => {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
FileImage,
|
||||
ImagePlus,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Scale,
|
||||
} from '@lucide/vue'
|
||||
@@ -32,25 +31,17 @@ const emit = defineEmits<{ changed: [] }>()
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const correctionFormRef = ref<FormInstance>()
|
||||
const workspace = ref<WeightWorkspaceResource | 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 correctionOpen = ref(false)
|
||||
const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref<ApiErrorDetails | null>(null)
|
||||
const previewUrl = ref('')
|
||||
const correction = reactive({
|
||||
resultId: '',
|
||||
version: 0,
|
||||
value: null as number | null,
|
||||
unit: 'KG' as 'KG' | 'LB',
|
||||
reason: '',
|
||||
})
|
||||
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB' }>>({})
|
||||
let loadSequence = 0
|
||||
|
||||
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
|
||||
@@ -64,18 +55,6 @@ const confirmedCount = computed(() => rows.value.filter((row) => row.recognition
|
||||
const pendingCount = computed(() => rows.value.length - confirmedCount.value)
|
||||
const confirmedTotal = computed(() => workspace.value?.actualGrossWeightKg ?? null)
|
||||
|
||||
const correctionRules: FormRules<typeof correction> = {
|
||||
value: [
|
||||
{ required: true, message: '请输入有效重量', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => Number(value) > 0 ? callback() : callback(new Error('重量必须大于 0')),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
unit: [{ required: true, message: '请选择重量单位', trigger: 'change' }],
|
||||
reason: [{ required: true, message: '请填写人工修正原因', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function formatWeight(value: number | null, unit = 'KG') {
|
||||
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
|
||||
}
|
||||
@@ -103,9 +82,34 @@ function isActionLoading(action: string, id: string) {
|
||||
return activeAction.value === actionKey(action, id)
|
||||
}
|
||||
|
||||
function normalizeUnit(value: string | null) {
|
||||
return value?.toUpperCase().startsWith('LB') ? 'LB' as const : 'KG' as const
|
||||
}
|
||||
|
||||
function syncWeightDrafts(results: WeightResultResource[]) {
|
||||
weightDrafts.value = Object.fromEntries(results.map((row) => [row.id, {
|
||||
value: row.adjustedWeightValue ?? row.ocrWeightValue,
|
||||
unit: normalizeUnit(row.adjustedWeightUnit ?? row.ocrWeightUnit),
|
||||
}]))
|
||||
}
|
||||
|
||||
function isWeightDraftValid(row: WeightResultResource) {
|
||||
const draft = weightDrafts.value[row.id]
|
||||
return Boolean(draft && Number.isFinite(Number(draft.value)) && Number(draft.value) > 0)
|
||||
}
|
||||
|
||||
function hasWeightDraftChanged(row: WeightResultResource) {
|
||||
const draft = weightDrafts.value[row.id]
|
||||
if (!draft) return false
|
||||
const savedValue = row.adjustedWeightValue ?? row.ocrWeightValue
|
||||
const savedUnit = normalizeUnit(row.adjustedWeightUnit ?? row.ocrWeightUnit)
|
||||
return Number(draft.value) !== Number(savedValue) || draft.unit !== savedUnit
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
if (!customerId.value || !props.inboundId || !canView.value) {
|
||||
workspace.value = null
|
||||
weightDrafts.value = {}
|
||||
return
|
||||
}
|
||||
const sequence = ++loadSequence
|
||||
@@ -113,7 +117,10 @@ async function loadWorkspace() {
|
||||
loadError.value = null
|
||||
try {
|
||||
const result = await getWeightWorkspace(customerId.value, props.inboundId)
|
||||
if (sequence === loadSequence) workspace.value = result
|
||||
if (sequence === loadSequence) {
|
||||
workspace.value = result
|
||||
syncWeightDrafts(result.weightResults)
|
||||
}
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return
|
||||
workspace.value = null
|
||||
@@ -171,45 +178,31 @@ async function handleFileChange(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
function openCorrection(row: WeightResultResource) {
|
||||
correction.resultId = row.id
|
||||
correction.version = row.version
|
||||
correction.value = row.adjustedWeightValue ?? row.ocrWeightValue
|
||||
correction.unit = (row.adjustedWeightUnit ?? row.ocrWeightUnit) === 'LB' ? 'LB' : 'KG'
|
||||
correction.reason = ''
|
||||
correctionOpen.value = true
|
||||
void correctionFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function submitCorrection() {
|
||||
if (!await correctionFormRef.value?.validate().catch(() => false) || correction.value == null) return
|
||||
const key = actionKey('correct', correction.resultId)
|
||||
activeAction.value = key
|
||||
actionError.value = null
|
||||
try {
|
||||
await correctWeightResult(customerId.value, props.inboundId, correction.resultId, {
|
||||
version: correction.version,
|
||||
adjustedWeightValue: correction.value,
|
||||
adjustedWeightUnit: correction.unit,
|
||||
adjustmentReason: correction.reason.trim(),
|
||||
})
|
||||
correctionOpen.value = false
|
||||
ElMessage.success('重量修正已保存,请确认后计入总重量')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '重量修正失败,请刷新后重试')
|
||||
} finally {
|
||||
activeAction.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmResult(row: WeightResultResource) {
|
||||
const draft = weightDrafts.value[row.id]
|
||||
if (!draft || !isWeightDraftValid(row)) {
|
||||
ElMessage.warning('请输入大于 0 的有效重量')
|
||||
return
|
||||
}
|
||||
const key = actionKey('confirm', row.id)
|
||||
activeAction.value = key
|
||||
actionError.value = null
|
||||
try {
|
||||
await confirmWeightResult(customerId.value, props.inboundId, row.id, { version: row.version })
|
||||
let version = row.version
|
||||
if (hasWeightDraftChanged(row)) {
|
||||
if (!canEdit.value) {
|
||||
ElMessage.warning('当前账号缺少重量修改权限')
|
||||
return
|
||||
}
|
||||
const corrected = await correctWeightResult(customerId.value, props.inboundId, row.id, {
|
||||
version,
|
||||
adjustedWeightValue: Number(draft.value),
|
||||
adjustedWeightUnit: draft.unit,
|
||||
adjustmentReason: '人工核对后修改识别重量',
|
||||
})
|
||||
version = corrected.version
|
||||
}
|
||||
await confirmWeightResult(customerId.value, props.inboundId, row.id, { version })
|
||||
ElMessage.success('重量已确认并计入实际总重量')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
@@ -270,9 +263,9 @@ watch(
|
||||
() => {
|
||||
++loadSequence
|
||||
workspace.value = null
|
||||
weightDrafts.value = {}
|
||||
loadError.value = null
|
||||
actionError.value = null
|
||||
correctionOpen.value = false
|
||||
previewOpen.value = false
|
||||
releasePreview()
|
||||
void loadWorkspace()
|
||||
@@ -386,6 +379,40 @@ defineExpose({ reload: loadWorkspace })
|
||||
<span v-if="row.confirmedAt">确认时间:{{ formatTime(row.confirmedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="row.recognitionStatus !== 'CONFIRMED' && weightDrafts[row.id]"
|
||||
class="weight-row__editor"
|
||||
>
|
||||
<label>
|
||||
<span>确认重量</span>
|
||||
<el-input-number
|
||||
v-model="weightDrafts[row.id].value"
|
||||
:aria-label="`称重图片序号 ${index + 1} 的确认重量`"
|
||||
:min="0.001"
|
||||
:max="999999999999999.999"
|
||||
:precision="3"
|
||||
:controls="false"
|
||||
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>单位</span>
|
||||
<el-segmented
|
||||
v-model="weightDrafts[row.id].unit"
|
||||
:options="['KG', 'LB']"
|
||||
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
|
||||
/>
|
||||
</label>
|
||||
<el-button
|
||||
v-if="canConfirm && editable"
|
||||
type="success"
|
||||
:loading="isActionLoading('confirm', row.id)"
|
||||
:disabled="Boolean(activeAction) || !isWeightDraftValid(row) || (hasWeightDraftChanged(row) && !canEdit)"
|
||||
@click="confirmResult(row)"
|
||||
><CheckCircle2 :size="16" />确认并保存</el-button>
|
||||
<small>识别值已自动填入,可修改后直接确认保存。</small>
|
||||
</div>
|
||||
|
||||
<footer class="weight-row__actions">
|
||||
<el-button link type="primary" @click="openPreview(row)"><Eye :size="15" />查看图片</el-button>
|
||||
<el-button
|
||||
@@ -396,56 +423,12 @@ defineExpose({ reload: loadWorkspace })
|
||||
:disabled="Boolean(activeAction)"
|
||||
@click="retryOcr(row)"
|
||||
><RefreshCw :size="15" />重试 OCR</el-button>
|
||||
<el-button
|
||||
v-if="canEdit && editable"
|
||||
link
|
||||
type="primary"
|
||||
:disabled="Boolean(activeAction)"
|
||||
@click="openCorrection(row)"
|
||||
><Pencil :size="15" />人工修正</el-button>
|
||||
<el-button
|
||||
v-if="canConfirm && editable && row.recognitionStatus !== 'CONFIRMED'"
|
||||
link
|
||||
type="success"
|
||||
:loading="isActionLoading('confirm', row.id)"
|
||||
:disabled="Boolean(activeAction) || row.recognitionStatus === 'NEEDS_MANUAL'"
|
||||
@click="confirmResult(row)"
|
||||
><CheckCircle2 :size="15" />确认重量</el-button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<AppDialog
|
||||
v-model="correctionOpen"
|
||||
title="人工修正重量"
|
||||
confirm-text="保存修正"
|
||||
:confirm-loading="activeAction.startsWith('correct:')"
|
||||
@confirm="submitCorrection"
|
||||
>
|
||||
<el-form ref="correctionFormRef" :model="correction" :rules="correctionRules" label-position="top">
|
||||
<div class="correction-grid">
|
||||
<el-form-item label="修正重量" prop="value">
|
||||
<el-input-number v-model="correction.value" :min="0.001" :precision="3" :controls="false" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单位" prop="unit">
|
||||
<el-segmented v-model="correction.unit" :options="['KG', 'LB']" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="修正原因" prop="reason">
|
||||
<el-input
|
||||
v-model="correction.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="说明读数模糊、单位识别错误等具体原因"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
v-model="previewOpen"
|
||||
title="称重图片"
|
||||
@@ -503,10 +486,12 @@ defineExpose({ reload: loadWorkspace })
|
||||
.weight-row__raw span { color: var(--cc-muted); }
|
||||
.weight-row__raw p { margin: 0; color: #475467; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.weight-row__audit { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; color: var(--cc-muted); font-size: 11px; line-height: 1.5; }
|
||||
.weight-row__editor { display: grid; grid-template-columns: minmax(180px, 1fr) 150px auto; align-items: end; gap: 12px; margin-top: 14px; padding: 14px; border: 1px solid #b7dfca; border-radius: 7px; background: #f2fbf6; }
|
||||
.weight-row__editor label { display: grid; min-width: 0; gap: 6px; color: #475467; font-size: 11px; }
|
||||
.weight-row__editor :deep(.el-input-number), .weight-row__editor :deep(.el-segmented) { width: 100%; }
|
||||
.weight-row__editor > small { grid-column: 1 / -1; color: var(--cc-muted); font-size: 11px; line-height: 1.5; }
|
||||
.weight-row__actions { display: flex; flex-wrap: wrap; gap: 2px 12px; margin-top: 12px; }
|
||||
.weight-row__actions :deep(.el-button) { margin-left: 0; }
|
||||
.correction-grid { display: grid; grid-template-columns: minmax(0, 1fr) 150px; gap: 16px; }
|
||||
.correction-grid :deep(.el-input-number), .correction-grid :deep(.el-segmented) { width: 100%; }
|
||||
.weight-preview { display: block; max-width: 100%; max-height: min(68vh, 720px); margin: 0 auto; object-fit: contain; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -518,14 +503,16 @@ defineExpose({ reload: loadWorkspace })
|
||||
.weight-metrics > div:last-child { border-bottom: 0; }
|
||||
.weight-row__facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.weight-row__raw { grid-template-columns: 1fr; gap: 4px; }
|
||||
.weight-row__editor { grid-template-columns: minmax(0, 1fr) 132px; }
|
||||
.weight-row__editor > .el-button { grid-column: 1 / -1; width: 100%; }
|
||||
.weight-row__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.weight-row__actions :deep(.el-button) { width: 100%; justify-content: flex-start; }
|
||||
.correction-grid { grid-template-columns: 1fr; gap: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.weight-row__identity { grid-template-columns: 32px minmax(0, 1fr); }
|
||||
.weight-row__identity > .el-tag { grid-column: 2; justify-self: start; }
|
||||
.weight-row__facts, .weight-row__actions { grid-template-columns: 1fr; }
|
||||
.weight-row__facts, .weight-row__editor, .weight-row__actions { grid-template-columns: 1fr; }
|
||||
.weight-row__editor > .el-button { grid-column: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user