feat: 新增消息订阅页面
This commit is contained in:
parent
c072d53554
commit
074b05107e
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user