kt-template-admin/apps/web-antdv-next/src/views/qqbot/account/components/AccountMessagePushModal.tsx

426 lines
14 KiB
TypeScript

import type { PropType } from 'vue';
import type { VbenFormSchema } from '#/adapter/form';
import type { QqbotMessagePushApi } from '#/api/qqbot/message-push';
import { computed, defineComponent, markRaw, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm, z } from '#/adapter/form';
import {
createAccountMessagePushBinding,
updateAccountMessagePushBinding,
} from '#/api/qqbot/message-push';
import MessagePushTargetPicker, {
isValidMessagePushTargetId,
} from './MessagePushTargetPicker';
export interface AccountMessagePushModalExposed {
openCreate: () => void;
openEdit: (row: QqbotMessagePushApi.QqbotMessagePublishBindingView) => void;
}
interface AccountMessagePushFormValues {
enabled: boolean;
subscriptionId: string;
targets: QqbotMessagePushApi.QqbotMessagePublishTargetInput[];
templateId: string;
}
interface AccountMessagePushModalData {
selfId: string;
sessionRevision: number;
values: AccountMessagePushFormValues;
}
const DECIMAL_ID_PATTERN = /^[1-9]\d*$/;
export default defineComponent({
name: 'AccountMessagePushModal',
props: {
selfId: {
required: true,
type: String,
},
subscriptions: {
required: true,
type: Array as PropType<QqbotMessagePushApi.MessageSubscriptionView[]>,
},
targetOptions: {
default: undefined,
type: Object as PropType<
QqbotMessagePushApi.QqbotMessagePushTargetOptionsResponse | undefined
>,
},
targetOptionsLoading: {
default: false,
type: Boolean,
},
templates: {
required: true,
type: Array as PropType<QqbotMessagePushApi.MessageTemplateView[]>,
},
},
emits: ['saved'],
/** Owns one account-scoped create/edit session without selecting another account. */
setup(props, { emit, expose }) {
const editingId = ref<string>();
const modalOpen = ref(false);
const selectedSubscriptionId = ref('');
let sessionRevision = 0;
let sessionSelfId = '';
const [BindingForm, formApi] = useVbenForm({
commonConfig: {
labelClass: 'w-24',
},
/**
* Keeps the selected template only while it remains source-compatible.
* @param values - Current form values after the update.
* @param fieldsChanged - Exact form fields changed by this event.
*/
async handleValuesChange(values, fieldsChanged) {
if (!fieldsChanged.includes('subscriptionId')) return;
selectedSubscriptionId.value =
typeof values.subscriptionId === 'string'
? values.subscriptionId
: '';
const templateId =
typeof values.templateId === 'string' ? values.templateId : '';
if (!templateId) return;
if (
!isTemplateCompatible(props, selectedSubscriptionId.value, templateId)
) {
await formApi.setValues({ templateId: '' });
}
},
layout: 'horizontal',
schema: createFormSchema(props, selectedSubscriptionId),
showDefaultActions: false,
wrapperClass: 'grid-cols-1',
});
/** Derives the title from the current isolated binding identity. */
const modalTitle = computed(() =>
editingId.value ? '编辑消息推送' : '新增消息推送',
);
const [Modal, modalApi] = useVbenModal({
class: 'w-[760px]',
fullscreenButton: false,
/** Validates and persists the active account-scoped session. */
async onConfirm() {
await submit();
},
/**
* Restores only the latest session after destroy-on-close content mounts.
* @param isOpen - Whether the modal content is currently mounted.
*/
async onOpenChange(isOpen: boolean) {
modalOpen.value = isOpen;
if (!isOpen) return;
const data = modalApi.getData<AccountMessagePushModalData>();
if (
data.sessionRevision !== sessionRevision ||
data.selfId !== props.selfId
) {
await modalApi.close();
return;
}
selectedSubscriptionId.value = data.values.subscriptionId;
await resetForm(data.values);
},
});
/** Opens a fresh four-field binding session for the current account path. */
function openCreate() {
editingId.value = undefined;
beginSession({
enabled: true,
subscriptionId: '',
targets: [],
templateId: '',
});
}
/**
* Opens an edit session with only API-provided editable binding values.
* @param row - Account binding selected from the panel-owned KtTable.
*/
function openEdit(row: QqbotMessagePushApi.QqbotMessagePublishBindingView) {
editingId.value = row.id;
beginSession({
enabled: row.enabled,
subscriptionId: row.subscriptionId,
targets: row.targets.map((target) => ({
targetId: target.targetId,
...(target.targetName ? { targetName: target.targetName } : {}),
targetType: target.targetType,
})),
templateId: row.templateId,
});
}
/**
* Stores one revision-bound modal payload before opening its content.
* @param values - Exact four editable values for the new modal session.
*/
function beginSession(values: AccountMessagePushFormValues) {
sessionRevision += 1;
sessionSelfId = props.selfId;
selectedSubscriptionId.value = values.subscriptionId;
modalApi
.setData({
selfId: sessionSelfId,
sessionRevision,
values,
} satisfies AccountMessagePushModalData)
.open();
}
/**
* Resets the mounted form before installing one isolated session.
* @param values - Exact values captured before opening the modal.
*/
async function resetForm(values: AccountMessagePushFormValues) {
await formApi.resetForm();
await formApi.setValues(values);
await formApi.resetValidate();
}
/** Validates and persists one session without putting selfId in the body. */
async function submit() {
const revision = sessionRevision;
const selfId = sessionSelfId;
const currentEditingId = editingId.value;
if (!selfId || selfId !== props.selfId) return;
const { valid } = await formApi.validate();
if (revision !== sessionRevision || selfId !== props.selfId) return;
if (!valid) return;
const values = await formApi.getValues<AccountMessagePushFormValues>();
if (revision !== sessionRevision || selfId !== props.selfId) return;
const payload = normalizeBindingPayload(props, values);
if (!payload) return;
if (revision !== sessionRevision || selfId !== props.selfId) return;
modalApi.lock();
try {
await (currentEditingId
? updateAccountMessagePushBinding(selfId, currentEditingId, payload)
: createAccountMessagePushBinding(selfId, payload));
if (revision !== sessionRevision || selfId !== props.selfId) return;
await modalApi.close();
emit('saved');
} finally {
modalApi.unlock();
}
}
/**
* Invalidates an open old-account session before its path identity changes.
*/
async function invalidateForSelfIdChange() {
sessionRevision += 1;
sessionSelfId = '';
editingId.value = undefined;
selectedSubscriptionId.value = '';
if (modalOpen.value) await modalApi.close();
}
watch(
() => props.selfId,
(selfId, previousSelfId) => {
if (selfId === previousSelfId) return;
void invalidateForSelfIdChange();
},
);
expose({ openCreate, openEdit } satisfies AccountMessagePushModalExposed);
return () => (
<Modal title={modalTitle.value}>
<BindingForm class="mx-2" />
</Modal>
);
},
});
/**
* Builds the exact account-binding schema with the local controlled picker.
* @param props - Page-owned subscriptions, templates, and target candidates.
* @param selectedSubscriptionId - Current subscription driving template options.
* @returns Four fields in subscription/template/targets/enabled order.
*/
function createFormSchema(
props: Readonly<{
selfId: string;
subscriptions: QqbotMessagePushApi.MessageSubscriptionView[];
targetOptions:
| QqbotMessagePushApi.QqbotMessagePushTargetOptionsResponse
| undefined;
targetOptionsLoading: boolean;
templates: QqbotMessagePushApi.MessageTemplateView[];
}>,
selectedSubscriptionId: Readonly<{ value: string }>,
): VbenFormSchema[] {
return [
{
component: 'Select',
componentProps: () => ({
options: props.subscriptions.map((subscription) => ({
disabled: !subscription.enabled || !subscription.valid,
label: formatSubscriptionLabel(subscription),
value: subscription.id,
})),
}),
fieldName: 'subscriptionId',
label: '消息订阅',
rules: z.string().regex(DECIMAL_ID_PATTERN),
},
{
component: 'Select',
componentProps: () => ({
options: compatibleTemplates(props, selectedSubscriptionId.value).map(
(template) => ({
disabled: !template.enabled,
label: `${template.name} · ${template.sourceName}`,
value: template.id,
}),
),
}),
fieldName: 'templateId',
label: '消息模板',
rules: z.string().regex(DECIMAL_ID_PATTERN),
},
{
component: markRaw(MessagePushTargetPicker),
componentProps: () => ({
available: props.targetOptions?.available ?? true,
loading: props.targetOptionsLoading,
options: props.targetOptions?.options || [],
reasonCode: props.targetOptions?.reasonCode ?? null,
}),
fieldName: 'targets',
label: '推送目标',
modelPropName: 'value',
rules: z
.array(
z.object({
targetId: z.string().regex(/^[1-9]\d{4,19}$/),
targetName: z.string().max(120).optional(),
targetType: z.enum(['group', 'private']),
}),
)
.min(1)
.max(100),
},
{
component: 'Switch',
defaultValue: true,
fieldName: 'enabled',
label: '启用',
},
];
}
/**
* Formats one subscription while retaining server validity information.
* @param subscription - Global subscription available to the current account.
* @returns Select label with a stable invalid reason when applicable.
*/
function formatSubscriptionLabel(
subscription: QqbotMessagePushApi.MessageSubscriptionView,
): string {
const reason =
subscription.valid && subscription.enabled
? ''
: ` · ${subscription.invalidReasonCode || 'disabled'}`;
return `${subscription.name} · ${subscription.sourceName}${reason}`;
}
/**
* Returns templates whose source matches the selected global subscription.
* @param props - Current page-owned subscription/template collections.
* @param subscriptionId - Exact selected subscription string ID.
* @returns Source-compatible template rows in server order.
*/
function compatibleTemplates(
props: Readonly<{
subscriptions: QqbotMessagePushApi.MessageSubscriptionView[];
templates: QqbotMessagePushApi.MessageTemplateView[];
}>,
subscriptionId: string,
): QqbotMessagePushApi.MessageTemplateView[] {
const sourceKey = props.subscriptions.find(
(subscription) => subscription.id === subscriptionId,
)?.sourceKey;
if (!sourceKey) return [];
return props.templates.filter((template) => template.sourceKey === sourceKey);
}
/**
* Checks template/source compatibility without interpreting numeric IDs.
* @param props - Current subscription/template collections.
* @param subscriptionId - Selected subscription string ID.
* @param templateId - Existing template string ID.
* @returns Whether both rows exist and share the exact source key.
*/
function isTemplateCompatible(
props: Readonly<{
subscriptions: QqbotMessagePushApi.MessageSubscriptionView[];
templates: QqbotMessagePushApi.MessageTemplateView[];
}>,
subscriptionId: string,
templateId: string,
): boolean {
return compatibleTemplates(props, subscriptionId).some(
(template) => template.id === templateId,
);
}
/**
* Validates the exact four-field payload before invoking the account API.
* @param props - Current source-compatible metadata.
* @param values - Form-owned binding values.
* @returns A clean API payload or undefined when any ID/target is invalid.
*/
function normalizeBindingPayload(
props: Readonly<{
subscriptions: QqbotMessagePushApi.MessageSubscriptionView[];
templates: QqbotMessagePushApi.MessageTemplateView[];
}>,
values: AccountMessagePushFormValues,
): QqbotMessagePushApi.QqbotMessagePublishBindingInput | undefined {
if (
!DECIMAL_ID_PATTERN.test(values.subscriptionId) ||
!DECIMAL_ID_PATTERN.test(values.templateId) ||
!isTemplateCompatible(props, values.subscriptionId, values.templateId) ||
!Array.isArray(values.targets) ||
values.targets.length === 0 ||
values.targets.length > 100
) {
return undefined;
}
const targets: QqbotMessagePushApi.QqbotMessagePublishTargetInput[] = [];
for (const target of values.targets) {
if (
!target ||
typeof target.targetId !== 'string' ||
!isValidMessagePushTargetId(target.targetId) ||
(target.targetType !== 'group' && target.targetType !== 'private')
) {
return undefined;
}
targets.push({
targetId: target.targetId,
...(target.targetName?.trim() ? { targetName: target.targetName } : {}),
targetType: target.targetType,
});
}
return {
enabled: !!values.enabled,
subscriptionId: values.subscriptionId,
targets,
templateId: values.templateId,
};
}