Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
2 changed files with 101 additions and 109 deletions
Showing only changes of commit 2425e0cac1 - Show all commits
@@ -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>