fix: 隔离消息推送编辑会话
This commit is contained in:
parent
b42a393a41
commit
1d07233114
@ -46,7 +46,6 @@ const mocks = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
/** Creates a fluent no-op validation rule for schema-only unit tests. */
|
||||
function createRule(): any {
|
||||
const rule: any = {};
|
||||
for (const method of ['max', 'min', 'optional', 'or', 'trim']) {
|
||||
@ -60,7 +59,6 @@ vi.mock('#/adapter/form', () => ({
|
||||
mocks.formOptions = options;
|
||||
const Form = defineComponent({
|
||||
name: 'MockForm',
|
||||
/** Renders a stable form marker without owning form state. */
|
||||
setup() {
|
||||
return () => h('form', { 'data-testid': 'subscription-form' });
|
||||
},
|
||||
@ -78,7 +76,6 @@ vi.mock('@vben/common-ui', () => ({
|
||||
mocks.modalOptions = options;
|
||||
const Modal = defineComponent({
|
||||
name: 'MockModal',
|
||||
/** Renders modal content while the mock API controls lifecycle calls. */
|
||||
setup(_, { slots }) {
|
||||
return () => h('section', slots.default?.());
|
||||
},
|
||||
@ -92,7 +89,6 @@ vi.mock('#/api/qqbot/message-push', () => ({
|
||||
updateMessageSubscription: mocks.update,
|
||||
}));
|
||||
|
||||
/** Creates the immutable source catalog fixture consumed by the modal. */
|
||||
function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] {
|
||||
return [
|
||||
{
|
||||
@ -106,7 +102,6 @@ function createSources(): QqbotMessagePushApi.SystemMessageSourceDefinition[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** Creates string-ID STUN choices including disabled API-owned reasons. */
|
||||
function createStunOptions(): QqbotMessagePushApi.StunMappingPortChangedOptionsResponse {
|
||||
return {
|
||||
ddnsRecords: [
|
||||
@ -158,7 +153,6 @@ function createStunOptions(): QqbotMessagePushApi.StunMappingPortChangedOptionsR
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates one editable row with only the global subscription contract. */
|
||||
function createRow(): QqbotMessagePushApi.MessageSubscriptionView {
|
||||
return {
|
||||
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() {
|
||||
return mount(MessageSubscriptionModal, {
|
||||
props: {
|
||||
@ -265,6 +266,151 @@ describe('message subscription modal', () => {
|
||||
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 () => {
|
||||
const wrapper = mountModal();
|
||||
(wrapper.vm as any).openEdit(createRow());
|
||||
|
||||
@ -48,21 +48,14 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
emits: ['saved'],
|
||||
/**
|
||||
* Owns one source-only create/edit form without fetching page metadata or lists.
|
||||
*/
|
||||
setup(props, { emit, expose }) {
|
||||
const editingRow = ref<QqbotMessagePushApi.MessageSubscriptionView>();
|
||||
const selectedPortForwardId = ref<string>();
|
||||
let sessionRevision = 0;
|
||||
const [SubscriptionForm, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
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) {
|
||||
if (!fieldsChanged.includes('portForwardId')) return;
|
||||
selectedPortForwardId.value =
|
||||
@ -95,10 +88,6 @@ export default defineComponent({
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
class: 'w-[680px]',
|
||||
fullscreenButton: false,
|
||||
/**
|
||||
* Persists the current session while containing failures at ModalApi's
|
||||
* non-awaited callback boundary.
|
||||
*/
|
||||
async onConfirm() {
|
||||
try {
|
||||
await submit();
|
||||
@ -106,10 +95,6 @@ export default defineComponent({
|
||||
// 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) {
|
||||
if (!isOpen) return;
|
||||
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() {
|
||||
sessionRevision += 1;
|
||||
editingRow.value = undefined;
|
||||
modalApi
|
||||
.setData({
|
||||
@ -135,11 +120,8 @@ export default defineComponent({
|
||||
.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) {
|
||||
sessionRevision += 1;
|
||||
editingRow.value = row;
|
||||
modalApi
|
||||
.setData({
|
||||
@ -155,21 +137,20 @@ export default defineComponent({
|
||||
.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) {
|
||||
await formApi.resetForm();
|
||||
await formApi.setValues(values);
|
||||
await formApi.resetValidate();
|
||||
}
|
||||
|
||||
/** Persists the exact source-only payload and closes only after success. */
|
||||
async function submit() {
|
||||
const revision = sessionRevision;
|
||||
const editingId = editingRow.value?.id;
|
||||
const { valid } = await formApi.validate();
|
||||
if (revision !== sessionRevision) return;
|
||||
if (!valid) return;
|
||||
const values = await formApi.getValues<MessageSubscriptionFormValues>();
|
||||
if (revision !== sessionRevision) return;
|
||||
const payload: QqbotMessagePushApi.MessageSubscriptionInput = {
|
||||
enabled: !!values.enabled,
|
||||
name: values.name.trim(),
|
||||
@ -180,12 +161,14 @@ export default defineComponent({
|
||||
},
|
||||
sourceKey: values.sourceKey,
|
||||
};
|
||||
if (revision !== sessionRevision) return;
|
||||
|
||||
modalApi.lock();
|
||||
try {
|
||||
await (editingRow.value
|
||||
? updateMessageSubscription(editingRow.value.id, payload)
|
||||
await (editingId
|
||||
? updateMessageSubscription(editingId, payload)
|
||||
: createMessageSubscription(payload));
|
||||
if (revision !== sessionRevision) return;
|
||||
await modalApi.close();
|
||||
emit('saved');
|
||||
} 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(
|
||||
props: Readonly<{
|
||||
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(
|
||||
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(
|
||||
option: QqbotMessagePushApi.StunMappingPortChangedOptionsResponse['ddnsRecords'][number],
|
||||
) {
|
||||
|
||||
@ -49,7 +49,6 @@ const mocks = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
/** 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']) {
|
||||
@ -63,7 +62,6 @@ vi.mock('#/adapter/form', () => ({
|
||||
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(
|
||||
@ -98,7 +96,6 @@ vi.mock('@vben/common-ui', () => ({
|
||||
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' }, [
|
||||
@ -118,7 +115,6 @@ vi.mock('#/api/qqbot/message-push', () => ({
|
||||
updateMessageTemplate: mocks.update,
|
||||
}));
|
||||
|
||||
/** Creates one source with a distinguishable variable catalog. */
|
||||
function createSource(
|
||||
sourceKey = 'source-a',
|
||||
variableKey = 'endpoint',
|
||||
@ -141,7 +137,6 @@ function createSource(
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates one unsafe-integer string-ID editable template row. */
|
||||
function createRow(): QqbotMessagePushApi.MessageTemplateView {
|
||||
return {
|
||||
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) {
|
||||
return mount(MessageTemplateModal, {
|
||||
props: {
|
||||
@ -260,6 +262,137 @@ describe('message template modal', () => {
|
||||
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 () => {
|
||||
const deferred = new Map<
|
||||
string,
|
||||
|
||||
@ -53,7 +53,6 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
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<
|
||||
@ -69,15 +68,11 @@ export default defineComponent({
|
||||
>();
|
||||
let sourceRevision = 0;
|
||||
let previewRevision = 0;
|
||||
let sessionRevision = 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;
|
||||
@ -96,17 +91,12 @@ export default defineComponent({
|
||||
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,
|
||||
/**
|
||||
* Persists the exact payload while containing failures at ModalApi's
|
||||
* non-awaited callback boundary.
|
||||
*/
|
||||
async onConfirm() {
|
||||
try {
|
||||
await submit();
|
||||
@ -114,10 +104,6 @@ export default defineComponent({
|
||||
// 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) {
|
||||
if (!isOpen) return;
|
||||
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() {
|
||||
sessionRevision += 1;
|
||||
editingRow.value = undefined;
|
||||
modalApi
|
||||
.setData({
|
||||
@ -144,11 +130,8 @@ export default defineComponent({
|
||||
.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) {
|
||||
sessionRevision += 1;
|
||||
editingRow.value = row;
|
||||
modalApi
|
||||
.setData({
|
||||
@ -163,17 +146,12 @@ export default defineComponent({
|
||||
.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;
|
||||
@ -181,18 +159,12 @@ export default defineComponent({
|
||||
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;
|
||||
@ -206,11 +178,6 @@ export default defineComponent({
|
||||
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) {
|
||||
const revision = ++sourceRevision;
|
||||
clearPreview();
|
||||
@ -241,7 +208,6 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
/** Requests server-authoritative preview only after an explicit valid click. */
|
||||
async function handlePreview() {
|
||||
if (!props.canPreview) return;
|
||||
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() {
|
||||
const revision = sessionRevision;
|
||||
const editingId = editingRow.value?.id;
|
||||
const { valid } = await formApi.validate();
|
||||
if (revision !== sessionRevision) return;
|
||||
if (!valid) return;
|
||||
const values = await formApi.getValues<MessageTemplateFormValues>();
|
||||
if (revision !== sessionRevision) return;
|
||||
const payload: QqbotMessagePushApi.MessageTemplateInput = {
|
||||
content: values.content,
|
||||
enabled: !!values.enabled,
|
||||
@ -277,11 +246,13 @@ export default defineComponent({
|
||||
remark: values.remark?.trim() || '',
|
||||
sourceKey: values.sourceKey,
|
||||
};
|
||||
if (revision !== sessionRevision) return;
|
||||
modalApi.lock();
|
||||
try {
|
||||
await (editingRow.value
|
||||
? updateMessageTemplate(editingRow.value.id, payload)
|
||||
await (editingId
|
||||
? updateMessageTemplate(editingId, payload)
|
||||
: createMessageTemplate(payload));
|
||||
if (revision !== sessionRevision) return;
|
||||
await modalApi.close();
|
||||
emit('saved');
|
||||
} finally {
|
||||
@ -289,7 +260,6 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders the permission-gated explicit preview action. */
|
||||
function renderPreviewAction() {
|
||||
return props.canPreview ? (
|
||||
<AButton loading={previewLoading.value} onClick={handlePreview}>
|
||||
@ -298,7 +268,6 @@ export default defineComponent({
|
||||
) : null;
|
||||
}
|
||||
|
||||
/** Renders server-returned preview text and variables as escaped text nodes. */
|
||||
function renderPreview() {
|
||||
const result = preview.value;
|
||||
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(
|
||||
props: Readonly<{
|
||||
canPreview: boolean;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user