Files
Aether/frontend/src/views/admin/modules/ImportantNotification.vue
T

701 lines
23 KiB
Vue
Raw Normal View History

<template>
<PageContainer>
<PageHeader
2026-05-22 01:45:46 +08:00
title="通知服务"
description="统一管理通知项、通知模板和推送服务选择"
/>
<div class="mt-6 space-y-6">
<CardSection
2026-05-22 01:45:46 +08:00
title="通知服务配置"
description="选择全局推送服务,并配置邮件和第三方推送渠道"
>
<template #actions>
<Button
size="sm"
:disabled="saving"
@click="saveConfig"
>
{{ saving ? '保存中...' : '保存' }}
</Button>
</template>
2026-05-22 01:45:46 +08:00
<div class="space-y-6">
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
<div>
2026-05-22 01:45:46 +08:00
<Label class="block text-sm font-medium">
全局推送服务
</Label>
2026-05-22 01:45:46 +08:00
<Select v-model="config.default_channel">
<SelectTrigger class="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
所有可用服务
</SelectItem>
<SelectItem value="email">
邮件
</SelectItem>
<SelectItem value="server_chan">
Server 酱
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<Label class="text-sm font-medium">
启用通知服务
</Label>
<p class="mt-1 text-xs text-muted-foreground">
{{ canEnableService ? '当前策略有可用推送服务' : '当前策略没有可用推送服务' }}
</p>
</div>
<Switch
v-model="config.enabled"
:disabled="!canEnableService"
/>
</div>
</div>
2026-05-22 01:45:46 +08:00
<div class="grid gap-6 border-t border-border/60 pt-5 lg:grid-cols-2">
<section class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<Label class="text-sm font-medium">
邮件配置
</Label>
<Badge :variant="emailReady ? 'success' : 'outline'">
{{ emailReady ? '可用' : '未就绪' }}
</Badge>
</div>
<p class="mt-1 text-xs text-muted-foreground">
SMTP 配置在
<RouterLink
to="/admin/email"
class="text-primary hover:underline"
>
邮件配置
</RouterLink>
中维护
</p>
</div>
<Switch
v-model="config.email_enabled"
:disabled="!smtpConfigured"
/>
</div>
2026-05-22 01:45:46 +08:00
<div>
<Label
for="notification-service-recipients"
class="block text-sm font-medium"
>
2026-05-22 01:45:46 +08:00
管理员收件人
</Label>
<Textarea
id="notification-service-recipients"
v-model="config.email_recipients"
rows="4"
placeholder="[email protected]&#10;[email protected]"
class="mt-1"
/>
</div>
</section>
2026-05-22 01:45:46 +08:00
<section class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<Label class="text-sm font-medium">
Server 酱
</Label>
<Badge :variant="serverChanReady ? 'success' : 'outline'">
{{ serverChanReady ? '可用' : '未就绪' }}
</Badge>
</div>
<p class="mt-1 text-xs text-muted-foreground">
第三方推送服务在扩展模块中独立启用
</p>
</div>
</div>
<RouterLink
to="/admin/modules/server-chan"
class="inline-flex h-11 items-center rounded-xl border border-border/60 bg-card/60 px-4 text-sm font-semibold text-foreground hover:border-primary/60 hover:bg-primary/10 hover:text-primary"
>
配置 Server 酱推送
</RouterLink>
</section>
</div>
</div>
</CardSection>
<CardSection
title="通知项"
description="每个通知项可以继承全局服务,也可以单独指定推送服务"
>
<template #actions>
<Button
size="sm"
variant="outline"
@click="addItem"
>
2026-05-22 01:45:46 +08:00
<Plus class="mr-1.5 h-4 w-4" />
添加通知项
</Button>
</template>
<div class="space-y-4">
<div
v-for="(item, index) in config.items"
:key="item.local_id"
class="rounded-lg border border-border/70 p-4"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<Label class="text-sm font-semibold">
{{ item.name || item.key || '未命名通知项' }}
</Label>
<Badge
v-if="item.system"
variant="outline"
>
内置
</Badge>
<Badge :variant="isItemReady(item) ? 'success' : 'outline'">
{{ isItemReady(item) ? '可投递' : '未就绪' }}
</Badge>
</div>
<p class="mt-1 truncate text-xs text-muted-foreground">
{{ item.key }}
</p>
</div>
<div class="flex items-center gap-2">
<Switch v-model="item.enabled" />
<Button
v-if="!item.system"
size="icon"
variant="ghost"
@click="removeItem(index)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
<div class="mt-4 grid gap-4 lg:grid-cols-2">
<div>
<Label class="block text-xs font-medium">
通知键
</Label>
<Input
v-model="item.key"
class="mt-1"
:disabled="item.system"
placeholder="custom_event"
/>
</div>
<div>
<Label class="block text-xs font-medium">
名称
</Label>
<Input
v-model="item.name"
class="mt-1"
placeholder="自定义通知"
/>
</div>
<div>
<Label class="block text-xs font-medium">
推送服务
</Label>
<Select v-model="item.channel">
<SelectTrigger class="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">
使用全局
</SelectItem>
<SelectItem value="all">
所有可用服务
</SelectItem>
<SelectItem value="email">
邮件
</SelectItem>
<SelectItem value="server_chan">
Server 酱
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between rounded-lg border border-border/70 px-4 py-3">
<div>
<Label class="text-xs font-medium">
用户邮件
</Label>
<p class="mt-1 text-xs text-muted-foreground">
允许发送到用户自己的邮箱
</p>
</div>
<Switch
v-model="item.user_email_enabled"
:disabled="!smtpConfigured"
/>
</div>
</div>
<div class="mt-4 grid gap-4">
<div>
<Label class="block text-xs font-medium">
标题模板
</Label>
<Input
v-model="item.title_template"
class="mt-1"
placeholder="{title}"
/>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div>
<Label class="block text-xs font-medium">
Markdown 模板
</Label>
<Textarea
v-model="item.markdown_template"
rows="5"
class="mt-1 font-mono text-sm"
placeholder="{body}"
/>
</div>
<div>
<Label class="block text-xs font-medium">
文本模板
</Label>
<Textarea
v-model="item.text_template"
rows="5"
class="mt-1 font-mono text-sm"
placeholder="{text_body}"
/>
</div>
</div>
</div>
</div>
</div>
</CardSection>
<CardSection
title="测试通知"
2026-05-22 01:45:46 +08:00
description="按已保存配置发送测试通知"
>
2026-05-22 01:45:46 +08:00
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_180px_auto]">
<Select v-model="testItemKey">
<SelectTrigger>
<SelectValue placeholder="选择通知项" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in config.items"
:key="item.local_id"
:value="item.key"
>
{{ item.name || item.key }}
</SelectItem>
</SelectContent>
</Select>
<Select v-model="testChannel">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">
按通知项
</SelectItem>
<SelectItem value="all">
所有可用服务
</SelectItem>
<SelectItem value="email">
邮件
</SelectItem>
<SelectItem value="server_chan">
Server 酱
</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
2026-05-22 01:45:46 +08:00
:disabled="testing || !testItemKey"
@click="handleTest"
>
2026-05-22 01:45:46 +08:00
<Send class="mr-1.5 h-4 w-4" />
{{ testing ? '发送中...' : '发送测试' }}
</Button>
</div>
<div
v-if="lastTestResult.length > 0"
class="mt-4 space-y-2"
>
<div
v-for="item in lastTestResult"
:key="item.channel"
2026-05-22 01:45:46 +08:00
class="flex items-center justify-between gap-4 rounded-md border border-border px-3 py-2 text-sm"
>
<span>{{ formatChannel(item.channel) }}</span>
<span :class="item.success ? 'text-green-600 dark:text-green-400' : 'text-destructive'">
{{ item.message }}
</span>
</div>
</div>
</CardSection>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
2026-05-22 01:45:46 +08:00
import { Plus, Send, Trash2 } from 'lucide-vue-next'
import {
Badge,
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Textarea,
} from '@/components/ui'
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
import { adminApi } from '@/api/admin'
2026-05-22 01:45:46 +08:00
import { modulesApi, type ModuleStatus } from '@/api/modules'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
2026-05-22 01:45:46 +08:00
type DeliveryChannel = 'global' | 'all' | 'email' | 'server_chan'
interface NotificationItem {
local_id: string
key: string
name: string
enabled: boolean
channel: DeliveryChannel
title_template: string
markdown_template: string
text_template: string
user_email_enabled: boolean
system: boolean
}
interface NotificationConfig {
enabled: boolean
email_enabled: boolean
email_recipients: string
default_channel: Exclude<DeliveryChannel, 'global'>
items: NotificationItem[]
}
const CONFIG_KEYS = {
enabled: 'module.important_notification.enabled',
email_enabled: 'module.important_notification.email_enabled',
email_recipients: 'module.important_notification.email_recipients',
2026-05-22 01:45:46 +08:00
default_channel: 'module.important_notification.default_channel',
items: 'module.important_notification.items',
server_chan_send_key: 'module.server_chan_push.send_key',
} as const
2026-05-22 01:45:46 +08:00
const DEFAULT_ITEMS: NotificationItem[] = [
{
local_id: 'provider_quota_alert',
key: 'provider_quota_alert',
name: '号池额度不足',
enabled: true,
channel: 'global',
title_template: '',
markdown_template: '',
text_template: '',
user_email_enabled: false,
system: true,
},
{
local_id: 'provider_pool_abnormal',
key: 'provider_pool_abnormal',
name: '号池异常',
enabled: true,
channel: 'global',
title_template: '号池异常:{provider_name}',
markdown_template: '号池 `{provider_name}` 出现异常,请检查服务状态。',
text_template: '号池 {provider_name} 出现异常,请检查服务状态。',
user_email_enabled: false,
system: true,
},
{
local_id: 'user_balance_low',
key: 'user_balance_low',
name: '用户余额不足',
enabled: true,
channel: 'email',
title_template: '余额不足提醒',
markdown_template: '你的账户余额已低于提醒阈值,请及时处理。',
text_template: '你的账户余额已低于提醒阈值,请及时处理。',
user_email_enabled: true,
system: true,
},
]
const { success, error } = useToast()
const saving = ref(false)
2026-05-22 01:45:46 +08:00
const testing = ref(false)
const smtpConfigured = ref(false)
const serverChanKeyIsSet = ref(false)
2026-05-22 01:45:46 +08:00
const serverChanStatus = ref<ModuleStatus | null>(null)
const testItemKey = ref('provider_quota_alert')
const testChannel = ref<DeliveryChannel>('global')
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
2026-05-22 01:45:46 +08:00
const config = ref<NotificationConfig>({
enabled: false,
email_enabled: false,
email_recipients: '',
2026-05-22 01:45:46 +08:00
default_channel: 'all',
items: cloneDefaultItems(),
})
2026-05-22 01:45:46 +08:00
const emailReady = computed(() => {
return config.value.email_enabled && smtpConfigured.value && config.value.email_recipients.trim() !== ''
})
2026-05-22 01:45:46 +08:00
const serverChanReady = computed(() => {
return serverChanStatus.value?.enabled === true && serverChanKeyIsSet.value
})
const canEnableService = computed(() => {
if (deliveryReady(config.value.default_channel)) return true
return config.value.items.some(item => item.enabled && isItemReady(item))
})
onMounted(() => {
loadConfig()
})
async function loadConfig() {
try {
const [
moduleStatus,
emailEnabled,
recipients,
2026-05-22 01:45:46 +08:00
defaultChannel,
items,
serverChanModuleStatus,
serverChanKey,
smtpHost,
smtpFromEmail,
] = await Promise.all([
modulesApi.getStatus('important_notification'),
adminApi.getSystemConfig(CONFIG_KEYS.email_enabled),
adminApi.getSystemConfig(CONFIG_KEYS.email_recipients),
2026-05-22 01:45:46 +08:00
adminApi.getSystemConfig(CONFIG_KEYS.default_channel),
adminApi.getSystemConfig(CONFIG_KEYS.items),
modulesApi.getStatus('server_chan_push'),
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_send_key),
adminApi.getSystemConfig('smtp_host'),
adminApi.getSystemConfig('smtp_from_email'),
])
config.value.enabled = moduleStatus.enabled === true
config.value.email_enabled = emailEnabled.value === true
config.value.email_recipients = normalizeRecipients(recipients.value)
2026-05-22 01:45:46 +08:00
config.value.default_channel = normalizeDefaultChannel(defaultChannel.value)
config.value.items = normalizeItems(items.value)
serverChanStatus.value = serverChanModuleStatus
serverChanKeyIsSet.value = serverChanKey.is_set === true
smtpConfigured.value = isNonEmptyString(smtpHost.value) && isNonEmptyString(smtpFromEmail.value)
2026-05-22 01:45:46 +08:00
if (!config.value.items.some(item => item.key === testItemKey.value)) {
testItemKey.value = config.value.items[0]?.key || ''
}
} catch (err) {
2026-05-22 01:45:46 +08:00
error(parseApiError(err, '加载通知服务配置失败'))
log.error('加载通知服务配置失败:', err)
}
}
async function saveConfig() {
saving.value = true
try {
2026-05-22 01:45:46 +08:00
if (!canEnableService.value) {
config.value.enabled = false
}
await Promise.all([
2026-05-22 01:45:46 +08:00
adminApi.updateSystemConfig(CONFIG_KEYS.email_enabled, config.value.email_enabled, '通知服务邮件推送开关'),
adminApi.updateSystemConfig(CONFIG_KEYS.email_recipients, config.value.email_recipients, '通知服务管理员收件人'),
adminApi.updateSystemConfig(CONFIG_KEYS.default_channel, config.value.default_channel, '通知服务全局推送服务'),
adminApi.updateSystemConfig(CONFIG_KEYS.items, serializeItems(), '通知服务通知项和模板'),
])
2026-05-22 01:45:46 +08:00
await adminApi.updateSystemConfig(CONFIG_KEYS.enabled, config.value.enabled, '通知服务总开关')
success('通知服务配置已保存')
} catch (err) {
2026-05-22 01:45:46 +08:00
error(parseApiError(err, '保存通知服务配置失败'))
log.error('保存通知服务配置失败:', err)
} finally {
saving.value = false
}
}
2026-05-22 01:45:46 +08:00
async function handleTest() {
testing.value = true
try {
2026-05-22 01:45:46 +08:00
const result = await adminApi.testImportantNotification({
item_key: testItemKey.value,
channel: testChannel.value === 'global' ? undefined : testChannel.value,
})
lastTestResult.value = result.channels || []
if (result.success) {
success(result.message || '测试通知已发送')
} else {
error(result.message || '测试通知发送失败')
}
} catch (err) {
error(parseApiError(err, '测试通知发送失败'))
2026-05-22 01:45:46 +08:00
log.error('测试通知服务失败:', err)
} finally {
2026-05-22 01:45:46 +08:00
testing.value = false
}
}
2026-05-22 01:45:46 +08:00
function addItem() {
const suffix = Date.now().toString(36)
const key = `custom_${suffix}`
config.value.items.push({
local_id: key,
key,
name: '自定义通知',
enabled: true,
channel: 'global',
title_template: '',
markdown_template: '',
text_template: '',
user_email_enabled: false,
system: false,
})
testItemKey.value = key
}
function removeItem(index: number) {
const [removed] = config.value.items.splice(index, 1)
if (removed?.key === testItemKey.value) {
testItemKey.value = config.value.items[0]?.key || ''
}
}
function isItemReady(item: NotificationItem): boolean {
if (!item.enabled) return false
return deliveryReady(resolveItemChannel(item))
}
function deliveryReady(channel: Exclude<DeliveryChannel, 'global'>): boolean {
if (channel === 'all') return emailReady.value || serverChanReady.value
if (channel === 'email') return emailReady.value
if (channel === 'server_chan') return serverChanReady.value
return false
}
function resolveItemChannel(item: NotificationItem): Exclude<DeliveryChannel, 'global'> {
return item.channel === 'global' ? config.value.default_channel : item.channel
}
function serializeItems() {
return config.value.items.map(item => ({
key: normalizeItemKey(item.key),
name: item.name.trim() || normalizeItemKey(item.key),
enabled: item.enabled,
channel: item.channel,
title_template: item.title_template.trim(),
markdown_template: item.markdown_template.trim(),
text_template: item.text_template.trim(),
user_email_enabled: item.user_email_enabled,
system: item.system,
}))
}
function normalizeItems(value: unknown): NotificationItem[] {
if (!Array.isArray(value)) return cloneDefaultItems()
const items = value
.map((item, index) => normalizeItem(item, index))
.filter((item): item is NotificationItem => item !== null)
return items.length > 0 ? items : cloneDefaultItems()
}
function normalizeItem(value: unknown, index: number): NotificationItem | null {
if (!value || typeof value !== 'object') return null
const raw = value as Record<string, unknown>
const key = normalizeItemKey(raw.key)
if (!key) return null
return {
local_id: `${key}_${index}`,
key,
name: typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : key,
enabled: raw.enabled !== false,
channel: normalizeItemChannel(raw.channel),
title_template: typeof raw.title_template === 'string' ? raw.title_template : '',
markdown_template: typeof raw.markdown_template === 'string' ? raw.markdown_template : '',
text_template: typeof raw.text_template === 'string' ? raw.text_template : '',
user_email_enabled: raw.user_email_enabled === true,
system: raw.system === true,
}
}
function normalizeItemKey(value: unknown): string {
if (typeof value !== 'string') return ''
return value.trim().replace(/[^A-Za-z0-9_.:-]/g, '_').slice(0, 64)
}
function normalizeItemChannel(value: unknown): DeliveryChannel {
if (value === 'all' || value === 'email' || value === 'server_chan') return value
return 'global'
}
function normalizeDefaultChannel(value: unknown): Exclude<DeliveryChannel, 'global'> {
if (value === 'email' || value === 'server_chan') return value
return 'all'
}
function cloneDefaultItems(): NotificationItem[] {
return DEFAULT_ITEMS.map(item => ({ ...item }))
}
function isNonEmptyString(value: unknown): boolean {
return typeof value === 'string' && value.trim() !== ''
}
function normalizeRecipients(value: unknown): string {
if (Array.isArray(value)) {
return value
.map(item => String(item).trim())
.filter(Boolean)
.join('\n')
}
return typeof value === 'string' ? value : ''
}
function formatChannel(channel: string): string {
if (channel === 'email') return '邮件'
if (channel === 'server_chan') return 'Server 酱'
2026-05-22 01:45:46 +08:00
if (channel === 'user_email') return '用户邮件'
if (channel === 'module') return '模块'
2026-05-22 01:45:46 +08:00
if (channel === 'item') return '通知项'
if (channel === 'none') return '无可用服务'
return channel
}
</script>