diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue index 98e5584d..d696f1a3 100644 --- a/src/modules/customers/pages/CustomersView.vue +++ b/src/modules/customers/pages/CustomersView.vue @@ -76,6 +76,7 @@ const decisionOpen = ref(false) const reviewSubmitting = ref(false) const approvalResultOpen = ref(false) const approvalResult = ref(null) +const credentialSource = ref<'creation' | 'approval'>('approval') const decision = reactive({ type: 'approve' as 'approve' | 'reject', comment: '' }) const decisionFormRef = ref() const passwordDialogOpen = ref(false) @@ -558,8 +559,17 @@ async function saveCustomer() { saved = await updatePlatformTenant(editingId.value, payload) } else { const { unifiedSocialCreditCode: _qualificationCreditCode, ...payload } = form.value - saved = await createPlatformTenant(payload) + const created = await createPlatformTenant(payload) + saved = created.tenant editingId.value = String(saved.id) + credentialSource.value = 'creation' + approvalResult.value = { + tenantId: saved.id, + administratorCreated: true, + administratorUsername: created.administratorUsername, + temporaryPassword: created.temporaryPassword, + } + approvalResultOpen.value = true } editorStatus.value = saved.status savedFormSnapshot.value = JSON.stringify(form.value) @@ -780,6 +790,7 @@ async function submitDecision() { : '企业资料已审核通过,注册账号继续作为企业管理员' ElMessage.success(successMessage) if (approved?.administratorCreated) { + credentialSource.value = 'approval' approvalResult.value = approved approvalResultOpen.value = true } @@ -930,9 +941,9 @@ onMounted(() => { void loadCustomers() }) - +
- +
登录账号
{{ approvalResult.administratorUsername || '未返回' }}
临时密码
{{ approvalResult.temporaryPassword || '未返回' }}
diff --git a/src/modules/iam/api/platformIam.test.ts b/src/modules/iam/api/platformIam.test.ts index b91ab80c..be69e0ca 100644 --- a/src/modules/iam/api/platformIam.test.ts +++ b/src/modules/iam/api/platformIam.test.ts @@ -281,10 +281,11 @@ describe('platform IAM roles', () => { establishedDate: '2020-01-01', registeredCapital: 1000, registeredCapitalCurrency: 'CNY', registeredAddress: '深圳市宝安区', internalRemark: '重点客户', } - vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: tenant }) + const creation = { tenant, administratorUsername: '13800138000', temporaryPassword: 'Password1234' } + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: creation }) vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: tenant }) - await expect(createPlatformTenant(create)).resolves.toEqual(tenant) + await expect(createPlatformTenant(create)).resolves.toEqual(creation) await expect(updatePlatformTenant('8', create)).resolves.toEqual(tenant) await expect(changePlatformTenantStatus('8', 'DISABLED')).resolves.toEqual(tenant) diff --git a/src/modules/iam/api/platformIam.ts b/src/modules/iam/api/platformIam.ts index 007202c8..a66db1c3 100644 --- a/src/modules/iam/api/platformIam.ts +++ b/src/modules/iam/api/platformIam.ts @@ -141,6 +141,12 @@ export interface EnterpriseApprovalResponse { temporaryPassword: string | null } +export interface PlatformTenantCreationResponse { + tenant: PlatformTenantResource + administratorUsername: string + temporaryPassword: string +} + export interface ResetPlatformTenantOwnerPasswordRequest { newPassword: string forceChangePassword: boolean @@ -350,7 +356,7 @@ export async function checkPlatformTenantContactPhone(contactPhone: string, excl } export function createPlatformTenant(payload: CreatePlatformTenantRequest) { - return postData('/platform/v1/iam/tenants', payload) + return postData('/platform/v1/iam/tenants', payload) } export function updatePlatformTenant(tenantId: number | string, payload: UpdatePlatformTenantRequest) { diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts b/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts index 83ad97ba..d1e5d6f7 100644 --- a/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts @@ -40,7 +40,7 @@ const pendingWeight: WeightResultResource = { ocrRawText: '285.5', ocrWeightValue: 285.5, ocrWeightUnit: 'KG', - confidence: 0.50972, + confidence: 0.98, adjustedWeightValue: null, adjustedWeightUnit: null, finalWeightKg: null, @@ -83,9 +83,11 @@ function mountPanel(permissions = allWeightPermissions) { }) } -async function openWeightDialog(wrapper: VueWrapper) { +async function openWeightDialog(wrapper: VueWrapper, reviewEvidence = true) { await wrapper.get('button[aria-label="查看称重图片 1"]').trigger('click') await flushPromises() + const evidence = document.body.querySelector('.ocr-review-check input[type="checkbox"]') + if (reviewEvidence && evidence) await new DOMWrapper(evidence).setValue(true) } function dialogWeightInput() { @@ -106,6 +108,12 @@ function dialogConfirmButton() { return new DOMWrapper(button) } +function dialogEvidenceCheckbox() { + const input = document.body.querySelector('.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() @@ -191,12 +199,17 @@ describe('WeightOcrPanel confirmation behavior', () => { expect(deleteWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', 0) }) - it('confirms an unchanged OCR value with one request', async () => { + it('requires image review before confirming even a high-confidence OCR value', async () => { const wrapper = mountPanel() await flushPromises() - await openWeightDialog(wrapper) + await openWeightDialog(wrapper, false) - expect(document.body.textContent).not.toContain('识别重量 285.500 KG') + 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) await dialogConfirmButton().trigger('click') await flushPromises() @@ -205,6 +218,28 @@ describe('WeightOcrPanel confirmation behavior', () => { expect(correctWeightResult).not.toHaveBeenCalled() }) + it('requires explicit image review before confirming a low-confidence OCR value', async () => { + vi.mocked(getWeightWorkspace).mockResolvedValue({ + ...workspace, + weightResults: [{ ...pendingWeight, ocrRawText: '5360', ocrWeightValue: 5360, confidence: 0.51863 }], + }) + const wrapper = mountPanel() + await flushPromises() + await openWeightDialog(wrapper, false) + + 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() + + await dialogEvidenceCheckbox().setValue(true) + await dialogConfirmButton().trigger('click') + await flushPromises() + + expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 }) + }) + it('sends the adjusted value and a specific audit reason in the same confirmation request', async () => { const wrapper = mountPanel() await flushPromises() @@ -379,6 +414,7 @@ describe('WeightOcrPanel confirmation behavior', () => { expect(dialogConfirmButton().attributes('disabled')).toBeDefined() await dialogWeightInput().setValue('1.500') + await dialogEvidenceCheckbox().setValue(true) await dialogConfirmButton().trigger('click') await flushPromises() diff --git a/src/modules/inbounds/weight/components/WeightOcrPanel.vue b/src/modules/inbounds/weight/components/WeightOcrPanel.vue index 1426ee3b..de5bce75 100644 --- a/src/modules/inbounds/weight/components/WeightOcrPanel.vue +++ b/src/modules/inbounds/weight/components/WeightOcrPanel.vue @@ -41,6 +41,7 @@ const previewUrl = ref('') const previewShowsRecognition = ref(false) const previewResultId = ref('') const previewSnapshot = ref(null) +const previewEvidenceReviewed = ref(false) const weightDrafts = ref>({}) let loadSequence = 0 @@ -55,11 +56,34 @@ const editable = computed(() => Boolean(workspace.value && editableStatuses.has( const rows = computed(() => workspace.value?.weightResults ?? []) const previewResult = computed(() => rows.value.find((row) => row.id === previewResultId.value) || previewSnapshot.value) +const previewConfirmDisabled = computed(() => { + const row = previewResult.value + 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 ({ + PENDING_CONFIRM: '待确认', + NEEDS_MANUAL: '需人工录入', + CONFIRMED: '已确认', + } as Record)[status] || status +} + +function requiresEvidenceReview(row: WeightResultResource) { + return row.recognitionStatus !== 'CONFIRMED' && row.adjustedWeightValue == null +} + function actionKey(action: string, id: string) { return `${action}:${id}` } @@ -201,6 +225,10 @@ async function confirmResult(row: WeightResultResource, closeAfterSuccess = fals ElMessage.warning('请输入大于 0 的有效重量') return } + if (requiresEvidenceReview(row) && !previewEvidenceReviewed.value) { + ElMessage.warning('请先对照图片核对小数点和重量单位') + return + } const key = actionKey('confirm', row.id) activeAction.value = key actionError.value = null @@ -238,6 +266,7 @@ 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 @@ -300,7 +329,12 @@ watch( { immediate: true }, ) -watch(previewOpen, (open) => { if (!open) releasePreview() }) +watch(previewOpen, (open) => { + if (!open) { + previewEvidenceReviewed.value = false + releasePreview() + } +}) onBeforeUnmount(releasePreview) defineExpose({ reload: loadWorkspace }) @@ -409,7 +443,7 @@ defineExpose({ reload: loadWorkspace }) title="确认称重图片" :loading="previewLoading" :confirm-loading="Boolean(previewResult && isActionLoading('confirm', previewResult.id))" - :confirm-disabled="!previewResult || !isWeightDraftValid(previewResult) || Boolean(activeAction)" + :confirm-disabled="previewConfirmDisabled" :show-confirm="Boolean(previewResult && previewResult.recognitionStatus !== 'CONFIRMED' && canConfirm && editable)" confirm-text="确认重量" confirm-type="success" @@ -427,6 +461,22 @@ defineExpose({ reload: loadWorkspace }) @@ -479,10 +535,18 @@ defineExpose({ reload: loadWorkspace }) .weight-card__value strong { color: #101828; font-size: 17px; } .weight-card__value:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 4px; } .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; } .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; } @@ -497,5 +561,7 @@ defineExpose({ reload: loadWorkspace }) .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; } }