feat: 新增消息模板页面
This commit is contained in:
parent
c6bfd5851d
commit
352e10ee39
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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('')),
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -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('<plain>');
|
||||
expect(wrapper.html()).not.toContain('innerHTML');
|
||||
});
|
||||
});
|
||||
282
apps/web-antdv-next/src/views/qqbot/message-template/list.tsx
Normal file
282
apps/web-antdv-next/src/views/qqbot/message-template/list.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user