fix: 隔离消息推送编辑会话

This commit is contained in:
sunlei 2026-07-25 09:17:01 +08:00
parent b42a393a41
commit 1d07233114
4 changed files with 314 additions and 107 deletions

View File

@ -46,7 +46,6 @@ const mocks = vi.hoisted(() => {
}; };
}); });
/** Creates a fluent no-op validation rule for schema-only unit tests. */
function createRule(): any { function createRule(): any {
const rule: any = {}; const rule: any = {};
for (const method of ['max', 'min', 'optional', 'or', 'trim']) { for (const method of ['max', 'min', 'optional', 'or', 'trim']) {
@ -60,7 +59,6 @@ vi.mock('#/adapter/form', () => ({
mocks.formOptions = options; mocks.formOptions = options;
const Form = defineComponent({ const Form = defineComponent({
name: 'MockForm', name: 'MockForm',
/** Renders a stable form marker without owning form state. */
setup() { setup() {
return () => h('form', { 'data-testid': 'subscription-form' }); return () => h('form', { 'data-testid': 'subscription-form' });
}, },
@ -78,7 +76,6 @@ vi.mock('@vben/common-ui', () => ({
mocks.modalOptions = options; mocks.modalOptions = options;
const Modal = defineComponent({ const Modal = defineComponent({
name: 'MockModal', name: 'MockModal',
/** Renders modal content while the mock API controls lifecycle calls. */
setup(_, { slots }) { setup(_, { slots }) {
return () => h('section', slots.default?.()); return () => h('section', slots.default?.());
}, },
@ -92,7 +89,6 @@ vi.mock('#/api/qqbot/message-push', () => ({
updateMessageSubscription: mocks.update, updateMessageSubscription: mocks.update,
})); }));
/** Creates the immutable source catalog fixture consumed by the modal. */
function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] { function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] {
return [ return [
{ {
@ -106,7 +102,6 @@ function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] {
]; ];
} }
/** Creates string-ID STUN choices including disabled API-owned reasons. */
function createStunOptions(): QqbotMessagePushApi.StunMappingPortChangedOptionsResponse { function createStunOptions(): QqbotMessagePushApi.StunMappingPortChangedOptionsResponse {
return { return {
ddnsRecords: [ ddnsRecords: [
@ -158,7 +153,6 @@ function createStunOptions(): QqbotMessagePushApi.StunMappingPortChangedOptionsR
}; };
} }
/** Creates one editable row with only the global subscription contract. */
function createRow(): QqbotMessagePushApi.MessageSubscriptionView { function createRow(): QqbotMessagePushApi.MessageSubscriptionView {
return { return {
createTime: '2026-07-24 10:00:00', createTime: '2026-07-24 10:00:00',
@ -179,7 +173,14 @@ function createRow(): QqbotMessagePushApi.MessageSubscriptionView {
}; };
} }
/** Mounts the modal with page-owned source and STUN metadata. */ function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function mountModal() { function mountModal() {
return mount(MessageSubscriptionModal, { return mount(MessageSubscriptionModal, {
props: { props: {
@ -265,6 +266,151 @@ describe('message subscription modal', () => {
expect(setOrder).toBeLessThan(validateOrder); expect(setOrder).toBeLessThan(validateOrder);
}); });
it('discards an old create confirmation before a new edit session submits its own row', async () => {
const validation = createDeferred<{ valid: boolean }>();
mocks.formApi.validate.mockReturnValueOnce(validation.promise);
const wrapper = mountModal();
(wrapper.vm as any).openCreate();
await flushPromises();
const staleConfirmation = mocks.modalOptions.onConfirm();
await flushPromises();
const editRow = {
...createRow(),
enabled: false,
id: '10000000000000002',
name: '新编辑订阅',
remark: '新编辑备注',
};
(wrapper.vm as any).openEdit(editRow);
await flushPromises();
const editValues = {
ddnsRecordId: '2041700000000000002',
enabled: false,
name: ' 新编辑订阅 ',
portForwardId: '2041700000000000001',
remark: ' 新编辑备注 ',
sourceKey: 'network.stun.mapping-port-changed',
};
mocks.formApi.getValues.mockResolvedValue(editValues);
const closeCountBeforeResume = mocks.modalApi.close.mock.calls.length;
validation.resolve({ valid: true });
await staleConfirmation;
await flushPromises();
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.modalApi.lock).not.toHaveBeenCalled();
expect(mocks.modalApi.unlock).not.toHaveBeenCalled();
expect(mocks.modalApi.close).toHaveBeenCalledTimes(closeCountBeforeResume);
expect(wrapper.emitted('saved')).toBeUndefined();
expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({
ddnsRecordId: '2041700000000000002',
enabled: false,
name: '新编辑订阅',
portForwardId: '2041700000000000001',
remark: '新编辑备注',
sourceKey: 'network.stun.mapping-port-changed',
});
await mocks.modalOptions.onConfirm();
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.update).toHaveBeenCalledOnce();
expect(mocks.update).toHaveBeenCalledWith('10000000000000002', {
enabled: false,
name: '新编辑订阅',
remark: '新编辑备注',
sourceConfig: {
ddnsRecordId: '2041700000000000002',
portForwardId: '2041700000000000001',
},
sourceKey: 'network.stun.mapping-port-changed',
});
expect(mocks.modalApi.close).toHaveBeenCalledTimes(
closeCountBeforeResume + 1,
);
expect(wrapper.emitted('saved')).toHaveLength(1);
});
it('discards an old edit confirmation before a new create session submits its own payload', async () => {
const values = createDeferred<{
ddnsRecordId: string;
enabled: boolean;
name: string;
portForwardId: string;
remark: string;
sourceKey: string;
}>();
mocks.formApi.getValues.mockReturnValueOnce(values.promise);
const wrapper = mountModal();
(wrapper.vm as any).openEdit(createRow());
await flushPromises();
const staleConfirmation = mocks.modalOptions.onConfirm();
await flushPromises();
expect(mocks.formApi.getValues).toHaveBeenCalledOnce();
(wrapper.vm as any).openCreate();
await flushPromises();
const createValues = {
ddnsRecordId: '2041700000000000002',
enabled: true,
name: ' 新创建订阅 ',
portForwardId: '2041700000000000001',
remark: ' 新创建备注 ',
sourceKey: 'network.stun.mapping-port-changed',
};
mocks.formApi.getValues.mockResolvedValue(createValues);
const closeCountBeforeResume = mocks.modalApi.close.mock.calls.length;
values.resolve({
ddnsRecordId: '2041700000000002',
enabled: false,
name: '旧编辑订阅',
portForwardId: '2041700000000001',
remark: '旧编辑备注',
sourceKey: 'network.stun.mapping-port-changed',
});
await staleConfirmation;
await flushPromises();
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.modalApi.lock).not.toHaveBeenCalled();
expect(mocks.modalApi.unlock).not.toHaveBeenCalled();
expect(mocks.modalApi.close).toHaveBeenCalledTimes(closeCountBeforeResume);
expect(wrapper.emitted('saved')).toBeUndefined();
expect(mocks.formApi.setValues).toHaveBeenLastCalledWith({
ddnsRecordId: undefined,
enabled: true,
name: '',
portForwardId: undefined,
remark: '',
sourceKey: 'network.stun.mapping-port-changed',
});
await mocks.modalOptions.onConfirm();
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.create).toHaveBeenCalledOnce();
expect(mocks.create).toHaveBeenCalledWith({
enabled: true,
name: '新创建订阅',
remark: '新创建备注',
sourceConfig: {
ddnsRecordId: '2041700000000000002',
portForwardId: '2041700000000000001',
},
sourceKey: 'network.stun.mapping-port-changed',
});
expect(mocks.modalApi.close).toHaveBeenCalledTimes(
closeCountBeforeResume + 1,
);
expect(wrapper.emitted('saved')).toHaveLength(1);
});
it('preserves string IDs and exposes disabled reasons on matching options', async () => { it('preserves string IDs and exposes disabled reasons on matching options', async () => {
const wrapper = mountModal(); const wrapper = mountModal();
(wrapper.vm as any).openEdit(createRow()); (wrapper.vm as any).openEdit(createRow());

View File

@ -48,21 +48,14 @@ export default defineComponent({
}, },
}, },
emits: ['saved'], emits: ['saved'],
/**
* Owns one source-only create/edit form without fetching page metadata or lists.
*/
setup(props, { emit, expose }) { setup(props, { emit, expose }) {
const editingRow = ref<QqbotMessagePushApi.MessageSubscriptionView>(); const editingRow = ref<QqbotMessagePushApi.MessageSubscriptionView>();
const selectedPortForwardId = ref<string>(); const selectedPortForwardId = ref<string>();
let sessionRevision = 0;
const [SubscriptionForm, formApi] = useVbenForm({ const [SubscriptionForm, formApi] = useVbenForm({
commonConfig: { commonConfig: {
labelClass: 'w-24', labelClass: 'w-24',
}, },
/**
* Keeps the DDNS choice compatible with the selected port-forward option.
* @param values - Current form values after the change.
* @param fieldsChanged - Field names changed by this form event.
*/
async handleValuesChange(values, fieldsChanged) { async handleValuesChange(values, fieldsChanged) {
if (!fieldsChanged.includes('portForwardId')) return; if (!fieldsChanged.includes('portForwardId')) return;
selectedPortForwardId.value = selectedPortForwardId.value =
@ -95,10 +88,6 @@ export default defineComponent({
const [Modal, modalApi] = useVbenModal({ const [Modal, modalApi] = useVbenModal({
class: 'w-[680px]', class: 'w-[680px]',
fullscreenButton: false, fullscreenButton: false,
/**
* Persists the current session while containing failures at ModalApi's
* non-awaited callback boundary.
*/
async onConfirm() { async onConfirm() {
try { try {
await submit(); await submit();
@ -106,10 +95,6 @@ export default defineComponent({
// The request/form layer already presents the persistence error. // The request/form layer already presents the persistence error.
} }
}, },
/**
* Restores session values after destroy-on-close modal content has mounted.
* @param isOpen - Whether the modal content is visible.
*/
async onOpenChange(isOpen: boolean) { async onOpenChange(isOpen: boolean) {
if (!isOpen) return; if (!isOpen) return;
const { values } = modalApi.getData<MessageSubscriptionModalData>(); const { values } = modalApi.getData<MessageSubscriptionModalData>();
@ -118,8 +103,8 @@ export default defineComponent({
}, },
}); });
/** Opens a fresh subscription form with no values retained from an edit. */
function openCreate() { function openCreate() {
sessionRevision += 1;
editingRow.value = undefined; editingRow.value = undefined;
modalApi modalApi
.setData({ .setData({
@ -135,11 +120,8 @@ export default defineComponent({
.open(); .open();
} }
/**
* Opens an edit session with only the six user-editable values.
* @param row - Subscription row selected from the page-owned KtTable.
*/
function openEdit(row: QqbotMessagePushApi.MessageSubscriptionView) { function openEdit(row: QqbotMessagePushApi.MessageSubscriptionView) {
sessionRevision += 1;
editingRow.value = row; editingRow.value = row;
modalApi modalApi
.setData({ .setData({
@ -155,21 +137,20 @@ export default defineComponent({
.open(); .open();
} }
/**
* Resets the mounted form before installing one isolated modal session.
* @param values - Exact editable values for the current create/edit session.
*/
async function resetForm(values: MessageSubscriptionFormValues) { async function resetForm(values: MessageSubscriptionFormValues) {
await formApi.resetForm(); await formApi.resetForm();
await formApi.setValues(values); await formApi.setValues(values);
await formApi.resetValidate(); await formApi.resetValidate();
} }
/** Persists the exact source-only payload and closes only after success. */
async function submit() { async function submit() {
const revision = sessionRevision;
const editingId = editingRow.value?.id;
const { valid } = await formApi.validate(); const { valid } = await formApi.validate();
if (revision !== sessionRevision) return;
if (!valid) return; if (!valid) return;
const values = await formApi.getValues<MessageSubscriptionFormValues>(); const values = await formApi.getValues<MessageSubscriptionFormValues>();
if (revision !== sessionRevision) return;
const payload: QqbotMessagePushApi.MessageSubscriptionInput = { const payload: QqbotMessagePushApi.MessageSubscriptionInput = {
enabled: !!values.enabled, enabled: !!values.enabled,
name: values.name.trim(), name: values.name.trim(),
@ -180,12 +161,14 @@ export default defineComponent({
}, },
sourceKey: values.sourceKey, sourceKey: values.sourceKey,
}; };
if (revision !== sessionRevision) return;
modalApi.lock(); modalApi.lock();
try { try {
await (editingRow.value await (editingId
? updateMessageSubscription(editingRow.value.id, payload) ? updateMessageSubscription(editingId, payload)
: createMessageSubscription(payload)); : createMessageSubscription(payload));
if (revision !== sessionRevision) return;
await modalApi.close(); await modalApi.close();
emit('saved'); emit('saved');
} finally { } finally {
@ -203,12 +186,6 @@ export default defineComponent({
}, },
}); });
/**
* Builds the locked six-field schema from page-owned metadata.
* @param props - Source catalog and STUN choices loaded once by the page.
* @param selectedPortForwardId - Current port used to filter matching DDNS rows.
* @returns Vben form fields in the approved source-only order.
*/
function createFormSchema( function createFormSchema(
props: Readonly<{ props: Readonly<{
sources: QqbotMessagePushApi.SystemMessageSourceDefinition[]; sources: QqbotMessagePushApi.SystemMessageSourceDefinition[];
@ -280,11 +257,6 @@ function createFormSchema(
]; ];
} }
/**
* Formats one server-evaluated port choice without coercing its string ID.
* @param option - Port-forward option returned by the source options API.
* @returns Select option preserving eligibility and stable reason code.
*/
function formatPortForwardOption( function formatPortForwardOption(
option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['portForwards'][number], option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['portForwards'][number],
) { ) {
@ -298,11 +270,6 @@ function formatPortForwardOption(
}; };
} }
/**
* Formats one server-evaluated DDNS choice without coercing its string ID.
* @param option - DDNS option already filtered to the selected port.
* @returns Select option preserving eligibility and stable reason code.
*/
function formatDdnsOption( function formatDdnsOption(
option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['ddnsRecords'][number], option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['ddnsRecords'][number],
) { ) {

View File

@ -49,7 +49,6 @@ const mocks = vi.hoisted(() => {
}; };
}); });
/** Creates a fluent no-op validation rule for schema behavior tests. */
function createRule(): any { function createRule(): any {
const rule: any = {}; const rule: any = {};
for (const method of ['max', 'min', 'optional', 'or', 'trim']) { for (const method of ['max', 'min', 'optional', 'or', 'trim']) {
@ -63,7 +62,6 @@ vi.mock('#/adapter/form', () => ({
mocks.formOptions = options; mocks.formOptions = options;
const Form = defineComponent({ const Form = defineComponent({
name: 'MockTemplateForm', name: 'MockTemplateForm',
/** Renders the production schema's local content component with live props. */
setup() { setup() {
return () => { return () => {
const content = mocks.formOptions.schema.find( const content = mocks.formOptions.schema.find(
@ -98,7 +96,6 @@ vi.mock('@vben/common-ui', () => ({
mocks.modalOptions = options; mocks.modalOptions = options;
const Modal = defineComponent({ const Modal = defineComponent({
name: 'MockTemplateModal', name: 'MockTemplateModal',
/** Renders the actual form plus the explicit prepend-footer action slot. */
setup(_, { slots }) { setup(_, { slots }) {
return () => return () =>
h('section', { 'data-testid': 'template-modal' }, [ h('section', { 'data-testid': 'template-modal' }, [
@ -118,7 +115,6 @@ vi.mock('#/api/qqbot/message-push', () => ({
updateMessageTemplate: mocks.update, updateMessageTemplate: mocks.update,
})); }));
/** Creates one source with a distinguishable variable catalog. */
function createSource( function createSource(
sourceKey = 'source-a', sourceKey = 'source-a',
variableKey = 'endpoint', variableKey = 'endpoint',
@ -141,7 +137,6 @@ function createSource(
}; };
} }
/** Creates one unsafe-integer string-ID editable template row. */
function createRow(): QqbotMessagePushApi.MessageTemplateView { function createRow(): QqbotMessagePushApi.MessageTemplateView {
return { return {
content: 'old [CQ:at,qq=12345]', content: 'old [CQ:at,qq=12345]',
@ -157,7 +152,14 @@ function createRow(): QqbotMessagePushApi.MessageTemplateView {
}; };
} }
/** Mounts the modal with page-owned source labels. */ function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function mountModal(canPreview = true) { function mountModal(canPreview = true) {
return mount(MessageTemplateModal, { return mount(MessageTemplateModal, {
props: { props: {
@ -260,6 +262,137 @@ describe('message template modal', () => {
expect(setOrder).toBeLessThan(validateOrder); expect(setOrder).toBeLessThan(validateOrder);
}); });
it('discards an old create confirmation before a new edit session submits its own row', async () => {
const validation = createDeferred<{ valid: boolean }>();
mocks.formApi.validate.mockReturnValueOnce(validation.promise);
const wrapper = mountModal();
(wrapper.vm as any).openCreate();
await flushPromises();
Object.assign(mocks.formValues, {
content: 'old create',
enabled: true,
name: 'old create',
remark: '',
sourceKey: 'source-a',
});
const staleConfirmation = mocks.modalOptions.onConfirm();
await flushPromises();
const editRow = {
...createRow(),
content: 'new edit ${{port}}',
id: '10000000000000002',
name: ' new edit template ',
remark: ' new edit note ',
};
(wrapper.vm as any).openEdit(editRow);
await flushPromises();
const closeCountBeforeResume = mocks.modalApi.close.mock.calls.length;
validation.resolve({ valid: true });
await staleConfirmation;
await flushPromises();
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.modalApi.lock).not.toHaveBeenCalled();
expect(mocks.modalApi.unlock).not.toHaveBeenCalled();
expect(mocks.modalApi.close).toHaveBeenCalledTimes(closeCountBeforeResume);
expect(wrapper.emitted('saved')).toBeUndefined();
expect(mocks.formValues).toEqual({
content: 'new edit ${{port}}',
enabled: false,
name: ' new edit template ',
remark: ' new edit note ',
sourceKey: 'source-b',
});
await mocks.modalOptions.onConfirm();
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.update).toHaveBeenCalledOnce();
expect(mocks.update).toHaveBeenCalledWith('10000000000000002', {
content: 'new edit ${{port}}',
enabled: false,
name: 'new edit template',
remark: 'new edit note',
sourceKey: 'source-b',
});
expect(mocks.modalApi.close).toHaveBeenCalledTimes(
closeCountBeforeResume + 1,
);
expect(wrapper.emitted('saved')).toHaveLength(1);
});
it('discards an old edit confirmation before a new create session submits its own payload', async () => {
const values = createDeferred<{
content: string;
enabled: boolean;
name: string;
remark: string;
sourceKey: string;
}>();
mocks.formApi.getValues.mockReturnValueOnce(values.promise);
const wrapper = mountModal();
(wrapper.vm as any).openEdit(createRow());
await flushPromises();
const staleConfirmation = mocks.modalOptions.onConfirm();
await flushPromises();
expect(mocks.formApi.getValues).toHaveBeenCalledOnce();
(wrapper.vm as any).openCreate();
await flushPromises();
Object.assign(mocks.formValues, {
content: 'new create ${{endpoint}}',
enabled: true,
name: ' new create template ',
remark: ' new create note ',
sourceKey: 'source-a',
});
const closeCountBeforeResume = mocks.modalApi.close.mock.calls.length;
values.resolve({
content: 'old edit [CQ:at,qq=12345]',
enabled: false,
name: 'old edit template',
remark: 'old edit note',
sourceKey: 'source-b',
});
await staleConfirmation;
await flushPromises();
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.modalApi.lock).not.toHaveBeenCalled();
expect(mocks.modalApi.unlock).not.toHaveBeenCalled();
expect(mocks.modalApi.close).toHaveBeenCalledTimes(closeCountBeforeResume);
expect(wrapper.emitted('saved')).toBeUndefined();
expect(mocks.formValues).toEqual({
content: 'new create ${{endpoint}}',
enabled: true,
name: ' new create template ',
remark: ' new create note ',
sourceKey: 'source-a',
});
await mocks.modalOptions.onConfirm();
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.create).toHaveBeenCalledOnce();
expect(mocks.create).toHaveBeenCalledWith({
content: 'new create ${{endpoint}}',
enabled: true,
name: 'new create template',
remark: 'new create note',
sourceKey: 'source-a',
});
expect(mocks.modalApi.close).toHaveBeenCalledTimes(
closeCountBeforeResume + 1,
);
expect(wrapper.emitted('saved')).toHaveLength(1);
});
it('loads exact details and prevents a late source from overwriting the latest', async () => { it('loads exact details and prevents a late source from overwriting the latest', async () => {
const deferred = new Map< const deferred = new Map<
string, string,

View File

@ -53,7 +53,6 @@ export default defineComponent({
}, },
}, },
emits: ['saved'], emits: ['saved'],
/** Owns one isolated template edit session, source detail cache, and preview. */
setup(props, { emit, expose }) { setup(props, { emit, expose }) {
const editingRow = ref<QqbotMessagePushApi.MessageTemplateView>(); const editingRow = ref<QqbotMessagePushApi.MessageTemplateView>();
const variables = ref< const variables = ref<
@ -69,15 +68,11 @@ export default defineComponent({
>(); >();
let sourceRevision = 0; let sourceRevision = 0;
let previewRevision = 0; let previewRevision = 0;
let sessionRevision = 0;
const [TemplateForm, formApi] = useVbenForm({ const [TemplateForm, formApi] = useVbenForm({
commonConfig: { commonConfig: {
labelClass: 'w-24', 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) { async handleValuesChange(values, fieldsChanged) {
if (fieldsChanged.includes('content')) clearPreview(); if (fieldsChanged.includes('content')) clearPreview();
if (!fieldsChanged.includes('sourceKey')) return; if (!fieldsChanged.includes('sourceKey')) return;
@ -96,17 +91,12 @@ export default defineComponent({
showDefaultActions: false, showDefaultActions: false,
wrapperClass: 'grid-cols-1', wrapperClass: 'grid-cols-1',
}); });
/** Tracks the title from isolated create/edit identity only. */
const modalTitle = computed(() => const modalTitle = computed(() =>
editingRow.value ? '编辑消息模板' : '新建消息模板', editingRow.value ? '编辑消息模板' : '新建消息模板',
); );
const [Modal, modalApi] = useVbenModal({ const [Modal, modalApi] = useVbenModal({
class: 'w-[760px]', class: 'w-[760px]',
fullscreenButton: false, fullscreenButton: false,
/**
* Persists the exact payload while containing failures at ModalApi's
* non-awaited callback boundary.
*/
async onConfirm() { async onConfirm() {
try { try {
await submit(); await submit();
@ -114,10 +104,6 @@ export default defineComponent({
// The request/form layer already presents the persistence error. // The request/form layer already presents the persistence error.
} }
}, },
/**
* Resets every mounted form/session state before loading authoritative detail.
* @param isOpen - Whether destroy-on-close modal content is mounted.
*/
async onOpenChange(isOpen: boolean) { async onOpenChange(isOpen: boolean) {
if (!isOpen) return; if (!isOpen) return;
const { values } = modalApi.getData<MessageTemplateModalData>(); const { values } = modalApi.getData<MessageTemplateModalData>();
@ -128,8 +114,8 @@ export default defineComponent({
}, },
}); });
/** Opens a fresh create session without values from the preceding edit. */
function openCreate() { function openCreate() {
sessionRevision += 1;
editingRow.value = undefined; editingRow.value = undefined;
modalApi modalApi
.setData({ .setData({
@ -144,11 +130,8 @@ export default defineComponent({
.open(); .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) { function openEdit(row: QqbotMessagePushApi.MessageTemplateView) {
sessionRevision += 1;
editingRow.value = row; editingRow.value = row;
modalApi modalApi
.setData({ .setData({
@ -163,17 +146,12 @@ export default defineComponent({
.open(); .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) { async function resetForm(values: MessageTemplateFormValues) {
await formApi.resetForm(); await formApi.resetForm();
await formApi.setValues(values); await formApi.setValues(values);
await formApi.resetValidate(); await formApi.resetValidate();
} }
/** Invalidates stale source/preview work and clears every transient display. */
function invalidateSession() { function invalidateSession() {
sourceRevision += 1; sourceRevision += 1;
detailLoading.value = false; detailLoading.value = false;
@ -181,18 +159,12 @@ export default defineComponent({
clearPreview(); clearPreview();
} }
/** Clears authoritative preview data and invalidates any in-flight preview. */
function clearPreview() { function clearPreview() {
previewRevision += 1; previewRevision += 1;
preview.value = undefined; preview.value = undefined;
previewLoading.value = false; 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) { function getCachedSourceDetail(sourceKey: string) {
const cached = detailCache.get(sourceKey); const cached = detailCache.get(sourceKey);
if (cached) return cached; if (cached) return cached;
@ -206,11 +178,6 @@ export default defineComponent({
return request; return request;
} }
/**
* Replaces suggestions only when this exact source request remains latest.
* Expected detail failures stay inside the non-awaited form/modal callbacks.
* @param sourceKey - Newly selected source identity.
*/
async function loadSourceDetail(sourceKey: string) { async function loadSourceDetail(sourceKey: string) {
const revision = ++sourceRevision; const revision = ++sourceRevision;
clearPreview(); clearPreview();
@ -241,7 +208,6 @@ export default defineComponent({
} }
} }
/** Requests server-authoritative preview only after an explicit valid click. */
async function handlePreview() { async function handlePreview() {
if (!props.canPreview) return; if (!props.canPreview) return;
const [sourceValidation, contentValidation] = await Promise.all([ const [sourceValidation, contentValidation] = await Promise.all([
@ -265,11 +231,14 @@ export default defineComponent({
} }
} }
/** Persists content byte-for-byte and closes/emits only after success. */
async function submit() { async function submit() {
const revision = sessionRevision;
const editingId = editingRow.value?.id;
const { valid } = await formApi.validate(); const { valid } = await formApi.validate();
if (revision !== sessionRevision) return;
if (!valid) return; if (!valid) return;
const values = await formApi.getValues<MessageTemplateFormValues>(); const values = await formApi.getValues<MessageTemplateFormValues>();
if (revision !== sessionRevision) return;
const payload: QqbotMessagePushApi.MessageTemplateInput = { const payload: QqbotMessagePushApi.MessageTemplateInput = {
content: values.content, content: values.content,
enabled: !!values.enabled, enabled: !!values.enabled,
@ -277,11 +246,13 @@ export default defineComponent({
remark: values.remark?.trim() || '', remark: values.remark?.trim() || '',
sourceKey: values.sourceKey, sourceKey: values.sourceKey,
}; };
if (revision !== sessionRevision) return;
modalApi.lock(); modalApi.lock();
try { try {
await (editingRow.value await (editingId
? updateMessageTemplate(editingRow.value.id, payload) ? updateMessageTemplate(editingId, payload)
: createMessageTemplate(payload)); : createMessageTemplate(payload));
if (revision !== sessionRevision) return;
await modalApi.close(); await modalApi.close();
emit('saved'); emit('saved');
} finally { } finally {
@ -289,7 +260,6 @@ export default defineComponent({
} }
} }
/** Renders the permission-gated explicit preview action. */
function renderPreviewAction() { function renderPreviewAction() {
return props.canPreview ? ( return props.canPreview ? (
<AButton loading={previewLoading.value} onClick={handlePreview}> <AButton loading={previewLoading.value} onClick={handlePreview}>
@ -298,7 +268,6 @@ export default defineComponent({
) : null; ) : null;
} }
/** Renders server-returned preview text and variables as escaped text nodes. */
function renderPreview() { function renderPreview() {
const result = preview.value; const result = preview.value;
if (!result) return null; if (!result) return null;
@ -331,14 +300,6 @@ export default defineComponent({
}, },
}); });
/**
* 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( function createFormSchema(
props: Readonly<{ props: Readonly<{
canPreview: boolean; canPreview: boolean;