mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
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:
@@ -918,6 +918,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> {
|
||||
|
||||
@@ -712,6 +712,7 @@ export interface ProviderWithEndpointsSummary {
|
||||
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
|
||||
ops_architecture_id?: string // 扩展操作使用的架构 ID(如 cubence, anyrouter)
|
||||
kiro_simulated_cache_enabled?: boolean
|
||||
ops_quota_alert_enabled?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 黑白名单,控制系统访问权限',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -959,16 +959,16 @@ const MOCK_MODULE_DEFINITIONS: Array<Omit<ModuleStatus, 'active' | 'health'> & {
|
||||
admin_menu_order: 55,
|
||||
},
|
||||
{
|
||||
name: 'notification_email',
|
||||
display_name: '异常通知',
|
||||
description: '为 5xx 异常发送邮件通知,可在模块管理中启用或禁用',
|
||||
name: 'important_notification',
|
||||
display_name: '重要通知',
|
||||
description: '统一发送邮件和 Server 酱重要通知,供额度提醒等后台任务使用',
|
||||
category: 'integration',
|
||||
available: true,
|
||||
enabled: false,
|
||||
config_validated: false,
|
||||
config_error: '请先完成邮件配置(SMTP)',
|
||||
admin_route: null,
|
||||
admin_menu_icon: 'Mail',
|
||||
config_error: '请先完成重要通知通道配置',
|
||||
admin_route: '/admin/modules/important-notification',
|
||||
admin_menu_icon: 'BellRing',
|
||||
admin_menu_group: 'system',
|
||||
admin_menu_order: 58,
|
||||
},
|
||||
|
||||
@@ -274,6 +274,18 @@ 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')),
|
||||
meta: { module: 'important_notification' }
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'EmailSettings',
|
||||
|
||||
355
frontend/src/views/admin/modules/ImportantNotification.vue
Normal file
355
frontend/src/views/admin/modules/ImportantNotification.vue
Normal 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 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>
|
||||
205
frontend/src/views/admin/modules/ServerChanSettings.vue
Normal file
205
frontend/src/views/admin/modules/ServerChanSettings.vue
Normal 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}** {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>
|
||||
Reference in New Issue
Block a user