diff --git a/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx b/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx new file mode 100644 index 0000000..74a42b4 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.spec.tsx @@ -0,0 +1,391 @@ +/* @vitest-environment happy-dom */ + +/* eslint-disable vue/one-component-per-file */ + +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 MessageSubscriptionModal from './MessageSubscriptionModal'; + +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(async () => {}), + validate: vi.fn(async () => ({ valid: true })), + }; + return { + create: vi.fn(), + formApi, + formOptions: undefined as any, + modalApi, + modalOptions: undefined as any, + update: vi.fn(), + }; +}); + +/** Creates a fluent no-op validation rule for schema-only unit tests. */ +function createRule(): any { + const rule: any = {}; + for (const method of ['max', 'min', 'optional', 'or', 'trim']) { + rule[method] = vi.fn(() => rule); + } + return rule; +} + +vi.mock('#/adapter/form', () => ({ + useVbenForm: vi.fn((options) => { + mocks.formOptions = options; + const Form = defineComponent({ + name: 'MockForm', + /** Renders a stable form marker without owning form state. */ + setup() { + return () => h('form', { 'data-testid': 'subscription-form' }); + }, + }); + return [Form, mocks.formApi]; + }), + z: { + literal: vi.fn(createRule), + string: vi.fn(createRule), + }, +})); + +vi.mock('@vben/common-ui', () => ({ + useVbenModal: vi.fn((options) => { + mocks.modalOptions = options; + const Modal = defineComponent({ + name: 'MockModal', + /** Renders modal content while the mock API controls lifecycle calls. */ + setup(_, { slots }) { + return () => h('section', slots.default?.()); + }, + }); + return [Modal, mocks.modalApi]; + }), +})); + +vi.mock('#/api/qqbot/message-push', () => ({ + createMessageSubscription: mocks.create, + updateMessageSubscription: mocks.update, +})); + +/** Creates the immutable source catalog fixture consumed by the modal. */ +function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] { + return [ + { + description: 'STUN 端口变化', + displayName: 'STUN 映射端口变更', + sourceKey: 'network.stun.mapping-port-changed', + subscriptionFields: [], + variables: [], + version: 1, + }, + ]; +} + +/** Creates string-ID STUN choices including disabled API-owned reasons. */ +function createStunOptions(): QqbotMessagePushApi.StunMappingPortChangedOptionsResponse { + return { + ddnsRecords: [ + { + disabledReasonCode: null, + eligible: true, + fqdn: 'pal.kwitsukasa.top', + id: '2041700000000000002', + name: 'Pal DDNS', + portForwardId: '2041700000000000001', + }, + { + disabledReasonCode: 'ddns_disabled', + eligible: false, + fqdn: 'disabled.kwitsukasa.top', + id: '2041700000000000003', + name: 'Disabled DDNS', + portForwardId: '2041700000000000001', + }, + { + disabledReasonCode: null, + eligible: true, + fqdn: 'other.kwitsukasa.top', + id: '2041700000000000004', + name: 'Other DDNS', + portForwardId: '2041700000000000005', + }, + ], + portForwards: [ + { + disabledReasonCode: null, + eligible: true, + externalPort: 8213, + id: '2041700000000000001', + internalPort: 8213, + name: 'Pal UDP', + protocol: 'udp', + }, + { + disabledReasonCode: 'keeper_disabled', + eligible: false, + externalPort: 8214, + id: '2041700000000000005', + internalPort: 8214, + name: 'Disabled UDP', + protocol: 'udp', + }, + ], + }; +} + +/** Creates one editable row with only the global subscription contract. */ +function createRow(): QqbotMessagePushApi.MessageSubscriptionView { + return { + createTime: '2026-07-24 10:00:00', + enabled: false, + id: '10000000000000001', + invalidReasonCode: null, + name: '旧订阅', + remark: '旧备注', + sourceConfig: { + ddnsRecordId: '2041700000000000002', + portForwardId: '2041700000000000001', + }, + sourceKey: 'network.stun.mapping-port-changed', + sourceName: 'STUN 映射端口变更', + sourceSummary: 'Pal UDP · pal.kwitsukasa.top', + updateTime: '2026-07-24 10:00:00', + valid: true, + }; +} + +/** Mounts the modal with page-owned source and STUN metadata. */ +function mountModal() { + return mount(MessageSubscriptionModal, { + props: { + sources: createSources(), + stunOptions: createStunOptions(), + }, + }); +} + +describe('message subscription modal', () => { + beforeEach(() => { + vi.clearAllMocks(); + 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({}); + mocks.formApi.getValues.mockResolvedValue({ + ddnsRecordId: '2041700000000000002', + enabled: true, + name: ' 帕鲁端口变更 ', + portForwardId: '2041700000000000001', + remark: ' managed ', + sourceKey: 'network.stun.mapping-port-changed', + }); + }); + + it('contains the exact source-only fields in their locked order', () => { + mountModal(); + + const fields = mocks.formOptions.schema.map( + (field: any) => field.fieldName, + ); + expect(fields).toEqual([ + 'name', + 'sourceKey', + 'portForwardId', + 'ddnsRecordId', + 'enabled', + 'remark', + ]); + expect(JSON.stringify(mocks.formOptions.schema)).not.toMatch( + /account|selfId|template|target|group|private|publish|delivery|event|worker|queue/i, + ); + }); + + it('opens before reset/set/reset-validate and clears edit state for create', async () => { + const wrapper = mountModal(); + mocks.formApi.resetForm.mockImplementation(async () => { + expect(mocks.modalApi.open).toHaveBeenCalled(); + }); + + (wrapper.vm as any).openEdit(createRow()); + await flushPromises(); + (wrapper.vm as any).openCreate(); + await flushPromises(); + + expect(mocks.formApi.resetForm).toHaveBeenCalledTimes(2); + expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({ + ddnsRecordId: undefined, + enabled: true, + name: '', + portForwardId: undefined, + remark: '', + sourceKey: 'network.stun.mapping-port-changed', + }); + expect(mocks.formApi.resetValidate).toHaveBeenCalledTimes(2); + const resetOrder = + mocks.formApi.resetForm.mock.invocationCallOrder.at(-1) || 0; + const setOrder = + mocks.formApi.setValues.mock.invocationCallOrder.at(-1) || 0; + const validateOrder = + mocks.formApi.resetValidate.mock.invocationCallOrder.at(-1) || 0; + expect(resetOrder).toBeLessThan(setOrder); + expect(setOrder).toBeLessThan(validateOrder); + }); + + it('preserves string IDs and exposes disabled reasons on matching options', async () => { + const wrapper = mountModal(); + (wrapper.vm as any).openEdit(createRow()); + await flushPromises(); + const portField = mocks.formOptions.schema.find( + (field: any) => field.fieldName === 'portForwardId', + ); + const ddnsField = mocks.formOptions.schema.find( + (field: any) => field.fieldName === 'ddnsRecordId', + ); + const portOptions = portField.componentProps().options; + const ddnsOptions = ddnsField.componentProps().options; + + expect(portOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + disabled: true, + value: '2041700000000000005', + }), + ]), + ); + expect(portOptions[1].label).toContain('keeper_disabled'); + expect(ddnsOptions).toHaveLength(2); + expect(ddnsOptions[1]).toMatchObject({ + disabled: true, + value: '2041700000000000003', + }); + expect(ddnsOptions[1].label).toContain('ddns_disabled'); + expect(typeof portOptions[0].value).toBe('string'); + expect(typeof ddnsOptions[0].value).toBe('string'); + }); + + it('clears only an incompatible DDNS when the selected port changes', async () => { + mountModal(); + + await mocks.formOptions.handleValuesChange( + { + ddnsRecordId: '2041700000000000002', + portForwardId: '2041700000000000001', + }, + ['portForwardId'], + ); + expect(mocks.formApi.setValues).not.toHaveBeenCalledWith({ + ddnsRecordId: undefined, + }); + + await mocks.formOptions.handleValuesChange( + { + ddnsRecordId: '2041700000000000002', + portForwardId: '2041700000000000005', + }, + ['portForwardId'], + ); + expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({ + ddnsRecordId: undefined, + }); + const ddnsField = mocks.formOptions.schema.find( + (field: any) => field.fieldName === 'ddnsRecordId', + ); + expect(ddnsField.componentProps().options).toEqual([ + expect.objectContaining({ value: '2041700000000000004' }), + ]); + }); + + it('submits the exact trimmed source-only create payload', async () => { + const wrapper = mountModal(); + (wrapper.vm as any).openCreate(); + await flushPromises(); + + await mocks.modalOptions.onConfirm(); + + expect(mocks.create).toHaveBeenCalledWith({ + enabled: true, + name: '帕鲁端口变更', + remark: 'managed', + sourceConfig: { + ddnsRecordId: '2041700000000000002', + portForwardId: '2041700000000000001', + }, + sourceKey: 'network.stun.mapping-port-changed', + }); + expect(JSON.stringify(mocks.create.mock.calls[0]?.[0])).not.toMatch( + /account|selfId|template|target|group|private|publish|delivery|event|worker|queue/i, + ); + }); + + it('updates with the original unsafe-integer string ID unchanged', async () => { + const wrapper = mountModal(); + (wrapper.vm as any).openEdit(createRow()); + await flushPromises(); + + await mocks.modalOptions.onConfirm(); + + expect(mocks.update).toHaveBeenCalledWith( + '10000000000000001', + expect.any(Object), + ); + }); + + it('locks, closes, emits once, and unlocks only after successful persistence', async () => { + const wrapper = mountModal(); + (wrapper.vm as any).openCreate(); + await flushPromises(); + + await mocks.modalOptions.onConfirm(); + + expect(mocks.modalApi.lock).toHaveBeenCalledOnce(); + expect(mocks.modalApi.close).toHaveBeenCalledOnce(); + expect(wrapper.emitted('saved')).toHaveLength(1); + expect(mocks.modalApi.unlock).toHaveBeenCalledOnce(); + expect(mocks.modalApi.lock.mock.invocationCallOrder[0]).toBeLessThan( + mocks.create.mock.invocationCallOrder[0], + ); + }); + + it('keeps the modal usable and emits nothing after a rejected mutation', async () => { + mocks.create.mockRejectedValue(new Error('save failed')); + const wrapper = mountModal(); + (wrapper.vm as any).openCreate(); + await flushPromises(); + + await expect(mocks.modalOptions.onConfirm()).rejects.toThrow('save failed'); + + expect(mocks.modalApi.lock).toHaveBeenCalledOnce(); + expect(mocks.modalApi.close).not.toHaveBeenCalled(); + expect(wrapper.emitted('saved')).toBeUndefined(); + expect(mocks.modalApi.unlock).toHaveBeenCalledOnce(); + + mocks.create.mockResolvedValue({}); + await mocks.modalOptions.onConfirm(); + + expect(mocks.modalApi.close).toHaveBeenCalledOnce(); + expect(wrapper.emitted('saved')).toHaveLength(1); + expect(mocks.modalApi.unlock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.tsx b/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.tsx new file mode 100644 index 0000000..d9924fe --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/message-subscription/components/MessageSubscriptionModal.tsx @@ -0,0 +1,310 @@ +import type { PropType } from 'vue'; + +import type { VbenFormSchema } from '#/adapter/form'; +import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; + +import { computed, defineComponent, ref } from 'vue'; + +import { useVbenModal } from '@vben/common-ui'; + +import { useVbenForm, z } from '#/adapter/form'; +import { + createMessageSubscription, + updateMessageSubscription, +} from '#/api/qqbot/message-push'; + +export interface MessageSubscriptionModalExposed { + openCreate: () => void; + openEdit: (row: QqbotMessagePushApi.MessageSubscriptionView) => void; +} + +interface MessageSubscriptionFormValues { + ddnsRecordId?: string; + enabled: boolean; + name: string; + portForwardId?: string; + remark?: string; + sourceKey: string; +} + +interface MessageSubscriptionModalData { + values: MessageSubscriptionFormValues; +} + +export default defineComponent({ + name: 'MessageSubscriptionModal', + props: { + sources: { + required: true, + type: Array as PropType< + QqbotMessagePushApi.SystemMessageSourceDefinition[] + >, + }, + stunOptions: { + default: undefined, + type: Object as PropType< + QqbotMessagePushApi.StunMappingPortChangedOptionsResponse | undefined + >, + }, + }, + emits: ['saved'], + /** + * Owns one source-only create/edit form without fetching page metadata or lists. + */ + setup(props, { emit, expose }) { + const editingRow = ref(); + const selectedPortForwardId = ref(); + const [SubscriptionForm, formApi] = useVbenForm({ + commonConfig: { + labelClass: 'w-24', + }, + /** + * Keeps the DDNS choice compatible with the selected port-forward option. + * @param values - Current form values after the change. + * @param fieldsChanged - Field names changed by this form event. + */ + async handleValuesChange(values, fieldsChanged) { + if (!fieldsChanged.includes('portForwardId')) return; + selectedPortForwardId.value = + typeof values.portForwardId === 'string' + ? values.portForwardId + : undefined; + const currentDdnsId = + typeof values.ddnsRecordId === 'string' + ? values.ddnsRecordId + : undefined; + if (!currentDdnsId) return; + const currentDdns = props.stunOptions?.ddnsRecords.find( + (option) => option.id === currentDdnsId, + ); + if ( + currentDdns && + currentDdns.portForwardId !== selectedPortForwardId.value + ) { + await formApi.setValues({ ddnsRecordId: undefined }); + } + }, + layout: 'horizontal', + schema: createFormSchema(props, selectedPortForwardId), + showDefaultActions: false, + wrapperClass: 'grid-cols-1', + }); + const modalTitle = computed(() => + editingRow.value ? '编辑消息订阅' : '新建消息订阅', + ); + const [Modal, modalApi] = useVbenModal({ + class: 'w-[680px]', + fullscreenButton: false, + /** Validates and persists the current modal session. */ + async onConfirm() { + await submit(); + }, + /** + * Restores session values after destroy-on-close modal content has mounted. + * @param isOpen - Whether the modal content is visible. + */ + async onOpenChange(isOpen: boolean) { + if (!isOpen) return; + const { values } = modalApi.getData(); + selectedPortForwardId.value = values.portForwardId; + await resetForm(values); + }, + }); + + /** Opens a fresh subscription form with no values retained from an edit. */ + function openCreate() { + editingRow.value = undefined; + modalApi + .setData({ + values: { + ddnsRecordId: undefined, + enabled: true, + name: '', + portForwardId: undefined, + remark: '', + sourceKey: props.sources[0]?.sourceKey || '', + }, + } satisfies MessageSubscriptionModalData) + .open(); + } + + /** + * Opens an edit session with only the six user-editable values. + * @param row - Subscription row selected from the page-owned KtTable. + */ + function openEdit(row: QqbotMessagePushApi.MessageSubscriptionView) { + editingRow.value = row; + modalApi + .setData({ + values: { + ddnsRecordId: row.sourceConfig.ddnsRecordId, + enabled: row.enabled, + name: row.name, + portForwardId: row.sourceConfig.portForwardId, + remark: row.remark || '', + sourceKey: row.sourceKey, + }, + } satisfies MessageSubscriptionModalData) + .open(); + } + + /** + * Resets the mounted form before installing one isolated modal session. + * @param values - Exact editable values for the current create/edit session. + */ + async function resetForm(values: MessageSubscriptionFormValues) { + await formApi.resetForm(); + await formApi.setValues(values); + await formApi.resetValidate(); + } + + /** Persists the exact source-only payload and closes only after success. */ + async function submit() { + const { valid } = await formApi.validate(); + if (!valid) return; + const values = await formApi.getValues(); + const payload: QqbotMessagePushApi.MessageSubscriptionInput = { + enabled: !!values.enabled, + name: values.name.trim(), + remark: values.remark?.trim() || '', + sourceConfig: { + ddnsRecordId: values.ddnsRecordId, + portForwardId: values.portForwardId, + }, + sourceKey: values.sourceKey, + }; + + modalApi.lock(); + try { + await (editingRow.value + ? updateMessageSubscription(editingRow.value.id, payload) + : createMessageSubscription(payload)); + await modalApi.close(); + emit('saved'); + } finally { + modalApi.unlock(); + } + } + + expose({ openCreate, openEdit } satisfies MessageSubscriptionModalExposed); + + return () => ( + + + + ); + }, +}); + +/** + * Builds the locked six-field schema from page-owned metadata. + * @param props - Source catalog and STUN choices loaded once by the page. + * @param selectedPortForwardId - Current port used to filter matching DDNS rows. + * @returns Vben form fields in the approved source-only order. + */ +function createFormSchema( + props: Readonly<{ + sources: QqbotMessagePushApi.SystemMessageSourceDefinition[]; + stunOptions: + | QqbotMessagePushApi.StunMappingPortChangedOptionsResponse + | undefined; + }>, + selectedPortForwardId: Readonly<{ value: string | undefined }>, +): VbenFormSchema[] { + return [ + { + component: 'Input', + componentProps: { allowClear: true, maxlength: 100 }, + fieldName: 'name', + label: '订阅名称', + rules: z.string().trim().min(1).max(100), + }, + { + component: 'Select', + componentProps: () => ({ + options: props.sources.map((source) => ({ + label: `${source.displayName} · ${source.sourceKey}`, + value: source.sourceKey, + })), + }), + fieldName: 'sourceKey', + label: '消息源', + rules: z.string().min(1), + }, + { + component: 'Select', + componentProps: () => ({ + allowClear: true, + options: (props.stunOptions?.portForwards || []).map((option) => + formatPortForwardOption(option), + ), + }), + fieldName: 'portForwardId', + label: '端口转发', + rules: z.string().min(1), + }, + { + component: 'Select', + componentProps: () => ({ + allowClear: true, + options: (props.stunOptions?.ddnsRecords || []) + .filter( + (option) => option.portForwardId === selectedPortForwardId.value, + ) + .map((option) => formatDdnsOption(option)), + }), + fieldName: 'ddnsRecordId', + label: 'IPv4 DDNS', + rules: z.string().min(1), + }, + { + component: 'Switch', + defaultValue: true, + fieldName: 'enabled', + label: '启用', + }, + { + component: 'Textarea', + componentProps: { allowClear: true, maxlength: 500, rows: 3 }, + fieldName: 'remark', + label: '备注', + rules: z.string().max(500).optional().or(z.literal('')), + }, + ]; +} + +/** + * Formats one server-evaluated port choice without coercing its string ID. + * @param option - Port-forward option returned by the source options API. + * @returns Select option preserving eligibility and stable reason code. + */ +function formatPortForwardOption( + option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['portForwards'][number], +) { + const reason = option.eligible + ? '' + : ` · ${option.disabledReasonCode || 'unavailable'}`; + return { + disabled: !option.eligible, + label: `${option.name} · ${option.protocol.toUpperCase()}:${option.externalPort}${reason}`, + value: option.id, + }; +} + +/** + * Formats one server-evaluated DDNS choice without coercing its string ID. + * @param option - DDNS option already filtered to the selected port. + * @returns Select option preserving eligibility and stable reason code. + */ +function formatDdnsOption( + option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['ddnsRecords'][number], +) { + const reason = option.eligible + ? '' + : ` · ${option.disabledReasonCode || 'unavailable'}`; + return { + disabled: !option.eligible, + label: `${option.name} · ${option.fqdn}${reason}`, + value: option.id, + }; +} diff --git a/apps/web-antdv-next/src/views/qqbot/message-subscription/list.spec.tsx b/apps/web-antdv-next/src/views/qqbot/message-subscription/list.spec.tsx new file mode 100644 index 0000000..4880577 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/message-subscription/list.spec.tsx @@ -0,0 +1,356 @@ +/* @vitest-environment happy-dom */ + +/* eslint-disable vue/one-component-per-file */ + +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 MessageSubscriptionList from './list'; + +const mocks = vi.hoisted(() => ({ + accessCodes: new Set(), + api: { + delete: vi.fn(), + getList: vi.fn(), + getOptions: vi.fn(), + getSources: vi.fn(), + toggle: vi.fn(), + }, + modalOpenCreate: vi.fn(), + modalOpenEdit: vi.fn(), + tableApi: { + reload: vi.fn(), + }, + tableOptions: undefined as any, +})); + +vi.mock('@vben/access', () => ({ + useAccess: () => ({ + hasAccessByCodes: (codes: string[]) => + codes.every((code) => mocks.accessCodes.has(code)), + }), +})); + +vi.mock('@vben/common-ui', () => ({ + Page: defineComponent({ + name: 'MockPage', + props: { autoContentHeight: Boolean }, + /** Renders a stable marker exposing the root height contract. */ + setup(props, { slots }) { + return () => + h( + 'main', + { + 'data-auto-content-height': String(props.autoContentHeight), + 'data-testid': 'page-root', + }, + slots.default?.(), + ); + }, + }), +})); + +vi.mock('antdv-next', () => ({ + Tag: defineComponent({ + name: 'MockTag', + /** Renders enabled-state tag text for the body-cell contract. */ + setup(_, { slots }) { + return () => h('span', slots.default?.()); + }, + }), +})); + +vi.mock('#/components/ktTable', () => ({ + KtTable: defineComponent({ + name: 'MockKtTable', + /** Renders the native table marker and its built-in explicit refresh control. */ + setup(_, { slots }) { + return () => + h('section', { 'data-testid': 'subscription-table' }, [ + h( + 'button', + { + 'data-testid': 'explicit-refresh', + onClick: () => mocks.tableApi.reload(), + }, + 'refresh', + ), + slots.bodyCell?.({ + column: { key: 'enabled' }, + record: { enabled: true }, + }), + ]); + }, + }), + useKtTable: vi.fn((options) => { + mocks.tableOptions = options; + return [vi.fn(), mocks.tableApi]; + }), +})); + +vi.mock('./components/MessageSubscriptionModal', () => ({ + default: defineComponent({ + name: 'MockMessageSubscriptionModal', + emits: ['saved'], + /** Exposes create/edit commands and one deterministic saved trigger. */ + 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', () => ({ + deleteMessageSubscription: mocks.api.delete, + getMessagePushSources: mocks.api.getSources, + getMessageSubscriptionList: mocks.api.getList, + getStunMappingPortChangedOptions: mocks.api.getOptions, + setMessageSubscriptionEnabled: mocks.api.toggle, +})); + +/** Creates one table row whose identifiers exceed JavaScript's safe integer. */ +function createRow(): QqbotMessagePushApi.MessageSubscriptionView { + return { + createTime: '2026-07-24 10:00:00', + enabled: true, + id: '10000000000000001', + invalidReasonCode: null, + name: '帕鲁端口变更', + remark: null, + sourceConfig: { + ddnsRecordId: '2041700000000000002', + portForwardId: '2041700000000000001', + }, + sourceKey: 'network.stun.mapping-port-changed', + sourceName: 'STUN 映射端口变更', + sourceSummary: 'Pal UDP · pal.kwitsukasa.top', + updateTime: '2026-07-24 10:00:00', + valid: true, + }; +} + +describe('message subscription list', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + mocks.accessCodes = new Set([ + 'QqBot:MessageSubscription:Create', + 'QqBot:MessageSubscription:Delete', + 'QqBot:MessageSubscription:List', + 'QqBot:MessageSubscription:Toggle', + 'QqBot:MessageSubscription:Update', + ]); + mocks.tableOptions = undefined; + mocks.tableApi.reload.mockResolvedValue(undefined); + mocks.api.getSources.mockResolvedValue([ + { + description: 'STUN 端口变化', + displayName: 'STUN 映射端口变更', + sourceKey: 'network.stun.mapping-port-changed', + subscriptionFields: [], + variables: [], + version: 1, + }, + ]); + mocks.api.getOptions.mockResolvedValue({ + ddnsRecords: [], + portForwards: [], + }); + mocks.api.getList.mockResolvedValue({ + items: [createRow()], + total: 1, + }); + mocks.api.toggle.mockResolvedValue(createRow()); + mocks.api.delete.mockResolvedValue(true); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders one auto-height Page root and one native KtTable', async () => { + const wrapper = mount(MessageSubscriptionList); + await flushPromises(); + + expect(wrapper.findAll('[data-testid="page-root"]')).toHaveLength(1); + expect( + wrapper + .get('[data-testid="page-root"]') + .attributes('data-auto-content-height'), + ).toBe('true'); + expect(wrapper.findAll('[data-testid="subscription-table"]')).toHaveLength( + 1, + ); + expect(mocks.tableOptions.immediate).toBe(false); + expect(mocks.tableOptions.rowKey).toBe('id'); + }); + + it('pins the exact filters, columns, and strict page-result proxy', async () => { + mount(MessageSubscriptionList); + await flushPromises(); + const pageResult = { items: [createRow()], total: 1 }; + mocks.api.getList.mockResolvedValueOnce(pageResult); + + await expect( + mocks.tableOptions.api.list({ + enabled: true, + name: 'STUN', + pageNo: 1, + pageSize: 10, + sourceKey: 'network.stun.mapping-port-changed', + }), + ).resolves.toBe(pageResult); + + expect( + mocks.tableOptions.formOptions.schema.map( + (field: any) => field.fieldName, + ), + ).toEqual(['name', 'sourceKey', 'enabled']); + expect(mocks.tableOptions.columns.map((column: any) => column.key)).toEqual( + ['name', 'source', 'sourceSummary', 'enabled', 'remark', 'updateTime'], + ); + }); + + it('makes zero requests and renders no table or modal without List permission', async () => { + mocks.accessCodes = new Set([ + 'QqBot:MessageSubscription:Create', + 'QqBot:MessageSubscription:Update', + ]); + const wrapper = mount(MessageSubscriptionList); + await flushPromises(); + + expect(mocks.tableApi.reload).not.toHaveBeenCalled(); + expect(mocks.api.getSources).not.toHaveBeenCalled(); + expect(mocks.api.getOptions).not.toHaveBeenCalled(); + expect(wrapper.find('[data-testid="subscription-table"]').exists()).toBe( + false, + ); + expect(wrapper.find('[data-testid="modal-saved"]').exists()).toBe(false); + }); + + it('loads list and metadata once, then remains timer-free for 60 seconds', async () => { + mount(MessageSubscriptionList); + await flushPromises(); + + expect(mocks.tableApi.reload).toHaveBeenCalledOnce(); + expect(mocks.api.getSources).toHaveBeenCalledOnce(); + expect(mocks.api.getOptions).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(60_000); + await flushPromises(); + + expect(mocks.tableApi.reload).toHaveBeenCalledOnce(); + expect(mocks.api.getSources).toHaveBeenCalledOnce(); + expect(mocks.api.getOptions).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('assigns the exact create/edit/toggle/delete permissions and confirmation', () => { + mount(MessageSubscriptionList); + + expect(mocks.tableOptions.buttons[0].permissionCodes).toEqual([ + 'QqBot:MessageSubscription:Create', + ]); + expect( + Object.fromEntries( + mocks.tableOptions.rowActions.map((action: any) => [ + action.key, + action.permissionCodes, + ]), + ), + ).toEqual({ + delete: ['QqBot:MessageSubscription:Delete'], + edit: ['QqBot:MessageSubscription:Update'], + toggle: ['QqBot:MessageSubscription:Toggle'], + }); + expect( + mocks.tableOptions.rowActions.find( + (action: any) => action.key === 'delete', + ).confirm, + ).toBeTypeOf('function'); + }); + + it('reloads the row context once after successful string-ID mutations', async () => { + mount(MessageSubscriptionList); + const row = createRow(); + 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.toggle).toHaveBeenCalledWith('10000000000000001', false); + expect(mocks.api.delete).toHaveBeenCalledWith('10000000000000001'); + expect(context.reload).toHaveBeenCalledTimes(2); + }); + + it('does not reload the row context after failed mutations', async () => { + mount(MessageSubscriptionList); + const row = createRow(); + 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', + ); + mocks.api.toggle.mockRejectedValueOnce(new Error('toggle failed')); + mocks.api.delete.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('opens the page modal and reloads the list once after saved', async () => { + const wrapper = mount(MessageSubscriptionList); + await flushPromises(); + mocks.tableApi.reload.mockClear(); + const row = createRow(); + + await mocks.tableOptions.buttons[0].onClick({}); + await mocks.tableOptions.rowActions + .find((action: any) => action.key === 'edit') + .onClick(row, { reload: vi.fn() }); + await wrapper.get('[data-testid="modal-saved"]').trigger('click'); + + expect(mocks.modalOpenCreate).toHaveBeenCalledOnce(); + expect(mocks.modalOpenEdit).toHaveBeenCalledWith(row); + expect(mocks.tableApi.reload).toHaveBeenCalledOnce(); + }); + + it('explicit refresh reloads only the list and leaves metadata cached', async () => { + const wrapper = mount(MessageSubscriptionList); + await flushPromises(); + mocks.tableApi.reload.mockClear(); + + await wrapper.get('[data-testid="explicit-refresh"]').trigger('click'); + await flushPromises(); + + expect(mocks.tableApi.reload).toHaveBeenCalledOnce(); + expect(mocks.api.getSources).toHaveBeenCalledOnce(); + expect(mocks.api.getOptions).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/apps/web-antdv-next/src/views/qqbot/message-subscription/list.tsx b/apps/web-antdv-next/src/views/qqbot/message-subscription/list.tsx new file mode 100644 index 0000000..b7cb706 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/message-subscription/list.tsx @@ -0,0 +1,293 @@ +import type { TableColumnType } from 'antdv-next'; + +import type { MessageSubscriptionModalExposed } from './components/MessageSubscriptionModal'; + +import type { QqbotMessagePushApi } from '#/api/qqbot/message-push'; +import type { + KtTableApi, + KtTableButton, + KtTableContext, + KtTableRowAction, +} from '#/components/ktTable'; + +import { defineComponent, onMounted, ref } from 'vue'; + +import { useAccess } from '@vben/access'; +import { Page } from '@vben/common-ui'; +import { Plus } from '@vben/icons'; + +import { Tag } from 'antdv-next'; + +import { + deleteMessageSubscription, + getMessagePushSources, + getMessageSubscriptionList, + getStunMappingPortChangedOptions, + setMessageSubscriptionEnabled, +} from '#/api/qqbot/message-push'; +import { KtTable, useKtTable } from '#/components/ktTable'; + +import MessageSubscriptionModal from './components/MessageSubscriptionModal'; + +const AKtTable = KtTable as any; + +export default defineComponent({ + name: 'QqBotMessageSubscriptionList', + /** + * Owns the permission-gated subscription table, metadata cache, and row reloads. + */ + setup() { + const { hasAccessByCodes } = useAccess(); + const canList = hasAccessByCodes(['QqBot:MessageSubscription:List']); + const modalRef = ref(); + const sources = ref( + [], + ); + const stunOptions = + ref(); + const columns: Array< + TableColumnType + > = [ + { + dataIndex: 'name', + key: 'name', + title: '订阅名称', + width: 180, + }, + { + key: 'source', + title: '消息源', + width: 260, + }, + { + dataIndex: 'sourceSummary', + key: 'sourceSummary', + title: '来源摘要', + width: 260, + }, + { + dataIndex: 'enabled', + key: 'enabled', + title: '状态', + width: 90, + }, + { + dataIndex: 'remark', + key: 'remark', + title: '备注', + width: 220, + }, + { + dataIndex: 'updateTime', + key: 'updateTime', + title: '更新时间', + width: 180, + }, + ]; + const api: KtTableApi = { + /** Passes the caller's strict `{ items, total }` page through unchanged. */ + list: async (params) => await getMessageSubscriptionList(params), + }; + const buttons: Array< + KtTableButton + > = [ + { + icon: , + key: 'create', + label: '新建订阅', + onClick: openCreate, + permissionCodes: ['QqBot:MessageSubscription:Create'], + type: 'primary', + }, + ]; + const rowActions: Array< + KtTableRowAction + > = [ + { + key: 'edit', + label: '编辑', + onClick: openEdit, + permissionCodes: ['QqBot:MessageSubscription:Update'], + }, + { + key: 'toggle', + label: '启停', + onClick: handleToggle, + permissionCodes: ['QqBot:MessageSubscription:Toggle'], + }, + { + confirm: getDeleteConfirm, + danger: true, + key: 'delete', + label: '删除', + onClick: handleDelete, + permissionCodes: ['QqBot:MessageSubscription:Delete'], + }, + ]; + const [registerTable, tableApi] = + useKtTable({ + api, + buttons, + columns, + formOptions: { + schema: [ + { + component: 'Input', + componentProps: { allowClear: true }, + fieldName: 'name', + label: '订阅名称', + }, + { + component: 'Select', + componentProps: () => ({ + allowClear: true, + options: sources.value.map((source) => ({ + label: source.displayName, + value: source.sourceKey, + })), + }), + fieldName: 'sourceKey', + label: '消息源', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '启用', value: true }, + { label: '停用', value: false }, + ], + }, + fieldName: 'enabled', + label: '启用状态', + }, + ], + }, + immediate: false, + rowActions, + rowActionVisibleCount: 3, + rowKey: 'id', + tableTitle: '消息订阅', + }); + + /** Opens a blank modal session through the page-owned exposed ref. */ + function openCreate() { + modalRef.value?.openCreate(); + } + + /** + * Opens one row without copying page-only runtime state into the modal. + * @param row - Subscription selected from KtTable. + */ + function openEdit(row: QqbotMessagePushApi.MessageSubscriptionView) { + modalRef.value?.openEdit(row); + } + + /** + * Builds KtTable's delete confirmation text for one subscription. + * @param row - Subscription awaiting delete confirmation. + * @returns Human-readable confirmation text containing the row name. + */ + function getDeleteConfirm( + row: QqbotMessagePushApi.MessageSubscriptionView, + ) { + return `确认删除消息订阅「${row.name}」吗?`; + } + + /** + * Toggles one row and reloads only that table context after success. + * @param row - Subscription whose enabled state is inverted. + * @param context - KtTable row-action context owning the list reload. + */ + async function handleToggle( + row: QqbotMessagePushApi.MessageSubscriptionView, + context: KtTableContext, + ) { + await setMessageSubscriptionEnabled(row.id, !row.enabled); + await context.reload(); + } + + /** + * Deletes one row and reloads only that table context after success. + * @param row - Subscription confirmed through KtTable's action system. + * @param context - KtTable row-action context owning the list reload. + */ + async function handleDelete( + row: QqbotMessagePushApi.MessageSubscriptionView, + context: KtTableContext, + ) { + await deleteMessageSubscription(row.id); + await context.reload(); + } + + /** Reloads the mutable list exactly once after a successful modal save. */ + async function handleModalSaved() { + await tableApi.reload(); + } + + /** Loads immutable source metadata once for the authorized page mount. */ + async function loadMetadata() { + const [nextSources, nextStunOptions] = await Promise.all([ + getMessagePushSources(), + getStunMappingPortChangedOptions(), + ]); + sources.value = nextSources; + stunOptions.value = nextStunOptions; + } + + /** Starts the single authorized list and metadata load for this mount. */ + async function activatePage() { + if (!canList) return; + await Promise.all([tableApi.reload(), loadMetadata()]); + } + + /** + * Renders source, status, and empty-value presentation for data columns. + * @param slot - KtTable body-cell slot payload. + * @param slot.column - Column whose custom presentation is requested. + * @param slot.record - Subscription row rendered by the table. + * @returns Custom cell content or undefined for native field rendering. + */ + function renderBodyCell(slot: { + column: TableColumnType; + record: QqbotMessagePushApi.MessageSubscriptionView; + }) { + const { column, record } = slot; + if (column.key === 'source') { + return `${record.sourceName} · ${record.sourceKey}`; + } + if (column.key === 'enabled') { + return ( + + {record.enabled ? '启用' : '停用'} + + ); + } + if (column.key === 'remark') { + return record.remark || '-'; + } + return undefined; + } + + onMounted(activatePage); + + return () => ( + + {canList ? ( + <> + + + + ) : null} + + ); + }, +});