Merge branch 'pr-503'

# Conflicts:
#	apps/aether-gateway/src/handlers/admin/provider/summary/value.rs
#	apps/aether-gateway/src/lib.rs
#	apps/aether-gateway/src/maintenance/mod.rs
#	apps/aether-gateway/src/maintenance/runtime/workers.rs
#	frontend/src/api/endpoints/types/provider.ts
This commit is contained in:
fawney19
2026-05-22 00:19:23 +08:00
36 changed files with 2595 additions and 604 deletions
@@ -0,0 +1,355 @@
<template>
<PageContainer>
<PageHeader
title="重要通知"
description="配置后台任务使用的邮件和 Server 酱通知通道"
/>
<div class="mt-6 space-y-6">
<CardSection
title="模块开关"
description="启用后,额度提醒等后台任务可以发送重要通知"
>
<template #actions>
<Button
size="sm"
:disabled="saving"
@click="saveConfig"
>
{{ saving ? '保存中...' : '保存' }}
</Button>
</template>
<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">
{{ anyChannelConfigurable
? '至少配置一个可用通道后再启用'
: '请先完成邮件或 Server 酱通道配置后再启用'
}}
</p>
</div>
<Switch
v-model="config.enabled"
:disabled="!anyChannelConfigurable"
/>
</div>
</CardSection>
<CardSection
title="邮件通知"
description="使用系统 SMTP 配置向固定收件人发送提醒"
>
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<Label class="text-sm font-medium">
启用邮件通道
</Label>
<p
v-if="emailChannelConfigurable"
class="mt-1 text-xs text-muted-foreground"
>
SMTP 服务在邮件配置中维护
</p>
<p
v-else
class="mt-1 text-xs text-destructive"
>
<template v-if="!smtpConfigured">
请先在
<RouterLink
to="/admin/email"
class="hover:underline"
>
邮件配置
</RouterLink>
中配置 SMTP,
</template>
<template v-else>
请先
</template>
填写至少一个收件人后再启用
</p>
</div>
<Switch
v-model="config.email_enabled"
:disabled="!emailChannelConfigurable"
/>
</div>
<div>
<Label
for="important-notification-recipients"
class="block text-sm font-medium"
>
收件人
</Label>
<Textarea
id="important-notification-recipients"
v-model="config.email_recipients"
rows="4"
placeholder="[email protected]&#10;[email protected]"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
支持换行、逗号或分号分隔
</p>
</div>
</div>
</CardSection>
<CardSection
title="Server 酱"
description="通过 Server 酱 Turbo SendKey 推送微信提醒"
>
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<Label class="text-sm font-medium">
启用 Server 酱通道
</Label>
<p
v-if="serverChanKeyIsSet"
class="mt-1 text-xs text-muted-foreground"
>
请求地址使用 Server 酱 Turbo 官方接口
</p>
<p
v-else
class="mt-1 text-xs text-destructive"
>
请先前往
<RouterLink
to="/admin/server-chan"
class="hover:underline"
>
Server 酱
</RouterLink>
配置 SendKey 后再启用
</p>
</div>
<Switch
v-model="config.server_chan_enabled"
:disabled="!serverChanKeyIsSet"
/>
</div>
<p
v-if="serverChanKeyIsSet"
class="text-xs text-muted-foreground"
>
前往
<RouterLink
to="/admin/server-chan"
class="text-primary hover:underline"
>
Server 酱
</RouterLink>
配置 SendKey 与通知模板。
</p>
</div>
</CardSection>
<CardSection
title="测试通知"
description="按当前已保存配置发送一条重要通知测试"
>
<div class="flex flex-wrap gap-2">
<Button
variant="outline"
:disabled="testingAll || !anyChannelConfigurable"
@click="testChannel('all')"
>
{{ testingAll ? '发送中...' : '测试全部通道' }}
</Button>
<Button
variant="outline"
:disabled="testingEmail || !emailChannelConfigurable"
@click="testChannel('email')"
>
{{ testingEmail ? '发送中...' : '测试邮件' }}
</Button>
</div>
<div
v-if="lastTestResult.length > 0"
class="mt-4 space-y-2"
>
<div
v-for="item in lastTestResult"
:key="item.channel"
class="flex items-center justify-between 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'
import Button from '@/components/ui/button.vue'
import Label from '@/components/ui/label.vue'
import Switch from '@/components/ui/switch.vue'
import Textarea from '@/components/ui/textarea.vue'
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
import { adminApi } from '@/api/admin'
import { modulesApi } from '@/api/modules'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
const CONFIG_KEYS = {
enabled: 'module.important_notification.enabled',
email_enabled: 'module.important_notification.email_enabled',
email_recipients: 'module.important_notification.email_recipients',
server_chan_enabled: 'module.important_notification.server_chan_enabled',
server_chan_send_key: 'module.important_notification.server_chan_send_key',
} as const
interface ImportantNotificationConfig {
enabled: boolean
email_enabled: boolean
email_recipients: string
server_chan_enabled: boolean
}
const { success, error } = useToast()
const saving = ref(false)
const testingAll = ref(false)
const testingEmail = ref(false)
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
const smtpConfigured = ref(false)
const serverChanKeyIsSet = ref(false)
const config = ref<ImportantNotificationConfig>({
enabled: false,
email_enabled: false,
email_recipients: '',
server_chan_enabled: false,
})
const emailChannelConfigurable = computed(() => {
return smtpConfigured.value && config.value.email_recipients.trim() !== ''
})
const anyChannelConfigurable = computed(() => {
return emailChannelConfigurable.value || serverChanKeyIsSet.value
})
onMounted(() => {
loadConfig()
})
async function loadConfig() {
try {
const [
moduleStatus,
emailEnabled,
recipients,
serverChanEnabled,
serverChanKey,
smtpHost,
smtpFromEmail,
] = await Promise.all([
modulesApi.getStatus('important_notification'),
adminApi.getSystemConfig(CONFIG_KEYS.email_enabled),
adminApi.getSystemConfig(CONFIG_KEYS.email_recipients),
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_enabled),
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)
config.value.server_chan_enabled = serverChanEnabled.value === true
serverChanKeyIsSet.value = serverChanKey.is_set === true
smtpConfigured.value = isNonEmptyString(smtpHost.value) && isNonEmptyString(smtpFromEmail.value)
} catch (err) {
error(parseApiError(err, '加载重要通知配置失败'))
log.error('加载重要通知配置失败:', err)
}
}
async function saveConfig() {
saving.value = true
try {
if (!config.value.enabled) {
await adminApi.updateSystemConfig(CONFIG_KEYS.enabled, false, '重要通知模块总开关')
}
await Promise.all([
adminApi.updateSystemConfig(CONFIG_KEYS.email_enabled, config.value.email_enabled, '重要通知邮件通道开关'),
adminApi.updateSystemConfig(CONFIG_KEYS.email_recipients, config.value.email_recipients, '重要通知邮件收件人'),
adminApi.updateSystemConfig(CONFIG_KEYS.server_chan_enabled, config.value.server_chan_enabled, '重要通知 Server 酱通道开关'),
])
if (config.value.enabled) {
await adminApi.updateSystemConfig(CONFIG_KEYS.enabled, true, '重要通知模块总开关')
}
success('重要通知配置已保存')
} catch (err) {
error(parseApiError(err, '保存重要通知配置失败'))
log.error('保存重要通知配置失败:', err)
} finally {
saving.value = false
}
}
async function testChannel(channel: 'all' | 'email') {
setTesting(channel, true)
try {
const result = await adminApi.testImportantNotification(channel)
lastTestResult.value = result.channels || []
if (result.success) {
success(result.message || '测试通知已发送')
} else {
error(result.message || '测试通知发送失败')
}
} catch (err) {
error(parseApiError(err, '测试通知发送失败'))
log.error('测试重要通知失败:', err)
} finally {
setTesting(channel, false)
}
}
function setTesting(channel: 'all' | 'email', value: boolean) {
if (channel === 'all') testingAll.value = value
if (channel === 'email') testingEmail.value = value
}
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 酱'
if (channel === 'module') return '模块'
return channel
}
</script>
@@ -0,0 +1,205 @@
<template>
<PageContainer>
<PageHeader
title="Server 酱"
description="配置 Server 酱 Turbo SendKey 与微信通知模板"
/>
<div class="mt-6 space-y-6">
<CardSection
title="SendKey"
description="使用 Server 酱 Turbo 官方 SendKey 推送微信通知"
>
<template #actions>
<Button
size="sm"
:disabled="saving"
@click="saveConfig"
>
{{ saving ? '保存中...' : '保存' }}
</Button>
</template>
<div>
<Label
for="server-chan-send-key"
class="block text-sm font-medium"
>
SendKey
</Label>
<Input
id="server-chan-send-key"
v-model="sendKeyInput"
masked
:placeholder="sendKeyIsSet ? '已设置(留空保持不变)' : 'SCTxxxxxxxxxxxxxxxxxxxxxxxx'"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
可在 <span class="font-mono">sct.ftqq.com</span> 控制台获取
</p>
</div>
</CardSection>
<CardSection
title="通知模板"
description="可选 Markdown 模板,支持 {title} 和 {body} 变量;留空则使用默认正文"
>
<div>
<Label
for="server-chan-template"
class="block text-sm font-medium"
>
模板内容
</Label>
<textarea
id="server-chan-template"
v-model="templateInput"
rows="10"
class="mt-1 w-full font-mono text-sm bg-muted/30 border border-border rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-y"
placeholder="**{title}**&#10;&#10;{body}"
spellcheck="false"
/>
<p class="mt-2 text-xs text-muted-foreground">
示例:<span class="font-mono">**{title}**\n\n{body}\n\n来自 Aether</span>
</p>
</div>
</CardSection>
<CardSection
title="测试 Server 酱"
description="按当前已保存配置向微信发送一条测试通知"
>
<div class="flex flex-wrap gap-2">
<Button
variant="outline"
:disabled="testing"
@click="handleTest"
>
{{ testing ? '发送中...' : '测试 Server 酱' }}
</Button>
</div>
<div
v-if="lastTestResult.length > 0"
class="mt-4 space-y-2"
>
<div
v-for="item in lastTestResult"
:key="item.channel"
class="flex items-center justify-between 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 { onMounted, ref } from 'vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
import { adminApi } from '@/api/admin'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
const CONFIG_KEYS = {
server_chan_send_key: 'module.important_notification.server_chan_send_key',
server_chan_template: 'module.important_notification.server_chan_template',
} as const
const { success, error } = useToast()
const saving = ref(false)
const testing = ref(false)
const sendKeyIsSet = ref(false)
const sendKeyInput = ref('')
const templateInput = ref('')
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
onMounted(() => {
loadConfig()
})
async function loadConfig() {
try {
const [sendKey, template] = await Promise.all([
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_send_key),
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_template),
])
sendKeyIsSet.value = sendKey.is_set === true
sendKeyInput.value = ''
templateInput.value = typeof template.value === 'string' ? template.value : ''
} catch (err) {
error(parseApiError(err, '加载 Server 酱配置失败'))
log.error('加载 Server 酱配置失败:', err)
}
}
async function saveConfig() {
saving.value = true
try {
const updates: Array<Promise<unknown>> = [
adminApi.updateSystemConfig(
CONFIG_KEYS.server_chan_template,
templateInput.value,
'重要通知 Server 酱 通知模板',
),
]
const trimmedKey = sendKeyInput.value.trim()
if (trimmedKey) {
updates.push(
adminApi.updateSystemConfig(
CONFIG_KEYS.server_chan_send_key,
trimmedKey,
'重要通知 Server 酱 SendKey',
),
)
}
await Promise.all(updates)
if (trimmedKey) {
sendKeyIsSet.value = true
sendKeyInput.value = ''
}
success('Server 酱配置已保存')
} catch (err) {
error(parseApiError(err, '保存 Server 酱配置失败'))
log.error('保存 Server 酱配置失败:', err)
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
const result = await adminApi.testImportantNotification('server_chan')
lastTestResult.value = result.channels || []
if (result.success) {
success(result.message || '测试通知已发送')
} else {
error(result.message || '测试通知发送失败')
}
} catch (err) {
error(parseApiError(err, '测试通知发送失败'))
log.error('测试 Server 酱失败:', err)
} finally {
testing.value = false
}
}
function formatChannel(channel: string): string {
if (channel === 'server_chan') return 'Server 酱'
if (channel === 'email') return '邮件'
if (channel === 'module') return '模块'
return channel
}
</script>