Files
admin-web/src/modules/notifications/components/MessageComposerDialog.vue
T

98 lines
8.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { PlatformTenantSelector } from '@/modules/iam'
import { createNotification } from '../api/notificationCenter'
import type { NotificationTemplate, RecipientType } from '../types'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
const props = defineProps<{ templates: NotificationTemplate[] }>()
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{ success: [] }>()
const submitting = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const scope = ref<'platform' | 'customer'>('platform')
const recipientIdsText = ref('')
const variables = reactive<Record<string, string>>({})
const form = reactive({ tenantId: '0', templateCode: '', recipientType: 'PLATFORM_ROLE' as RecipientType, roleCode: '', scheduledAt: '' })
const selectedTemplate = computed(() => props.templates.find((item) => item.templateCode === form.templateCode) ?? null)
function toDateTimeValue(date: Date) {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
const scheduled = computed({
get: () => Boolean(form.scheduledAt),
set: (value: boolean) => {
if (value && !form.scheduledAt) form.scheduledAt = toDateTimeValue(new Date(Date.now() + 5 * 60 * 1000))
if (!value) form.scheduledAt = ''
},
})
const needsIds = computed(() => ['USER', 'MEMBERSHIP'].includes(form.recipientType))
const needsRole = computed(() => ['TENANT_ROLE', 'PLATFORM_ROLE'].includes(form.recipientType))
const allowedRecipients = computed(() => scope.value === 'platform'
? [{ label: '指定平台用户', value: 'USER' }, { label: '平台角色', value: 'PLATFORM_ROLE' }]
: [{ label: '指定企业成员', value: 'USER' }, { label: '企业角色', value: 'TENANT_ROLE' }, { label: '企业全部成员', value: 'TENANT_ALL' }])
watch(scope, (value) => { form.tenantId = value === 'platform' ? '0' : ''; form.recipientType = value === 'platform' ? 'PLATFORM_ROLE' : 'TENANT_ALL'; form.roleCode = ''; recipientIdsText.value = '' })
watch(selectedTemplate, (value) => { for (const key of Object.keys(variables)) delete variables[key]; for (const key of value?.variableWhitelist ?? []) variables[key] = '' })
function reset() { scope.value = 'platform'; Object.assign(form, { tenantId: '0', templateCode: '', recipientType: 'PLATFORM_ROLE', roleCode: '', scheduledAt: '' }); recipientIdsText.value = ''; error.value = null; for (const key of Object.keys(variables)) delete variables[key] }
const MAX_LONG_ID = 9_223_372_036_854_775_807n
function parseIds() { return [...new Set(recipientIdsText.value.split(/[\s,,]+/).map((value) => value.trim()).filter(Boolean))] }
function idempotencyKey() { return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `notification-${Date.now()}-${Math.random().toString(36).slice(2)}` }
async function submit() {
if (submitting.value) return
error.value = null
const recipientIds = parseIds()
if (!form.templateCode) return ElMessage.warning('请选择已激活模板')
if (scope.value === 'customer' && !form.tenantId) return ElMessage.warning('请选择目标企业')
if (needsIds.value && (!recipientIds.length || recipientIds.length > 1000 || recipientIds.some((value) => !/^\d+$/.test(value) || BigInt(value) > MAX_LONG_ID))) return ElMessage.warning('请输入有效的数字用户 ID,单次最多 1000 个且不能超过 19 位')
if (needsRole.value && !form.roleCode.trim()) return ElMessage.warning('请输入角色编码')
if (Object.values(variables).some((value) => !value.trim())) return ElMessage.warning('请填写全部模板变量')
submitting.value = true
try {
await createNotification({ tenantId: form.tenantId, templateCode: form.templateCode, recipientType: form.recipientType, ...(needsIds.value ? { recipientIds } : {}), ...(needsRole.value ? { roleCode: form.roleCode.trim() } : {}), variables: { ...variables }, scheduledAt: form.scheduledAt || null, idempotencyKey: idempotencyKey() })
ElMessage.success(form.scheduledAt ? '消息已定时' : '消息发送任务已创建')
open.value = false; reset(); emit('success')
} catch (reason) { error.value = getApiErrorDetails(reason, '消息发送失败') } finally { submitting.value = false }
}
</script>
<template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="open" title="新建消息" width="min(680px, calc(100vw - 28px))" fullscreenable :confirm-loading="submitting" :show-confirm="false" destroy-on-close @cancel="reset">
<OperationErrorToast :details="error" />
<el-form class="density-form density-dialog-sections" label-position="left" label-width="100px" :disabled="submitting">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="发送设置">
<h3 class="density-dialog-section__title">发送设置 <InlineHelp title="发送说明">消息由已激活模板生成,仅发送站内信;可指定平台员工或企业接收人,并按需启用定时发送。</InlineHelp></h3>
<table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="发送范围" required><el-segmented v-model="scope" :options="[{ label: '平台员工', value: 'platform' }, { label: '指定企业', value: 'customer' }]" /></el-form-item></td></tr>
<tr v-if="scope === 'customer'"><td><PlatformTenantSelector v-model="form.tenantId" label="目标企业" :disabled="submitting" /></td></tr>
<tr><td><el-form-item label="发送方式"><span>站内信</span></el-form-item></td></tr>
<tr><td><el-form-item label="消息模板" required><el-select v-model="form.templateCode" filterable placeholder="请选择已激活模板"><el-option v-for="item in templates" :key="item.id" :label="`${item.templateName}(${item.templateCode})`" :value="item.templateCode" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="接收对象" required><el-select v-model="form.recipientType"><el-option v-for="item in allowedRecipients" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></td></tr>
<tr v-if="needsRole"><td><el-form-item label="角色编码" required><el-input v-model="form.roleCode" maxlength="64" placeholder="请输入服务端角色编码" /></el-form-item></td></tr>
<tr v-if="needsIds"><td><el-form-item label="接收人用户 ID" required><el-input v-model="recipientIdsText" type="textarea" :rows="2" placeholder="多个用户 ID 使用逗号或换行分隔,最多 1000 个" /></el-form-item></td></tr>
<tr><td><el-form-item label="定时发送"><div class="schedule-row"><el-switch v-model="scheduled" aria-label="启用定时发送" /><span>启用</span><el-date-picker v-model="form.scheduledAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择发送时间" :disabled="!scheduled" /></div></el-form-item></td></tr>
</tbody></table>
</section>
<section v-if="selectedTemplate?.variableWhitelist.length" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="模板变量">
<h3 class="density-dialog-section__title">模板变量</h3>
<table class="density-table density-table--editable"><tbody><tr v-for="key in selectedTemplate.variableWhitelist" :key="key"><td><el-form-item :label="key" label-position="top" label-width="auto" class="message-variable-field" required><el-input v-model="variables[key]" maxlength="2000" placeholder="请输入变量值" /></el-form-item></td></tr></tbody></table>
</section>
</el-form>
<template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="submitting" @click="close">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">{{ scheduled ? '确认定时发送' : '立即发送' }}</el-button></div></template>
</AppDialog>
</template>
<style scoped>
.message-variable-field :deep(.el-form-item__label) { max-width: 100%; overflow-wrap: anywhere; }
.schedule-row { display: grid; width: 100%; grid-template-columns: auto auto minmax(180px, 1fr); align-items: center; gap: var(--admin-space-3); }
.schedule-row :deep(.el-date-editor) { width: 100%; }
@media(max-width:560px) { .schedule-row { grid-template-columns: auto 1fr; } .schedule-row :deep(.el-date-editor) { grid-column: 1 / -1; } }
</style>