From 4c41a2bdff4cf0e16e37e7349bc234b40a1b839f Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 24 Jul 2026 17:14:13 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E9=A2=84=E8=A7=88=E4=B8=8E=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/MessageTemplateModal.spec.tsx | 108 ++++++++++++++++-- .../components/MessageTemplateModal.tsx | 8 ++ 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx index 1649e51..3590b28 100644 --- a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx +++ b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx @@ -34,7 +34,7 @@ const mocks = vi.hoisted(() => { resetValidate: vi.fn(async () => {}), setValues: vi.fn(async (values) => Object.assign(formValues, values)), validate: vi.fn(async () => ({ valid: true })), - validateField: vi.fn(async () => ({ valid: true })), + validateField: vi.fn(async (_fieldName: string) => ({ valid: true })), }; return { create: vi.fn(), @@ -187,6 +187,7 @@ describe('message template modal', () => { }); mocks.create.mockResolvedValue({}); mocks.update.mockResolvedValue({}); + mocks.formApi.validateField.mockResolvedValue({ valid: true }); mocks.detail.mockImplementation(async (sourceKey: string) => createSource(sourceKey, sourceKey === 'source-a' ? 'endpoint' : 'port'), ); @@ -305,7 +306,7 @@ describe('message template modal', () => { expect(mocks.formApi.validateField).toHaveBeenCalledWith('content'); }); - it('deduplicates exact-key detail and evicts a rejected cache entry for retry', async () => { + it('deduplicates exact-key detail and contains a rejected source-change callback before retry', async () => { let resolveSourceA!: ( source: QqbotMessagePushApi.SystemMessageSourceDefinition, ) => void; @@ -330,21 +331,48 @@ describe('message template modal', () => { await flushPromises(); mocks.detail.mockRejectedValueOnce(new Error('detail failed')); - await expect( - mocks.formOptions.handleValuesChange( - { ...mocks.formValues, sourceKey: 'source-b' }, - ['sourceKey'], - ), - ).rejects.toThrow('detail failed'); + const failedSourceChange = mocks.formOptions.handleValuesChange( + { ...mocks.formValues, sourceKey: 'source-b' }, + ['sourceKey'], + ); + await expect(failedSourceChange).resolves.toBeUndefined(); + const content = mocks.formOptions.schema.find( + (field: any) => field.fieldName === 'content', + ); + expect(content.componentProps().variables).toEqual([]); + expect(content.componentProps().loading).toBe(false); + expect(mocks.formApi.validateField).toHaveBeenCalledWith('content'); + mocks.detail.mockResolvedValueOnce(createSource('source-b', 'port')); await mocks.formOptions.handleValuesChange( { ...mocks.formValues, sourceKey: 'source-b' }, ['sourceKey'], ); expect(mocks.detail).toHaveBeenCalledTimes(3); + expect(content.componentProps().variables[0].key).toBe('port'); + expect(content.componentProps().loading).toBe(false); + }); + + it('contains a non-awaited modal-open detail failure and remains usable for retry', async () => { + mocks.detail.mockRejectedValueOnce(new Error('open detail failed')); + const wrapper = mountModal(); + + (wrapper.vm as any).openCreate(); + await flushPromises(); + const content = mocks.formOptions.schema.find( (field: any) => field.fieldName === 'content', ); + expect(content.componentProps().variables).toEqual([]); + expect(content.componentProps().loading).toBe(false); + expect(wrapper.find('footer button').exists()).toBe(true); + + mocks.detail.mockResolvedValueOnce(createSource('source-a', 'endpoint')); + (wrapper.vm as any).openCreate(); + await flushPromises(); + + expect(mocks.detail).toHaveBeenCalledTimes(2); + expect(content.componentProps().variables[0].key).toBe('endpoint'); expect(content.componentProps().loading).toBe(false); }); @@ -359,6 +387,10 @@ describe('message template modal', () => { await wrapper.get('footer button').trigger('click'); await flushPromises(); + expect(mocks.formApi.validateField.mock.calls).toEqual([ + ['sourceKey'], + ['content'], + ]); expect(mocks.preview).toHaveBeenCalledOnce(); expect(mocks.preview).toHaveBeenCalledWith({ content: '[CQ:at,qq=12345] ${{endpoint}}', @@ -377,16 +409,72 @@ describe('message template modal', () => { expect(mocks.preview).toHaveBeenCalledOnce(); }); - it('makes preview impossible without Preview permission or valid values', async () => { + it('makes preview impossible without Preview permission or exact-field validity', async () => { const wrapper = mountModal(false); expect(wrapper.find('footer button').exists()).toBe(false); + expect(mocks.formApi.validateField).not.toHaveBeenCalled(); expect(mocks.preview).not.toHaveBeenCalled(); wrapper.unmount(); const permitted = mountModal(true); - Object.assign(mocks.formValues, { content: '', sourceKey: 'source-a' }); + Object.assign(mocks.formValues, { + content: 'x'.repeat(2001), + sourceKey: 'source-a', + }); + mocks.formApi.validateField.mockImplementation( + async (fieldName: string) => ({ + valid: fieldName !== 'content', + }), + ); await permitted.get('footer button').trigger('click'); + await flushPromises(); + expect(mocks.formApi.validateField.mock.calls).toEqual([ + ['sourceKey'], + ['content'], + ]); expect(mocks.preview).not.toHaveBeenCalled(); + + mocks.formApi.validateField.mockClear(); + mocks.formApi.validateField.mockImplementation( + async (fieldName: string) => ({ + valid: fieldName !== 'sourceKey', + }), + ); + Object.assign(mocks.formValues, { + content: '${{endpoint}}', + sourceKey: 'schema-invalid-source', + }); + await permitted.get('footer button').trigger('click'); + await flushPromises(); + expect(mocks.formApi.validateField.mock.calls).toEqual([ + ['sourceKey'], + ['content'], + ]); + expect(mocks.preview).not.toHaveBeenCalled(); + }); + + it('does not validate unrelated invalid name or remark before preview', async () => { + const wrapper = mountModal(true); + Object.assign(mocks.formValues, { + content: '${{endpoint}}', + name: '', + remark: 'x'.repeat(501), + sourceKey: 'source-a', + }); + mocks.formApi.validateField.mockImplementation( + async (fieldName: string) => ({ + valid: fieldName !== 'name' && fieldName !== 'remark', + }), + ); + + await wrapper.get('footer button').trigger('click'); + await flushPromises(); + + expect(mocks.formApi.validateField.mock.calls).toEqual([ + ['sourceKey'], + ['content'], + ]); + expect(mocks.preview).toHaveBeenCalledOnce(); }); it('submits exact create/update payloads while preserving content and string ID', async () => { diff --git a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx index 58fecf1..a6f2b0c 100644 --- a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx +++ b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx @@ -201,6 +201,7 @@ export default defineComponent({ /** * Replaces suggestions only when this exact source request remains latest. + * Expected detail failures stay inside the non-awaited form/modal callbacks. * @param sourceKey - Newly selected source identity. */ async function loadSourceDetail(sourceKey: string) { @@ -220,6 +221,8 @@ export default defineComponent({ ) { variables.value = source.variables; } + } catch { + // The request layer owns user-facing errors; an empty catalog remains usable. } finally { if ( revision === sourceRevision && @@ -234,6 +237,11 @@ export default defineComponent({ /** Requests server-authoritative preview only after an explicit valid click. */ async function handlePreview() { if (!props.canPreview) return; + const [sourceValidation, contentValidation] = await Promise.all([ + formApi.validateField('sourceKey'), + formApi.validateField('content'), + ]); + if (!sourceValidation.valid || !contentValidation.valid) return; const values = await formApi.getValues(); if (!values.sourceKey || !values.content) return; const revision = ++previewRevision;