feat: 重要通知模块、Server 酱独立配置与额度提醒

- 新增重要通知统一模块(邮件 + Server 酱)作为后台任务通知出口
- 拆出独立的 Server 酱 配置页(SendKey + Markdown 模板,支持 {title}/{body} 变量替换),通过仪表盘内置工具入口进入
- 新增提供商额度提醒后台 worker:余额低于阈值时通过重要通知推送,提供商配置页加入额度提醒开关与阈值
- 重要通知页加入配置可用性守卫:未配置任一通道时禁用总开关,未配置邮件/SendKey 时禁用对应通道开关
- 测试通知端点支持 channel 过滤(all/email/server_chan),并绕过总开关与通道开关,便于配置阶段先验证通道
- 修复:测试通知路由未在 buffered-body 白名单导致 channel 参数丢失、测试时邮件分支被误触发
- 修复:sub2api 验证响应中 username 为 null 时正确回退到 email,避免误报"验证响应缺少: 用户信息"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
yangrs
2026-05-18 23:51:14 +08:00
parent d51b44d642
commit 6c16f399d4
32 changed files with 2441 additions and 82 deletions

View File

@@ -912,6 +912,19 @@ export const adminApi = {
return response.data
},
async testImportantNotification(channel: 'all' | 'email' | 'server_chan' = 'all'): Promise<{
success: boolean
message: string
channels: Array<{ channel: string; success: boolean; message: string }>
}> {
const response = await apiClient.post<{
success: boolean
message: string
channels: Array<{ channel: string; success: boolean; message: string }>
}>('/api/admin/system/important-notification/test', { channel })
return response.data
},
// 邮件模板相关
// 获取所有邮件模板
async getEmailTemplates(): Promise<EmailTemplatesResponse> {

View File

@@ -661,6 +661,7 @@ export interface ProviderWithEndpointsSummary {
failover_rules?: FailoverRulesConfig | null
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
ops_architecture_id?: string // 扩展操作使用的架构 ID如 cubence, anyrouter
ops_quota_alert_enabled?: boolean
created_at: string
updated_at: string
}

View File

@@ -127,12 +127,19 @@ export interface ActionConfigRequest {
}
/** 保存配置请求 */
export interface QuotaAlertConfig {
enabled: boolean
threshold_amount: number
fetch_interval_seconds: number
}
export interface SaveConfigRequest {
architecture_id: string
base_url?: string
connector: ConnectorConfigRequest
actions: Record<string, ActionConfigRequest>
schedule: Record<string, string>
quota_alert?: QuotaAlertConfig
}
/** 连接请求 */
@@ -190,6 +197,7 @@ export interface ProviderOpsConfigResponse {
config: Record<string, unknown>
credentials: Record<string, unknown>
}
quota_alert?: QuotaAlertConfig
}
/**

View File

@@ -1,4 +1,4 @@
import { Mail, Shield, AlertTriangle } from 'lucide-vue-next'
import { Mail, Shield, AlertTriangle, Send } from 'lucide-vue-next'
import type { LucideIcon } from 'lucide-vue-next'
export interface BuiltinTool {
@@ -15,6 +15,12 @@ export const BUILTIN_TOOLS: BuiltinTool[] = [
href: '/admin/email',
icon: Mail,
},
{
name: 'Server 酱',
description: '配置 Server 酱 SendKey 与通知模板',
href: '/admin/server-chan',
icon: Send,
},
{
name: 'IP 安全',
description: '管理 IP 黑白名单,控制系统访问权限',

View File

@@ -239,6 +239,47 @@
</template>
</template>
</template>
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
<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">
余额低于阈值时通过重要通知发送提醒
</p>
</div>
<Switch v-model="quotaAlert.enabled" />
</div>
<div
v-if="quotaAlert.enabled"
class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3"
>
<div class="space-y-2">
<Label>提醒阈值</Label>
<Input
v-model.number="quotaAlert.threshold_amount"
type="number"
min="0"
step="0.0001"
placeholder="0"
/>
</div>
<div class="space-y-2">
<Label>获取频率(秒)</Label>
<Input
v-model.number="quotaAlert.fetch_interval_seconds"
type="number"
min="30"
max="86400"
step="1"
placeholder="30"
/>
</div>
</div>
</div>
</div>
</form>
@@ -305,6 +346,7 @@ import {
getProviderOpsConfig,
deleteProviderOpsConfig,
type ArchitectureInfo,
type QuotaAlertConfig,
} from '@/api/providerOps'
import { parseApiError } from '@/utils/errorParser'
import { useToast } from '@/composables/useToast'
@@ -375,6 +417,12 @@ const architecturesLoaded = ref(false)
const selectedArchitectureId = ref('new_api')
const selectedAuthType = ref('')
const formData = ref<Record<string, unknown>>({})
const quotaAlert = ref<QuotaAlertConfig>({
enabled: false,
threshold_amount: 0,
fetch_interval_seconds: 30,
})
const savedQuotaAlertSignature = ref(quotaAlertSignature(quotaAlert.value))
// 当前架构支持的认证方式
const currentAuthTypes = computed(() => {
@@ -421,8 +469,15 @@ const canVerify = computed(() => {
})
// 保存按钮是否可用:验证成功且表单未变动
const quotaAlertChanged = computed(() => {
return quotaAlertSignature(quotaAlert.value) !== savedQuotaAlertSignature.value
})
const canSave = computed(() => {
return verifyStatus.value === 'success' && !formChanged.value
return (
(verifyStatus.value === 'success' && !formChanged.value)
|| (hasExistingConfig.value && quotaAlertChanged.value && !formChanged.value)
)
})
// 字段分组
@@ -495,6 +550,11 @@ function formatQuota(quota: number): string {
return quota.toLocaleString()
}
function finiteNumber(value: unknown): number | null {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : null
}
async function handleVerify() {
const schema = currentSchema.value
if (!schema) return
@@ -565,8 +625,10 @@ async function handleVerify() {
const displayName = result.data?.display_name || result.data?.username
const extra = result.data?.extra
let balanceText = `余额: ${formatQuota(quota)}`
if (extra && extra.balance !== undefined && extra.points !== undefined) {
balanceText = `余额: ${formatQuota(extra.balance)} | 积分: ${formatQuota(extra.points)}`
const extraBalance = finiteNumber(extra?.balance)
const extraPoints = finiteNumber(extra?.points)
if (extraBalance !== null && extraPoints !== null) {
balanceText = `余额: ${formatQuota(extraBalance)} | 积分: ${formatQuota(extraPoints)}`
}
showSuccess(`用户: ${displayName} | ${balanceText}`, '验证成功')
}
@@ -624,8 +686,10 @@ async function handleSave() {
formData.value,
props.providerWebsite,
)
request.quota_alert = normalizedQuotaAlert()
const result = await saveProviderOpsConfig(props.providerId, request)
if (result.success) {
savedQuotaAlertSignature.value = quotaAlertSignature(quotaAlert.value)
showSuccess(result.message || '配置已保存', '保存成功')
emit('saved')
emit('update:open', false)
@@ -660,6 +724,7 @@ async function handleClear() {
formChanged.value = false
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
loadQuotaAlert(null)
resetFormData()
emit('saved')
emit('update:open', false)
@@ -673,18 +738,27 @@ async function handleClear() {
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function stringOrDefault(value: unknown, fallback: string): string {
return typeof value === 'string' && value.trim() ? value : fallback
}
function loadFromConfig(config: Record<string, unknown>) {
if (!config?.connector) return
const connector = isRecord(config.connector) ? config.connector : null
if (!connector) return
hasExistingConfig.value = true
// 根据已保存的 architecture_id 选择对应架构
const architectureId = config.architecture_id || 'new_api'
const architectureId = stringOrDefault(config.architecture_id, 'new_api')
const archExists = architectures.value.some((a) => a.architecture_id === architectureId)
selectedArchitectureId.value = archExists ? architectureId : 'new_api'
// 从已保存的 connector auth_type 恢复认证方式选择
const savedAuthType = config.connector?.auth_type
const savedAuthType = stringOrDefault(connector.auth_type, '')
const authTypes = currentAuthTypes.value
if (savedAuthType && authTypes.some((t) => t.type === savedAuthType)) {
selectedAuthType.value = savedAuthType
@@ -694,7 +768,10 @@ function loadFromConfig(config: Record<string, unknown>) {
const schema = currentSchema.value
if (schema) {
const parsedData = parseConfigFromSchema(schema, config)
const parsedData = parseConfigFromSchema(schema, {
...config,
connector,
})
// 敏感字段:脱敏值放到 placeholder表单值设为空
sensitivePlaceholders.value = {}
@@ -707,6 +784,46 @@ function loadFromConfig(config: Record<string, unknown>) {
formData.value = parsedData
}
loadQuotaAlert(config.quota_alert)
}
function defaultQuotaAlert(): QuotaAlertConfig {
return {
enabled: false,
threshold_amount: 0,
fetch_interval_seconds: 30,
}
}
function normalizeQuotaAlert(value: unknown): QuotaAlertConfig {
if (!value || typeof value !== 'object') return defaultQuotaAlert()
const item = value as Record<string, unknown>
const threshold = Number(item.threshold_amount)
const interval = Number(item.fetch_interval_seconds)
return {
enabled: item.enabled === true,
threshold_amount: Number.isFinite(threshold) && threshold >= 0 ? threshold : 0,
fetch_interval_seconds: Number.isFinite(interval) && interval >= 30 ? Math.min(Math.floor(interval), 86400) : 30,
}
}
function normalizedQuotaAlert(): QuotaAlertConfig {
return normalizeQuotaAlert(quotaAlert.value)
}
function quotaAlertSignature(value: QuotaAlertConfig): string {
const normalized = normalizeQuotaAlert(value)
return JSON.stringify([
normalized.enabled,
normalized.threshold_amount,
normalized.fetch_interval_seconds,
])
}
function loadQuotaAlert(value: unknown) {
const normalized = normalizeQuotaAlert(value)
quotaAlert.value = normalized
savedQuotaAlertSignature.value = quotaAlertSignature(normalized)
}
/** 确保架构列表已加载 */
@@ -747,11 +864,13 @@ watch(
architecture_id: config.architecture_id,
base_url: config.base_url,
connector: config.connector,
quota_alert: config.quota_alert,
}
loadFromConfig(configData)
} else {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
loadQuotaAlert(null)
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
resetFormData()
@@ -759,6 +878,7 @@ watch(
} catch {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
loadQuotaAlert(null)
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
resetFormData()
@@ -768,6 +888,7 @@ watch(
} else {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
loadQuotaAlert(null)
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
resetFormData()

View File

@@ -252,6 +252,17 @@ const routes: RouteRecordRaw[] = [
component: () => importWithRetry(() => import('@/views/admin/modules/ChatPiiRedaction.vue')),
meta: { module: 'chat_pii_redaction' }
},
{
path: 'modules/important-notification',
name: 'ImportantNotificationModule',
component: () => importWithRetry(() => import('@/views/admin/modules/ImportantNotification.vue')),
meta: { module: 'important_notification' }
},
{
path: 'server-chan',
name: 'ServerChanSettings',
component: () => importWithRetry(() => import('@/views/admin/modules/ServerChanSettings.vue'))
},
{
path: 'email',
name: 'EmailSettings',

View File

@@ -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="ops@example.com&#10;admin@example.com"
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>

View File

@@ -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>