diff --git a/src/modules/customers/pages/CustomersView.test.ts b/src/modules/customers/pages/CustomersView.test.ts index 7c354dc2..604fbf32 100644 --- a/src/modules/customers/pages/CustomersView.test.ts +++ b/src/modules/customers/pages/CustomersView.test.ts @@ -194,9 +194,13 @@ describe('CustomersView', () => { expect(customersSource).not.toContain(' { + it('keeps qualification file controls usable when the editor dialog is narrow', () => { expect(customersSource).toContain('class="qualification-validity" role="cell"') expect(customersSource).toContain('grid-template-columns:170px minmax(0,1fr) 220px') + expect(customersSource).toContain('.qualification-table { container-type:inline-size;') + expect(customersSource).toContain('@container (max-width:660px)') + expect(customersSource).toContain('.qualification-row { grid-template-columns:112px minmax(0,1fr); }') + expect(customersSource).toContain('.qualification-label { grid-row:span 2; align-items:center; }') expect(customersSource).toContain('.qualification-validity :deep(.el-date-editor) { width:100%; min-width:0; }') expect(customersSource).toContain('.qualification-file-name { min-width:0;') expect(readFileSync(resolve(process.cwd(), 'src/modules/customers/customerDensity.css'), 'utf8')).toContain('max-width: none; overflow-wrap: anywhere; white-space: normal;') @@ -313,8 +317,41 @@ describe('CustomersView', () => { expect(updatePlatformTenant).toHaveBeenCalledWith(fullTenant.id, expect.objectContaining({ contactPhone: '0755-12345678' })) expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false) + expect(document.body.textContent).toContain('客户资料已保存') + expect(document.body.textContent).not.toContain('尚未提交审核') expect(document.body.textContent).toContain('不会修改企业管理员登录手机号') expect(customersSource).not.toContain('联系电话(登录用户名)') + expect(customersSource.indexOf('if (!failures.length) editorOpen.value = false')).toBeLessThan(customersSource.indexOf('await loadCustomers()')) + }) + + it('allows an active customer to replace qualifications and select a validity date', async () => { + const activeTenant = { ...fullTenant, status: 'ACTIVE' as const } + vi.mocked(listPlatformTenants).mockResolvedValue({ records: [activeTenant], page: 1, size: 10, total: 1, pages: 1 }) + vi.mocked(getPlatformTenant).mockResolvedValue(activeTenant) + vi.mocked(listTenantQualifications).mockResolvedValue([ + { + id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE', + certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'APPROVED', reviewComment: null, validTo: null, + }, + { + id: 'qualification-freight', tenantId: fullTenant.id, qualificationType: 'FREIGHT_FORWARDER_CERTIFICATE', + certificateNo: null, documentUrl: '/files/freight.png', status: 'APPROVED', reviewComment: null, validTo: null, + }, + ]) + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + tableButtonByText('编辑')?.click() + await flushPromises() + + const fileInputs = [...document.querySelectorAll('.qualification-file input[type="file"]')] + const validityInputs = [...document.querySelectorAll('.qualification-validity input')] + expect(fileInputs).toHaveLength(4) + expect(fileInputs.every((input) => !input.disabled)).toBe(true) + expect(validityInputs.map((input) => input.disabled)).toEqual([true, false, true, false]) + + selectQualificationFile(0, new File(['license-new'], 'license-new.png', { type: 'image/png' })) + await flushPromises() + expect(validityInputs[0]?.disabled).toBe(false) }) it('does not count rejected or superseded history as a current qualification when resubmitting', async () => { diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue index 7e6f38d3..61558dba 100644 --- a/src/modules/customers/pages/CustomersView.vue +++ b/src/modules/customers/pages/CustomersView.vue @@ -228,7 +228,8 @@ let reviewLoadSequence = 0 let editorLoadSequence = 0 const editorTitle = computed(() => editingId.value ? '编辑客户' : '新增客户') const editorDialogWidth = 'min(1120px, calc(100vw - 28px))' -const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value)) +const canUploadQualifications = computed(() => !editorStatus.value + || ['PROFILE_REQUIRED', 'REJECTED', 'ACTIVE', 'DISABLED'].includes(editorStatus.value)) const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || '')) const uploadingQualifications = computed(() => qualifications.value.some((item) => item.uploading)) const hasSelectedQualifications = computed(() => qualifications.value.some((item) => item.file)) @@ -603,19 +604,22 @@ async function saveCustomer() { editorStatus.value = saved.status savedFormSnapshot.value = JSON.stringify(form.value) const failures = hadSelectedQualifications ? await uploadSelectedQualifications(String(saved.id)) : [] + const canSubmitSavedCustomerForReview = ['PROFILE_REQUIRED', 'REJECTED'].includes(saved.status) if (failures.length) { ElMessage.warning(`客户资料已保存,但${failures.join('、')}上传失败;失败文件已保留,请修正后再次保存`) - } else if (hadBusinessLicense && hadLegalPersonId) { + } else if (canSubmitSavedCustomerForReview && hadBusinessLicense && hadLegalPersonId) { ElMessage.success('营业执照、统一社会信用代码和选填的法人身份证已保存,尚未提交审核') - } else if (hadBusinessLicense) { + } else if (canSubmitSavedCustomerForReview && hadBusinessLicense) { ElMessage.success('营业执照和统一社会信用代码已保存,法人身份证可按需补充,尚未提交审核') - } else if (hadSelectedQualifications) { + } else if (canSubmitSavedCustomerForReview && hadSelectedQualifications) { ElMessage.success('客户资料和已选资质已保存,尚未提交审核') + } else if (hadSelectedQualifications) { + ElMessage.success('客户资料和已选资质已保存') } else { ElMessage.success('客户资料已保存') } - await loadCustomers() if (!failures.length) editorOpen.value = false + await loadCustomers() } catch (error) { ElMessage.error(getApiErrorMessage(error, '客户资料保存失败,请稍后重试')) } finally { saving.value = false } } @@ -1112,7 +1116,7 @@ onMounted(() => { void loadCustomers() })
{{ draft.file?.name || draft.uploadedName || '未选择文件' }}{{ draft.error }}
-
+

支持 PDF、JPG、PNG,单个文件不超过 5MB;* 为提交审核必填项。

@@ -1222,7 +1226,7 @@ onMounted(() => { void loadCustomers() }) .capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:var(--admin-space-3, 8px); } .capital-input :deep(.el-input-number) { width:100%; } .capital-input :deep(.el-select) { width:100%; } .qualification-heading { display:flex; justify-content:flex-end; margin-bottom:var(--admin-space-5, 12px); } .qualification-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; } .qualification-hint span { color:var(--el-color-danger); } .qualification-label > span { color:var(--el-color-danger); } .qualification-credit-code { max-width:420px; margin-top:var(--admin-space-6, 16px); } .qualification-credit-code small { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); line-height:1.5; } .field-hint { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); line-height:1.5; } -.qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); font-weight:600; } +.qualification-table { container-type:inline-size; overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); font-weight:600; } .qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); } .qualification-optional { padding:2px 5px; border-radius:var(--admin-radius, 4px); background:var(--el-fill-color-light); color:var(--cc-muted); font-size:var(--admin-font-caption, 10px); font-weight:500; line-height:1.4; } .qualification-file { gap:var(--admin-space-5, 12px); } .qualification-file-control { display:flex; min-width:0; flex:1; flex-wrap:wrap; align-items:center; gap:var(--admin-space-3, 8px); } .file-picker { position:relative; display:inline-flex; flex:0 0 auto; align-items:center; gap:7px; overflow:hidden; padding:0 var(--admin-space-5, 12px); border:1px solid var(--cc-border); border-radius:6px; background:var(--admin-surface, #fff); color:var(--cc-text); cursor:pointer; } .file-picker:focus-within { outline:2px solid color-mix(in srgb,var(--cc-primary) 35%,transparent); outline-offset:2px; } .file-picker.is-disabled { cursor:not-allowed; opacity:.6; } .file-picker input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:inherit; } .qualification-file-name { min-width:0; overflow:hidden; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); text-overflow:ellipsis; } .qualification-validity :deep(.el-date-editor) { width:100%; min-width:0; } .qualification-error { width:100%; color:var(--el-color-danger); line-height:1.4; } @@ -1230,6 +1234,12 @@ onMounted(() => { void loadCustomers() }) .qualification-preview-trigger:hover { border-color:var(--cc-primary); color:var(--cc-primary); } .qualification-preview-trigger:focus-visible { outline:2px solid color-mix(in srgb,var(--cc-primary) 42%,transparent); outline-offset:2px; } .qualification-preview-trigger:disabled { cursor:wait; opacity:.72; } +@container (max-width:660px) { + .qualification-row { grid-template-columns:112px minmax(0,1fr); } + .qualification-header { display:none; } + .qualification-label { grid-row:span 2; align-items:center; } + .qualification-file,.qualification-validity { min-height:44px; } +} .qualification-preview-trigger img { width:100%; height:100%; object-fit:contain; background:var(--admin-surface, #fff); } .qualification-preview-placeholder { display:grid; place-items:center; gap:2px; } .qualification-preview-placeholder small { font-size:var(--admin-font-caption, 10px); font-weight:700; } diff --git a/src/modules/notifications/components/MessageComposerDialog.vue b/src/modules/notifications/components/MessageComposerDialog.vue index 145eae30..0ce075d0 100644 --- a/src/modules/notifications/components/MessageComposerDialog.vue +++ b/src/modules/notifications/components/MessageComposerDialog.vue @@ -53,7 +53,7 @@ async function submit() { if (Object.values(variables).some((value) => !value.trim())) return ElMessage.warning('请填写全部模板变量') submitting.value = true try { - await createNotification({ tenantId: form.tenantId, templateCode: form.templateCode, recipientType: form.recipientType, ...(needsIds.value ? { recipientIds } : {}), ...(needsRole.value ? { roleCode: form.roleCode.trim() } : {}), variables: { ...variables }, scheduledAt: form.scheduledAt ? new Date(form.scheduledAt).toISOString() : null, idempotencyKey: idempotencyKey() }) + await createNotification({ tenantId: form.tenantId, templateCode: form.templateCode, recipientType: form.recipientType, ...(needsIds.value ? { recipientIds } : {}), ...(needsRole.value ? { roleCode: form.roleCode.trim() } : {}), variables: { ...variables }, scheduledAt: form.scheduledAt || null, idempotencyKey: idempotencyKey() }) ElMessage.success(form.scheduledAt ? '消息已定时' : '消息发送任务已创建') open.value = false; reset(); emit('success') } catch (reason) { error.value = getApiErrorDetails(reason, '消息发送失败') } finally { submitting.value = false } diff --git a/src/modules/notifications/notificationsDensity.behavior.test.ts b/src/modules/notifications/notificationsDensity.behavior.test.ts index b59b6d9a..ef546a29 100644 --- a/src/modules/notifications/notificationsDensity.behavior.test.ts +++ b/src/modules/notifications/notificationsDensity.behavior.test.ts @@ -129,7 +129,7 @@ describe('notification density in native lists and overlays', () => { await wrapper.get('[role="switch"]').trigger('click') await flushPromises() await click(wrapper, '确认定时发送') - expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ tenantId: '0', templateCode: template.templateCode, recipientType: 'PLATFORM_ROLE', roleCode: 'TEST_OPERATOR', variables: {}, scheduledAt: expect.any(String), idempotencyKey: expect.any(String) })) + expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ tenantId: '0', templateCode: template.templateCode, recipientType: 'PLATFORM_ROLE', roleCode: 'TEST_OPERATOR', variables: {}, scheduledAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/), idempotencyKey: expect.any(String) })) expect(wrapper.emitted('success')).toHaveLength(1) }) })