feat: 支持OCR重量直接修改确认
This commit is contained in:
@@ -14,7 +14,7 @@ describe('WeightOcrPanel contract', () => {
|
|||||||
expect(template).not.toContain('租户')
|
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('uploadWeightImage')
|
||||||
expect(source).toContain('downloadWeightImage')
|
expect(source).toContain('downloadWeightImage')
|
||||||
expect(source).toContain('retryWeightRecognition')
|
expect(source).toContain('retryWeightRecognition')
|
||||||
@@ -23,6 +23,11 @@ describe('WeightOcrPanel contract', () => {
|
|||||||
expect(source).toContain('实际总毛重(服务端汇总)')
|
expect(source).toContain('实际总毛重(服务端汇总)')
|
||||||
expect(source).toContain('称重图片序号 #{{ index + 1 }}')
|
expect(source).toContain('称重图片序号 #{{ index + 1 }}')
|
||||||
expect(source).toContain('workspace.value?.actualGrossWeightKg')
|
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', () => {
|
it('matches backend image limits and preserves missing OCR text honestly', () => {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Eye,
|
Eye,
|
||||||
FileImage,
|
FileImage,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
Pencil,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Scale,
|
Scale,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
@@ -32,25 +31,17 @@ const emit = defineEmits<{ changed: [] }>()
|
|||||||
|
|
||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
const fileInput = ref<HTMLInputElement>()
|
const fileInput = ref<HTMLInputElement>()
|
||||||
const correctionFormRef = ref<FormInstance>()
|
|
||||||
const workspace = ref<WeightWorkspaceResource | null>(null)
|
const workspace = ref<WeightWorkspaceResource | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
const actionError = ref<ApiErrorDetails | null>(null)
|
const actionError = ref<ApiErrorDetails | null>(null)
|
||||||
const activeAction = ref('')
|
const activeAction = ref('')
|
||||||
const correctionOpen = ref(false)
|
|
||||||
const previewOpen = ref(false)
|
const previewOpen = ref(false)
|
||||||
const previewLoading = ref(false)
|
const previewLoading = ref(false)
|
||||||
const previewError = ref<ApiErrorDetails | null>(null)
|
const previewError = ref<ApiErrorDetails | null>(null)
|
||||||
const previewUrl = ref('')
|
const previewUrl = ref('')
|
||||||
const correction = reactive({
|
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB' }>>({})
|
||||||
resultId: '',
|
|
||||||
version: 0,
|
|
||||||
value: null as number | null,
|
|
||||||
unit: 'KG' as 'KG' | 'LB',
|
|
||||||
reason: '',
|
|
||||||
})
|
|
||||||
let loadSequence = 0
|
let loadSequence = 0
|
||||||
|
|
||||||
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
|
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 pendingCount = computed(() => rows.value.length - confirmedCount.value)
|
||||||
const confirmedTotal = computed(() => workspace.value?.actualGrossWeightKg ?? null)
|
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') {
|
function formatWeight(value: number | null, unit = 'KG') {
|
||||||
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
|
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
|
||||||
}
|
}
|
||||||
@@ -103,9 +82,34 @@ function isActionLoading(action: string, id: string) {
|
|||||||
return activeAction.value === actionKey(action, id)
|
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() {
|
async function loadWorkspace() {
|
||||||
if (!customerId.value || !props.inboundId || !canView.value) {
|
if (!customerId.value || !props.inboundId || !canView.value) {
|
||||||
workspace.value = null
|
workspace.value = null
|
||||||
|
weightDrafts.value = {}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const sequence = ++loadSequence
|
const sequence = ++loadSequence
|
||||||
@@ -113,7 +117,10 @@ async function loadWorkspace() {
|
|||||||
loadError.value = null
|
loadError.value = null
|
||||||
try {
|
try {
|
||||||
const result = await getWeightWorkspace(customerId.value, props.inboundId)
|
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) {
|
} catch (error) {
|
||||||
if (sequence !== loadSequence) return
|
if (sequence !== loadSequence) return
|
||||||
workspace.value = null
|
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) {
|
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)
|
const key = actionKey('confirm', row.id)
|
||||||
activeAction.value = key
|
activeAction.value = key
|
||||||
actionError.value = null
|
actionError.value = null
|
||||||
try {
|
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('重量已确认并计入实际总重量')
|
ElMessage.success('重量已确认并计入实际总重量')
|
||||||
await loadWorkspace()
|
await loadWorkspace()
|
||||||
emit('changed')
|
emit('changed')
|
||||||
@@ -270,9 +263,9 @@ watch(
|
|||||||
() => {
|
() => {
|
||||||
++loadSequence
|
++loadSequence
|
||||||
workspace.value = null
|
workspace.value = null
|
||||||
|
weightDrafts.value = {}
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
actionError.value = null
|
actionError.value = null
|
||||||
correctionOpen.value = false
|
|
||||||
previewOpen.value = false
|
previewOpen.value = false
|
||||||
releasePreview()
|
releasePreview()
|
||||||
void loadWorkspace()
|
void loadWorkspace()
|
||||||
@@ -386,6 +379,40 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
<span v-if="row.confirmedAt">确认时间:{{ formatTime(row.confirmedAt) }}</span>
|
<span v-if="row.confirmedAt">确认时间:{{ formatTime(row.confirmedAt) }}</span>
|
||||||
</div>
|
</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">
|
<footer class="weight-row__actions">
|
||||||
<el-button link type="primary" @click="openPreview(row)"><Eye :size="15" />查看图片</el-button>
|
<el-button link type="primary" @click="openPreview(row)"><Eye :size="15" />查看图片</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
@@ -396,56 +423,12 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
:disabled="Boolean(activeAction)"
|
:disabled="Boolean(activeAction)"
|
||||||
@click="retryOcr(row)"
|
@click="retryOcr(row)"
|
||||||
><RefreshCw :size="15" />重试 OCR</el-button>
|
><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>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</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
|
<AppDialog
|
||||||
v-model="previewOpen"
|
v-model="previewOpen"
|
||||||
title="称重图片"
|
title="称重图片"
|
||||||
@@ -503,10 +486,12 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
.weight-row__raw span { color: var(--cc-muted); }
|
.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__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__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 { display: flex; flex-wrap: wrap; gap: 2px 12px; margin-top: 12px; }
|
||||||
.weight-row__actions :deep(.el-button) { margin-left: 0; }
|
.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; }
|
.weight-preview { display: block; max-width: 100%; max-height: min(68vh, 720px); margin: 0 auto; object-fit: contain; }
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -518,14 +503,16 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
.weight-metrics > div:last-child { border-bottom: 0; }
|
.weight-metrics > div:last-child { border-bottom: 0; }
|
||||||
.weight-row__facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.weight-row__facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.weight-row__raw { grid-template-columns: 1fr; gap: 4px; }
|
.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 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.weight-row__actions :deep(.el-button) { width: 100%; justify-content: flex-start; }
|
.weight-row__actions :deep(.el-button) { width: 100%; justify-content: flex-start; }
|
||||||
.correction-grid { grid-template-columns: 1fr; gap: 0; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 360px) {
|
@media (max-width: 360px) {
|
||||||
.weight-row__identity { grid-template-columns: 32px minmax(0, 1fr); }
|
.weight-row__identity { grid-template-columns: 32px minmax(0, 1fr); }
|
||||||
.weight-row__identity > .el-tag { grid-column: 2; justify-self: start; }
|
.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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user