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