diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountConfigPanel.spec.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/AccountConfigPanel.spec.tsx new file mode 100644 index 0000000..56d33ae --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/components/AccountConfigPanel.spec.tsx @@ -0,0 +1,250 @@ +/* @vitest-environment happy-dom */ + +/* eslint-disable vue/one-component-per-file, vue/require-default-prop */ + +import type { QqbotApi } from '#/api/qqbot'; + +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h } from 'vue'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import AccountConfigPanel from './AccountConfigPanel'; +import AccountMessagePushPanel from './AccountMessagePushPanel'; + +const mocks = vi.hoisted(() => ({ + api: { + bindCommand: vi.fn(), + bindEvent: vi.fn(), + bindRule: vi.fn(), + getCommands: vi.fn(), + getEvents: vi.fn(), + getRules: vi.fn(), + unbindCommand: vi.fn(), + unbindEvent: vi.fn(), + unbindRule: vi.fn(), + }, + legacyTableProps: [] as Array>, +})); + +vi.mock('#/api/qqbot', () => ({ + bindQqbotAccountCommand: mocks.api.bindCommand, + bindQqbotAccountRule: mocks.api.bindRule, + getQqbotCommandList: mocks.api.getCommands, + getQqbotRuleList: mocks.api.getRules, + unbindQqbotAccountCommand: mocks.api.unbindCommand, + unbindQqbotAccountRule: mocks.api.unbindRule, +})); + +vi.mock('#/api/qqbot/plugin', () => ({ + bindQqbotEventPlugin: mocks.api.bindEvent, + getQqbotEventPluginList: mocks.api.getEvents, + unbindQqbotEventPlugin: mocks.api.unbindEvent, +})); + +vi.mock('#/components/ktTable', () => ({ + KtTable: defineComponent({ + name: 'MockLegacyKtTable', + inheritAttrs: false, + /** + * Renders the real parent-owned header callbacks and records legacy table props. + */ + setup(_, { attrs, slots }) { + return () => { + mocks.legacyTableProps.push(attrs); + return h('section', { 'data-testid': 'legacy-table' }, [ + slots.title?.(), + slots.headerControls?.(), + ]); + }; + }, + }), +})); + +vi.mock('antdv-next', () => ({ + message: { + success: vi.fn(), + warning: vi.fn(), + }, + Spin: defineComponent({ + name: 'MockSpin', + /** Keeps the legacy table subtree mounted for regression assertions. */ + setup(_, { slots }) { + return () => h('div', slots.default?.()); + }, + }), + Tabs: defineComponent({ + name: 'MockTabs', + props: { + activeKey: String, + items: Array, + }, + emits: ['update:activeKey'], + /** Exposes every configured tab through a deterministic clickable button. */ + setup(props, { emit }) { + return () => + h( + 'nav', + ( + props.items as Array<{ key: string; label: string }> | undefined + )?.map((item) => + h( + 'button', + { + 'data-tab-key': item.key, + onClick: () => emit('update:activeKey', item.key), + }, + item.label, + ), + ), + ); + }, + }), + Tag: defineComponent({ + name: 'MockTag', + /** Renders tag text without changing the parent structure. */ + setup(_, { slots }) { + return () => h('span', slots.default?.()); + }, + }), +})); + +vi.mock('./AccountMessagePushPanel', () => ({ + default: defineComponent({ + name: 'AccountMessagePushPanel', + props: { + headerControls: Function, + selfId: String, + title: Function, + }, + /** Renders both inherited header callbacks to prove the ownership boundary. */ + setup(props) { + return () => + h('section', { 'data-testid': 'message-push-panel' }, [ + (props.title as (() => unknown) | undefined)?.(), + (props.headerControls as (() => unknown) | undefined)?.(), + ] as any); + }, + }), +})); + +/** Creates the current route account with an unsafe-integer string self ID. */ +function createAccount(): QqbotApi.Account { + return { + connectStatus: 'online', + connectionMode: 'reverse-ws', + enabled: true, + id: '1', + name: 'Bot A', + selfId: '10000000000000001', + }; +} + +describe('qqbot account config panel', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.legacyTableProps.length = 0; + mocks.api.getCommands.mockResolvedValue({ list: [], total: 0 }); + mocks.api.getEvents.mockResolvedValue([]); + mocks.api.getRules.mockResolvedValue({ list: [], total: 0 }); + }); + + it('appends the exact fourth tab after all existing tabs', async () => { + const wrapper = mount(AccountConfigPanel, { + props: { account: createAccount() }, + }); + await flushPromises(); + + expect( + wrapper + .findAll('[data-tab-key]') + .map((tab) => [tab.attributes('data-tab-key'), tab.text()]), + ).toEqual([ + ['command', '在线命令'], + ['event', '事件触发'], + ['rule', '自动回复规则'], + ['message-push', '消息推送'], + ]); + }); + + it('keeps the three legacy tabs on their original KtTable boundary', async () => { + const wrapper = mount(AccountConfigPanel, { + props: { account: createAccount() }, + }); + await flushPromises(); + const expectedColumns = { + command: [ + 'name', + 'code', + 'aliases', + 'pluginKey', + 'targetType', + 'enabled', + 'bound', + ], + event: ['name', 'key', 'triggerType', 'description', 'bound'], + rule: [ + 'name', + 'keyword', + 'matchType', + 'targetType', + 'replyContent', + 'enabled', + 'bound', + ], + }; + + for (const key of ['command', 'event', 'rule']) { + await wrapper.get(`[data-tab-key="${key}"]`).trigger('click'); + await flushPromises(); + expect(wrapper.findAll('[data-testid="legacy-table"]')).toHaveLength(1); + expect(wrapper.find('[data-testid="message-push-panel"]').exists()).toBe( + false, + ); + const latest = mocks.legacyTableProps.at(-1); + expect( + latest?.columns.map((column: { key: string }) => column.key), + ).toEqual(expectedColumns[key as keyof typeof expectedColumns]); + expect( + latest?.rowActions.map((action: { key: string }) => action.key), + ).toEqual(['bind', 'unbind']); + expect(Array.isArray(latest?.dataSource)).toBe(true); + expect(typeof latest?.rowKey).toBe( + key === 'event' ? 'function' : 'string', + ); + } + }); + + it('passes the exact implicit publisher and existing header callbacks', async () => { + const wrapper = mount(AccountConfigPanel, { + props: { account: createAccount() }, + }); + await wrapper.get('[data-tab-key="message-push"]').trigger('click'); + await flushPromises(); + + const panel = wrapper.getComponent(AccountMessagePushPanel); + expect(panel.props('selfId')).toBe('10000000000000001'); + expect(panel.props('title')).toBeTypeOf('function'); + expect(panel.props('headerControls')).toBeTypeOf('function'); + expect(wrapper.text()).toContain('Self ID:10000000000000001'); + expect(wrapper.text()).toContain('Bot A'); + expect(wrapper.find('[data-testid="legacy-table"]').exists()).toBe(false); + }); + + it('preserves one stable outer element root across tab changes', async () => { + const wrapper = mount(AccountConfigPanel, { + props: { account: createAccount() }, + }); + const root = wrapper.element; + + await wrapper.get('[data-tab-key="message-push"]').trigger('click'); + await flushPromises(); + expect(wrapper.element).toBe(root); + expect(wrapper.findAll('.qqbot-account-config-panel')).toHaveLength(1); + + await wrapper.get('[data-tab-key="command"]').trigger('click'); + await flushPromises(); + expect(wrapper.element).toBe(root); + expect(wrapper.findAll('.qqbot-account-config-panel')).toHaveLength(1); + }); +}); diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountConfigPanel.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/AccountConfigPanel.tsx index 28b5046..164ba2d 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/components/AccountConfigPanel.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/components/AccountConfigPanel.tsx @@ -30,6 +30,7 @@ import { qqbotRuleTargetOptions, } from '../../modules/options'; import { getQqbotStatusColor, getQqbotStatusLabel } from '../../modules/status'; +import AccountMessagePushPanel from './AccountMessagePushPanel'; const AKtTable = KtTable as any; const ASpin = Spin as any; @@ -39,6 +40,7 @@ const configTabItems = [ { key: 'command', label: '在线命令' }, { key: 'event', label: '事件触发' }, { key: 'rule', label: '自动回复规则' }, + { key: 'message-push', label: '消息推送' }, ] as const; type ConfigTabKey = (typeof configTabItems)[number]['key']; @@ -431,28 +433,36 @@ export default defineComponent({ return () => (
- + {activeTab.value === 'message-push' ? ( + + ) : ( + + )}
); }, diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.spec.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.spec.tsx new file mode 100644 index 0000000..084fd1d --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.spec.tsx @@ -0,0 +1,564 @@ +/* @vitest-environment happy-dom */ + +/* eslint-disable no-template-curly-in-string, vue/one-component-per-file, vue/require-default-prop */ + +import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; + +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h } from 'vue'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import AccountMessagePushModal from './AccountMessagePushModal'; + +interface FormValues { + enabled: boolean; + subscriptionId: string; + targets: QqbotMessagePushApi.QqbotMessagePublishTargetInput[]; + templateId: string; +} + +const mocks = vi.hoisted(() => { + const modalApi = { + close: vi.fn(async () => {}), + getData: vi.fn(), + lock: vi.fn(), + open: vi.fn(), + setData: vi.fn(), + unlock: vi.fn(), + }; + const formApi = { + getValues: vi.fn(), + resetForm: vi.fn(async () => {}), + resetValidate: vi.fn(async () => {}), + setValues: vi.fn(), + validate: vi.fn(async () => ({ valid: true })), + }; + return { + create: vi.fn(), + currentValues: { + enabled: true, + subscriptionId: '', + targets: [], + templateId: '', + } as FormValues, + formApi, + formOptions: undefined as any, + modalApi, + modalOptions: undefined as any, + update: vi.fn(), + }; +}); + +/** Creates one fluent no-op Zod rule for schema-focused tests. */ +function createRule(): any { + const rule: any = {}; + for (const method of [ + 'array', + 'max', + 'min', + 'object', + 'optional', + 'or', + 'regex', + ]) { + rule[method] = vi.fn(() => rule); + } + return rule; +} + +vi.mock('#/adapter/form', () => ({ + useVbenForm: vi.fn((options) => { + mocks.formOptions = options; + const Form = defineComponent({ + name: 'MockBindingForm', + /** Renders the schema's real local target-picker component and model wiring. */ + setup() { + return () => { + const targetField = mocks.formOptions.schema.find( + (field: any) => field.fieldName === 'targets', + ); + const TargetPicker = targetField?.component; + return h('form', { 'data-testid': 'binding-form' }, [ + TargetPicker + ? h(TargetPicker, { + ...(typeof targetField.componentProps === 'function' + ? targetField.componentProps() + : targetField.componentProps), + 'onUpdate:value': ( + value: QqbotMessagePushApi.QqbotMessagePublishTargetInput[], + ) => { + mocks.currentValues.targets = value; + }, + value: mocks.currentValues.targets, + }) + : null, + ]); + }; + }, + }); + return [Form, mocks.formApi]; + }), + z: { + array: vi.fn(createRule), + enum: vi.fn(createRule), + literal: vi.fn(createRule), + object: vi.fn(createRule), + string: vi.fn(createRule), + }, +})); + +vi.mock('@vben/common-ui', () => ({ + useVbenModal: vi.fn((options) => { + mocks.modalOptions = options; + const Modal = defineComponent({ + name: 'MockBindingModal', + /** Renders modal content while lifecycle remains API-controlled. */ + setup(_, { slots }) { + return () => h('section', slots.default?.()); + }, + }); + return [Modal, mocks.modalApi]; + }), +})); + +vi.mock('./MessagePushTargetPicker', () => ({ + default: defineComponent({ + name: 'MockMessagePushTargetPicker', + props: { + available: Boolean, + disabled: Boolean, + loading: Boolean, + options: Array, + reasonCode: String, + value: Array, + }, + emits: ['update:value'], + /** Exposes one button that exercises the Vben `modelPropName:value` path. */ + setup(_, { emit }) { + return () => + h( + 'button', + { + 'data-testid': 'target-picker', + onClick: () => + emit('update:value', [ + { targetId: '12345', targetType: 'group' }, + ]), + }, + 'target-picker', + ); + }, + }), + /** + * Mirrors the production helper so modal tests retain the exact validation boundary. + */ + isValidMessagePushTargetId: (targetId: string) => + /^[1-9]\d{4,19}$/.test(targetId), +})); + +vi.mock('#/api/qqbot/message-push', () => ({ + createAccountMessagePushBinding: mocks.create, + updateAccountMessagePushBinding: mocks.update, +})); + +/** Creates two source families to test compatible-template filtering. */ +function createSubscriptions(): QqbotMessagePushApi.MessageSubscriptionView[] { + return [ + { + createTime: '2026-07-24 10:00:00', + enabled: true, + id: '20000000000000001', + invalidReasonCode: null, + name: 'STUN A', + remark: null, + sourceConfig: { + ddnsRecordId: '60000000000000001', + portForwardId: '70000000000000001', + }, + sourceKey: 'network.stun.mapping-port-changed', + sourceName: 'STUN 端口变更', + sourceSummary: 'A', + updateTime: '2026-07-24 10:00:00', + valid: true, + }, + { + createTime: '2026-07-24 10:00:00', + enabled: true, + id: '20000000000000002', + invalidReasonCode: null, + name: 'STUN B', + remark: null, + sourceConfig: { + ddnsRecordId: '60000000000000002', + portForwardId: '70000000000000002', + }, + sourceKey: 'network.stun.mapping-port-changed', + sourceName: 'STUN 端口变更', + sourceSummary: 'B', + updateTime: '2026-07-24 10:00:00', + valid: true, + }, + { + createTime: '2026-07-24 10:00:00', + enabled: true, + id: '20000000000000003', + invalidReasonCode: null, + name: 'Other', + remark: null, + sourceConfig: { + ddnsRecordId: '60000000000000003', + portForwardId: '70000000000000003', + }, + sourceKey: 'system.other', + sourceName: 'Other', + sourceSummary: 'Other', + updateTime: '2026-07-24 10:00:00', + valid: true, + }, + ]; +} + +/** Creates templates in both source families. */ +function createTemplates(): QqbotMessagePushApi.MessageTemplateView[] { + return [ + { + content: '${{endpoint}}', + createTime: '2026-07-24 10:00:00', + enabled: true, + id: '50000000000000001', + name: 'STUN 模板', + referenceCount: 0, + remark: null, + sourceKey: 'network.stun.mapping-port-changed', + sourceName: 'STUN 端口变更', + updateTime: '2026-07-24 10:00:00', + }, + { + content: 'other', + createTime: '2026-07-24 10:00:00', + enabled: true, + id: '50000000000000002', + name: 'Other 模板', + referenceCount: 0, + remark: null, + sourceKey: 'system.other', + sourceName: 'Other', + updateTime: '2026-07-24 10:00:00', + }, + ]; +} + +/** Creates an edit row whose targets retain API-provided names as strings. */ +function createBinding(): QqbotMessagePushApi.QqbotMessagePublishBindingView { + return { + available: true, + createTime: '2026-07-24 10:00:00', + enabled: false, + id: '10000000000000001', + invalidReasonCode: null, + sourceKey: 'network.stun.mapping-port-changed', + sourceName: 'STUN 端口变更', + subscriptionId: '20000000000000001', + subscriptionName: 'STUN A', + targets: [ + { + enabled: true, + id: '30000000000000001', + targetId: '40000000000000001', + targetName: '保留群名', + targetType: 'group', + }, + { + enabled: true, + id: '30000000000000002', + targetId: '40000000000000002', + targetName: null, + targetType: 'private', + }, + ], + templateId: '50000000000000001', + templateName: 'STUN 模板', + updateTime: '2026-07-24 10:00:00', + }; +} + +/** Mounts the modal with one implicit publisher and page-owned metadata. */ +function mountModal(selfId = '10000000000000001') { + return mount(AccountMessagePushModal, { + props: { + selfId, + subscriptions: createSubscriptions(), + targetOptions: { + available: false, + options: [], + reasonCode: 'onebot_unavailable', + }, + targetOptionsLoading: false, + templates: createTemplates(), + }, + }); +} + +describe('account message-push modal', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.currentValues = { + enabled: true, + subscriptionId: '20000000000000001', + targets: [{ targetId: '40000000000000001', targetType: 'group' }], + templateId: '50000000000000001', + }; + mocks.formApi.getValues.mockImplementation(async () => ({ + ...mocks.currentValues, + targets: mocks.currentValues.targets.map((target) => ({ ...target })), + })); + mocks.formApi.resetForm.mockImplementation(async () => { + mocks.currentValues = { + enabled: true, + subscriptionId: '', + targets: [], + templateId: '', + }; + }); + mocks.formApi.setValues.mockImplementation( + async (patch: Partial) => { + mocks.currentValues = { ...mocks.currentValues, ...patch }; + }, + ); + mocks.modalApi.getData.mockImplementation( + () => mocks.modalApi.setData.mock.calls.at(-1)?.[0] || {}, + ); + mocks.modalApi.setData.mockImplementation(() => mocks.modalApi); + mocks.modalApi.open.mockImplementation(() => { + void mocks.modalOptions.onOpenChange?.(true); + return mocks.modalApi; + }); + mocks.create.mockResolvedValue({}); + mocks.update.mockResolvedValue({}); + }); + + it('renders the exact four fields and local value-model target picker', async () => { + const wrapper = mountModal(); + const fields = mocks.formOptions.schema.map( + (field: any) => field.fieldName, + ); + + expect(fields).toEqual([ + 'subscriptionId', + 'templateId', + 'targets', + 'enabled', + ]); + expect(JSON.stringify(fields)).not.toMatch(/account|selfId/i); + const targetField = mocks.formOptions.schema.find( + (field: any) => field.fieldName === 'targets', + ); + expect(targetField.modelPropName).toBe('value'); + expect( + wrapper + .get('[data-testid="binding-form"]') + .find('[data-testid="target-picker"]') + .exists(), + ).toBe(true); + + await wrapper.get('[data-testid="target-picker"]').trigger('click'); + expect(mocks.currentValues.targets).toEqual([ + { targetId: '12345', targetType: 'group' }, + ]); + }); + + it('isolates edit/create sessions and preserves only editable string data', async () => { + const wrapper = mountModal(); + (wrapper.vm as any).openEdit(createBinding()); + await flushPromises(); + + expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({ + enabled: false, + subscriptionId: '20000000000000001', + targets: [ + { + targetId: '40000000000000001', + targetName: '保留群名', + targetType: 'group', + }, + { + targetId: '40000000000000002', + targetType: 'private', + }, + ], + templateId: '50000000000000001', + }); + + (wrapper.vm as any).openCreate(); + await flushPromises(); + expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({ + enabled: true, + subscriptionId: '', + targets: [], + templateId: '', + }); + expect(mocks.formApi.resetForm).toHaveBeenCalledTimes(2); + expect(mocks.formApi.resetValidate).toHaveBeenCalledTimes(2); + }); + + it('filters templates by source and clears only an incompatible choice', async () => { + mountModal(); + const templateField = mocks.formOptions.schema.find( + (field: any) => field.fieldName === 'templateId', + ); + + await mocks.formOptions.handleValuesChange( + { + subscriptionId: '20000000000000001', + templateId: '50000000000000001', + }, + ['subscriptionId'], + ); + expect(templateField.componentProps().options).toEqual([ + expect.objectContaining({ value: '50000000000000001' }), + ]); + expect(mocks.formApi.setValues).not.toHaveBeenCalledWith({ + templateId: '', + }); + + await mocks.formOptions.handleValuesChange( + { + subscriptionId: '20000000000000002', + templateId: '50000000000000001', + }, + ['subscriptionId'], + ); + expect(mocks.formApi.setValues).not.toHaveBeenCalledWith({ + templateId: '', + }); + + await mocks.formOptions.handleValuesChange( + { + subscriptionId: '20000000000000003', + templateId: '50000000000000001', + }, + ['subscriptionId'], + ); + expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({ + templateId: '', + }); + expect(templateField.componentProps().options).toEqual([ + expect.objectContaining({ value: '50000000000000002' }), + ]); + }); + + it('creates and updates exact four-member bodies with selfId only as path arg', async () => { + const wrapper = mountModal(); + (wrapper.vm as any).openCreate(); + await flushPromises(); + mocks.currentValues = { + enabled: true, + subscriptionId: '20000000000000001', + targets: [ + { + targetId: '40000000000000001', + targetName: '测试群', + targetType: 'group', + }, + ], + templateId: '50000000000000001', + }; + await mocks.modalOptions.onConfirm(); + + const expectedBody: QqbotMessagePushApi.QqbotMessagePublishBindingInput = { + enabled: true, + subscriptionId: '20000000000000001', + targets: [ + { + targetId: '40000000000000001', + targetName: '测试群', + targetType: 'group', + }, + ], + templateId: '50000000000000001', + }; + expect(mocks.create).toHaveBeenCalledWith( + '10000000000000001', + expectedBody, + ); + expect( + Object.keys(mocks.create.mock.calls[0]?.[1] || {}).toSorted(), + ).toEqual(['enabled', 'subscriptionId', 'targets', 'templateId']); + expect(JSON.stringify(mocks.create.mock.calls[0]?.[1])).not.toMatch( + /selfId|account/i, + ); + + (wrapper.vm as any).openEdit(createBinding()); + await flushPromises(); + mocks.currentValues = expectedBody; + await mocks.modalOptions.onConfirm(); + expect(mocks.update).toHaveBeenCalledWith( + '10000000000000001', + '10000000000000001', + expectedBody, + ); + }); + + it('refuses empty, excessive, malformed, and non-string targets before API calls', async () => { + const wrapper = mountModal(); + (wrapper.vm as any).openCreate(); + await flushPromises(); + + for (const targets of [ + [], + Array.from({ length: 101 }, () => ({ + targetId: '12345', + targetType: 'group' as const, + })), + [{ targetId: '01234', targetType: 'group' as const }], + [{ targetId: 12_345 as unknown as string, targetType: 'group' as const }], + [{ targetId: '12345', targetType: 'channel' as 'group' }], + ]) { + mocks.currentValues.targets = targets; + await mocks.modalOptions.onConfirm(); + } + + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.update).not.toHaveBeenCalled(); + }); + + it('closes and invalidates an open session when the implicit selfId changes', async () => { + const wrapper = mountModal(); + (wrapper.vm as any).openCreate(); + await flushPromises(); + + await wrapper.setProps({ selfId: '10000000000000002' }); + await flushPromises(); + await mocks.modalOptions.onConfirm(); + + expect(mocks.modalApi.close).toHaveBeenCalledOnce(); + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.update).not.toHaveBeenCalled(); + }); + + it('keeps a rejected session open and closes/emits exactly once on success', async () => { + mocks.create.mockRejectedValueOnce(new Error('save failed')); + const wrapper = mountModal(); + (wrapper.vm as any).openCreate(); + await flushPromises(); + mocks.currentValues = { + enabled: true, + subscriptionId: '20000000000000001', + targets: [{ targetId: '12345', targetType: 'group' }], + templateId: '50000000000000001', + }; + + await expect(mocks.modalOptions.onConfirm()).rejects.toThrow('save failed'); + expect(mocks.modalApi.lock).toHaveBeenCalledOnce(); + expect(mocks.modalApi.close).not.toHaveBeenCalled(); + expect(mocks.modalApi.unlock).toHaveBeenCalledOnce(); + expect(wrapper.emitted('saved')).toBeUndefined(); + + mocks.create.mockResolvedValue({}); + await mocks.modalOptions.onConfirm(); + expect(mocks.modalApi.close).toHaveBeenCalledOnce(); + expect(mocks.modalApi.unlock).toHaveBeenCalledTimes(2); + expect(wrapper.emitted('saved')).toHaveLength(1); + }); +}); diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx new file mode 100644 index 0000000..f6de598 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx @@ -0,0 +1,422 @@ +import type { PropType } from 'vue'; + +import type { VbenFormSchema } from '#/adapter/form'; +import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; + +import { computed, defineComponent, markRaw, ref, watch } from 'vue'; + +import { useVbenModal } from '@vben/common-ui'; + +import { useVbenForm, z } from '#/adapter/form'; +import { + createAccountMessagePushBinding, + updateAccountMessagePushBinding, +} from '#/api/qqbot/message-push'; + +import MessagePushTargetPicker, { + isValidMessagePushTargetId, +} from './MessagePushTargetPicker'; + +export interface AccountMessagePushModalExposed { + openCreate: () => void; + openEdit: (row: QqbotMessagePushApi.QqbotMessagePublishBindingView) => void; +} + +interface AccountMessagePushFormValues { + enabled: boolean; + subscriptionId: string; + targets: QqbotMessagePushApi.QqbotMessagePublishTargetInput[]; + templateId: string; +} + +interface AccountMessagePushModalData { + selfId: string; + sessionRevision: number; + values: AccountMessagePushFormValues; +} + +const DECIMAL_ID_PATTERN = /^[1-9]\d*$/; + +export default defineComponent({ + name: 'AccountMessagePushModal', + props: { + selfId: { + required: true, + type: String, + }, + subscriptions: { + required: true, + type: Array as PropType, + }, + targetOptions: { + default: undefined, + type: Object as PropType< + QqbotMessagePushApi.QqbotMessagePushTargetOptionsResponse | undefined + >, + }, + targetOptionsLoading: { + default: false, + type: Boolean, + }, + templates: { + required: true, + type: Array as PropType, + }, + }, + emits: ['saved'], + /** Owns one account-scoped create/edit session without selecting another account. */ + setup(props, { emit, expose }) { + const editingId = ref(); + const modalOpen = ref(false); + const selectedSubscriptionId = ref(''); + let sessionRevision = 0; + let sessionSelfId = ''; + const [BindingForm, formApi] = useVbenForm({ + commonConfig: { + labelClass: 'w-24', + }, + /** + * Keeps the selected template only while it remains source-compatible. + * @param values - Current form values after the update. + * @param fieldsChanged - Exact form fields changed by this event. + */ + async handleValuesChange(values, fieldsChanged) { + if (!fieldsChanged.includes('subscriptionId')) return; + selectedSubscriptionId.value = + typeof values.subscriptionId === 'string' + ? values.subscriptionId + : ''; + const templateId = + typeof values.templateId === 'string' ? values.templateId : ''; + if (!templateId) return; + if ( + !isTemplateCompatible(props, selectedSubscriptionId.value, templateId) + ) { + await formApi.setValues({ templateId: '' }); + } + }, + layout: 'horizontal', + schema: createFormSchema(props, selectedSubscriptionId), + showDefaultActions: false, + wrapperClass: 'grid-cols-1', + }); + /** Derives the title from the current isolated binding identity. */ + const modalTitle = computed(() => + editingId.value ? '编辑消息推送' : '新增消息推送', + ); + const [Modal, modalApi] = useVbenModal({ + class: 'w-[760px]', + fullscreenButton: false, + /** Validates and persists the active account-scoped session. */ + async onConfirm() { + await submit(); + }, + /** + * Restores only the latest session after destroy-on-close content mounts. + * @param isOpen - Whether the modal content is currently mounted. + */ + async onOpenChange(isOpen: boolean) { + modalOpen.value = isOpen; + if (!isOpen) return; + const data = modalApi.getData(); + if ( + data.sessionRevision !== sessionRevision || + data.selfId !== props.selfId + ) { + await modalApi.close(); + return; + } + selectedSubscriptionId.value = data.values.subscriptionId; + await resetForm(data.values); + }, + }); + + /** Opens a fresh four-field binding session for the current account path. */ + function openCreate() { + editingId.value = undefined; + beginSession({ + enabled: true, + subscriptionId: '', + targets: [], + templateId: '', + }); + } + + /** + * Opens an edit session with only API-provided editable binding values. + * @param row - Account binding selected from the panel-owned KtTable. + */ + function openEdit(row: QqbotMessagePushApi.QqbotMessagePublishBindingView) { + editingId.value = row.id; + beginSession({ + enabled: row.enabled, + subscriptionId: row.subscriptionId, + targets: row.targets.map((target) => ({ + targetId: target.targetId, + ...(target.targetName ? { targetName: target.targetName } : {}), + targetType: target.targetType, + })), + templateId: row.templateId, + }); + } + + /** + * Stores one revision-bound modal payload before opening its content. + * @param values - Exact four editable values for the new modal session. + */ + function beginSession(values: AccountMessagePushFormValues) { + sessionRevision += 1; + sessionSelfId = props.selfId; + selectedSubscriptionId.value = values.subscriptionId; + modalApi + .setData({ + selfId: sessionSelfId, + sessionRevision, + values, + } satisfies AccountMessagePushModalData) + .open(); + } + + /** + * Resets the mounted form before installing one isolated session. + * @param values - Exact values captured before opening the modal. + */ + async function resetForm(values: AccountMessagePushFormValues) { + await formApi.resetForm(); + await formApi.setValues(values); + await formApi.resetValidate(); + } + + /** Validates and persists one session without putting selfId in the body. */ + async function submit() { + const revision = sessionRevision; + const selfId = sessionSelfId; + if (!selfId || selfId !== props.selfId) return; + const { valid } = await formApi.validate(); + if (!valid) return; + const values = await formApi.getValues(); + const payload = normalizeBindingPayload(props, values); + if (!payload) return; + const currentEditingId = editingId.value; + + modalApi.lock(); + try { + await (currentEditingId + ? updateAccountMessagePushBinding(selfId, currentEditingId, payload) + : createAccountMessagePushBinding(selfId, payload)); + if (revision !== sessionRevision || selfId !== props.selfId) return; + await modalApi.close(); + emit('saved'); + } finally { + modalApi.unlock(); + } + } + + /** + * Invalidates an open old-account session before its path identity changes. + */ + async function invalidateForSelfIdChange() { + sessionRevision += 1; + sessionSelfId = ''; + editingId.value = undefined; + selectedSubscriptionId.value = ''; + if (modalOpen.value) await modalApi.close(); + } + + watch( + () => props.selfId, + (selfId, previousSelfId) => { + if (selfId === previousSelfId) return; + void invalidateForSelfIdChange(); + }, + ); + + expose({ openCreate, openEdit } satisfies AccountMessagePushModalExposed); + + return () => ( + + + + ); + }, +}); + +/** + * Builds the exact account-binding schema with the local controlled picker. + * @param props - Page-owned subscriptions, templates, and target candidates. + * @param selectedSubscriptionId - Current subscription driving template options. + * @returns Four fields in subscription/template/targets/enabled order. + */ +function createFormSchema( + props: Readonly<{ + selfId: string; + subscriptions: QqbotMessagePushApi.MessageSubscriptionView[]; + targetOptions: + | QqbotMessagePushApi.QqbotMessagePushTargetOptionsResponse + | undefined; + targetOptionsLoading: boolean; + templates: QqbotMessagePushApi.MessageTemplateView[]; + }>, + selectedSubscriptionId: Readonly<{ value: string }>, +): VbenFormSchema[] { + return [ + { + component: 'Select', + componentProps: () => ({ + options: props.subscriptions.map((subscription) => ({ + disabled: !subscription.enabled || !subscription.valid, + label: formatSubscriptionLabel(subscription), + value: subscription.id, + })), + }), + fieldName: 'subscriptionId', + label: '消息订阅', + rules: z.string().regex(DECIMAL_ID_PATTERN), + }, + { + component: 'Select', + componentProps: () => ({ + options: compatibleTemplates(props, selectedSubscriptionId.value).map( + (template) => ({ + disabled: !template.enabled, + label: `${template.name} · ${template.sourceName}`, + value: template.id, + }), + ), + }), + fieldName: 'templateId', + label: '消息模板', + rules: z.string().regex(DECIMAL_ID_PATTERN), + }, + { + component: markRaw(MessagePushTargetPicker), + componentProps: () => ({ + available: props.targetOptions?.available ?? true, + loading: props.targetOptionsLoading, + options: props.targetOptions?.options || [], + reasonCode: props.targetOptions?.reasonCode ?? null, + }), + fieldName: 'targets', + label: '推送目标', + modelPropName: 'value', + rules: z + .array( + z.object({ + targetId: z.string().regex(/^[1-9]\d{4,19}$/), + targetName: z.string().max(120).optional(), + targetType: z.enum(['group', 'private']), + }), + ) + .min(1) + .max(100), + }, + { + component: 'Switch', + defaultValue: true, + fieldName: 'enabled', + label: '启用', + }, + ]; +} + +/** + * Formats one subscription while retaining server validity information. + * @param subscription - Global subscription available to the current account. + * @returns Select label with a stable invalid reason when applicable. + */ +function formatSubscriptionLabel( + subscription: QqbotMessagePushApi.MessageSubscriptionView, +): string { + const reason = + subscription.valid && subscription.enabled + ? '' + : ` · ${subscription.invalidReasonCode || 'disabled'}`; + return `${subscription.name} · ${subscription.sourceName}${reason}`; +} + +/** + * Returns templates whose source matches the selected global subscription. + * @param props - Current page-owned subscription/template collections. + * @param subscriptionId - Exact selected subscription string ID. + * @returns Source-compatible template rows in server order. + */ +function compatibleTemplates( + props: Readonly<{ + subscriptions: QqbotMessagePushApi.MessageSubscriptionView[]; + templates: QqbotMessagePushApi.MessageTemplateView[]; + }>, + subscriptionId: string, +): QqbotMessagePushApi.MessageTemplateView[] { + const sourceKey = props.subscriptions.find( + (subscription) => subscription.id === subscriptionId, + )?.sourceKey; + if (!sourceKey) return []; + return props.templates.filter((template) => template.sourceKey === sourceKey); +} + +/** + * Checks template/source compatibility without interpreting numeric IDs. + * @param props - Current subscription/template collections. + * @param subscriptionId - Selected subscription string ID. + * @param templateId - Existing template string ID. + * @returns Whether both rows exist and share the exact source key. + */ +function isTemplateCompatible( + props: Readonly<{ + subscriptions: QqbotMessagePushApi.MessageSubscriptionView[]; + templates: QqbotMessagePushApi.MessageTemplateView[]; + }>, + subscriptionId: string, + templateId: string, +): boolean { + return compatibleTemplates(props, subscriptionId).some( + (template) => template.id === templateId, + ); +} + +/** + * Validates the exact four-field payload before invoking the account API. + * @param props - Current source-compatible metadata. + * @param values - Form-owned binding values. + * @returns A clean API payload or undefined when any ID/target is invalid. + */ +function normalizeBindingPayload( + props: Readonly<{ + subscriptions: QqbotMessagePushApi.MessageSubscriptionView[]; + templates: QqbotMessagePushApi.MessageTemplateView[]; + }>, + values: AccountMessagePushFormValues, +): QqbotMessagePushApi.QqbotMessagePublishBindingInput | undefined { + if ( + !DECIMAL_ID_PATTERN.test(values.subscriptionId) || + !DECIMAL_ID_PATTERN.test(values.templateId) || + !isTemplateCompatible(props, values.subscriptionId, values.templateId) || + !Array.isArray(values.targets) || + values.targets.length === 0 || + values.targets.length > 100 + ) { + return undefined; + } + const targets: QqbotMessagePushApi.QqbotMessagePublishTargetInput[] = []; + for (const target of values.targets) { + if ( + !target || + typeof target.targetId !== 'string' || + !isValidMessagePushTargetId(target.targetId) || + (target.targetType !== 'group' && target.targetType !== 'private') + ) { + return undefined; + } + targets.push({ + targetId: target.targetId, + ...(target.targetName?.trim() ? { targetName: target.targetName } : {}), + targetType: target.targetType, + }); + } + return { + enabled: !!values.enabled, + subscriptionId: values.subscriptionId, + targets, + templateId: values.templateId, + }; +} diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.spec.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.spec.tsx new file mode 100644 index 0000000..a9b8358 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.spec.tsx @@ -0,0 +1,548 @@ +/* @vitest-environment happy-dom */ + +/* eslint-disable no-template-curly-in-string, vue/one-component-per-file, vue/require-default-prop */ + +import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; + +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent, h } from 'vue'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import AccountMessagePushPanel from './AccountMessagePushPanel'; + +const mocks = vi.hoisted(() => { + const state = { + accessCodes: new Set(), + api: { + deleteBinding: vi.fn(), + getBindings: vi.fn(), + getSubscriptions: vi.fn(), + getTargets: vi.fn(), + getTemplates: vi.fn(), + toggleBinding: vi.fn(), + }, + modalOpenCreate: vi.fn(), + modalOpenEdit: vi.fn(), + registeredTableOptions: undefined as any, + registerTable: vi.fn(), + renderedPage: undefined as any, + tableApi: { + reload: vi.fn(), + }, + tableOptions: undefined as any, + }; + /** Installs the real list callback only after the rendered KtTable registers. */ + state.registerTable.mockImplementation(() => { + state.registeredTableOptions = state.tableOptions; + }); + /** Reloads through the registered list callback and records the applied page. */ + state.tableApi.reload.mockImplementation(async () => { + if (!state.registeredTableOptions) { + throw new Error('[MockKtTable]: table is not registered yet.'); + } + const page = await state.registeredTableOptions.api.list({ + pageNo: 1, + pageSize: 10, + }); + state.renderedPage = page; + return page; + }); + return state; +}); + +vi.mock('@vben/access', () => ({ + useAccess: () => ({ + hasAccessByCodes: (codes: string[]) => + codes.every((code) => mocks.accessCodes.has(code)), + }), +})); + +vi.mock('@vben/icons', () => ({ + Plus: defineComponent({ + name: 'MockPlusIcon', + setup: () => () => h('i'), + }), +})); + +vi.mock('antdv-next', () => ({ + Space: defineComponent({ + name: 'MockSpace', + setup: + (_: unknown, { slots }: any) => + () => + h('span', slots.default?.()), + }), + Tag: defineComponent({ + name: 'MockTag', + setup: + (_: unknown, { slots }: any) => + () => + h('span', slots.default?.()), + }), +})); + +vi.mock('#/components/ktTable', () => ({ + KtTable: defineComponent({ + name: 'MockAccountMessagePushKtTable', + emits: ['register'], + /** Registers first, then renders inherited account header callbacks. */ + setup(_, { emit, slots }) { + emit('register', {}); + return () => + h('section', { 'data-testid': 'message-push-table' }, [ + slots.title?.(), + slots.headerControls?.(), + ]); + }, + }), + useKtTable: vi.fn((options) => { + mocks.tableOptions = options; + return [mocks.registerTable, mocks.tableApi]; + }), +})); + +vi.mock('./AccountMessagePushModal', () => ({ + default: defineComponent({ + name: 'MockAccountMessagePushModal', + props: { + selfId: String, + subscriptions: Array, + targetOptions: Object, + targetOptionsLoading: Boolean, + templates: Array, + }, + emits: ['saved'], + /** Exposes create/edit commands plus a deterministic save event. */ + setup(_, { emit, expose }) { + expose({ + openCreate: mocks.modalOpenCreate, + openEdit: mocks.modalOpenEdit, + }); + return () => + h( + 'button', + { + 'data-testid': 'modal-saved', + onClick: () => emit('saved'), + }, + 'saved', + ); + }, + }), +})); + +vi.mock('#/api/qqbot/message-push', () => ({ + deleteAccountMessagePushBinding: mocks.api.deleteBinding, + getAccountMessagePushBindings: mocks.api.getBindings, + getAccountMessagePushTargets: mocks.api.getTargets, + getMessageSubscriptionList: mocks.api.getSubscriptions, + getMessageTemplateList: mocks.api.getTemplates, + setAccountMessagePushBindingEnabled: mocks.api.toggleBinding, +})); + +/** Creates one unsafe-integer string-ID binding row. */ +function createBinding( + overrides: Partial = {}, +): QqbotMessagePushApi.QqbotMessagePublishBindingView { + return { + available: true, + createTime: '2026-07-24 10:00:00', + enabled: true, + id: '10000000000000001', + invalidReasonCode: null, + sourceKey: 'network.stun.mapping-port-changed', + sourceName: 'STUN 映射端口变更', + subscriptionId: '20000000000000001', + subscriptionName: '帕鲁端口变更', + targets: [ + { + enabled: true, + id: '30000000000000001', + targetId: '40000000000000001', + targetName: '测试群', + targetType: 'group', + }, + ], + templateId: '50000000000000001', + templateName: '默认模板', + updateTime: '2026-07-24 10:00:00', + ...overrides, + }; +} + +/** Creates one global subscription fixture for the account modal. */ +function createSubscription( + id = '20000000000000001', +): QqbotMessagePushApi.MessageSubscriptionView { + return { + createTime: '2026-07-24 10:00:00', + enabled: true, + id, + invalidReasonCode: null, + name: `订阅 ${id}`, + remark: null, + sourceConfig: { + ddnsRecordId: '60000000000000001', + portForwardId: '70000000000000001', + }, + sourceKey: 'network.stun.mapping-port-changed', + sourceName: 'STUN 映射端口变更', + sourceSummary: '帕鲁 · pal.kwitsukasa.top', + updateTime: '2026-07-24 10:00:00', + valid: true, + }; +} + +/** Creates one source-compatible template fixture. */ +function createTemplate( + id = '50000000000000001', +): QqbotMessagePushApi.MessageTemplateView { + return { + content: '当前STUN的端口已变更为${{endpoint}}', + createTime: '2026-07-24 10:00:00', + enabled: true, + id, + name: `模板 ${id}`, + referenceCount: 0, + remark: null, + sourceKey: 'network.stun.mapping-port-changed', + sourceName: 'STUN 映射端口变更', + updateTime: '2026-07-24 10:00:00', + }; +} + +/** Creates a manually controlled promise for stale-response assertions. */ +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +/** Mounts the panel with visible account header callbacks. */ +function mountPanel(selfId = '10000000000000001') { + return mount(AccountMessagePushPanel, { + props: { + headerControls: () => h('div', { 'data-testid': 'account-tabs' }), + selfId, + title: () => h('div', { 'data-testid': 'account-title' }), + }, + }); +} + +describe('account message-push panel', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + mocks.accessCodes = new Set([ + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Delete', + 'QqBot:Account:MessagePush:List', + 'QqBot:Account:MessagePush:Toggle', + 'QqBot:Account:MessagePush:Update', + ]); + mocks.registeredTableOptions = undefined; + mocks.renderedPage = undefined; + mocks.tableOptions = undefined; + mocks.api.getBindings.mockResolvedValue([createBinding()]); + mocks.api.getSubscriptions.mockResolvedValue({ + items: [createSubscription()], + total: 1, + }); + mocks.api.getTemplates.mockResolvedValue({ + items: [createTemplate()], + total: 1, + }); + mocks.api.getTargets.mockResolvedValue({ + available: true, + options: [ + { + label: '测试群', + targetId: '40000000000000001', + targetType: 'group', + }, + ], + reasonCode: null, + }); + mocks.api.toggleBinding.mockResolvedValue(createBinding()); + mocks.api.deleteBinding.mockResolvedValue(true); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('registers one native table and performs one full authorized load', async () => { + const wrapper = mountPanel(); + await flushPromises(); + + expect(wrapper.findAll('.qqbot-account-message-push-panel')).toHaveLength( + 1, + ); + expect(wrapper.findAll('[data-testid="message-push-table"]')).toHaveLength( + 1, + ); + expect(wrapper.find('[data-testid="account-title"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="account-tabs"]').exists()).toBe(true); + expect(mocks.registerTable).toHaveBeenCalledOnce(); + expect(mocks.tableOptions).toMatchObject({ + immediate: false, + rowKey: 'id', + showPagination: false, + showTableSetting: false, + }); + expect(mocks.api.getBindings).toHaveBeenCalledOnce(); + expect(mocks.api.getBindings).toHaveBeenCalledWith('10000000000000001'); + expect(mocks.api.getSubscriptions).toHaveBeenCalledWith({ + pageNo: 1, + pageSize: 100, + }); + expect(mocks.api.getTemplates).toHaveBeenCalledWith({ + pageNo: 1, + pageSize: 100, + }); + expect(mocks.api.getTargets).toHaveBeenCalledWith('10000000000000001'); + expect(mocks.renderedPage).toEqual({ + items: [createBinding()], + total: 1, + }); + }); + + it('hard-gates registration and every request when List is missing', async () => { + mocks.accessCodes = new Set([ + 'QqBot:Account:MessagePush:Create', + 'QqBot:Account:MessagePush:Update', + ]); + const wrapper = mountPanel(); + await flushPromises(); + + expect(wrapper.find('[data-testid="message-push-table"]').exists()).toBe( + false, + ); + expect(wrapper.find('[data-testid="modal-saved"]').exists()).toBe(false); + expect(mocks.registerTable).not.toHaveBeenCalled(); + expect(mocks.tableApi.reload).not.toHaveBeenCalled(); + expect(mocks.api.getBindings).not.toHaveBeenCalled(); + expect(mocks.api.getSubscriptions).not.toHaveBeenCalled(); + expect(mocks.api.getTemplates).not.toHaveBeenCalled(); + expect(mocks.api.getTargets).not.toHaveBeenCalled(); + }); + + it('lets List-only users load metadata without target discovery', async () => { + mocks.accessCodes = new Set(['QqBot:Account:MessagePush:List']); + mountPanel(); + await flushPromises(); + + expect(mocks.api.getBindings).toHaveBeenCalledOnce(); + expect(mocks.api.getSubscriptions).toHaveBeenCalledOnce(); + expect(mocks.api.getTemplates).toHaveBeenCalledOnce(); + expect(mocks.api.getTargets).not.toHaveBeenCalled(); + }); + + it('loads all global metadata pages up to the server total', async () => { + mocks.api.getSubscriptions + .mockResolvedValueOnce({ + items: Array.from({ length: 100 }, (_, index) => + createSubscription(`2${String(index).padStart(16, '0')}`), + ), + total: 101, + }) + .mockResolvedValueOnce({ + items: [createSubscription('29999999999999999')], + total: 101, + }); + mocks.api.getTemplates + .mockResolvedValueOnce({ + items: Array.from({ length: 100 }, (_, index) => + createTemplate(`5${String(index).padStart(16, '0')}`), + ), + total: 101, + }) + .mockResolvedValueOnce({ + items: [createTemplate('59999999999999999')], + total: 101, + }); + const wrapper = mountPanel(); + await flushPromises(); + + expect(mocks.api.getSubscriptions).toHaveBeenNthCalledWith(2, { + pageNo: 2, + pageSize: 100, + }); + expect(mocks.api.getTemplates).toHaveBeenNthCalledWith(2, { + pageNo: 2, + pageSize: 100, + }); + const modal = wrapper.getComponent({ name: 'MockAccountMessagePushModal' }); + expect(modal.props('subscriptions')).toHaveLength(101); + expect(modal.props('templates')).toHaveLength(101); + }); + + it('uses a revision guard for real selfId changes and ignores stale rows', async () => { + const wrapper = mountPanel('10001'); + await flushPromises(); + vi.clearAllMocks(); + const oldRequest = + deferred(); + mocks.api.getBindings + .mockImplementationOnce(() => oldRequest.promise) + .mockResolvedValueOnce([ + createBinding({ id: '90000000000000003', subscriptionName: 'new' }), + ]); + + await wrapper.setProps({ selfId: '10002' }); + await wrapper.setProps({ selfId: '10003' }); + await flushPromises(); + oldRequest.resolve([ + createBinding({ id: '90000000000000002', subscriptionName: 'old' }), + ]); + await flushPromises(); + + expect(mocks.api.getBindings).toHaveBeenNthCalledWith(1, '10002'); + expect(mocks.api.getBindings).toHaveBeenNthCalledWith(2, '10003'); + expect(mocks.renderedPage).toEqual({ + items: [ + createBinding({ + id: '90000000000000003', + subscriptionName: 'new', + }), + ], + total: 1, + }); + + const callsBeforeIdentical = mocks.api.getBindings.mock.calls.length; + await wrapper.setProps({ selfId: '10003' }); + await flushPromises(); + expect(mocks.api.getBindings).toHaveBeenCalledTimes(callsBeforeIdentical); + }); + + it('pins columns, exact permissions, and account-scoped actions', () => { + mountPanel(); + + expect(mocks.tableOptions.columns.map((column: any) => column.key)).toEqual( + [ + 'subscription', + 'source', + 'template', + 'targets', + 'enabled', + 'updateTime', + ], + ); + expect( + Object.fromEntries( + mocks.tableOptions.buttons.map((button: any) => [ + button.key, + button.permissionCodes, + ]), + ), + ).toEqual({ + create: ['QqBot:Account:MessagePush:Create'], + refresh: ['QqBot:Account:MessagePush:List'], + }); + expect( + Object.fromEntries( + mocks.tableOptions.rowActions.map((action: any) => [ + action.key, + action.permissionCodes, + ]), + ), + ).toEqual({ + delete: ['QqBot:Account:MessagePush:Delete'], + edit: ['QqBot:Account:MessagePush:Update'], + toggle: ['QqBot:Account:MessagePush:Toggle'], + }); + expect( + mocks.tableOptions.rowActions.find( + (action: any) => action.key === 'delete', + ).confirm, + ).toBeTypeOf('function'); + }); + + it('opens create/edit and reloads bindings once after modal saved', async () => { + const wrapper = mountPanel(); + await flushPromises(); + mocks.tableApi.reload.mockClear(); + mocks.api.getBindings.mockClear(); + const row = createBinding(); + + await mocks.tableOptions.buttons + .find((button: any) => button.key === 'create') + .onClick({}); + await mocks.tableOptions.rowActions + .find((action: any) => action.key === 'edit') + .onClick(row, {}); + await wrapper.get('[data-testid="modal-saved"]').trigger('click'); + await flushPromises(); + + expect(mocks.modalOpenCreate).toHaveBeenCalledOnce(); + expect(mocks.modalOpenEdit).toHaveBeenCalledWith(row); + expect(mocks.tableApi.reload).toHaveBeenCalledOnce(); + expect(mocks.api.getBindings).toHaveBeenCalledOnce(); + }); + + it('reloads once after successful toggle/delete and never after rejection', async () => { + mountPanel(); + await flushPromises(); + const row = createBinding(); + const context = { reload: vi.fn(async () => {}) }; + const toggle = mocks.tableOptions.rowActions.find( + (action: any) => action.key === 'toggle', + ); + const remove = mocks.tableOptions.rowActions.find( + (action: any) => action.key === 'delete', + ); + + await toggle.onClick(row, context); + await remove.onClick(row, context); + expect(mocks.api.toggleBinding).toHaveBeenCalledWith( + '10000000000000001', + '10000000000000001', + false, + ); + expect(mocks.api.deleteBinding).toHaveBeenCalledWith( + '10000000000000001', + '10000000000000001', + ); + expect(context.reload).toHaveBeenCalledTimes(2); + + context.reload.mockClear(); + mocks.api.toggleBinding.mockRejectedValueOnce(new Error('toggle failed')); + mocks.api.deleteBinding.mockRejectedValueOnce(new Error('delete failed')); + await expect(toggle.onClick(row, context)).rejects.toThrow('toggle failed'); + await expect(remove.onClick(row, context)).rejects.toThrow('delete failed'); + expect(context.reload).not.toHaveBeenCalled(); + }); + + it('explicit refresh reloads only bindings and idle time adds no work', async () => { + mountPanel(); + await flushPromises(); + mocks.tableApi.reload.mockClear(); + mocks.api.getBindings.mockClear(); + const subscriptionCalls = mocks.api.getSubscriptions.mock.calls.length; + const templateCalls = mocks.api.getTemplates.mock.calls.length; + const targetCalls = mocks.api.getTargets.mock.calls.length; + + const refresh = mocks.tableOptions.buttons.find( + (button: any) => button.key === 'refresh', + ); + expect(refresh.operation).toBe('reload'); + await mocks.tableApi.reload(); + await flushPromises(); + + expect(mocks.api.getBindings).toHaveBeenCalledOnce(); + expect(mocks.api.getSubscriptions).toHaveBeenCalledTimes(subscriptionCalls); + expect(mocks.api.getTemplates).toHaveBeenCalledTimes(templateCalls); + expect(mocks.api.getTargets).toHaveBeenCalledTimes(targetCalls); + + vi.advanceTimersByTime(60_000); + await flushPromises(); + expect(mocks.api.getBindings).toHaveBeenCalledOnce(); + expect(mocks.api.getSubscriptions).toHaveBeenCalledTimes(subscriptionCalls); + expect(mocks.api.getTemplates).toHaveBeenCalledTimes(templateCalls); + expect(mocks.api.getTargets).toHaveBeenCalledTimes(targetCalls); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.tsx new file mode 100644 index 0000000..b8379bd --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushPanel.tsx @@ -0,0 +1,417 @@ +import type { TableColumnType } from 'antdv-next'; + +import type { PropType, VNodeChild } from 'vue'; + +import type { AccountMessagePushModalExposed } from './AccountMessagePushModal'; + +import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; +import type { + KtTableApi, + KtTableButton, + KtTableContext, + KtTableRowAction, +} from '#/components/ktTable'; + +import { defineComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue'; + +import { useAccess } from '@vben/access'; +import { Plus } from '@vben/icons'; + +import { Space, Tag } from 'antdv-next'; + +import { + deleteAccountMessagePushBinding, + getAccountMessagePushBindings, + getAccountMessagePushTargets, + getMessageSubscriptionList, + getMessageTemplateList, + setAccountMessagePushBindingEnabled, +} from '#/api/qqbot/message-push'; +import { KtTable, useKtTable } from '#/components/ktTable'; + +import AccountMessagePushModal from './AccountMessagePushModal'; + +const AKtTable = KtTable as any; + +export interface AccountMessagePushPanelProps { + headerControls: () => VNodeChild; + selfId: string; + title: () => VNodeChild; +} + +const PERMISSIONS = { + create: 'QqBot:Account:MessagePush:Create', + delete: 'QqBot:Account:MessagePush:Delete', + list: 'QqBot:Account:MessagePush:List', + toggle: 'QqBot:Account:MessagePush:Toggle', + update: 'QqBot:Account:MessagePush:Update', +} as const; + +export default defineComponent({ + name: 'AccountMessagePushPanel', + props: { + headerControls: { + required: true, + type: Function as PropType<() => VNodeChild>, + }, + selfId: { + required: true, + type: String, + }, + title: { + required: true, + type: Function as PropType<() => VNodeChild>, + }, + }, + /** Owns one permission-gated account binding table and per-account option cache. */ + setup(props) { + const { hasAccessByCodes } = useAccess(); + const canList = hasAccessByCodes([PERMISSIONS.list]); + const canLoadTargets = + hasAccessByCodes([PERMISSIONS.create]) || + hasAccessByCodes([PERMISSIONS.update]); + const modalRef = ref(); + const subscriptions = ref( + [], + ); + const templates = ref([]); + const targetOptions = + ref(); + const targetOptionsLoading = ref(false); + let loadRevision = 0; + let latestBindingPage: { + items: QqbotMessagePushApi.QqbotMessagePublishBindingView[]; + total: number; + } = { items: [], total: 0 }; + + const columns: Array< + TableColumnType + > = [ + { + dataIndex: 'subscriptionName', + key: 'subscription', + title: '消息订阅', + width: 210, + }, + { + key: 'source', + title: '消息源', + width: 260, + }, + { + dataIndex: 'templateName', + key: 'template', + title: '消息模板', + width: 200, + }, + { + key: 'targets', + title: '推送目标', + width: 300, + }, + { + dataIndex: 'enabled', + key: 'enabled', + title: '状态', + width: 90, + }, + { + dataIndex: 'updateTime', + key: 'updateTime', + title: '更新时间', + width: 180, + }, + ]; + const api: KtTableApi = + { + /** + * Adapts the account binding array while returning the latest page for stale requests. + * @returns Strict KtTable page retaining every string identifier. + */ + list: async () => { + const revision = loadRevision; + const selfId = props.selfId; + const bindings = await getAccountMessagePushBindings(selfId); + if (revision !== loadRevision || selfId !== props.selfId) { + return latestBindingPage; + } + latestBindingPage = { + items: bindings, + total: bindings.length, + }; + return latestBindingPage; + }, + }; + const buttons: Array< + KtTableButton + > = [ + { + icon: , + key: 'create', + label: '新增推送', + onClick: openCreate, + permissionCodes: [PERMISSIONS.create], + type: 'primary', + }, + { + key: 'refresh', + label: '刷新', + operation: 'reload', + permissionCodes: [PERMISSIONS.list], + }, + ]; + const rowActions: Array< + KtTableRowAction + > = [ + { + key: 'edit', + label: '编辑', + onClick: openEdit, + permissionCodes: [PERMISSIONS.update], + }, + { + key: 'toggle', + label: '启停', + onClick: handleToggle, + permissionCodes: [PERMISSIONS.toggle], + }, + { + confirm: getDeleteConfirm, + danger: true, + key: 'delete', + label: '解绑', + onClick: handleDelete, + permissionCodes: [PERMISSIONS.delete], + }, + ]; + const [registerTable, tableApi] = + useKtTable({ + api, + buttons, + columns, + immediate: false, + rowActions, + rowActionVisibleCount: 3, + rowKey: 'id', + showDefaultButtons: false, + showFooter: false, + showIndex: false, + showPagination: false, + showTableSetting: false, + size: 'small', + }); + + /** Opens a blank binding session for this panel's implicit account. */ + function openCreate() { + modalRef.value?.openCreate(); + } + + /** + * Opens one existing binding without deriving another account identity. + * @param row - Binding selected from this account's KtTable. + */ + function openEdit(row: QqbotMessagePushApi.QqbotMessagePublishBindingView) { + modalRef.value?.openEdit(row); + } + + /** + * Builds account-binding removal confirmation text. + * @param row - Binding awaiting removal. + * @returns Confirmation containing the global subscription name. + */ + function getDeleteConfirm( + row: QqbotMessagePushApi.QqbotMessagePublishBindingView, + ): string { + return `确认解绑消息订阅「${row.subscriptionName}」吗?`; + } + + /** + * Toggles one binding and reloads only the mutable binding list after success. + * @param row - Binding whose enabled state is inverted. + * @param context - Registered KtTable action context. + */ + async function handleToggle( + row: QqbotMessagePushApi.QqbotMessagePublishBindingView, + context: KtTableContext, + ) { + await setAccountMessagePushBindingEnabled( + props.selfId, + row.id, + !row.enabled, + ); + await context.reload(); + } + + /** + * Removes one binding and reloads only the mutable binding list after success. + * @param row - Binding confirmed for account-scoped removal. + * @param context - Registered KtTable action context. + */ + async function handleDelete( + row: QqbotMessagePushApi.QqbotMessagePublishBindingView, + context: KtTableContext, + ) { + await deleteAccountMessagePushBinding(props.selfId, row.id); + await context.reload(); + } + + /** Reloads only bindings after one successful modal persistence. */ + async function handleModalSaved() { + await tableApi.reload(); + } + + /** + * Loads the complete subscription/template collection in bounded 100-row pages. + * @param loader - One strict page caller receiving pageNo/pageSize. + * @returns Concatenated server rows until total is reached or progress stops. + */ + async function loadAllPages( + loader: (params: { + pageNo: number; + pageSize: number; + }) => Promise>, + ): Promise { + const rows: Row[] = []; + for (let pageNo = 1; pageNo <= 1000; pageNo += 1) { + const page = await loader({ pageNo, pageSize: 100 }); + rows.push(...page.items); + if (rows.length >= page.total || page.items.length === 0) break; + } + return rows; + } + + /** + * Loads per-account modal metadata once and applies only the latest revision. + * @param selfId - Exact account identity captured for this load. + * @param revision - Monotonic revision assigned by `loadAccount`. + */ + async function loadMetadata(selfId: string, revision: number) { + targetOptionsLoading.value = canLoadTargets; + const [subscriptionResult, templateResult, targetResult] = + await Promise.allSettled([ + loadAllPages((params) => getMessageSubscriptionList(params)), + loadAllPages((params) => getMessageTemplateList(params)), + canLoadTargets + ? getAccountMessagePushTargets(selfId) + : Promise.resolve(undefined), + ]); + if (revision !== loadRevision || selfId !== props.selfId) return; + if (subscriptionResult.status === 'fulfilled') { + subscriptions.value = subscriptionResult.value; + } + if (templateResult.status === 'fulfilled') { + templates.value = templateResult.value; + } + if (targetResult.status === 'fulfilled') { + targetOptions.value = targetResult.value; + } + targetOptionsLoading.value = false; + } + + /** + * Starts exactly one logical load for a new nonempty account identity. + * @param selfId - Current implicit account identity. + */ + async function loadAccount(selfId: string) { + const revision = ++loadRevision; + latestBindingPage = { items: [], total: 0 }; + subscriptions.value = []; + templates.value = []; + targetOptions.value = undefined; + if (!selfId) { + targetOptionsLoading.value = false; + return; + } + await Promise.allSettled([ + tableApi.reload(), + loadMetadata(selfId, revision), + ]); + } + + /** Starts the one authorized initial account load after table registration. */ + function activatePanel() { + if (canList && props.selfId) void loadAccount(props.selfId); + } + + /** Invalidates any pending request when this tab's panel unmounts. */ + function invalidatePendingLoad() { + loadRevision += 1; + } + + /** + * Renders source, targets, availability, and enabled state. + * @param slot - KtTable body-cell slot payload. + * @param slot.column - Current data column. + * @param slot.record - Current account binding. + * @returns Custom cell content or undefined for native field rendering. + */ + function renderBodyCell(slot: { + column: TableColumnType; + record: QqbotMessagePushApi.QqbotMessagePublishBindingView; + }) { + const { column, record } = slot; + if (column.key === 'source') { + return `${record.sourceName} · ${record.sourceKey}`; + } + if (column.key === 'targets') { + return ( + + {record.targets.map((target) => ( + + {target.targetType === 'group' ? '群' : '私聊'} ·{' '} + {target.targetName || target.targetId} + + ))} + + ); + } + if (column.key === 'enabled') { + let color = 'warning'; + let label = record.invalidReasonCode || 'unavailable'; + if (record.available) { + color = record.enabled ? 'success' : 'default'; + label = record.enabled ? '启用' : '停用'; + } + return {label}; + } + return undefined; + } + + onMounted(activatePanel); + onBeforeUnmount(invalidatePendingLoad); + watch( + () => props.selfId, + (selfId, previousSelfId) => { + if (!canList || selfId === previousSelfId) return; + void loadAccount(selfId); + }, + ); + + return () => ( + + ); + }, +}); diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker.spec.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker.spec.tsx new file mode 100644 index 0000000..0acbfb5 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker.spec.tsx @@ -0,0 +1,307 @@ +/* @vitest-environment happy-dom */ + +import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; + +import { flushPromises, mount } from '@vue/test-utils'; + +import Select from 'antdv-next/dist/select/index'; +import { describe, expect, it } from 'vitest'; + +import MessagePushTargetPicker, { + isValidMessagePushTargetId, +} from './MessagePushTargetPicker'; + +/** Creates type-scoped known group/private target candidates. */ +function createOptions(): QqbotMessagePushApi.QqbotMessagePushTargetOption[] { + return [ + { + label: '帕鲁测试群', + targetId: '10000000000000001', + targetType: 'group', + }, + { + label: '测试好友', + targetId: '20000000000000001', + targetType: 'private', + }, + ]; +} + +/** Mounts the controlled picker with optional contract overrides. */ +function mountPicker( + overrides: Partial<{ + available: boolean; + disabled: boolean; + loading: boolean; + options: QqbotMessagePushApi.QqbotMessagePushTargetOption[]; + reasonCode: null | string; + value: QqbotMessagePushApi.QqbotMessagePublishTargetInput[]; + }> = {}, +) { + return mount(MessagePushTargetPicker, { + props: { + available: true, + options: createOptions(), + reasonCode: null, + value: [], + ...overrides, + }, + }); +} + +/** Reads the newest controlled value emitted by the picker. */ +function latestValue( + wrapper: ReturnType, +): QqbotMessagePushApi.QqbotMessagePublishTargetInput[] { + const emitted = wrapper.emitted('update:value')?.at(-1)?.[0]; + if (!Array.isArray(emitted)) throw new Error('Expected one target update'); + return emitted as QqbotMessagePushApi.QqbotMessagePublishTargetInput[]; +} + +describe('message-push target picker', () => { + it.each([ + ['', false], + ['01234', false], + ['1234', false], + ['12345', true], + ['12345678901234567890', true], + ['123456789012345678901', false], + ])('validates the exact manual target matrix for %s', (targetId, valid) => { + expect(isValidMessagePushTargetId(targetId)).toBe(valid); + }); + + it('renders exactly two controlled tags Selects with type-scoped options', () => { + const wrapper = mountPicker(); + const selects = wrapper.findAllComponents(Select); + + expect(selects).toHaveLength(2); + expect(selects[0]?.props()).toMatchObject({ + mode: 'tags', + value: [], + }); + expect(selects[1]?.props()).toMatchObject({ + mode: 'tags', + value: [], + }); + expect(selects[0]?.props('labelInValue')).not.toBe(true); + expect(selects[1]?.props('labelInValue')).not.toBe(true); + expect(selects[0]?.props('options')).toEqual([ + expect.objectContaining({ + targetId: '10000000000000001', + value: '10000000000000001', + }), + ]); + expect(selects[1]?.props('options')).toEqual([ + expect.objectContaining({ + targetId: '20000000000000001', + value: '20000000000000001', + }), + ]); + }); + + it.each([ + [0, '测试群'], + [0, '100000000000000'], + [1, '好友'], + [1, '200000000000000'], + ])('searches selector %s by known label or string ID', (index, query) => { + const wrapper = mountPicker(); + const select = wrapper.findAllComponents(Select)[index]; + const option = (select?.props('options') || [])[0] as Record< + string, + unknown + >; + const filter = select?.props('filterOption') as ( + input: string, + option: Record, + ) => boolean; + + expect(filter(query as string, option)).toBe(true); + expect(filter('unrelated', option)).toBe(false); + }); + + it('uses actual controlled Select updates for multiple known/manual string IDs', async () => { + const wrapper = mountPicker(); + const [group, privateSelect] = wrapper.findAllComponents(Select); + + group?.vm.$emit('update:value', ['10000000000000001', '30000000000000001']); + await flushPromises(); + expect(latestValue(wrapper)).toEqual([ + { + targetId: '10000000000000001', + targetName: '帕鲁测试群', + targetType: 'group', + }, + { targetId: '30000000000000001', targetType: 'group' }, + ]); + + await wrapper.setProps({ value: latestValue(wrapper) }); + privateSelect?.vm.$emit('update:value', [ + '20000000000000001', + '40000000000000001', + ]); + await flushPromises(); + const combined = latestValue(wrapper); + expect(combined).toEqual([ + { + targetId: '10000000000000001', + targetName: '帕鲁测试群', + targetType: 'group', + }, + { targetId: '30000000000000001', targetType: 'group' }, + { + targetId: '20000000000000001', + targetName: '测试好友', + targetType: 'private', + }, + { targetId: '40000000000000001', targetType: 'private' }, + ]); + expect(combined.every(({ targetId }) => typeof targetId === 'string')).toBe( + true, + ); + }); + + it('rejects invalid/non-string values and remounts only that Select', async () => { + const wrapper = mountPicker({ + value: [ + { + targetId: '20000000000000001', + targetName: '测试好友', + targetType: 'private', + }, + ], + }); + const initial = wrapper.findAllComponents(Select); + const groupKey = initial[0]?.vm.$.vnode.key; + const privateKey = initial[1]?.vm.$.vnode.key; + + initial[0]?.vm.$emit('update:value', [ + ' 12345 ', + '01234', + 12_345, + { value: '99999' }, + '12345', + ]); + await flushPromises(); + + expect(latestValue(wrapper)).toEqual([ + { targetId: '12345', targetType: 'group' }, + { + targetId: '20000000000000001', + targetName: '测试好友', + targetType: 'private', + }, + ]); + const next = wrapper.findAllComponents(Select); + expect(next[0]?.vm.$.vnode.key).not.toBe(groupKey); + expect(next[1]?.vm.$.vnode.key).toBe(privateKey); + expect(next[0]?.props('value')).toEqual([]); + }); + + it('shows the exact unavailable reason while manual input remains enabled', async () => { + const wrapper = mountPicker({ + available: false, + options: [], + reasonCode: 'onebot_unavailable', + }); + const [group, privateSelect] = wrapper.findAllComponents(Select); + + expect(wrapper.text()).toContain('onebot_unavailable'); + expect(group?.props('disabled')).toBe(false); + expect(privateSelect?.props('disabled')).toBe(false); + group?.vm.$emit('update:value', ['12345']); + await flushPromises(); + expect(latestValue(wrapper)).toEqual([ + { targetId: '12345', targetType: 'group' }, + ]); + }); + + it('keeps names only for the same type and omits unknown manual names', async () => { + const equalId = '10000000000000001'; + const wrapper = mountPicker({ + options: [ + { + label: '群名称', + targetId: equalId, + targetType: 'group', + }, + { + label: '私聊名称', + targetId: equalId, + targetType: 'private', + }, + ], + }); + const [group, privateSelect] = wrapper.findAllComponents(Select); + group?.vm.$emit('update:value', [equalId, '30000000000000001']); + await flushPromises(); + await wrapper.setProps({ value: latestValue(wrapper) }); + privateSelect?.vm.$emit('update:value', [equalId]); + await flushPromises(); + + expect(latestValue(wrapper)).toEqual([ + { + targetId: equalId, + targetName: '群名称', + targetType: 'group', + }, + { targetId: '30000000000000001', targetType: 'group' }, + { + targetId: equalId, + targetName: '私聊名称', + targetType: 'private', + }, + ]); + }); + + it('preserves an existing same-type API name when candidates are unavailable', async () => { + const wrapper = mountPicker({ + available: false, + options: [], + reasonCode: 'onebot_unavailable', + value: [ + { + targetId: '10000000000000001', + targetName: '历史群名', + targetType: 'group', + }, + ], + }); + const group = wrapper.findAllComponents(Select)[0]; + + group?.vm.$emit('update:value', ['10000000000000001', '30000000000000001']); + await flushPromises(); + expect(latestValue(wrapper)).toEqual([ + { + targetId: '10000000000000001', + targetName: '历史群名', + targetType: 'group', + }, + { targetId: '30000000000000001', targetType: 'group' }, + ]); + }); + + it('removing one type leaves the other controlled values untouched', async () => { + const wrapper = mountPicker({ + value: [ + { targetId: '10000000000000001', targetType: 'group' }, + { + targetId: '20000000000000001', + targetName: '测试好友', + targetType: 'private', + }, + ], + }); + const group = wrapper.findAllComponents(Select)[0]; + + group?.vm.$emit('update:value', []); + await flushPromises(); + expect(latestValue(wrapper)).toEqual([ + { + targetId: '20000000000000001', + targetName: '测试好友', + targetType: 'private', + }, + ]); + }); +}); diff --git a/apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker.tsx b/apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker.tsx new file mode 100644 index 0000000..4006606 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/components/MessagePushTargetPicker.tsx @@ -0,0 +1,322 @@ +import type { PropType } from 'vue'; + +import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; + +import { computed, defineComponent, ref } from 'vue'; + +import Alert from 'antdv-next/dist/alert/index'; +import Select from 'antdv-next/dist/select/index'; + +const AAlert = Alert as any; +const ASelect = Select as any; + +type TargetType = QqbotMessagePushApi.QqbotMessagePushTargetType; + +interface TargetSelectOption { + label: string; + targetId: string; + value: string; +} + +export interface MessagePushTargetPickerProps { + available: boolean; + disabled?: boolean; + loading?: boolean; + options: QqbotMessagePushApi.QqbotMessagePushTargetOption[]; + reasonCode: null | string; + value: QqbotMessagePushApi.QqbotMessagePublishTargetInput[]; +} + +/** + * Validates one QQ group/user target without numeric conversion. + * @param targetId - Trimmed target identifier supplied by the Select runtime. + * @returns Whether the identifier is 5–20 digits and has no leading zero. + */ +export function isValidMessagePushTargetId(targetId: string): boolean { + return /^[1-9]\d{4,19}$/.test(targetId); +} + +export default defineComponent({ + name: 'MessagePushTargetPicker', + props: { + available: { + required: true, + type: Boolean, + }, + disabled: { + default: false, + type: Boolean, + }, + loading: { + default: false, + type: Boolean, + }, + options: { + required: true, + type: Array as PropType< + QqbotMessagePushApi.QqbotMessagePushTargetOption[] + >, + }, + reasonCode: { + default: null, + type: String as PropType, + }, + value: { + default: () => [], + type: Array as PropType< + QqbotMessagePushApi.QqbotMessagePublishTargetInput[] + >, + }, + }, + emits: { + /** + * Emits one normalized controlled target collection. + * @param value - Group-first then private targets with string IDs only. + * @returns Whether the payload remains an array for Vue emit validation. + */ + 'update:value': ( + value: QqbotMessagePushApi.QqbotMessagePublishTargetInput[], + ) => Array.isArray(value), + }, + /** Owns only selector remount revisions; all durable values stay controlled. */ + setup(props, { emit }) { + const groupRevision = ref(0); + const privateRevision = ref(0); + + /** Derives the currently controlled group IDs without cloning other fields. */ + const groupIds = computed(() => targetIdsForType(props.value, 'group')); + /** Derives the currently controlled private IDs independently. */ + const privateIds = computed(() => targetIdsForType(props.value, 'private')); + /** Maps only server-returned group choices to raw Select values. */ + const groupOptions = computed(() => + createSelectOptions(props.options, 'group'), + ); + /** Maps only server-returned private choices to raw Select values. */ + const privateOptions = computed(() => + createSelectOptions(props.options, 'private'), + ); + + /** + * Cleans one installed Select update and remounts only rejected input. + * @param targetType - Selector whose controlled string values changed. + * @param runtimeValue - Raw installed Select emission; may contain bad runtime values. + */ + function handleValueUpdate(targetType: TargetType, runtimeValue: unknown) { + const targetIds = normalizeTargetIds(runtimeValue); + if (containsRejectedTarget(runtimeValue, targetIds)) { + if (targetType === 'group') groupRevision.value += 1; + else privateRevision.value += 1; + } + emit( + 'update:value', + mergeTargets(props.value, props.options, targetType, targetIds), + ); + } + + return () => ( +
+ {props.available ? null : ( + + )} +
+
群聊目标
+ + handleValueUpdate('group', value) + } + options={groupOptions.value} + placeholder="选择或输入群号" + value={groupIds.value} + /> +
+
+
私聊目标
+ + handleValueUpdate('private', value) + } + options={privateOptions.value} + placeholder="选择或输入 QQ 号" + value={privateIds.value} + /> +
+
+ ); + }, +}); + +/** + * Returns controlled target IDs for exactly one target type. + * @param targets - Current API/form-controlled targets. + * @param targetType - Group or private selector identity. + * @returns Original string IDs in their controlled order. + */ +function targetIdsForType( + targets: QqbotMessagePushApi.QqbotMessagePublishTargetInput[], + targetType: TargetType, +): string[] { + return targets + .filter((target) => target.targetType === targetType) + .map((target) => target.targetId); +} + +/** + * Maps one type's API candidates to plain string-value Select options. + * @param options - Current OneBot candidate snapshot. + * @param targetType - Type allowed in the target selector. + * @returns Type-scoped candidates retaining their server labels. + */ +function createSelectOptions( + options: QqbotMessagePushApi.QqbotMessagePushTargetOption[], + targetType: TargetType, +): TargetSelectOption[] { + return options + .filter((option) => option.targetType === targetType) + .map((option) => ({ + label: option.label, + targetId: option.targetId, + value: option.targetId, + })); +} + +/** + * Searches one known candidate by case-insensitive label and string ID. + * @param input - Search text entered into the installed Select. + * @param option - Candidate option produced by `createSelectOptions`. + * @returns Whether its label or target ID contains the query. + */ +function filterTargetOption( + input: string, + option: Record, +): boolean { + const query = input.toLocaleLowerCase(); + return [option.label, option.targetId, option.value].some( + (value) => + typeof value === 'string' && value.toLocaleLowerCase().includes(query), + ); +} + +/** + * Sanitizes the installed tags-mode payload without coercing non-string values. + * @param runtimeValue - Raw `update:value` payload from Antdv Next. + * @returns Trimmed, valid, deduplicated string IDs in input order. + */ +function normalizeTargetIds(runtimeValue: unknown): string[] { + if (!Array.isArray(runtimeValue)) return []; + const seen = new Set(); + const result: string[] = []; + for (const value of runtimeValue) { + if (typeof value !== 'string') continue; + const targetId = value.trim(); + if (!isValidMessagePushTargetId(targetId) || seen.has(targetId)) continue; + seen.add(targetId); + result.push(targetId); + } + return result; +} + +/** + * Detects internal Select state that differs from the cleaned controlled value. + * @param runtimeValue - Raw installed component emission. + * @param normalized - Accepted target IDs. + * @returns Whether the affected Select must remount to discard invalid state. + */ +function containsRejectedTarget( + runtimeValue: unknown, + normalized: string[], +): boolean { + if (!Array.isArray(runtimeValue)) return true; + if (runtimeValue.length !== normalized.length) return true; + return runtimeValue.some( + (value, index) => + typeof value !== 'string' || value.trim() !== normalized[index], + ); +} + +/** + * Rebuilds group-first/private-second targets after one selector update. + * @param current - Controlled value carrying existing API name snapshots. + * @param options - Current type-scoped candidate labels. + * @param changedType - Selector whose IDs are replaced. + * @param changedIds - Sanitized replacement IDs. + * @returns Normalized targets with no cross-type name leakage. + */ +function mergeTargets( + current: QqbotMessagePushApi.QqbotMessagePublishTargetInput[], + options: QqbotMessagePushApi.QqbotMessagePushTargetOption[], + changedType: TargetType, + changedIds: string[], +): QqbotMessagePushApi.QqbotMessagePublishTargetInput[] { + const idsByType: Record = { + group: + changedType === 'group' ? changedIds : targetIdsForType(current, 'group'), + private: + changedType === 'private' + ? changedIds + : targetIdsForType(current, 'private'), + }; + return (['group', 'private'] as const).flatMap((targetType) => + idsByType[targetType].map((targetId) => { + const targetName = resolveTargetName( + current, + options, + targetType, + targetId, + ); + return { + targetId, + ...(targetName ? { targetName } : {}), + targetType, + }; + }), + ); +} + +/** + * Resolves a display name from only the same target type and identifier. + * @param current - Existing API/form values with optional name snapshots. + * @param options - Current OneBot candidates. + * @param targetType - Exact group/private namespace. + * @param targetId - Exact string target identifier. + * @returns Current candidate label, same-type existing name, or undefined. + */ +function resolveTargetName( + current: QqbotMessagePushApi.QqbotMessagePublishTargetInput[], + options: QqbotMessagePushApi.QqbotMessagePushTargetOption[], + targetType: TargetType, + targetId: string, +): string | undefined { + const candidate = options.find( + (option) => + option.targetType === targetType && option.targetId === targetId, + ); + const candidateName = candidate?.label; + const comparableCandidateName = candidateName?.trim(); + if (candidateName && comparableCandidateName !== targetId) { + return candidateName; + } + const existingName = current.find( + (target) => + target.targetType === targetType && target.targetId === targetId, + )?.targetName; + return existingName?.trim() ? existingName : undefined; +}