feat: 新增消息订阅页面

This commit is contained in:
sunlei 2026-07-24 15:47:32 +08:00
parent c072d53554
commit 074b05107e
4 changed files with 1350 additions and 0 deletions

View File

@ -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);
});
});

View File

@ -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<QqbotMessagePushApi.MessageSubscriptionView>();
const selectedPortForwardId = ref<string>();
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<MessageSubscriptionModalData>();
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<MessageSubscriptionFormValues>();
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 () => (
<Modal title={modalTitle.value}>
<SubscriptionForm class="mx-2" />
</Modal>
);
},
});
/**
* 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,
};
}

View File

@ -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<string>(),
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);
});
});

View File

@ -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<MessageSubscriptionModalExposed>();
const sources = ref<QqbotMessagePushApi.SystemMessageSourceDefinition[]>(
[],
);
const stunOptions =
ref<QqbotMessagePushApi.StunMappingPortChangedOptionsResponse>();
const columns: Array<
TableColumnType<QqbotMessagePushApi.MessageSubscriptionView>
> = [
{
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<QqbotMessagePushApi.MessageSubscriptionView> = {
/** Passes the caller's strict `{ items, total }` page through unchanged. */
list: async (params) => await getMessageSubscriptionList(params),
};
const buttons: Array<
KtTableButton<QqbotMessagePushApi.MessageSubscriptionView>
> = [
{
icon: <Plus class="kt-table__button-icon" />,
key: 'create',
label: '新建订阅',
onClick: openCreate,
permissionCodes: ['QqBot:MessageSubscription:Create'],
type: 'primary',
},
];
const rowActions: Array<
KtTableRowAction<QqbotMessagePushApi.MessageSubscriptionView>
> = [
{
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<QqbotMessagePushApi.MessageSubscriptionView>({
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<QqbotMessagePushApi.MessageSubscriptionView>,
) {
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<QqbotMessagePushApi.MessageSubscriptionView>,
) {
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<QqbotMessagePushApi.MessageSubscriptionView>;
record: QqbotMessagePushApi.MessageSubscriptionView;
}) {
const { column, record } = slot;
if (column.key === 'source') {
return `${record.sourceName} · ${record.sourceKey}`;
}
if (column.key === 'enabled') {
return (
<Tag color={record.enabled ? 'success' : 'default'}>
{record.enabled ? '启用' : '停用'}
</Tag>
);
}
if (column.key === 'remark') {
return record.remark || '-';
}
return undefined;
}
onMounted(activatePage);
return () => (
<Page autoContentHeight>
{canList ? (
<>
<AKtTable
onRegister={registerTable}
v-slots={{ bodyCell: renderBodyCell }}
/>
<MessageSubscriptionModal
onSaved={handleModalSaved}
ref={modalRef}
sources={sources.value}
stunOptions={stunOptions.value}
/>
</>
) : null}
</Page>
);
},
});