From 352e10ee39c2702c6fcfe9e2d5747ab8553780d7 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 24 Jul 2026 16:40:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MessageTemplateMentions.spec.tsx | 139 ++++++ .../components/MessageTemplateMentions.tsx | 115 +++++ .../components/MessageTemplateModal.spec.tsx | 446 ++++++++++++++++++ .../components/MessageTemplateModal.tsx | 384 +++++++++++++++ .../qqbot/message-template/list.spec.tsx | 402 ++++++++++++++++ .../src/views/qqbot/message-template/list.tsx | 282 +++++++++++ 6 files changed, 1768 insertions(+) create mode 100644 apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateMentions.spec.tsx create mode 100644 apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateMentions.tsx create mode 100644 apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx create mode 100644 apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx create mode 100644 apps/web-antdv-next/src/views/qqbot/message-template/list.spec.tsx create mode 100644 apps/web-antdv-next/src/views/qqbot/message-template/list.tsx diff --git a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateMentions.spec.tsx b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateMentions.spec.tsx new file mode 100644 index 0000000..b7c4dc1 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateMentions.spec.tsx @@ -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 { + 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, + ) => boolean; + + expect(option).toBeDefined(); + if (!option) throw new Error('Expected one variable option'); + const searchableOption = option as unknown as Record; + 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'); + }); +}); diff --git a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateMentions.tsx b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateMentions.tsx new file mode 100644 index 0000000..be69737 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateMentions.tsx @@ -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(() => + 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, + ) { + 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 () => ( + + ); + }, +}); + +/** + * 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; + return ['description', 'example', 'key', 'label'].every( + (field) => typeof candidate[field] === 'string', + ); +} diff --git a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx new file mode 100644 index 0000000..1649e51 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.spec.tsx @@ -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; + resolve: ( + value: QqbotMessagePushApi.SystemMessageSourceDefinition, + ) => void; + } + >(); + mocks.detail.mockImplementation((sourceKey: string) => { + let resolve!: ( + value: QqbotMessagePushApi.SystemMessageSourceDefinition, + ) => void; + const promise = + new Promise( + (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( + (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); + }); +}); diff --git a/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx new file mode 100644 index 0000000..58fecf1 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/message-template/components/MessageTemplateModal.tsx @@ -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(); + const variables = ref< + QqbotMessagePushApi.SystemMessageSourceVariableDefinition[] + >([]); + const detailLoading = ref(false); + const previewLoading = ref(false); + const preview = ref(); + const selectedSourceKey = ref(''); + const detailCache = new Map< + string, + Promise + >(); + 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(); + 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(); + 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(); + 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 ? ( + + 示例预览 + + ) : null; + } + + /** Renders server-returned preview text and variables as escaped text nodes. */ + function renderPreview() { + const result = preview.value; + if (!result) return null; + return ( +
+
服务端预览
+
+            {result.renderedMessage}
+          
+
+ {Object.entries(result.variables).map(([key, value]) => ( +
{`${key}: ${value}`}
+ ))} +
+
+ ); + } + + expose({ openCreate, openEdit } satisfies MessageTemplateModalExposed); + + return () => ( + + + {renderPreview()} + + ); + }, +}); + +/** + * 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('')), + }, + ]; +} diff --git a/apps/web-antdv-next/src/views/qqbot/message-template/list.spec.tsx b/apps/web-antdv-next/src/views/qqbot/message-template/list.spec.tsx new file mode 100644 index 0000000..2f366d1 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/message-template/list.spec.tsx @@ -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(), + 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] ' }), + }), + ]); + }, + }), + 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 { + 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] '); + expect(wrapper.html()).toContain('<plain>'); + expect(wrapper.html()).not.toContain('innerHTML'); + }); +}); diff --git a/apps/web-antdv-next/src/views/qqbot/message-template/list.tsx b/apps/web-antdv-next/src/views/qqbot/message-template/list.tsx new file mode 100644 index 0000000..e3650d3 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/message-template/list.tsx @@ -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(); + const sources = ref( + [], + ); + const columns: Array< + TableColumnType + > = [ + { 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 = { + /** Passes the caller's strict `{ items, total }` page through unchanged. */ + list: async (params) => await getMessageTemplateList(params), + }; + const buttons: Array< + KtTableButton + > = [ + { + icon: , + key: 'create', + label: '新建模板', + onClick: openCreate, + permissionCodes: ['QqBot:MessageTemplate:Create'], + type: 'primary', + }, + ]; + const rowActions: Array< + KtTableRowAction + > = [ + { + 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({ + 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, + ) { + 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, + ) { + 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; + record: QqbotMessagePushApi.MessageTemplateView; + }) { + const { column, record } = slot; + if (column.key === 'source') { + return `${record.sourceName} · ${record.sourceKey}`; + } + if (column.key === 'contentSummary') { + return ( + + {record.content} + + ); + } + if (column.key === 'enabled') { + return ( + + {record.enabled ? '启用' : '停用'} + + ); + } + return undefined; + } + + onMounted(activatePage); + + return () => ( + + {canList ? ( + <> + + + + ) : null} + + ); + }, +});