feat: restructure notification services

This commit is contained in:
fawney19
2026-05-22 01:45:46 +08:00
parent d5e64d6ad9
commit 504c1ccb37
28 changed files with 1941 additions and 460 deletions

View File

@@ -918,16 +918,20 @@ export const adminApi = {
return response.data
},
async testImportantNotification(channel: 'all' | 'email' | 'server_chan' = 'all'): Promise<{
async testImportantNotification(options: 'all' | 'email' | 'server_chan' | {
channel?: 'all' | 'email' | 'server_chan'
item_key?: string
} = 'all'): Promise<{
success: boolean
message: string
channels: Array<{ channel: string; success: boolean; message: string }>
}> {
const payload = typeof options === 'string' ? { channel: options } : options
const response = await apiClient.post<{
success: boolean
message: string
channels: Array<{ channel: string; success: boolean; message: string }>
}>('/api/admin/system/important-notification/test', { channel })
}>('/api/admin/system/important-notification/test', payload)
return response.data
},

View File

@@ -1,4 +1,4 @@
import { Mail, Shield, AlertTriangle, Send } from 'lucide-vue-next'
import { Mail, Shield, AlertTriangle, BellRing } from 'lucide-vue-next'
import type { LucideIcon } from 'lucide-vue-next'
export interface BuiltinTool {
@@ -16,10 +16,10 @@ export const BUILTIN_TOOLS: BuiltinTool[] = [
icon: Mail,
},
{
name: 'Server 酱',
description: '配置 Server 酱 SendKey 与通知模板',
href: '/admin/server-chan',
icon: Send,
name: '通知服务',
description: '管理通知项、模板和推送服务策略',
href: '/admin/notification-service',
icon: BellRing,
},
{
name: 'IP 安全',

View File

@@ -11,6 +11,9 @@ const authStoreMock = vi.hoisted(() => ({
}))
const routerPushMock = vi.hoisted(() => vi.fn())
const routeMock = vi.hoisted(() => ({
query: {},
}))
const toastMocks = vi.hoisted(() => ({
success: vi.fn(),
warning: vi.fn(),
@@ -27,6 +30,7 @@ const oauthApiMocks = vi.hoisted(() => ({
}))
vi.mock('vue-router', () => ({
useRoute: () => routeMock,
useRouter: () => ({
push: routerPushMock,
}),
@@ -157,6 +161,7 @@ beforeEach(() => {
authStoreMock.error = ''
authStoreMock.canAccessAdmin = false
authStoreMock.login.mockReset()
routeMock.query = {}
routerPushMock.mockReset()
toastMocks.success.mockReset()
toastMocks.warning.mockReset()

View File

@@ -247,7 +247,7 @@
额度提醒
</Label>
<p class="mt-1 text-xs text-muted-foreground">
余额低于阈值时通过重要通知发送提醒
余额低于阈值时通过通知服务发送提醒
</p>
</div>
<Switch v-model="quotaAlert.enabled" />

View File

@@ -14,7 +14,16 @@ const endpointMocks = vi.hoisted(() => ({
getAwsRegions: vi.fn(),
}))
vi.mock('@/api/endpoints', () => endpointMocks)
vi.mock('@/api/endpoints', async () => {
const actual = await vi.importActual<typeof import('@/api/endpoints/provider_oauth')>(
'@/api/endpoints/provider_oauth',
)
return {
...endpointMocks,
normalizeBatchImportCredentials: actual.normalizeBatchImportCredentials,
}
})
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
@@ -358,7 +367,7 @@ describe('OAuthAccountDialog Grok import', () => {
expect(endpointMocks.startBatchImportOAuthTask).toHaveBeenCalledWith(
'provider-1',
'sso-1\nsso-2',
'["sso-1","sso-2"]',
undefined,
)
expect(endpointMocks.importProviderRefreshToken).not.toHaveBeenCalled()

View File

@@ -214,6 +214,9 @@
</div>
<div class="rounded-lg border border-border bg-muted/30 p-3">
<div class="mb-3 text-xs font-semibold text-muted-foreground">
功能权限
</div>
<div class="flex items-center justify-between gap-3">
<Label class="text-sm font-medium">敏感信息保护</Label>
<Switch v-model="form.chat_pii_redaction_enabled" />
@@ -225,6 +228,15 @@
:disabled="!form.chat_pii_redaction_enabled"
/>
</div>
<div class="mt-3 flex items-center justify-between gap-3 border-t border-border/60 pt-3">
<div>
<Label class="text-sm font-medium">通知推送服务</Label>
<p class="mt-1 text-xs text-muted-foreground">
允许用户配置自己的第三方推送渠道
</p>
</div>
<Switch v-model="form.notification_push_service_enabled" />
</div>
</div>
</div>
</form>
@@ -271,6 +283,8 @@ import { log } from '@/utils/logger'
import { parseNumberInput } from '@/utils/form'
import {
mergeChatPiiRedactionFeatureSettings,
mergeNotificationPushServiceFeatureSettings,
readNotificationPushServiceFeatureSettings,
readChatPiiRedactionFeatureSettings,
} from '@/utils/featureSettings'
import {
@@ -323,6 +337,7 @@ const form = ref({
group_ids: [] as string[],
chat_pii_redaction_enabled: false,
chat_pii_redaction_placeholder_notice: true,
notification_push_service_enabled: false,
})
const groupOptions = computed(() => (props.groups || []).map((group) => ({
@@ -348,6 +363,7 @@ function resetForm() {
group_ids: [],
chat_pii_redaction_enabled: false,
chat_pii_redaction_placeholder_notice: true,
notification_push_service_enabled: false,
}
}
@@ -355,6 +371,7 @@ function loadUserData() {
if (!props.user) return
formNonce.value = createFieldNonce()
const redactionFeature = readChatPiiRedactionFeatureSettings(props.user.feature_settings)
const notificationPushFeature = readNotificationPushServiceFeatureSettings(props.user.feature_settings)
// 创建数组副本,避免与 props 数据共享引用
form.value = {
username: props.user.username,
@@ -368,6 +385,7 @@ function loadUserData() {
group_ids: props.user.group_ids ? [...props.user.group_ids] : [],
chat_pii_redaction_enabled: redactionFeature.enabled,
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
notification_push_service_enabled: notificationPushFeature.enabled,
}
}
@@ -442,10 +460,7 @@ async function handleSubmit() {
unlimited: form.value.unlimited,
role: form.value.role,
group_ids: [...form.value.group_ids],
feature_settings: mergeChatPiiRedactionFeatureSettings(props.user?.feature_settings, {
enabled: form.value.chat_pii_redaction_enabled,
inject_model_instruction: form.value.chat_pii_redaction_placeholder_notice,
}),
feature_settings: buildFeatureSettingsPayload(),
}
if (isEditMode.value && props.user?.id) {
@@ -472,6 +487,16 @@ async function handleSubmit() {
}
}
function buildFeatureSettingsPayload(): Record<string, unknown> | null {
const withRedaction = mergeChatPiiRedactionFeatureSettings(props.user?.feature_settings, {
enabled: form.value.chat_pii_redaction_enabled,
inject_model_instruction: form.value.chat_pii_redaction_placeholder_notice,
})
return mergeNotificationPushServiceFeatureSettings(withRedaction, {
enabled: form.value.notification_push_service_enabled,
})
}
// 设置保存状态(供父组件调用)
function setSaving(value: boolean) {
saving.value = value

View File

@@ -439,6 +439,7 @@ import {
Puzzle,
Zap,
FileUp,
Send,
Server,
SlidersHorizontal,
type LucideIcon,
@@ -748,6 +749,7 @@ const navigation = computed(() => {
Shield,
Puzzle,
Server,
Send,
SlidersHorizontal,
CreditCard,
Gift,

View File

@@ -910,6 +910,52 @@ export const MOCK_SYSTEM_CONFIGS: Array<{ key: string; value: unknown; descripti
{ key: 'default_cache_ttl', value: 3600, description: '默认缓存 TTL' },
{ key: 'fallback_enabled', value: true, description: '是否启用故障转移' },
{ key: 'max_fallback_attempts', value: 3, description: '最大故障转移次数' },
{ key: 'module.important_notification.enabled', value: false, description: '通知服务总开关' },
{ key: 'module.important_notification.email_enabled', value: false, description: '通知服务邮件推送开关' },
{ key: 'module.important_notification.email_recipients', value: '', description: '通知服务管理员收件人' },
{ key: 'module.important_notification.default_channel', value: 'all', description: '通知服务全局推送服务' },
{
key: 'module.important_notification.items',
value: [
{
key: 'provider_quota_alert',
name: '号池额度不足',
enabled: true,
channel: 'global',
title_template: '',
markdown_template: '',
text_template: '',
user_email_enabled: false,
system: true,
},
{
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,
},
{
key: 'user_balance_low',
name: '用户余额不足',
enabled: true,
channel: 'email',
title_template: '余额不足提醒',
markdown_template: '你的账户余额已低于提醒阈值,请及时处理。',
text_template: '你的账户余额已低于提醒阈值,请及时处理。',
user_email_enabled: true,
system: true,
},
],
description: '通知服务通知项和模板',
},
{ key: 'module.server_chan_push.enabled', value: false, description: 'Server 酱推送开关' },
{ key: 'module.server_chan_push.send_key', value: null, description: 'Server 酱 SendKey' },
{ key: 'module.server_chan_push.template', value: '', description: 'Server 酱推送模板' },
{ key: 'proxy_node_metrics_1m_retention_days', value: 30, description: '代理节点 1m 指标保留天数' },
{ key: 'proxy_node_metrics_1h_retention_days', value: 180, description: '代理节点 1h 指标保留天数' },
{ key: 'proxy_node_metrics_cleanup_batch_size', value: 5000, description: '代理节点指标每批次清理条数' }
@@ -960,18 +1006,32 @@ const MOCK_MODULE_DEFINITIONS: Array<Omit<ModuleStatus, 'active' | 'health'> & {
},
{
name: 'important_notification',
display_name: '重要通知',
description: '统一发送邮件和 Server 酱重要通知,供额度提醒等后台任务使用',
display_name: '通知服务',
description: '统一管理通知项、模板和推送服务选择,供后台任务和用户通知使用',
category: 'integration',
available: true,
enabled: false,
config_validated: false,
config_error: '请先完成重要通知通道配置',
admin_route: '/admin/modules/important-notification',
config_error: '请先完成通知服务推送渠道配置',
admin_route: '/admin/notification-service',
admin_menu_icon: 'BellRing',
admin_menu_group: 'system',
admin_menu_group: null,
admin_menu_order: 58,
},
{
name: 'server_chan_push',
display_name: 'Server 酱推送',
description: '第三方推送服务,配置 Server 酱 Turbo SendKey 并测试微信推送',
category: 'integration',
available: true,
enabled: false,
config_validated: false,
config_error: '请先配置 Server 酱 SendKey',
admin_route: '/admin/modules/server-chan',
admin_menu_icon: 'Send',
admin_menu_group: 'system',
admin_menu_order: 59,
},
{
name: 'chat_pii_redaction',
display_name: '敏感信息保护',

View File

@@ -1805,6 +1805,14 @@ registerDynamicRoute('GET', '/api/admin/system/configs/:configKey', async (_conf
if (!entry) {
throw { response: createMockResponse({ detail: `配置项 '${key}' 不存在` }, 404) }
}
if (key === 'module.server_chan_push.send_key') {
return createMockResponse({
key: entry.key,
value: null,
description: entry.description,
is_set: typeof entry.value === 'string' && entry.value.trim() !== '',
})
}
return createMockResponse({ key: entry.key, value: entry.value, description: entry.description })
})

View File

@@ -276,15 +276,23 @@ const routes: RouteRecordRaw[] = [
},
{
path: 'modules/important-notification',
redirect: '/admin/notification-service'
},
{
path: 'notification-service',
name: 'ImportantNotificationModule',
component: () => importWithRetry(() => import('@/views/admin/modules/ImportantNotification.vue')),
meta: { module: 'important_notification' }
},
{
path: 'server-chan',
redirect: '/admin/modules/server-chan'
},
{
path: 'modules/server-chan',
name: 'ServerChanSettings',
component: () => importWithRetry(() => import('@/views/admin/modules/ServerChanSettings.vue')),
meta: { module: 'important_notification' }
meta: { module: 'server_chan_push' }
},
{
path: 'email',

View File

@@ -0,0 +1,43 @@
function createMemoryStorage(): Storage {
const store = new Map<string, string>()
return {
get length() {
return store.size
},
clear() {
store.clear()
},
getItem(key: string) {
return store.get(String(key)) ?? null
},
key(index: number) {
return Array.from(store.keys())[index] ?? null
},
removeItem(key: string) {
store.delete(String(key))
},
setItem(key: string, value: string) {
store.set(String(key), String(value))
},
}
}
function installStorage(name: 'localStorage' | 'sessionStorage') {
const storage = createMemoryStorage()
Object.defineProperty(globalThis, name, {
value: storage,
configurable: true,
})
if (typeof window !== 'undefined') {
Object.defineProperty(window, name, {
value: storage,
configurable: true,
})
}
}
installStorage('localStorage')
installStorage('sessionStorage')

View File

@@ -3,6 +3,10 @@ export interface ChatPiiRedactionFeatureSettings {
inject_model_instruction: boolean
}
export interface NotificationPushServiceFeatureSettings {
enabled: boolean
}
export type FeatureSettingsMap = Record<string, unknown>
const DEFAULT_CHAT_PII_REDACTION_FEATURE_SETTINGS: ChatPiiRedactionFeatureSettings = {
@@ -10,6 +14,10 @@ const DEFAULT_CHAT_PII_REDACTION_FEATURE_SETTINGS: ChatPiiRedactionFeatureSettin
inject_model_instruction: true,
}
const DEFAULT_NOTIFICATION_PUSH_SERVICE_FEATURE_SETTINGS: NotificationPushServiceFeatureSettings = {
enabled: false,
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
@@ -49,3 +57,30 @@ export function mergeChatPiiRedactionFeatureSettings(
}
return Object.keys(settings).length > 0 ? settings : null
}
export function readNotificationPushServiceFeatureSettings(
featureSettings: unknown,
): NotificationPushServiceFeatureSettings {
const feature = isRecord(featureSettings)
? featureSettings.notification_push_service
: null
if (!isRecord(feature)) {
return { ...DEFAULT_NOTIFICATION_PUSH_SERVICE_FEATURE_SETTINGS }
}
return {
enabled: feature.enabled === true,
}
}
export function mergeNotificationPushServiceFeatureSettings(
featureSettings: unknown,
notificationPushService: NotificationPushServiceFeatureSettings,
): FeatureSettingsMap | null {
const settings: FeatureSettingsMap = isRecord(featureSettings)
? { ...featureSettings }
: {}
settings.notification_push_service = {
enabled: notificationPushService.enabled,
}
return Object.keys(settings).length > 0 ? settings : null
}

View File

@@ -275,6 +275,7 @@ const moduleOrder = ref<string[]>([])
const orderSaving = ref(false)
const draggedModuleName = ref<string | null>(null)
const dragOverModuleName = ref<string | null>(null)
const BUILTIN_BACKING_MODULES = new Set(['important_notification'])
// 过滤后的内置工具
const filteredBuiltinTools = computed(() => {
@@ -351,7 +352,9 @@ function moveNameToTargetIndex(names: string[], draggedName: string, targetName:
// 后端默认顺序
const defaultOrderedModules = computed(() => {
return Object.values(moduleStore.modules).sort(compareModuleDefaultOrder)
return Object.values(moduleStore.modules)
.filter(module => !BUILTIN_BACKING_MODULES.has(module.name))
.sort(compareModuleDefaultOrder)
})
// 所有模块列表(应用自定义展示顺序)

View File

@@ -1,14 +1,14 @@
<template>
<PageContainer>
<PageHeader
title="重要通知"
description="配置后台任务使用的邮件和 Server 酱通知通道"
title="通知服务"
description="统一管理通知项、通知模板和推送服务选择"
/>
<div class="mt-6 space-y-6">
<CardSection
title="模块开关"
description="启用后,额度提醒等后台任务可以发送重要通知"
title="通知服务配置"
description="选择全局推送服务,并配置邮件和第三方推送渠道"
>
<template #actions>
<Button
@@ -20,158 +20,321 @@
</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 class="space-y-6">
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
<div>
<Label class="text-sm font-medium">
启用邮件通道
<Label class="block 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>
<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>
<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>
<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>
<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"
<div>
<Label
for="notification-service-recipients"
class="block text-sm font-medium"
>
Server
</RouterLink>
配置 SendKey 后再启用
</p>
</div>
<Switch
v-model="config.server_chan_enabled"
:disabled="!serverChanKeyIsSet"
/>
</div>
管理员收件人
</Label>
<Textarea
id="notification-service-recipients"
v-model="config.email_recipients"
rows="4"
placeholder="ops@example.com&#10;admin@example.com"
class="mt-1"
/>
</div>
</section>
<p
v-if="serverChanKeyIsSet"
class="text-xs text-muted-foreground"
<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"
>
前往
<RouterLink
to="/admin/server-chan"
class="text-primary hover:underline"
>
Server
</RouterLink>
配置 SendKey 与通知模板
</p>
<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="测试通知"
description="按当前已保存配置发送一条重要通知测试"
description="按已保存配置发送测试通知"
>
<div class="flex flex-wrap gap-2">
<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"
:disabled="testingAll || !anyChannelConfigurable"
@click="testChannel('all')"
:disabled="testing || !testItemKey"
@click="handleTest"
>
{{ testingAll ? '发送中...' : '测试全部通道' }}
</Button>
<Button
variant="outline"
:disabled="testingEmail || !emailChannelConfigurable"
@click="testChannel('email')"
>
{{ testingEmail ? '发送中...' : '测试邮件' }}
<Send class="mr-1.5 h-4 w-4" />
{{ testing ? '发送中...' : '发送测试' }}
</Button>
</div>
@@ -182,7 +345,7 @@
<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"
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'">
@@ -198,55 +361,128 @@
<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 { 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'
import { modulesApi } from '@/api/modules'
import { modulesApi, type ModuleStatus } from '@/api/modules'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
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',
server_chan_enabled: 'module.important_notification.server_chan_enabled',
server_chan_send_key: 'module.important_notification.server_chan_send_key',
default_channel: 'module.important_notification.default_channel',
items: 'module.important_notification.items',
server_chan_send_key: 'module.server_chan_push.send_key',
} as const
interface ImportantNotificationConfig {
enabled: boolean
email_enabled: boolean
email_recipients: string
server_chan_enabled: boolean
}
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)
const testingAll = ref(false)
const testingEmail = ref(false)
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
const testing = ref(false)
const smtpConfigured = ref(false)
const serverChanKeyIsSet = ref(false)
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 }>>([])
const config = ref<ImportantNotificationConfig>({
const config = ref<NotificationConfig>({
enabled: false,
email_enabled: false,
email_recipients: '',
server_chan_enabled: false,
default_channel: 'all',
items: cloneDefaultItems(),
})
const emailChannelConfigurable = computed(() => {
return smtpConfigured.value && config.value.email_recipients.trim() !== ''
const emailReady = computed(() => {
return config.value.email_enabled && smtpConfigured.value && config.value.email_recipients.trim() !== ''
})
const anyChannelConfigurable = computed(() => {
return emailChannelConfigurable.value || serverChanKeyIsSet.value
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(() => {
@@ -259,7 +495,9 @@ async function loadConfig() {
moduleStatus,
emailEnabled,
recipients,
serverChanEnabled,
defaultChannel,
items,
serverChanModuleStatus,
serverChanKey,
smtpHost,
smtpFromEmail,
@@ -267,7 +505,9 @@ async function loadConfig() {
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.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'),
@@ -276,43 +516,49 @@ async function loadConfig() {
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
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)
if (!config.value.items.some(item => item.key === testItemKey.value)) {
testItemKey.value = config.value.items[0]?.key || ''
}
} catch (err) {
error(parseApiError(err, '加载重要通知配置失败'))
log.error('加载重要通知配置失败:', 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, '重要通知模块总开关')
if (!canEnableService.value) {
config.value.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 酱通道开关'),
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(), '通知服务通知项和模板'),
])
if (config.value.enabled) {
await adminApi.updateSystemConfig(CONFIG_KEYS.enabled, true, '重要通知模块总开关')
}
success('重要通知配置已保存')
await adminApi.updateSystemConfig(CONFIG_KEYS.enabled, config.value.enabled, '通知服务总开关')
success('通知服务配置已保存')
} catch (err) {
error(parseApiError(err, '保存重要通知配置失败'))
log.error('保存重要通知配置失败:', err)
error(parseApiError(err, '保存通知服务配置失败'))
log.error('保存通知服务配置失败:', err)
} finally {
saving.value = false
}
}
async function testChannel(channel: 'all' | 'email') {
setTesting(channel, true)
async function handleTest() {
testing.value = true
try {
const result = await adminApi.testImportantNotification(channel)
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 || '测试通知已发送')
@@ -321,15 +567,111 @@ async function testChannel(channel: 'all' | 'email') {
}
} catch (err) {
error(parseApiError(err, '测试通知发送失败'))
log.error('测试重要通知失败:', err)
log.error('测试通知服务失败:', err)
} finally {
setTesting(channel, false)
testing.value = false
}
}
function setTesting(channel: 'all' | 'email', value: boolean) {
if (channel === 'all') testingAll.value = value
if (channel === 'email') testingEmail.value = value
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 {
@@ -349,7 +691,10 @@ function normalizeRecipients(value: unknown): string {
function formatChannel(channel: string): string {
if (channel === 'email') return '邮件'
if (channel === 'server_chan') return 'Server 酱'
if (channel === 'user_email') return '用户邮件'
if (channel === 'module') return '模块'
if (channel === 'item') return '通知项'
if (channel === 'none') return '无可用服务'
return channel
}
</script>

View File

@@ -1,14 +1,14 @@
<template>
<PageContainer>
<PageHeader
title="Server 酱"
description="配置 Server 酱 Turbo SendKey 与微信通知模板"
title="Server 酱推送"
description="第三方推送服务,用于通知服务的 Server 酱渠道"
/>
<div class="mt-6 space-y-6">
<CardSection
title="SendKey"
description="使用 Server 酱 Turbo 官方 SendKey 推送微信通知"
title="服务配置"
description="配置 Server 酱 Turbo SendKey 和服务启用状态"
>
<template #actions>
<Button
@@ -20,29 +20,46 @@
</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 class="space-y-5">
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/70 px-4 py-3">
<div>
<Label class="text-sm font-medium">
启用 Server 酱推送
</Label>
<p class="mt-1 text-xs text-muted-foreground">
通知服务选择 Server 酱时会检查此开关
</p>
</div>
<Switch
v-model="enabled"
:disabled="!canEnable"
/>
</div>
<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>
</div>
</CardSection>
<CardSection
title="通知模板"
description="可选 Markdown 模板支持 {title} 和 {body} 变量;留空则使用默认正文"
description="Markdown 模板支持 {title} 和 {body} 变量"
>
<div>
<Label
@@ -51,32 +68,35 @@
>
模板内容
</Label>
<textarea
<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"
class="mt-1 font-mono text-sm"
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="按当前已保存配置向微信发送一条测试通知"
title="测试服务"
description="按已保存配置发送一条 Server 酱测试通知"
>
<div class="flex flex-wrap gap-2">
<Button
variant="outline"
:disabled="testing"
:disabled="testing || !sendKeyIsSet"
@click="handleTest"
>
{{ testing ? '发送中...' : '测试 Server 酱' }}
{{ testing ? '发送中...' : '发送测试' }}
</Button>
<RouterLink
to="/admin/notification-service"
class="inline-flex h-11 items-center rounded-xl px-3 text-sm text-primary hover:underline"
>
打开通知服务
</RouterLink>
</div>
<div
@@ -86,7 +106,7 @@
<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"
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'">
@@ -100,47 +120,53 @@
</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 { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { Button, Input, Label, Switch, Textarea } from '@/components/ui'
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 = {
server_chan_send_key: 'module.important_notification.server_chan_send_key',
server_chan_template: 'module.important_notification.server_chan_template',
enabled: 'module.server_chan_push.enabled',
send_key: 'module.server_chan_push.send_key',
template: 'module.server_chan_push.template',
} as const
const { success, error } = useToast()
const saving = ref(false)
const testing = ref(false)
const enabled = ref(false)
const sendKeyIsSet = ref(false)
const sendKeyInput = ref('')
const templateInput = ref('')
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
const canEnable = computed(() => sendKeyIsSet.value || sendKeyInput.value.trim() !== '')
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),
const [moduleStatus, sendKey, template] = await Promise.all([
modulesApi.getStatus('server_chan_push'),
adminApi.getSystemConfig(CONFIG_KEYS.send_key),
adminApi.getSystemConfig(CONFIG_KEYS.template),
])
enabled.value = moduleStatus.enabled === true
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)
error(parseApiError(err, '加载 Server 酱推送配置失败'))
log.error('加载 Server 酱推送配置失败:', err)
}
}
@@ -148,31 +174,25 @@ async function saveConfig() {
saving.value = true
try {
const updates: Array<Promise<unknown>> = [
adminApi.updateSystemConfig(
CONFIG_KEYS.server_chan_template,
templateInput.value,
'重要通知 Server 酱 通知模板',
),
adminApi.updateSystemConfig(CONFIG_KEYS.template, templateInput.value, 'Server 酱推送模板'),
]
const trimmedKey = sendKeyInput.value.trim()
if (trimmedKey) {
updates.push(
adminApi.updateSystemConfig(
CONFIG_KEYS.server_chan_send_key,
trimmedKey,
'重要通知 Server 酱 SendKey',
),
)
updates.push(adminApi.updateSystemConfig(CONFIG_KEYS.send_key, trimmedKey, 'Server 酱 SendKey'))
}
await Promise.all(updates)
if (trimmedKey) {
sendKeyIsSet.value = true
sendKeyInput.value = ''
}
success('Server 酱配置已保存')
if (!canEnable.value) {
enabled.value = false
}
await modulesApi.setEnabled('server_chan_push', enabled.value)
success('Server 酱推送配置已保存')
} catch (err) {
error(parseApiError(err, '保存 Server 酱配置失败'))
log.error('保存 Server 酱配置失败:', err)
error(parseApiError(err, '保存 Server 酱推送配置失败'))
log.error('保存 Server 酱推送配置失败:', err)
} finally {
saving.value = false
}
@@ -181,7 +201,7 @@ async function saveConfig() {
async function handleTest() {
testing.value = true
try {
const result = await adminApi.testImportantNotification('server_chan')
const result = await adminApi.testImportantNotification({ channel: 'server_chan' })
lastTestResult.value = result.channels || []
if (result.success) {
success(result.message || '测试通知已发送')
@@ -190,7 +210,7 @@ async function handleTest() {
}
} catch (err) {
error(parseApiError(err, '测试通知发送失败'))
log.error('测试 Server 酱失败:', err)
log.error('测试 Server 酱推送失败:', err)
} finally {
testing.value = false
}
@@ -200,6 +220,7 @@ function formatChannel(channel: string): string {
if (channel === 'server_chan') return 'Server 酱'
if (channel === 'email') return '邮件'
if (channel === 'module') return '模块'
if (channel === 'none') return '无可用服务'
return channel
}
</script>

View File

@@ -124,6 +124,25 @@
</div>
</Card>
<Card
v-if="featureSettingsForm.notificationPushServiceEnabled"
class="p-6"
>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="text-lg font-medium text-foreground">
通知推送服务
</h3>
<p class="mt-1 text-sm text-muted-foreground">
管理员已允许你配置自己的第三方推送渠道
</p>
</div>
<Badge variant="success">
已开放
</Badge>
</div>
</Card>
<!-- 密码设置LDAP 用户不显示 -->
<Card
v-if="profile?.auth_source !== 'ldap'"
@@ -499,7 +518,7 @@
邮件通知
</Label>
<p class="text-xs text-muted-foreground mt-1">
接收系统重要通知
接收系统通知邮件
</p>
</div>
<Switch
@@ -671,6 +690,7 @@ import { log } from '@/utils/logger'
import { getErrorMessage, getErrorStatus } from '@/types/api-error'
import {
mergeChatPiiRedactionFeatureSettings,
readNotificationPushServiceFeatureSettings,
readChatPiiRedactionFeatureSettings,
} from '@/utils/featureSettings'
@@ -715,6 +735,7 @@ const preferencesForm = ref({
const featureSettingsForm = ref({
chatPiiRedactionEnabled: false,
chatPiiRedactionInjectNotice: true,
notificationPushServiceEnabled: false,
})
const savingProfile = ref(false)
@@ -819,9 +840,11 @@ async function loadProfile() {
username: profile.value.username
}
const redactionFeature = readChatPiiRedactionFeatureSettings(profile.value.feature_settings)
const notificationPushFeature = readNotificationPushServiceFeatureSettings(profile.value.feature_settings)
featureSettingsForm.value = {
chatPiiRedactionEnabled: redactionFeature.enabled,
chatPiiRedactionInjectNotice: redactionFeature.inject_model_instruction,
notificationPushServiceEnabled: notificationPushFeature.enabled,
}
// 保存原始值
originalProfileForm.value = { ...profileForm.value }