feat: 新增消息模板页面

This commit is contained in:
sunlei 2026-07-24 16:40:21 +08:00
parent c6bfd5851d
commit 352e10ee39
6 changed files with 1768 additions and 0 deletions

View File

@ -0,0 +1,139 @@
/* @vitest-environment happy-dom */
/* eslint-disable no-template-curly-in-string */
import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
import { flushPromises, mount } from '@vue/test-utils';
import Mentions from 'antdv-next/dist/mentions/index';
import { describe, expect, it } from 'vitest';
import MessageTemplateMentions from './MessageTemplateMentions';
/** Creates one complete source-variable fixture for Mentions behavior. */
function createVariable(
overrides: Partial<QqbotMessagePushApi.SystemMessageSourceVariableDefinition> = {},
): QqbotMessagePushApi.SystemMessageSourceVariableDefinition {
return {
description: '组合后的完整服务端地址',
example: 'pal.kwitsukasa.top:38213',
key: 'endpoint',
label: '连接端点',
type: 'string',
...overrides,
};
}
describe('message template mentions', () => {
it('uses the installed runtime to insert exactly one complete token', async () => {
const wrapper = mount(MessageTemplateMentions, {
attachTo: document.body,
props: {
value: '$',
variables: [createVariable()],
},
});
const textarea = wrapper.get('textarea');
const element = textarea.element as HTMLTextAreaElement;
element.focus();
element.setSelectionRange(1, 1);
await textarea.trigger('keyup', { key: '$', keyCode: 52, which: 52 });
await textarea.trigger('keydown', {
key: 'Enter',
keyCode: 13,
which: 13,
});
await flushPromises();
expect(wrapper.emitted('update:value')?.at(-1)).toEqual(['${{endpoint}}']);
wrapper.unmount();
});
it('forwards the locked option values and Mentions mechanics', () => {
const variables = [
createVariable(),
createVariable({ key: 'port', label: '公网端口' }),
];
const wrapper = mount(MessageTemplateMentions, {
props: { value: 'literal', variables },
});
const mentions = wrapper.getComponent(Mentions);
const options = mentions.props('options') || [];
expect(options).toEqual([
expect.objectContaining({ value: '{{endpoint}}' }),
expect.objectContaining({ value: '{{port}}' }),
]);
expect(mentions.props()).toMatchObject({
prefix: '$',
rows: 4,
split: '',
value: 'literal',
});
expect(wrapper.get('textarea').attributes('maxlength')).toBe('2000');
expect(options.some((option) => option.value === 'endpoint')).toBe(false);
expect(options.some((option) => option.value === '${{endpoint}}')).toBe(
false,
);
});
it.each([
['key', 'ENDpoint'],
['label', '连接端'],
['description', '完整服务'],
['example', 'KWITSUKASA'],
])('searches independently by %s', (_, query) => {
const wrapper = mount(MessageTemplateMentions, {
props: { variables: [createVariable()] },
});
const mentions = wrapper.getComponent(Mentions);
const option = (mentions.props('options') || [])[0];
const filter = mentions.props('filterOption') as (
input: string,
option: Record<string, unknown>,
) => boolean;
expect(option).toBeDefined();
if (!option) throw new Error('Expected one variable option');
const searchableOption = option as unknown as Record<string, unknown>;
expect(filter(query, searchableOption)).toBe(true);
expect(filter('unrelated-value', searchableOption)).toBe(false);
});
it('keeps disabled, loading, and value controlled', async () => {
const wrapper = mount(MessageTemplateMentions, {
props: {
disabled: true,
loading: true,
value: 'server-owned',
variables: [createVariable()],
},
});
const mentions = wrapper.getComponent(Mentions);
expect(mentions.props()).toMatchObject({
disabled: true,
loading: true,
value: 'server-owned',
});
expect(wrapper.emitted('update:value')).toBeUndefined();
});
it('emits CQ-looking content as literal controlled text', async () => {
const wrapper = mount(MessageTemplateMentions, {
props: {
value: '[CQ:at,qq=12345]',
variables: [createVariable()],
},
});
const mentions = wrapper.getComponent(Mentions);
mentions.vm.$emit('update:value', '[CQ:at,qq=12345] $literal');
await flushPromises();
expect(wrapper.emitted('update:value')?.at(-1)).toEqual([
'[CQ:at,qq=12345] $literal',
]);
expect(wrapper.html()).not.toContain('innerHTML');
});
});

View File

@ -0,0 +1,115 @@
import type { PropType } from 'vue';
import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
import { computed, defineComponent } from 'vue';
import Mentions from 'antdv-next/dist/mentions/index';
interface MessageTemplateMentionOption {
label: string;
value: string;
variable: QqbotMessagePushApi.SystemMessageSourceVariableDefinition;
}
export default defineComponent({
name: 'MessageTemplateMentions',
props: {
disabled: {
default: false,
type: Boolean,
},
loading: {
default: false,
type: Boolean,
},
value: {
default: '',
type: String,
},
variables: {
default: () => [],
type: Array as PropType<
QqbotMessagePushApi.SystemMessageSourceVariableDefinition[]
>,
},
},
emits: {
/**
* Keeps the wrapper controlled and forwards only the complete plain-text value.
* @param value - Mentions textarea content after an explicit user update.
* @returns Always true because string payloads are the only supported update.
*/
'update:value': (value: string) => typeof value === 'string',
},
/** Creates a request-free controlled boundary around the installed Mentions. */
setup(props, { emit }) {
/** Maps server variables to the exact `{{key}}` values required after `$`. */
const options = computed<MessageTemplateMentionOption[]>(() =>
props.variables.map((variable) => ({
label: `${variable.key} · ${variable.label} · ${variable.description} · 示例:${variable.example}`,
value: `{{${variable.key}}}`,
variable,
})),
);
/**
* Filters one option across the four server-owned variable descriptors.
* @param input - Case-insensitive query entered after the `$` prefix.
* @param option - Mentions option carrying the original variable definition.
* @returns Whether key, label, description, or example includes the query.
*/
function filterVariableOption(
input: string,
option: Record<string, unknown>,
) {
const variable = option.variable;
if (!isVariableDefinition(variable)) return false;
const query = input.toLocaleLowerCase();
return [
variable.key,
variable.label,
variable.description,
variable.example,
].some((field) => field.toLocaleLowerCase().includes(query));
}
/**
* Emits the installed component's controlled value without interpreting its text.
* @param value - Literal textarea value, including CQ-looking substrings.
*/
function handleValueUpdate(value: string) {
emit('update:value', value);
}
return () => (
<Mentions
disabled={props.disabled}
filterOption={filterVariableOption}
loading={props.loading}
maxlength={2000}
onUpdate:value={handleValueUpdate}
options={options.value}
prefix="$"
rows={4}
split=""
value={props.value}
/>
);
},
});
/**
* Narrows an unknown Mentions option payload to the server variable contract.
* @param value - Candidate option metadata supplied by the installed component.
* @returns Whether all searchable variable fields are strings.
*/
function isVariableDefinition(
value: unknown,
): value is QqbotMessagePushApi.SystemMessageSourceVariableDefinition {
if (!value || typeof value !== 'object') return false;
const candidate = value as Record<string, unknown>;
return ['description', 'example', 'key', 'label'].every(
(field) => typeof candidate[field] === 'string',
);
}

View File

@ -0,0 +1,446 @@
/* @vitest-environment happy-dom */
/* eslint-disable no-template-curly-in-string, vue/one-component-per-file */
import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, h, nextTick } from 'vue';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import MessageTemplateMentions from './MessageTemplateMentions';
import MessageTemplateModal from './MessageTemplateModal';
const mocks = vi.hoisted(() => {
const formValues = {
content: '',
enabled: true,
name: '',
remark: '',
sourceKey: '',
};
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(async () => ({ ...formValues })),
resetForm: vi.fn(async () => {}),
resetValidate: vi.fn(async () => {}),
setValues: vi.fn(async (values) => Object.assign(formValues, values)),
validate: vi.fn(async () => ({ valid: true })),
validateField: vi.fn(async () => ({ valid: true })),
};
return {
create: vi.fn(),
detail: vi.fn(),
formApi,
formOptions: undefined as any,
formValues,
modalApi,
modalOptions: undefined as any,
preview: vi.fn(),
update: vi.fn(),
};
});
/** Creates a fluent no-op validation rule for schema behavior 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: 'MockTemplateForm',
/** Renders the production schema's local content component with live props. */
setup() {
return () => {
const content = mocks.formOptions.schema.find(
(field: any) => field.fieldName === 'content',
);
const componentProps =
typeof content.componentProps === 'function'
? content.componentProps()
: content.componentProps;
return h('form', { 'data-testid': 'template-form' }, [
h(content.component, {
...componentProps,
value: mocks.formValues.content,
'onUpdate:value': (value: string) => {
mocks.formValues.content = value;
},
}),
]);
};
},
});
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: 'MockTemplateModal',
/** Renders the actual form plus the explicit prepend-footer action slot. */
setup(_, { slots }) {
return () =>
h('section', { 'data-testid': 'template-modal' }, [
slots.default?.(),
h('footer', slots['prepend-footer']?.()),
]);
},
});
return [Modal, mocks.modalApi];
}),
}));
vi.mock('#/api/qqbot/message-push', () => ({
createMessageTemplate: mocks.create,
getMessagePushSourceDetail: mocks.detail,
previewMessageTemplate: mocks.preview,
updateMessageTemplate: mocks.update,
}));
/** Creates one source with a distinguishable variable catalog. */
function createSource(
sourceKey = 'source-a',
variableKey = 'endpoint',
): QqbotMessagePushApi.SystemMessageSourceDefinition {
return {
description: `${sourceKey} description`,
displayName: `${sourceKey} name`,
sourceKey,
subscriptionFields: [],
variables: [
{
description: `${variableKey} description`,
example: `${variableKey} example`,
key: variableKey,
label: `${variableKey} label`,
type: 'string',
},
],
version: 1,
};
}
/** Creates one unsafe-integer string-ID editable template row. */
function createRow(): QqbotMessagePushApi.MessageTemplateView {
return {
content: 'old [CQ:at,qq=12345]',
createTime: '2026-07-24 10:00:00',
enabled: false,
id: '10000000000000001',
name: 'old template',
referenceCount: 0,
remark: 'old remark',
sourceKey: 'source-b',
sourceName: 'source-b name',
updateTime: '2026-07-24 10:00:00',
};
}
/** Mounts the modal with page-owned source labels. */
function mountModal(canPreview = true) {
return mount(MessageTemplateModal, {
props: {
canPreview,
sources: [createSource(), createSource('source-b', 'port')],
},
});
}
describe('message template modal', () => {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(mocks.formValues, {
content: '',
enabled: true,
name: '',
remark: '',
sourceKey: '',
});
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.detail.mockImplementation(async (sourceKey: string) =>
createSource(sourceKey, sourceKey === 'source-a' ? 'endpoint' : 'port'),
);
mocks.preview.mockResolvedValue({
renderedMessage: 'server rendered',
variables: { endpoint: 'server.example:38213' },
});
});
it('registers the exact schema and actually renders the local Mentions form field', () => {
const wrapper = mountModal();
const fields = mocks.formOptions.schema.map(
(field: any) => field.fieldName,
);
const content = mocks.formOptions.schema.find(
(field: any) => field.fieldName === 'content',
);
expect(fields).toEqual([
'sourceKey',
'name',
'content',
'enabled',
'remark',
]);
expect(content.component).toBe(MessageTemplateMentions);
expect(content.modelPropName).toBe('value');
expect(
wrapper
.get('[data-testid="template-form"]')
.findComponent(MessageTemplateMentions)
.exists(),
).toBe(true);
expect(
mocks.formOptions.schema.find((field: any) => field.fieldName === 'name')
.componentProps.maxlength,
).toBe(100);
expect(
mocks.formOptions.schema.find(
(field: any) => field.fieldName === 'remark',
).componentProps.maxlength,
).toBe(500);
});
it('opens before reset/set/reset-validation and isolates edit then 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.setValues).toHaveBeenLastCalledWith({
content: '',
enabled: true,
name: '',
remark: '',
sourceKey: 'source-a',
});
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('loads exact details and prevents a late source from overwriting the latest', async () => {
const deferred = new Map<
string,
{
promise: Promise<QqbotMessagePushApi.SystemMessageSourceDefinition>;
resolve: (
value: QqbotMessagePushApi.SystemMessageSourceDefinition,
) => void;
}
>();
mocks.detail.mockImplementation((sourceKey: string) => {
let resolve!: (
value: QqbotMessagePushApi.SystemMessageSourceDefinition,
) => void;
const promise =
new Promise<QqbotMessagePushApi.SystemMessageSourceDefinition>(
(done) => {
resolve = done;
},
);
deferred.set(sourceKey, { promise, resolve });
return promise;
});
const wrapper = mountModal();
(wrapper.vm as any).openCreate();
await flushPromises();
expect(mocks.detail).toHaveBeenNthCalledWith(1, 'source-a');
const switchPromise = mocks.formOptions.handleValuesChange(
{ ...mocks.formValues, sourceKey: 'source-b' },
['sourceKey'],
);
expect(mocks.detail).toHaveBeenNthCalledWith(2, 'source-b');
const content = mocks.formOptions.schema.find(
(field: any) => field.fieldName === 'content',
);
expect(content.componentProps().variables).toEqual([]);
deferred.get('source-b')?.resolve(createSource('source-b', 'port'));
await switchPromise;
expect(content.componentProps().variables[0].key).toBe('port');
deferred.get('source-a')?.resolve(createSource('source-a', 'endpoint'));
await flushPromises();
expect(content.componentProps().variables[0].key).toBe('port');
expect(mocks.formApi.validateField).toHaveBeenCalledWith('content');
});
it('deduplicates exact-key detail and evicts a rejected cache entry for retry', async () => {
let resolveSourceA!: (
source: QqbotMessagePushApi.SystemMessageSourceDefinition,
) => void;
mocks.detail.mockImplementationOnce(
() =>
new Promise<QqbotMessagePushApi.SystemMessageSourceDefinition>(
(resolve) => {
resolveSourceA = resolve;
},
),
);
const wrapper = mountModal();
(wrapper.vm as any).openCreate();
await flushPromises();
const collidingLoad = mocks.formOptions.handleValuesChange(
{ ...mocks.formValues, sourceKey: 'source-a' },
['sourceKey'],
);
expect(mocks.detail).toHaveBeenCalledTimes(1);
resolveSourceA(createSource('source-a', 'endpoint'));
await collidingLoad;
await flushPromises();
mocks.detail.mockRejectedValueOnce(new Error('detail failed'));
await expect(
mocks.formOptions.handleValuesChange(
{ ...mocks.formValues, sourceKey: 'source-b' },
['sourceKey'],
),
).rejects.toThrow('detail failed');
mocks.detail.mockResolvedValueOnce(createSource('source-b', 'port'));
await mocks.formOptions.handleValuesChange(
{ ...mocks.formValues, sourceKey: 'source-b' },
['sourceKey'],
);
expect(mocks.detail).toHaveBeenCalledTimes(3);
const content = mocks.formOptions.schema.find(
(field: any) => field.fieldName === 'content',
);
expect(content.componentProps().loading).toBe(false);
});
it('allows preview only on explicit permitted click and clears it on content change', async () => {
const wrapper = mountModal(true);
Object.assign(mocks.formValues, {
content: '[CQ:at,qq=12345] ${{endpoint}}',
sourceKey: 'source-a',
});
expect(mocks.preview).not.toHaveBeenCalled();
await wrapper.get('footer button').trigger('click');
await flushPromises();
expect(mocks.preview).toHaveBeenCalledOnce();
expect(mocks.preview).toHaveBeenCalledWith({
content: '[CQ:at,qq=12345] ${{endpoint}}',
sourceKey: 'source-a',
});
expect(wrapper.text()).toContain('server rendered');
expect(wrapper.text()).toContain('server.example:38213');
await mocks.formOptions.handleValuesChange(
{ ...mocks.formValues, content: 'changed' },
['content'],
);
await nextTick();
expect(wrapper.text()).not.toContain('server rendered');
expect(mocks.detail).not.toHaveBeenCalled();
expect(mocks.preview).toHaveBeenCalledOnce();
});
it('makes preview impossible without Preview permission or valid values', async () => {
const wrapper = mountModal(false);
expect(wrapper.find('footer button').exists()).toBe(false);
expect(mocks.preview).not.toHaveBeenCalled();
wrapper.unmount();
const permitted = mountModal(true);
Object.assign(mocks.formValues, { content: '', sourceKey: 'source-a' });
await permitted.get('footer button').trigger('click');
expect(mocks.preview).not.toHaveBeenCalled();
});
it('submits exact create/update payloads while preserving content and string ID', async () => {
const wrapper = mountModal();
Object.assign(mocks.formValues, {
content: ' [CQ:at,qq=12345]\n${{endpoint}} ',
enabled: 1,
name: ' template ',
remark: ' note ',
sourceKey: 'source-a',
});
(wrapper.vm as any).openCreate();
await flushPromises();
Object.assign(mocks.formValues, {
content: ' [CQ:at,qq=12345]\n${{endpoint}} ',
enabled: 1,
name: ' template ',
remark: ' note ',
sourceKey: 'source-a',
});
await mocks.modalOptions.onConfirm();
const payload = {
content: ' [CQ:at,qq=12345]\n${{endpoint}} ',
enabled: true,
name: 'template',
remark: 'note',
sourceKey: 'source-a',
};
expect(mocks.create).toHaveBeenCalledWith(payload);
(wrapper.vm as any).openEdit(createRow());
await flushPromises();
Object.assign(mocks.formValues, payload);
await mocks.modalOptions.onConfirm();
expect(mocks.update).toHaveBeenCalledWith('10000000000000001', payload);
});
it('stays open/unlocked after failure and closes/emits once on retry success', async () => {
mocks.create.mockRejectedValueOnce(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.mockResolvedValueOnce({});
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,384 @@
import type { PropType } from 'vue';
import type { VbenFormSchema } from '#/adapter/form';
import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
import { computed, defineComponent, markRaw, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import Button from 'antdv-next/dist/button/index';
import { useVbenForm, z } from '#/adapter/form';
import {
createMessageTemplate,
getMessagePushSourceDetail,
previewMessageTemplate,
updateMessageTemplate,
} from '#/api/qqbot/message-push';
import MessageTemplateMentions from './MessageTemplateMentions';
const AButton = Button as any;
export interface MessageTemplateModalExposed {
openCreate: () => void;
openEdit: (row: QqbotMessagePushApi.MessageTemplateView) => void;
}
interface MessageTemplateFormValues {
content: string;
enabled: boolean;
name: string;
remark?: string;
sourceKey: string;
}
interface MessageTemplateModalData {
values: MessageTemplateFormValues;
}
export default defineComponent({
name: 'MessageTemplateModal',
props: {
canPreview: {
required: true,
type: Boolean,
},
sources: {
required: true,
type: Array as PropType<
QqbotMessagePushApi.SystemMessageSourceDefinition[]
>,
},
},
emits: ['saved'],
/** Owns one isolated template edit session, source detail cache, and preview. */
setup(props, { emit, expose }) {
const editingRow = ref<QqbotMessagePushApi.MessageTemplateView>();
const variables = ref<
QqbotMessagePushApi.SystemMessageSourceVariableDefinition[]
>([]);
const detailLoading = ref(false);
const previewLoading = ref(false);
const preview = ref<QqbotMessagePushApi.MessageTemplatePreview>();
const selectedSourceKey = ref('');
const detailCache = new Map<
string,
Promise<QqbotMessagePushApi.SystemMessageSourceDefinition>
>();
let sourceRevision = 0;
let previewRevision = 0;
const [TemplateForm, formApi] = useVbenForm({
commonConfig: {
labelClass: 'w-24',
},
/**
* Clears stale preview state and refreshes variables only for source changes.
* @param values - Current form values after the update.
* @param fieldsChanged - Exact fields changed by this form event.
*/
async handleValuesChange(values, fieldsChanged) {
if (fieldsChanged.includes('content')) clearPreview();
if (!fieldsChanged.includes('sourceKey')) return;
const sourceKey =
typeof values.sourceKey === 'string' ? values.sourceKey : '';
selectedSourceKey.value = sourceKey;
await loadSourceDetail(sourceKey);
},
layout: 'horizontal',
schema: createFormSchema(
props,
variables,
detailLoading,
selectedSourceKey,
),
showDefaultActions: false,
wrapperClass: 'grid-cols-1',
});
/** Tracks the title from isolated create/edit identity only. */
const modalTitle = computed(() =>
editingRow.value ? '编辑消息模板' : '新建消息模板',
);
const [Modal, modalApi] = useVbenModal({
class: 'w-[760px]',
fullscreenButton: false,
/** Validates and persists the exact five-field template payload. */
async onConfirm() {
await submit();
},
/**
* Resets every mounted form/session state before loading authoritative detail.
* @param isOpen - Whether destroy-on-close modal content is mounted.
*/
async onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const { values } = modalApi.getData<MessageTemplateModalData>();
invalidateSession();
selectedSourceKey.value = values.sourceKey;
await resetForm(values);
await loadSourceDetail(values.sourceKey);
},
});
/** Opens a fresh create session without values from the preceding edit. */
function openCreate() {
editingRow.value = undefined;
modalApi
.setData({
values: {
content: '',
enabled: true,
name: '',
remark: '',
sourceKey: props.sources[0]?.sourceKey || '',
},
} satisfies MessageTemplateModalData)
.open();
}
/**
* Opens an edit session with only the five user-editable values.
* @param row - Template row selected from the page-owned KtTable.
*/
function openEdit(row: QqbotMessagePushApi.MessageTemplateView) {
editingRow.value = row;
modalApi
.setData({
values: {
content: row.content,
enabled: row.enabled,
name: row.name,
remark: row.remark || '',
sourceKey: row.sourceKey,
},
} satisfies MessageTemplateModalData)
.open();
}
/**
* Resets and installs the current session values in the required order.
* @param values - Exact editable values stored before the modal opened.
*/
async function resetForm(values: MessageTemplateFormValues) {
await formApi.resetForm();
await formApi.setValues(values);
await formApi.resetValidate();
}
/** Invalidates stale source/preview work and clears every transient display. */
function invalidateSession() {
sourceRevision += 1;
detailLoading.value = false;
variables.value = [];
clearPreview();
}
/** Clears authoritative preview data and invalidates any in-flight preview. */
function clearPreview() {
previewRevision += 1;
preview.value = undefined;
previewLoading.value = false;
}
/**
* Reuses an exact-key in-flight/resolved source detail promise.
* @param sourceKey - Stable exact source identity selected in the form.
* @returns Authoritative source detail promise, evicted when rejected.
*/
function getCachedSourceDetail(sourceKey: string) {
const cached = detailCache.get(sourceKey);
if (cached) return cached;
const request = getMessagePushSourceDetail(sourceKey);
detailCache.set(sourceKey, request);
request.catch(() => {
if (detailCache.get(sourceKey) === request) {
detailCache.delete(sourceKey);
}
});
return request;
}
/**
* Replaces suggestions only when this exact source request remains latest.
* @param sourceKey - Newly selected source identity.
*/
async function loadSourceDetail(sourceKey: string) {
const revision = ++sourceRevision;
clearPreview();
variables.value = [];
detailLoading.value = !!sourceKey;
if (!sourceKey) {
await formApi.validateField('content');
return;
}
try {
const source = await getCachedSourceDetail(sourceKey);
if (
revision === sourceRevision &&
selectedSourceKey.value === sourceKey
) {
variables.value = source.variables;
}
} finally {
if (
revision === sourceRevision &&
selectedSourceKey.value === sourceKey
) {
detailLoading.value = false;
await formApi.validateField('content');
}
}
}
/** Requests server-authoritative preview only after an explicit valid click. */
async function handlePreview() {
if (!props.canPreview) return;
const values = await formApi.getValues<MessageTemplateFormValues>();
if (!values.sourceKey || !values.content) return;
const revision = ++previewRevision;
preview.value = undefined;
previewLoading.value = true;
try {
const result = await previewMessageTemplate({
content: values.content,
sourceKey: values.sourceKey,
});
if (revision === previewRevision) preview.value = result;
} finally {
if (revision === previewRevision) previewLoading.value = false;
}
}
/** Persists content byte-for-byte and closes/emits only after success. */
async function submit() {
const { valid } = await formApi.validate();
if (!valid) return;
const values = await formApi.getValues<MessageTemplateFormValues>();
const payload: QqbotMessagePushApi.MessageTemplateInput = {
content: values.content,
enabled: !!values.enabled,
name: values.name.trim(),
remark: values.remark?.trim() || '',
sourceKey: values.sourceKey,
};
modalApi.lock();
try {
await (editingRow.value
? updateMessageTemplate(editingRow.value.id, payload)
: createMessageTemplate(payload));
await modalApi.close();
emit('saved');
} finally {
modalApi.unlock();
}
}
/** Renders the permission-gated explicit preview action. */
function renderPreviewAction() {
return props.canPreview ? (
<AButton loading={previewLoading.value} onClick={handlePreview}>
</AButton>
) : null;
}
/** Renders server-returned preview text and variables as escaped text nodes. */
function renderPreview() {
const result = preview.value;
if (!result) return null;
return (
<section class="mx-2 mt-4 rounded border p-3">
<div class="mb-2 text-sm font-medium"></div>
<pre class="whitespace-pre-wrap break-words">
{result.renderedMessage}
</pre>
<div class="mt-2 text-xs text-gray-500">
{Object.entries(result.variables).map(([key, value]) => (
<div key={key}>{`${key}: ${value}`}</div>
))}
</div>
</section>
);
}
expose({ openCreate, openEdit } satisfies MessageTemplateModalExposed);
return () => (
<Modal
title={modalTitle.value}
v-slots={{ 'prepend-footer': renderPreviewAction }}
>
<TemplateForm class="mx-2" />
{renderPreview()}
</Modal>
);
},
});
/**
* Builds the exact template schema with a local controlled Mentions component.
* @param props - Page-owned source labels and preview permission.
* @param variables - Variables from the latest exact source detail.
* @param loading - Source-detail loading state forwarded to Mentions.
* @param selectedSourceKey - Current source identity used to disable no-source input.
* @returns Five fields in the locked source/name/content/enabled/remark order.
*/
function createFormSchema(
props: Readonly<{
canPreview: boolean;
sources: QqbotMessagePushApi.SystemMessageSourceDefinition[];
}>,
variables: Readonly<{
value: QqbotMessagePushApi.SystemMessageSourceVariableDefinition[];
}>,
loading: Readonly<{ value: boolean }>,
selectedSourceKey: Readonly<{ value: string }>,
): VbenFormSchema[] {
return [
{
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: 'Input',
componentProps: { allowClear: true, maxlength: 100 },
fieldName: 'name',
label: '模板名称',
rules: z.string().trim().min(1).max(100),
},
{
component: markRaw(MessageTemplateMentions),
componentProps: () => ({
disabled: !selectedSourceKey.value,
loading: loading.value,
variables: variables.value,
}),
fieldName: 'content',
label: '模板内容',
modelPropName: 'value',
rules: z.string().min(1).max(2000),
},
{
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('')),
},
];
}

View File

@ -0,0 +1,402 @@
/* @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 MessageTemplateList from './list';
const mocks = vi.hoisted(() => {
const registerTable = vi.fn();
const tableApi = {
reload: vi.fn(async () => {
if (!mocks.tableOptions) {
throw new Error('[MockKtTable]: table is not registered yet');
}
return mocks.tableOptions.api.list({ pageNo: 1, pageSize: 10 });
}),
};
return {
accessCodes: new Set<string>(),
api: {
delete: vi.fn(),
detail: vi.fn(),
getList: vi.fn(),
getSources: vi.fn(),
preview: vi.fn(),
toggle: vi.fn(),
},
modalOpenCreate: vi.fn(),
modalOpenEdit: vi.fn(),
registerTable,
tableApi,
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 one stable route root and exposes the height contract. */
setup(props, { slots }) {
return () =>
h(
'main',
{
'data-auto-content-height': String(props.autoContentHeight),
'data-testid': 'page-root',
},
slots.default?.(),
);
},
}),
}));
vi.mock('@vben/icons', () => ({
Plus: defineComponent({ render: () => h('i') }),
}));
vi.mock('antdv-next/dist/tag/index', () => ({
default: defineComponent({
name: 'MockTag',
/** Renders tag children as ordinary escaped text. */
setup(_, { slots }) {
return () => h('span', slots.default?.());
},
}),
}));
vi.mock('#/api/qqbot/message-push', () => ({
deleteMessageTemplate: mocks.api.delete,
getMessagePushSourceDetail: mocks.api.detail,
getMessagePushSources: mocks.api.getSources,
getMessageTemplateList: mocks.api.getList,
previewMessageTemplate: mocks.api.preview,
setMessageTemplateEnabled: mocks.api.toggle,
}));
vi.mock('#/components/ktTable', () => ({
KtTable: defineComponent({
name: 'MockKtTable',
emits: ['register'],
/** Emits the actual registration event before exposing refresh/list rendering. */
setup(_, { emit, slots }) {
emit('register', { registered: true });
return () =>
h('section', { 'data-testid': 'template-table' }, [
h(
'button',
{
'data-testid': 'explicit-refresh',
onClick: () => mocks.tableApi.reload(),
},
'refresh',
),
slots.bodyCell?.({
column: { key: 'contentSummary' },
record: createRow({ content: '[CQ:at,qq=12345] <plain>' }),
}),
]);
},
}),
useKtTable: vi.fn((options) => {
mocks.tableOptions = undefined;
mocks.registerTable.mockImplementation(() => {
mocks.tableOptions = options;
});
return [mocks.registerTable, mocks.tableApi];
}),
}));
vi.mock('./components/MessageTemplateModal', () => ({
default: defineComponent({
name: 'MockMessageTemplateModal',
props: {
canPreview: Boolean,
sources: {
default: () => [],
type: Array,
},
},
emits: ['saved'],
/** Exposes create/edit and a saved trigger through the rendered modal marker. */
setup(props, { emit, expose }) {
expose({
openCreate: mocks.modalOpenCreate,
openEdit: mocks.modalOpenEdit,
});
return () =>
h(
'button',
{
'data-can-preview': String(props.canPreview),
'data-testid': 'modal-saved',
onClick: () => emit('saved'),
},
'saved',
);
},
}),
}));
/** Creates one template row while preserving unsafe-integer IDs as strings. */
function createRow(
overrides: Partial<QqbotMessagePushApi.MessageTemplateView> = {},
): QqbotMessagePushApi.MessageTemplateView {
return {
content: 'content',
createTime: '2026-07-24 10:00:00',
enabled: true,
id: '10000000000000001',
name: 'template',
referenceCount: 0,
remark: null,
sourceKey: 'network.stun.mapping-port-changed',
sourceName: 'STUN 映射端口变更',
updateTime: '2026-07-24 10:00:00',
...overrides,
};
}
/** Creates the page-lifetime source directory fixture. */
function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] {
return [
{
description: 'STUN mapping changed',
displayName: 'STUN 映射端口变更',
sourceKey: 'network.stun.mapping-port-changed',
subscriptionFields: [],
variables: [],
version: 1,
},
];
}
describe('message template list', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mocks.tableOptions = undefined;
mocks.accessCodes = new Set([
'QqBot:MessageTemplate:Create',
'QqBot:MessageTemplate:Delete',
'QqBot:MessageTemplate:List',
'QqBot:MessageTemplate:Preview',
'QqBot:MessageTemplate:Toggle',
'QqBot:MessageTemplate:Update',
]);
mocks.api.getList.mockResolvedValue({
items: [createRow()],
total: 1,
});
mocks.api.getSources.mockResolvedValue(createSources());
mocks.api.toggle.mockResolvedValue({});
mocks.api.delete.mockResolvedValue(true);
});
afterEach(() => {
vi.useRealTimers();
});
it('renders one stable Page root and requires native KtTable registration', async () => {
const wrapper = mount(MessageTemplateList);
await flushPromises();
expect(wrapper.get('[data-testid="page-root"]').attributes()).toMatchObject(
{
'data-auto-content-height': 'true',
},
);
expect(wrapper.findAll('[data-testid="page-root"]')).toHaveLength(1);
expect(wrapper.find('[data-testid="template-table"]').exists()).toBe(true);
expect(mocks.tableOptions.immediate).toBe(false);
expect(mocks.tableOptions.rowKey).toBe('id');
expect(mocks.registerTable).toHaveBeenCalledOnce();
expect(mocks.api.getList).toHaveBeenCalledOnce();
});
it('pins exact filters, columns, and the unchanged strict page result', async () => {
mount(MessageTemplateList);
await flushPromises();
const result = { items: [createRow()], total: 1 };
mocks.api.getList.mockResolvedValueOnce(result);
await expect(
mocks.tableOptions.api.list({
enabled: true,
name: 'template',
pageNo: 1,
pageSize: 10,
sourceKey: 'network.stun.mapping-port-changed',
}),
).resolves.toBe(result);
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',
'contentSummary',
'referenceCount',
'enabled',
'updateTime',
],
);
});
it('makes zero requests and registration side paths without List permission', async () => {
mocks.accessCodes = new Set([
'QqBot:MessageTemplate:Create',
'QqBot:MessageTemplate:Preview',
]);
const wrapper = mount(MessageTemplateList);
await flushPromises();
expect(mocks.registerTable).not.toHaveBeenCalled();
expect(mocks.tableApi.reload).not.toHaveBeenCalled();
expect(mocks.api.getList).not.toHaveBeenCalled();
expect(mocks.api.getSources).not.toHaveBeenCalled();
expect(mocks.api.detail).not.toHaveBeenCalled();
expect(mocks.api.preview).not.toHaveBeenCalled();
expect(wrapper.find('[data-testid="template-table"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="modal-saved"]').exists()).toBe(false);
});
it('loads list/source once and stays request/timer-free for 60 seconds', async () => {
mount(MessageTemplateList);
await flushPromises();
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
expect(mocks.api.getList).toHaveBeenCalledOnce();
expect(mocks.api.getSources).toHaveBeenCalledOnce();
expect(mocks.api.detail).not.toHaveBeenCalled();
expect(mocks.api.preview).not.toHaveBeenCalled();
vi.advanceTimersByTime(60_000);
await flushPromises();
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
expect(mocks.api.getList).toHaveBeenCalledOnce();
expect(mocks.api.getSources).toHaveBeenCalledOnce();
expect(mocks.api.detail).not.toHaveBeenCalled();
expect(mocks.api.preview).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
it('assigns all exact permissions and passes Preview independently to modal', async () => {
const wrapper = mount(MessageTemplateList);
await flushPromises();
expect(mocks.tableOptions.buttons[0].permissionCodes).toEqual([
'QqBot:MessageTemplate:Create',
]);
expect(
Object.fromEntries(
mocks.tableOptions.rowActions.map((action: any) => [
action.key,
action.permissionCodes,
]),
),
).toEqual({
delete: ['QqBot:MessageTemplate:Delete'],
edit: ['QqBot:MessageTemplate:Update'],
toggle: ['QqBot:MessageTemplate:Toggle'],
});
expect(
wrapper.get('[data-testid="modal-saved"]').attributes('data-can-preview'),
).toBe('true');
});
it('disables referenced delete with a count reason and keeps confirmation otherwise', () => {
mount(MessageTemplateList);
const remove = mocks.tableOptions.rowActions.find(
(action: any) => action.key === 'delete',
);
const referenced = createRow({ referenceCount: 3 });
const unreferenced = createRow({ referenceCount: 0 });
expect(remove.disabled(referenced)).toBe(true);
expect(remove.disabledReason(referenced)).toContain('3');
expect(remove.disabled(unreferenced)).toBe(false);
expect(remove.disabledReason(unreferenced)).toBeUndefined();
expect(remove.confirm(unreferenced)).toContain('template');
});
it('reloads successful row mutations once and rejected mutations zero times', async () => {
mount(MessageTemplateList);
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);
context.reload.mockClear();
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 modal, reloads once after saved, and refreshes list metadata-free', async () => {
const wrapper = mount(MessageTemplateList);
await flushPromises();
mocks.tableApi.reload.mockClear();
mocks.api.getList.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');
await flushPromises();
expect(mocks.modalOpenCreate).toHaveBeenCalledOnce();
expect(mocks.modalOpenEdit).toHaveBeenCalledWith(row);
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
expect(mocks.api.getList).toHaveBeenCalledOnce();
expect(mocks.api.getSources).toHaveBeenCalledOnce();
expect(mocks.api.detail).not.toHaveBeenCalled();
expect(mocks.api.preview).not.toHaveBeenCalled();
mocks.tableApi.reload.mockClear();
mocks.api.getList.mockClear();
await wrapper.get('[data-testid="explicit-refresh"]').trigger('click');
await flushPromises();
expect(mocks.tableApi.reload).toHaveBeenCalledOnce();
expect(mocks.api.getList).toHaveBeenCalledOnce();
expect(mocks.api.getSources).toHaveBeenCalledOnce();
});
it('renders CQ-looking summaries as escaped plain text', async () => {
const wrapper = mount(MessageTemplateList);
await flushPromises();
expect(wrapper.text()).toContain('[CQ:at,qq=12345] <plain>');
expect(wrapper.html()).toContain('&lt;plain&gt;');
expect(wrapper.html()).not.toContain('innerHTML');
});
});

View File

@ -0,0 +1,282 @@
import type { TableColumnType } from 'antdv-next';
import type { MessageTemplateModalExposed } from './components/MessageTemplateModal';
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/dist/tag/index';
import {
deleteMessageTemplate,
getMessagePushSources,
getMessageTemplateList,
setMessageTemplateEnabled,
} from '#/api/qqbot/message-push';
import { KtTable, useKtTable } from '#/components/ktTable';
import MessageTemplateModal from './components/MessageTemplateModal';
const AKtTable = KtTable as any;
export default defineComponent({
name: 'QqBotMessageTemplateList',
/** Owns the permission-gated template list, source labels, and row mutations. */
setup() {
const { hasAccessByCodes } = useAccess();
const canList = hasAccessByCodes(['QqBot:MessageTemplate:List']);
const canPreview = hasAccessByCodes(['QqBot:MessageTemplate:Preview']);
const modalRef = ref<MessageTemplateModalExposed>();
const sources = ref<QqbotMessagePushApi.SystemMessageSourceDefinition[]>(
[],
);
const columns: Array<
TableColumnType<QqbotMessagePushApi.MessageTemplateView>
> = [
{ dataIndex: 'name', key: 'name', title: '模板名称', width: 180 },
{ key: 'source', title: '消息源', width: 260 },
{ key: 'contentSummary', title: '内容摘要', width: 320 },
{
dataIndex: 'referenceCount',
key: 'referenceCount',
title: '引用数量',
width: 100,
},
{ dataIndex: 'enabled', key: 'enabled', title: '状态', width: 90 },
{
dataIndex: 'updateTime',
key: 'updateTime',
title: '更新时间',
width: 180,
},
];
const api: KtTableApi<QqbotMessagePushApi.MessageTemplateView> = {
/** Passes the caller's strict `{ items, total }` page through unchanged. */
list: async (params) => await getMessageTemplateList(params),
};
const buttons: Array<
KtTableButton<QqbotMessagePushApi.MessageTemplateView>
> = [
{
icon: <Plus class="kt-table__button-icon" />,
key: 'create',
label: '新建模板',
onClick: openCreate,
permissionCodes: ['QqBot:MessageTemplate:Create'],
type: 'primary',
},
];
const rowActions: Array<
KtTableRowAction<QqbotMessagePushApi.MessageTemplateView>
> = [
{
key: 'edit',
label: '编辑',
onClick: openEdit,
permissionCodes: ['QqBot:MessageTemplate:Update'],
},
{
key: 'toggle',
label: '启停',
onClick: handleToggle,
permissionCodes: ['QqBot:MessageTemplate:Toggle'],
},
{
confirm: getDeleteConfirm,
danger: true,
disabled: (row) => row.referenceCount > 0,
disabledReason: getDeleteDisabledReason,
key: 'delete',
label: '删除',
onClick: handleDelete,
permissionCodes: ['QqBot:MessageTemplate:Delete'],
},
];
const [registerTable, tableApi] =
useKtTable<QqbotMessagePushApi.MessageTemplateView>({
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 template 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 - Template selected from KtTable.
*/
function openEdit(row: QqbotMessagePushApi.MessageTemplateView) {
modalRef.value?.openEdit(row);
}
/**
* Builds KtTable confirmation for one currently unreferenced template.
* @param row - Template awaiting delete confirmation.
* @returns Confirmation text containing the template name.
*/
function getDeleteConfirm(row: QqbotMessagePushApi.MessageTemplateView) {
return `确认删除消息模板「${row.name}」吗?`;
}
/**
* Explains why a referenced template cannot be deleted from the current row.
* @param row - Template whose current reference count controls the action.
* @returns Count-bearing reason, or undefined when delete is enabled.
*/
function getDeleteDisabledReason(
row: QqbotMessagePushApi.MessageTemplateView,
) {
return row.referenceCount > 0
? `已有 ${row.referenceCount} 个发布绑定引用,无法删除`
: undefined;
}
/**
* Toggles one row and reloads only its KtTable context after success.
* @param row - Template whose enabled state is inverted.
* @param context - Row-action context owning the affected list.
*/
async function handleToggle(
row: QqbotMessagePushApi.MessageTemplateView,
context: KtTableContext<QqbotMessagePushApi.MessageTemplateView>,
) {
await setMessageTemplateEnabled(row.id, !row.enabled);
await context.reload();
}
/**
* Deletes one row and reloads only its KtTable context after success.
* @param row - Unreferenced template confirmed through KtTable.
* @param context - Row-action context owning the affected list.
*/
async function handleDelete(
row: QqbotMessagePushApi.MessageTemplateView,
context: KtTableContext<QqbotMessagePushApi.MessageTemplateView>,
) {
await deleteMessageTemplate(row.id);
await context.reload();
}
/** Reloads the mutable list exactly once after one successful modal save. */
async function handleModalSaved() {
await tableApi.reload();
}
/** Loads the page-lifetime source directory once for an authorized mount. */
async function loadSources() {
sources.value = await getMessagePushSources();
}
/** Starts the only automatic list/source load for this route mount. */
async function activatePage() {
if (!canList) return;
await Promise.all([tableApi.reload(), loadSources()]);
}
/**
* Renders source, literal content summary, and enabled presentation.
* @param slot - KtTable body-cell payload.
* @param slot.column - Column requesting a custom presentation.
* @param slot.record - Template row rendered by the table.
* @returns Escaped text/status content or undefined for native rendering.
*/
function renderBodyCell(slot: {
column: TableColumnType<QqbotMessagePushApi.MessageTemplateView>;
record: QqbotMessagePushApi.MessageTemplateView;
}) {
const { column, record } = slot;
if (column.key === 'source') {
return `${record.sourceName} · ${record.sourceKey}`;
}
if (column.key === 'contentSummary') {
return (
<span class="line-clamp-2 whitespace-pre-wrap break-words">
{record.content}
</span>
);
}
if (column.key === 'enabled') {
return (
<Tag color={record.enabled ? 'success' : 'default'}>
{record.enabled ? '启用' : '停用'}
</Tag>
);
}
return undefined;
}
onMounted(activatePage);
return () => (
<Page autoContentHeight>
{canList ? (
<>
<AKtTable
onRegister={registerTable}
v-slots={{ bodyCell: renderBodyCell }}
/>
<MessageTemplateModal
canPreview={canPreview}
onSaved={handleModalSaved}
ref={modalRef}
sources={sources.value}
/>
</>
) : null}
</Page>
);
},
});