feat(notification): add Bark push support

Add Bark as a notification-service delivery channel, including encrypted Device Key configuration, server URL/template settings, module status integration, and admin UI support.
This commit is contained in:
AAEE86
2026-05-22 11:08:00 +08:00
parent 3714c211dc
commit ce44d35eb6
12 changed files with 713 additions and 12 deletions

View File

@@ -918,8 +918,8 @@ export const adminApi = {
return response.data
},
async testImportantNotification(options: 'all' | 'email' | 'server_chan' | {
channel?: 'all' | 'email' | 'server_chan'
async testImportantNotification(options: 'all' | 'email' | 'server_chan' | 'bark' | {
channel?: 'all' | 'email' | 'server_chan' | 'bark'
item_key?: string
} = 'all'): Promise<{
success: boolean

View File

@@ -956,6 +956,10 @@ export const MOCK_SYSTEM_CONFIGS: Array<{ key: string; value: unknown; descripti
{ 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: 'module.bark_push.enabled', value: false, description: 'Bark 推送开关' },
{ key: 'module.bark_push.device_key', value: null, description: 'Bark Device Key' },
{ key: 'module.bark_push.server_url', value: 'https://api.day.app', description: 'Bark 服务器地址' },
{ key: 'module.bark_push.template', value: '', description: 'Bark 推送模板' },
{ 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: '代理节点指标每批次清理条数' }
@@ -1032,6 +1036,20 @@ const MOCK_MODULE_DEFINITIONS: Array<Omit<ModuleStatus, 'active' | 'health'> & {
admin_menu_group: 'system',
admin_menu_order: 59,
},
{
name: 'bark_push',
display_name: 'Bark 推送',
description: '第三方推送服务,配置 Bark Device Key 并测试 iOS 推送',
category: 'integration',
available: true,
enabled: false,
config_validated: false,
config_error: '请先配置 Bark Device Key',
admin_route: '/admin/modules/bark',
admin_menu_icon: 'Send',
admin_menu_group: 'system',
admin_menu_order: 59,
},
{
name: 'chat_pii_redaction',
display_name: '敏感信息保护',

View File

@@ -1805,7 +1805,7 @@ 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') {
if (key === 'module.server_chan_push.send_key' || key === 'module.bark_push.device_key') {
return createMockResponse({
key: entry.key,
value: null,

View File

@@ -294,6 +294,16 @@ const routes: RouteRecordRaw[] = [
component: () => importWithRetry(() => import('@/views/admin/modules/ServerChanSettings.vue')),
meta: { module: 'server_chan_push' }
},
{
path: 'bark',
redirect: '/admin/modules/bark'
},
{
path: 'modules/bark',
name: 'BarkSettings',
component: () => importWithRetry(() => import('@/views/admin/modules/BarkSettings.vue')),
meta: { module: 'bark_push' }
},
{
path: 'email',
name: 'EmailSettings',

View File

@@ -0,0 +1,271 @@
<template>
<PageContainer>
<PageHeader
title="Bark 推送"
description="第三方推送服务,用于通知服务的 Bark 渠道"
/>
<div class="mt-6 space-y-6">
<CardSection
title="服务配置"
description="配置 Bark Device Key、服务器地址和服务启用状态"
>
<template #actions>
<Button
size="sm"
:disabled="saving"
@click="saveConfig"
>
{{ saving ? '保存中...' : '保存' }}
</Button>
</template>
<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">
启用 Bark 推送
</Label>
<p class="mt-1 text-xs text-muted-foreground">
通知服务选择 Bark 时会检查此开关
</p>
</div>
<Switch
v-model="enabled"
:disabled="!canEnable"
/>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div>
<Label
for="bark-device-key"
class="block text-sm font-medium"
>
Device Key
</Label>
<Input
id="bark-device-key"
v-model="deviceKeyInput"
masked
:placeholder="deviceKeyIsSet ? '已设置(留空保持不变)' : '从 Bark App 推送地址中获取'"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
Bark App 中推送地址
<span class="font-mono">https://api.day.app/xxxx</span>
<span class="font-mono">xxxx</span> 部分
</p>
</div>
<div>
<Label
for="bark-server-url"
class="block text-sm font-medium"
>
服务器地址
</Label>
<Input
id="bark-server-url"
v-model="serverUrlInput"
placeholder="https://api.day.app"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
支持官方服务或自建 Bark Server保存时会去掉末尾斜杠
</p>
</div>
</div>
</div>
</CardSection>
<CardSection
title="通知模板"
description="模板支持 {title} 和 {body} 变量"
>
<div>
<Label
for="bark-template"
class="block text-sm font-medium"
>
模板内容
</Label>
<Textarea
id="bark-template"
v-model="templateInput"
rows="10"
class="mt-1 font-mono text-sm"
placeholder="{body}"
spellcheck="false"
/>
</div>
</CardSection>
<CardSection
title="测试服务"
description="按已保存配置发送一条 Bark 测试通知"
>
<div class="flex flex-wrap gap-2">
<Button
variant="outline"
:disabled="testing || !deviceKeyIsSet"
@click="handleTest"
>
{{ 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
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 gap-4 rounded-md border border-border px-3 py-2 text-sm"
>
<span>{{ formatChannel(item.channel) }}</span>
<span :class="item.success ? 'text-green-600 dark:text-green-400' : 'text-destructive'">
{{ item.message }}
</span>
</div>
</div>
</CardSection>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
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 = {
enabled: 'module.bark_push.enabled',
device_key: 'module.bark_push.device_key',
server_url: 'module.bark_push.server_url',
template: 'module.bark_push.template',
} as const
const DEFAULT_SERVER_URL = 'https://api.day.app'
const { success, error } = useToast()
const saving = ref(false)
const testing = ref(false)
const enabled = ref(false)
const deviceKeyIsSet = ref(false)
const deviceKeyInput = ref('')
const serverUrlInput = ref(DEFAULT_SERVER_URL)
const templateInput = ref('')
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
const canEnable = computed(() => deviceKeyIsSet.value || deviceKeyInput.value.trim() !== '')
onMounted(() => {
loadConfig()
})
async function loadConfig() {
try {
const [moduleStatus, deviceKey, serverUrl, template] = await Promise.all([
modulesApi.getStatus('bark_push'),
adminApi.getSystemConfig(CONFIG_KEYS.device_key),
adminApi.getSystemConfig(CONFIG_KEYS.server_url),
adminApi.getSystemConfig(CONFIG_KEYS.template),
])
enabled.value = moduleStatus.enabled === true
deviceKeyIsSet.value = deviceKey.is_set === true
deviceKeyInput.value = ''
serverUrlInput.value = typeof serverUrl.value === 'string' && serverUrl.value.trim()
? serverUrl.value
: DEFAULT_SERVER_URL
templateInput.value = typeof template.value === 'string' ? template.value : ''
} catch (err) {
error(parseApiError(err, '加载 Bark 推送配置失败'))
log.error('加载 Bark 推送配置失败:', err)
}
}
async function saveConfig() {
saving.value = true
try {
const updates: Array<Promise<unknown>> = [
adminApi.updateSystemConfig(
CONFIG_KEYS.server_url,
normalizeServerUrl(serverUrlInput.value),
'Bark 服务器地址'
),
adminApi.updateSystemConfig(CONFIG_KEYS.template, templateInput.value, 'Bark 推送模板'),
]
const trimmedKey = deviceKeyInput.value.trim()
if (trimmedKey) {
updates.push(adminApi.updateSystemConfig(
CONFIG_KEYS.device_key,
trimmedKey,
'Bark Device Key'
))
}
await Promise.all(updates)
if (trimmedKey) {
deviceKeyIsSet.value = true
deviceKeyInput.value = ''
}
if (!canEnable.value) {
enabled.value = false
}
await modulesApi.setEnabled('bark_push', enabled.value)
success('Bark 推送配置已保存')
} catch (err) {
error(parseApiError(err, '保存 Bark 推送配置失败'))
log.error('保存 Bark 推送配置失败:', err)
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
const result = await adminApi.testImportantNotification({ channel: 'bark' })
lastTestResult.value = result.channels || []
if (result.success) {
success(result.message || '测试通知已发送')
} else {
error(result.message || '测试通知发送失败')
}
} catch (err) {
error(parseApiError(err, '测试通知发送失败'))
log.error('测试 Bark 推送失败:', err)
} finally {
testing.value = false
}
}
function normalizeServerUrl(value: string): string {
const trimmed = value.trim().replace(/\/+$/, '')
return trimmed || DEFAULT_SERVER_URL
}
function formatChannel(channel: string): string {
if (channel === 'bark') return 'Bark'
if (channel === 'server_chan') return 'Server 酱'
if (channel === 'email') return '邮件'
if (channel === 'module') return '模块'
if (channel === 'none') return '无可用服务'
return channel
}
</script>

View File

@@ -40,6 +40,9 @@
<SelectItem value="server_chan">
Server
</SelectItem>
<SelectItem value="bark">
Bark
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -60,7 +63,7 @@
</div>
</div>
<div class="grid gap-6 border-t border-border/60 pt-5 lg:grid-cols-2">
<div class="grid gap-6 border-t border-border/60 pt-5 lg:grid-cols-3">
<section class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
@@ -130,6 +133,31 @@
配置 Server 酱推送
</RouterLink>
</section>
<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">
Bark
</Label>
<Badge :variant="barkReady ? 'success' : 'outline'">
{{ barkReady ? '可用' : '未就绪' }}
</Badge>
</div>
<p class="mt-1 text-xs text-muted-foreground">
通过 Bark iOS 设备推送通知
</p>
</div>
</div>
<RouterLink
to="/admin/modules/bark"
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"
>
配置 Bark 推送
</RouterLink>
</section>
</div>
</div>
</CardSection>
@@ -231,6 +259,9 @@
<SelectItem value="server_chan">
Server
</SelectItem>
<SelectItem value="bark">
Bark
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -326,6 +357,9 @@
<SelectItem value="server_chan">
Server
</SelectItem>
<SelectItem value="bark">
Bark
</SelectItem>
</SelectContent>
</Select>
<Button
@@ -382,7 +416,7 @@ import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
type DeliveryChannel = 'global' | 'all' | 'email' | 'server_chan'
type DeliveryChannel = 'global' | 'all' | 'email' | 'server_chan' | 'bark'
interface NotificationItem {
local_id: string
@@ -412,6 +446,7 @@ const CONFIG_KEYS = {
default_channel: 'module.important_notification.default_channel',
items: 'module.important_notification.items',
server_chan_send_key: 'module.server_chan_push.send_key',
bark_device_key: 'module.bark_push.device_key',
} as const
const DEFAULT_ITEMS: NotificationItem[] = [
@@ -460,6 +495,8 @@ const testing = ref(false)
const smtpConfigured = ref(false)
const serverChanKeyIsSet = ref(false)
const serverChanStatus = ref<ModuleStatus | null>(null)
const barkKeyIsSet = ref(false)
const barkStatus = 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 }>>([])
@@ -480,6 +517,10 @@ const serverChanReady = computed(() => {
return serverChanStatus.value?.enabled === true && serverChanKeyIsSet.value
})
const barkReady = computed(() => {
return barkStatus.value?.enabled === true && barkKeyIsSet.value
})
const canEnableService = computed(() => {
if (deliveryReady(config.value.default_channel)) return true
return config.value.items.some(item => item.enabled && isItemReady(item))
@@ -499,6 +540,8 @@ async function loadConfig() {
items,
serverChanModuleStatus,
serverChanKey,
barkModuleStatus,
barkDeviceKey,
smtpHost,
smtpFromEmail,
] = await Promise.all([
@@ -509,6 +552,8 @@ async function loadConfig() {
adminApi.getSystemConfig(CONFIG_KEYS.items),
modulesApi.getStatus('server_chan_push'),
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_send_key),
modulesApi.getStatus('bark_push'),
adminApi.getSystemConfig(CONFIG_KEYS.bark_device_key),
adminApi.getSystemConfig('smtp_host'),
adminApi.getSystemConfig('smtp_from_email'),
])
@@ -520,6 +565,8 @@ async function loadConfig() {
config.value.items = normalizeItems(items.value)
serverChanStatus.value = serverChanModuleStatus
serverChanKeyIsSet.value = serverChanKey.is_set === true
barkStatus.value = barkModuleStatus
barkKeyIsSet.value = barkDeviceKey.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 || ''
@@ -604,9 +651,10 @@ function isItemReady(item: NotificationItem): boolean {
}
function deliveryReady(channel: Exclude<DeliveryChannel, 'global'>): boolean {
if (channel === 'all') return emailReady.value || serverChanReady.value
if (channel === 'all') return emailReady.value || serverChanReady.value || barkReady.value
if (channel === 'email') return emailReady.value
if (channel === 'server_chan') return serverChanReady.value
if (channel === 'bark') return barkReady.value
return false
}
@@ -661,12 +709,12 @@ function normalizeItemKey(value: unknown): string {
}
function normalizeItemChannel(value: unknown): DeliveryChannel {
if (value === 'all' || value === 'email' || value === 'server_chan') return value
if (value === 'all' || value === 'email' || value === 'server_chan' || value === 'bark') return value
return 'global'
}
function normalizeDefaultChannel(value: unknown): Exclude<DeliveryChannel, 'global'> {
if (value === 'email' || value === 'server_chan') return value
if (value === 'email' || value === 'server_chan' || value === 'bark') return value
return 'all'
}
@@ -691,6 +739,7 @@ function normalizeRecipients(value: unknown): string {
function formatChannel(channel: string): string {
if (channel === 'email') return '邮件'
if (channel === 'server_chan') return 'Server 酱'
if (channel === 'bark') return 'Bark'
if (channel === 'user_email') return '用户邮件'
if (channel === 'module') return '模块'
if (channel === 'item') return '通知项'