mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
Merge remote-tracking branch 'origin/main' into fix/gemini-cli-v1internal
# Conflicts: # apps/aether-gateway/src/ai_serving/transport.rs # apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/parse.rs # apps/aether-gateway/src/handlers/shared/catalog.rs # crates/aether-admin/src/provider/quota.rs # crates/aether-model-fetch/src/strategy.rs # crates/aether-provider-pool/src/lib.rs # crates/aether-provider-pool/src/service.rs # crates/aether-provider-transport/src/provider_types.rs # frontend/src/features/providers/components/ProviderDetailDrawer.vue # frontend/src/utils/__tests__/providerKeyQuota.spec.ts # frontend/src/utils/providerKeyQuota.ts # frontend/src/views/admin/PoolManagement.vue
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
// 所有模块列表(应用自定义展示顺序)
|
||||
|
||||
@@ -2089,6 +2089,7 @@ const showAccountQuotaColumn = computed(() => {
|
||||
return selectedProviderType.value === 'codex'
|
||||
|| selectedProviderType.value === 'gemini_cli'
|
||||
|| selectedProviderType.value === 'kiro'
|
||||
|| selectedProviderType.value === 'windsurf'
|
||||
|| selectedProviderType.value === 'antigravity'
|
||||
|| selectedProviderType.value === 'grok'
|
||||
|| selectedProviderType.value === 'chatgpt_web'
|
||||
@@ -2495,6 +2496,7 @@ const quotaRefreshSupported = computed(() => {
|
||||
return selectedProviderType.value === 'codex'
|
||||
|| selectedProviderType.value === 'kiro'
|
||||
|| selectedProviderType.value === 'gemini_cli'
|
||||
|| selectedProviderType.value === 'windsurf'
|
||||
|| selectedProviderType.value === 'antigravity'
|
||||
|| selectedProviderType.value === 'grok'
|
||||
|| selectedProviderType.value === 'chatgpt_web'
|
||||
@@ -3642,11 +3644,15 @@ function getQuotaAlertSnapshotState(key: PoolKeyDetail): { label: string, title:
|
||||
if (!quota) return null
|
||||
|
||||
const code = String(quota.code || '').trim().toLowerCase()
|
||||
if (code !== 'banned' && code !== 'forbidden') return null
|
||||
if (!['banned', 'forbidden', 'quarantined', 'rate_limited', 'exhausted'].includes(code)) return null
|
||||
|
||||
let label = String(quota.label || '').trim()
|
||||
if (!label) {
|
||||
label = code === 'banned' ? '账号封禁' : '访问受限'
|
||||
if (code === 'banned') label = '账号封禁'
|
||||
else if (code === 'forbidden') label = '访问受限'
|
||||
else if (code === 'quarantined') label = '账号隔离'
|
||||
else if (code === 'rate_limited') label = '速率受限'
|
||||
else label = '额度耗尽'
|
||||
} else if (label === '账号已封禁' || label === '封禁') {
|
||||
label = '账号封禁'
|
||||
}
|
||||
@@ -3703,6 +3709,7 @@ function normalizeQuotaLabel(label: string): string {
|
||||
}
|
||||
|
||||
function getQuotaProgressLabel(label: string): string {
|
||||
if (label === '日') return '日'
|
||||
if (label === '5H') return '5H'
|
||||
if (label === '周') return '周'
|
||||
if (label === 'Spark5H') return 'Spark5H'
|
||||
@@ -3772,14 +3779,19 @@ function getQuotaLabelOrder(label: string): number {
|
||||
if (label === 'Expert') return 2
|
||||
if (label === 'Heavy') return 3
|
||||
if (label === 'Grok 4.3') return 4
|
||||
if (label === '5H') return 0
|
||||
if (label === '周') return 1
|
||||
if (label === 'Spark5H') return 2
|
||||
if (label === 'Spark周') return 3
|
||||
if (label === '剩余') return 4
|
||||
if (label === '最低') return 5
|
||||
if (label === '生图') return 6
|
||||
return 10
|
||||
if (label === '日') return 5
|
||||
if (label === '5H') return 6
|
||||
if (label === '周') return 7
|
||||
if (label === 'Spark5H') return 8
|
||||
if (label === 'Spark周') return 9
|
||||
if (label === 'Prompt') return 10
|
||||
if (label === 'Flex') return 11
|
||||
if (label === '剩余') return 12
|
||||
if (label === '最低') return 13
|
||||
if (label === '生图') return 14
|
||||
if (label === '速率') return 15
|
||||
if (label === '模型') return 16
|
||||
return 20
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
@@ -4029,6 +4041,57 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
|
||||
}]
|
||||
}
|
||||
|
||||
if (providerType === 'windsurf') {
|
||||
const items: QuotaProgressItem[] = []
|
||||
for (const [label, code] of [
|
||||
['日', 'daily'],
|
||||
['周', 'weekly'],
|
||||
['Prompt', 'prompt'],
|
||||
['Flex', 'flex'],
|
||||
] as const) {
|
||||
const window = getQuotaSnapshotWindow(quota, code)
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (remainingPercent == null) continue
|
||||
const detail = typeof window?.used_value === 'number' && typeof window?.limit_value === 'number'
|
||||
? `${formatQuotaValue(window.used_value)}/${formatQuotaValue(window.limit_value)}`
|
||||
: typeof window?.remaining_value === 'number' && typeof window?.limit_value === 'number'
|
||||
? `剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
|
||||
: undefined
|
||||
items.push({
|
||||
label,
|
||||
remainingPercent,
|
||||
detail,
|
||||
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? null),
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
})
|
||||
}
|
||||
|
||||
const rateLimitWindow = getQuotaSnapshotWindow(quota, 'rate_limit')
|
||||
if (rateLimitWindow) {
|
||||
items.push({
|
||||
label: '速率',
|
||||
remainingPercent: rateLimitWindow.is_exhausted ? 0 : 100,
|
||||
resetAtSeconds: normalizeUnixSeconds(rateLimitWindow.reset_at ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(rateLimitWindow.reset_seconds ?? null),
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof quota.allowed_models_count === 'number' && Number.isFinite(quota.allowed_models_count)) {
|
||||
items.push({
|
||||
label: '模型',
|
||||
remainingPercent: 100,
|
||||
detail: `${quota.allowed_models_count} 个`,
|
||||
resetAtSeconds: null,
|
||||
resetSeconds: null,
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
if (providerType === 'antigravity') {
|
||||
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
|
||||
if (windows.length === 0) return []
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
:merge-mode="mergeMode"
|
||||
:merge-mode-select-open="mergeModeSelectOpen"
|
||||
:import-loading="importLoading"
|
||||
:import-progress="importProgress"
|
||||
@confirm="confirmImport"
|
||||
@update:import-dialog-open="importDialogOpen = $event"
|
||||
@update:import-result-dialog-open="importResultDialogOpen = $event"
|
||||
@@ -214,6 +215,7 @@
|
||||
:users-merge-mode="usersMergeMode"
|
||||
:users-merge-mode-select-open="usersMergeModeSelectOpen"
|
||||
:import-users-loading="importUsersLoading"
|
||||
:import-users-progress="importUsersProgress"
|
||||
@confirm="confirmImportUsers"
|
||||
@update:import-users-dialog-open="importUsersDialogOpen = $event"
|
||||
@update:import-users-result-dialog-open="importUsersResultDialogOpen = $event"
|
||||
@@ -221,7 +223,7 @@
|
||||
@update:users-merge-mode-select-open="usersMergeModeSelectOpen = $event"
|
||||
/>
|
||||
|
||||
<!-- 聚合数据导入对话框 -->
|
||||
<!-- 完整备份导入对话框 -->
|
||||
<AggregateImportDialog
|
||||
:aggregate-import-dialog-open="aggregateImportDialogOpen"
|
||||
:aggregate-import-result-dialog-open="aggregateImportResultDialogOpen"
|
||||
@@ -230,6 +232,7 @@
|
||||
:aggregate-merge-mode="aggregateMergeMode"
|
||||
:aggregate-merge-mode-select-open="aggregateMergeModeSelectOpen"
|
||||
:import-aggregate-loading="importAggregateLoading"
|
||||
:import-aggregate-progress="importAggregateProgress"
|
||||
@confirm="confirmImportAggregate"
|
||||
@update:aggregate-import-dialog-open="aggregateImportDialogOpen = $event"
|
||||
@update:aggregate-import-result-dialog-open="aggregateImportResultDialogOpen = $event"
|
||||
@@ -364,6 +367,7 @@ const {
|
||||
importResult,
|
||||
mergeMode,
|
||||
mergeModeSelectOpen,
|
||||
importProgress,
|
||||
handleExportConfig,
|
||||
handleConfigFileSelect,
|
||||
confirmImport,
|
||||
@@ -375,6 +379,7 @@ const {
|
||||
importUsersResult,
|
||||
usersMergeMode,
|
||||
usersMergeModeSelectOpen,
|
||||
importUsersProgress,
|
||||
handleExportUsers,
|
||||
handleUsersFileSelect,
|
||||
confirmImportUsers,
|
||||
@@ -386,6 +391,7 @@ const {
|
||||
aggregateImportResult,
|
||||
aggregateMergeMode,
|
||||
aggregateMergeModeSelectOpen,
|
||||
importAggregateProgress,
|
||||
handleExportAggregate,
|
||||
handleAggregateFileSelect,
|
||||
confirmImportAggregate,
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,749 @@
|
||||
<template>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="通知服务"
|
||||
description="统一管理通知项、通知模板和推送服务选择"
|
||||
/>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<CardSection
|
||||
title="通知服务配置"
|
||||
description="选择全局推送服务,并配置邮件和第三方推送渠道"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="saving"
|
||||
@click="saveConfig"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">
|
||||
全局推送服务
|
||||
</Label>
|
||||
<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>
|
||||
<SelectItem value="bark">
|
||||
Bark
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="notification-service-recipients"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
管理员收件人
|
||||
</Label>
|
||||
<Textarea
|
||||
id="notification-service-recipients"
|
||||
v-model="config.email_recipients"
|
||||
rows="4"
|
||||
placeholder="[email protected] [email protected]"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<CardSection
|
||||
title="通知项"
|
||||
description="每个通知项可以继承全局服务,也可以单独指定推送服务"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="addItem"
|
||||
>
|
||||
<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>
|
||||
<SelectItem value="bark">
|
||||
Bark
|
||||
</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="按已保存配置发送测试通知"
|
||||
>
|
||||
<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>
|
||||
<SelectItem value="bark">
|
||||
Bark
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="testing || !testItemKey"
|
||||
@click="handleTest"
|
||||
>
|
||||
<Send class="mr-1.5 h-4 w-4" />
|
||||
{{ testing ? '发送中...' : '发送测试' }}
|
||||
</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 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 { 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, 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' | 'bark'
|
||||
|
||||
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',
|
||||
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[] = [
|
||||
{
|
||||
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 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 }>>([])
|
||||
|
||||
const config = ref<NotificationConfig>({
|
||||
enabled: false,
|
||||
email_enabled: false,
|
||||
email_recipients: '',
|
||||
default_channel: 'all',
|
||||
items: cloneDefaultItems(),
|
||||
})
|
||||
|
||||
const emailReady = computed(() => {
|
||||
return config.value.email_enabled && smtpConfigured.value && config.value.email_recipients.trim() !== ''
|
||||
})
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const [
|
||||
moduleStatus,
|
||||
emailEnabled,
|
||||
recipients,
|
||||
defaultChannel,
|
||||
items,
|
||||
serverChanModuleStatus,
|
||||
serverChanKey,
|
||||
barkModuleStatus,
|
||||
barkDeviceKey,
|
||||
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.default_channel),
|
||||
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'),
|
||||
])
|
||||
|
||||
config.value.enabled = moduleStatus.enabled === true
|
||||
config.value.email_enabled = emailEnabled.value === true
|
||||
config.value.email_recipients = normalizeRecipients(recipients.value)
|
||||
config.value.default_channel = normalizeDefaultChannel(defaultChannel.value)
|
||||
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 || ''
|
||||
}
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '加载通知服务配置失败'))
|
||||
log.error('加载通知服务配置失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
saving.value = true
|
||||
try {
|
||||
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.default_channel, config.value.default_channel, '通知服务全局推送服务'),
|
||||
adminApi.updateSystemConfig(CONFIG_KEYS.items, serializeItems(), '通知服务通知项和模板'),
|
||||
])
|
||||
await adminApi.updateSystemConfig(CONFIG_KEYS.enabled, config.value.enabled, '通知服务总开关')
|
||||
success('通知服务配置已保存')
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '保存通知服务配置失败'))
|
||||
log.error('保存通知服务配置失败:', err)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
testing.value = true
|
||||
try {
|
||||
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 || '测试通知已发送')
|
||||
} else {
|
||||
error(result.message || '测试通知发送失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '测试通知发送失败'))
|
||||
log.error('测试通知服务失败:', err)
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 || barkReady.value
|
||||
if (channel === 'email') return emailReady.value
|
||||
if (channel === 'server_chan') return serverChanReady.value
|
||||
if (channel === 'bark') return barkReady.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' || value === 'bark') return value
|
||||
return 'global'
|
||||
}
|
||||
|
||||
function normalizeDefaultChannel(value: unknown): Exclude<DeliveryChannel, 'global'> {
|
||||
if (value === 'email' || value === 'server_chan' || value === 'bark') return value
|
||||
return 'all'
|
||||
}
|
||||
|
||||
function cloneDefaultItems(): NotificationItem[] {
|
||||
return DEFAULT_ITEMS.map(item => ({ ...item }))
|
||||
}
|
||||
|
||||
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 === 'bark') return 'Bark'
|
||||
if (channel === 'user_email') return '用户邮件'
|
||||
if (channel === 'module') return '模块'
|
||||
if (channel === 'item') return '通知项'
|
||||
if (channel === 'none') return '无可用服务'
|
||||
return channel
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Server 酱推送"
|
||||
description="第三方推送服务,用于通知服务的 Server 酱渠道"
|
||||
/>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<CardSection
|
||||
title="服务配置"
|
||||
description="配置 Server 酱 Turbo SendKey 和服务启用状态"
|
||||
>
|
||||
<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">
|
||||
启用 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} 变量"
|
||||
>
|
||||
<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 font-mono text-sm"
|
||||
placeholder="**{title}** {body}"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<CardSection
|
||||
title="测试服务"
|
||||
description="按已保存配置发送一条 Server 酱测试通知"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="testing || !sendKeyIsSet"
|
||||
@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.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 [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)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
saving.value = true
|
||||
try {
|
||||
const updates: Array<Promise<unknown>> = [
|
||||
adminApi.updateSystemConfig(CONFIG_KEYS.template, templateInput.value, 'Server 酱推送模板'),
|
||||
]
|
||||
const trimmedKey = sendKeyInput.value.trim()
|
||||
if (trimmedKey) {
|
||||
updates.push(adminApi.updateSystemConfig(CONFIG_KEYS.send_key, trimmedKey, 'Server 酱 SendKey'))
|
||||
}
|
||||
await Promise.all(updates)
|
||||
if (trimmedKey) {
|
||||
sendKeyIsSet.value = true
|
||||
sendKeyInput.value = ''
|
||||
}
|
||||
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)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
testing.value = true
|
||||
try {
|
||||
const result = await adminApi.testImportantNotification({ channel: '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 '模块'
|
||||
if (channel === 'none') return '无可用服务'
|
||||
return channel
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<!-- 聚合数据导入对话框 -->
|
||||
<!-- 完整备份导入对话框 -->
|
||||
<Dialog
|
||||
:open="aggregateImportDialogOpen"
|
||||
title="导入聚合数据"
|
||||
title="导入完整备份"
|
||||
description="选择冲突处理模式并确认导入"
|
||||
@update:open="$emit('update:aggregateImportDialogOpen', $event)"
|
||||
>
|
||||
@@ -12,7 +12,7 @@
|
||||
class="text-sm"
|
||||
>
|
||||
<p class="font-medium mb-2">
|
||||
聚合数据预览
|
||||
完整备份预览
|
||||
</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-muted-foreground">
|
||||
<div>
|
||||
@@ -84,8 +84,24 @@
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
注意:聚合数据会先导入配置数据,再导入用户数据;用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY。
|
||||
注意:完整备份会先导入配置数据,再导入用户数据;文件包含用户、用户组、API Keys 与钱包快照,用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY。
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="importAggregateProgress"
|
||||
class="space-y-2 rounded-md border border-border p-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>{{ importAggregateProgress.message }}</span>
|
||||
<span>{{ importAggregateProgress.percent }}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full bg-primary transition-all"
|
||||
:style="{ width: `${importAggregateProgress.percent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -104,10 +120,10 @@
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 聚合数据导入结果对话框 -->
|
||||
<!-- 完整备份导入结果对话框 -->
|
||||
<Dialog
|
||||
:open="aggregateImportResultDialogOpen"
|
||||
title="聚合数据导入完成"
|
||||
title="完整备份导入完成"
|
||||
@update:open="$emit('update:aggregateImportResultDialogOpen', $event)"
|
||||
>
|
||||
<div
|
||||
@@ -174,6 +190,7 @@ import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import type { AggregateExportData, AggregateImportResponse } from '@/api/admin'
|
||||
import type { ImportProgressState } from './composables/useConfigExportImport'
|
||||
|
||||
const props = defineProps<{
|
||||
aggregateImportDialogOpen: boolean
|
||||
@@ -183,6 +200,7 @@ const props = defineProps<{
|
||||
aggregateMergeMode: 'skip' | 'overwrite' | 'error'
|
||||
aggregateMergeModeSelectOpen: boolean
|
||||
importAggregateLoading: boolean
|
||||
importAggregateProgress: ImportProgressState | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -74,6 +74,22 @@
|
||||
<p class="text-xs text-muted-foreground">
|
||||
注意:相同的 API Keys 会自动跳过,不会创建重复记录。
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="importProgress"
|
||||
class="space-y-2 rounded-md border border-border p-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>{{ importProgress.message }}</span>
|
||||
<span>{{ importProgress.percent }}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full bg-primary transition-all"
|
||||
:style="{ width: `${importProgress.percent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -220,6 +236,7 @@ import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import type { ConfigExportData, ConfigImportResponse } from '@/api/admin'
|
||||
import type { ImportProgressState } from './composables/useConfigExportImport'
|
||||
|
||||
defineProps<{
|
||||
importDialogOpen: boolean
|
||||
@@ -229,6 +246,7 @@ defineProps<{
|
||||
mergeMode: 'skip' | 'overwrite' | 'error'
|
||||
mergeModeSelectOpen: boolean
|
||||
importLoading: boolean
|
||||
importProgress: ImportProgressState | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -209,10 +209,10 @@ const dataItems = computed<DataItem[]>(() => [
|
||||
},
|
||||
{
|
||||
key: 'aggregate',
|
||||
title: '聚合数据',
|
||||
description: '配置数据和用户数据的一体化备份文件',
|
||||
exportLabel: '导出聚合',
|
||||
importLabel: '导入聚合',
|
||||
title: '完整备份',
|
||||
description: '配置与用户数据的一体化备份,包含用户、用户组、API Keys 与钱包快照',
|
||||
exportLabel: '导出备份',
|
||||
importLabel: '导入备份',
|
||||
icon: markRaw(Layers3),
|
||||
exportLoading: props.aggregateExportLoading,
|
||||
importLoading: props.aggregateImportLoading,
|
||||
|
||||
@@ -67,6 +67,22 @@
|
||||
<p class="text-xs text-muted-foreground">
|
||||
注意:用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY 环境变量才能正常工作。
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="importUsersProgress"
|
||||
class="space-y-2 rounded-md border border-border p-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>{{ importUsersProgress.message }}</span>
|
||||
<span>{{ importUsersProgress.percent }}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full bg-primary transition-all"
|
||||
:style="{ width: `${importUsersProgress.percent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -175,6 +191,7 @@ import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import type { UsersExportData, UsersImportResponse } from '@/api/admin'
|
||||
import type { ImportProgressState } from './composables/useConfigExportImport'
|
||||
|
||||
defineProps<{
|
||||
importUsersDialogOpen: boolean
|
||||
@@ -184,6 +201,7 @@ defineProps<{
|
||||
usersMergeMode: 'skip' | 'overwrite' | 'error'
|
||||
usersMergeModeSelectOpen: boolean
|
||||
importUsersLoading: boolean
|
||||
importUsersProgress: ImportProgressState | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import type { AxiosProgressEvent } from 'axios'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
adminApi,
|
||||
@@ -22,6 +23,11 @@ const MAX_AGGREGATE_FILE_SIZE = MAX_AGGREGATE_FILE_SIZE_MB * BYTES_PER_MB
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
export interface ImportProgressState {
|
||||
percent: number
|
||||
message: string
|
||||
}
|
||||
|
||||
function asJsonObject(value: unknown): JsonObject | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as JsonObject
|
||||
@@ -56,6 +62,47 @@ function fileSizeLimitMessage(limitMb: number): string {
|
||||
return `文件大小不能超过 ${limitMb}MB`
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= BYTES_PER_MB) {
|
||||
return `${(bytes / BYTES_PER_MB).toFixed(1)}MB`
|
||||
}
|
||||
if (bytes >= 1024) {
|
||||
return `${Math.round(bytes / 1024)}KB`
|
||||
}
|
||||
return `${bytes}B`
|
||||
}
|
||||
|
||||
function setImportProgress(
|
||||
target: { value: ImportProgressState | null },
|
||||
percent: number,
|
||||
message: string,
|
||||
) {
|
||||
target.value = {
|
||||
percent: Math.max(0, Math.min(100, Math.round(percent))),
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
function buildUploadProgressHandler(
|
||||
target: { value: ImportProgressState | null },
|
||||
label: string,
|
||||
) {
|
||||
return (event: AxiosProgressEvent) => {
|
||||
if (!event.total) {
|
||||
setImportProgress(target, 15, `${label}上传中:${formatBytes(event.loaded)}`)
|
||||
return
|
||||
}
|
||||
|
||||
const uploadPercent = Math.min(85, 10 + (event.loaded / event.total) * 75)
|
||||
const loaded = formatBytes(event.loaded)
|
||||
const total = formatBytes(event.total)
|
||||
const message = event.loaded >= event.total
|
||||
? `${label}已上传,服务端正在校验并写入数据`
|
||||
: `${label}上传中:${loaded} / ${total}`
|
||||
setImportProgress(target, uploadPercent, message)
|
||||
}
|
||||
}
|
||||
|
||||
function downloadJson(data: unknown, filename: string) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -81,6 +128,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
const importResult = ref<ConfigImportResponse | null>(null)
|
||||
const mergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
||||
const mergeModeSelectOpen = ref(false)
|
||||
const importProgress = ref<ImportProgressState | null>(null)
|
||||
|
||||
// 用户数据导出/导入相关
|
||||
const exportUsersLoading = ref(false)
|
||||
@@ -92,8 +140,9 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
const importUsersResult = ref<UsersImportResponse | null>(null)
|
||||
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
||||
const usersMergeModeSelectOpen = ref(false)
|
||||
const importUsersProgress = ref<ImportProgressState | null>(null)
|
||||
|
||||
// 聚合数据导出/导入相关
|
||||
// 完整备份导出/导入相关
|
||||
const exportAggregateLoading = ref(false)
|
||||
const importAggregateLoading = ref(false)
|
||||
const aggregateImportDialogOpen = ref(false)
|
||||
@@ -102,6 +151,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
const aggregateImportResult = ref<AggregateImportResponse | null>(null)
|
||||
const aggregateMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
|
||||
const aggregateMergeModeSelectOpen = ref(false)
|
||||
const importAggregateProgress = ref<ImportProgressState | null>(null)
|
||||
|
||||
// 导出配置
|
||||
async function handleExportConfig() {
|
||||
@@ -182,11 +232,16 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
if (!importPreview.value) return
|
||||
|
||||
importLoading.value = true
|
||||
setImportProgress(importProgress, 5, '准备提交配置数据')
|
||||
await nextTick()
|
||||
try {
|
||||
const result = await adminApi.importConfig({
|
||||
...importPreview.value,
|
||||
merge_mode: mergeMode.value,
|
||||
}, {
|
||||
onUploadProgress: buildUploadProgressHandler(importProgress, '配置数据'),
|
||||
})
|
||||
setImportProgress(importProgress, 100, '配置数据导入完成')
|
||||
importResult.value = result
|
||||
importDialogOpen.value = false
|
||||
mergeModeSelectOpen.value = false
|
||||
@@ -197,6 +252,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
log.error('导入配置失败:', err)
|
||||
} finally {
|
||||
importLoading.value = false
|
||||
importProgress.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,11 +345,16 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
if (!importUsersPreview.value) return
|
||||
|
||||
importUsersLoading.value = true
|
||||
setImportProgress(importUsersProgress, 5, '准备提交用户数据')
|
||||
await nextTick()
|
||||
try {
|
||||
const result = await adminApi.importUsers({
|
||||
...importUsersPreview.value,
|
||||
merge_mode: usersMergeMode.value,
|
||||
}, {
|
||||
onUploadProgress: buildUploadProgressHandler(importUsersProgress, '用户数据'),
|
||||
})
|
||||
setImportProgress(importUsersProgress, 100, '用户数据导入完成')
|
||||
importUsersResult.value = result
|
||||
importUsersDialogOpen.value = false
|
||||
usersMergeModeSelectOpen.value = false
|
||||
@@ -304,10 +365,11 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
log.error('导入用户数据失败:', err)
|
||||
} finally {
|
||||
importUsersLoading.value = false
|
||||
importUsersProgress.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 导出聚合数据
|
||||
// 导出完整备份
|
||||
async function handleExportAggregate() {
|
||||
exportAggregateLoading.value = true
|
||||
try {
|
||||
@@ -316,16 +378,16 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
data,
|
||||
`${systemConfig.value.site_name.toLowerCase()}-data-${new Date().toISOString().slice(0, 10)}.json`,
|
||||
)
|
||||
success('聚合数据已导出')
|
||||
success('完整备份已导出')
|
||||
} catch (err) {
|
||||
error('导出聚合数据失败')
|
||||
log.error('导出聚合数据失败:', err)
|
||||
error('导出完整备份失败')
|
||||
log.error('导出完整备份失败:', err)
|
||||
} finally {
|
||||
exportAggregateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理聚合数据文件选择
|
||||
// 处理完整备份文件选择
|
||||
function handleAggregateFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
@@ -343,7 +405,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
const content = e.target?.result as string
|
||||
const root = asJsonObject(JSON.parse(content))
|
||||
if (!root) {
|
||||
error('无效的聚合数据文件:JSON 顶层必须是对象')
|
||||
error('无效的完整备份文件:JSON 顶层必须是对象')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -353,24 +415,24 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
} else if (looksLikeUsersExport(root)) {
|
||||
error('这是用户数据导出文件,请使用“导入用户数据”')
|
||||
} else {
|
||||
error('无效的聚合数据文件:未找到配置数据和用户数据')
|
||||
error('无效的完整备份文件:未找到配置数据和用户数据')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!root.version) {
|
||||
error('无效的聚合数据文件:缺少版本信息')
|
||||
error('无效的完整备份文件:缺少版本信息')
|
||||
return
|
||||
}
|
||||
|
||||
const configData = asJsonObject(root.config_data)
|
||||
const userData = asJsonObject(root.user_data)
|
||||
if (!configData || !looksLikeConfigExport(configData)) {
|
||||
error('无效的聚合数据文件:config_data 格式不正确')
|
||||
error('无效的完整备份文件:config_data 格式不正确')
|
||||
return
|
||||
}
|
||||
if (!userData || !looksLikeUsersExport(userData)) {
|
||||
error('无效的聚合数据文件:user_data 格式不正确')
|
||||
error('无效的完整备份文件:user_data 格式不正确')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -379,8 +441,8 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
aggregateMergeMode.value = 'skip'
|
||||
aggregateImportDialogOpen.value = true
|
||||
} catch (err) {
|
||||
error('解析聚合数据文件失败,请确保是有效的 JSON 文件')
|
||||
log.error('解析聚合数据文件失败:', err)
|
||||
error('解析完整备份文件失败,请确保是有效的 JSON 文件')
|
||||
log.error('解析完整备份文件失败:', err)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
@@ -388,26 +450,32 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
// 确认导入聚合数据
|
||||
// 确认导入完整备份
|
||||
async function confirmImportAggregate() {
|
||||
if (!aggregateImportPreview.value) return
|
||||
|
||||
importAggregateLoading.value = true
|
||||
setImportProgress(importAggregateProgress, 5, '准备提交完整备份')
|
||||
await nextTick()
|
||||
try {
|
||||
const result = await adminApi.importAggregateData({
|
||||
...aggregateImportPreview.value,
|
||||
merge_mode: aggregateMergeMode.value,
|
||||
}, {
|
||||
onUploadProgress: buildUploadProgressHandler(importAggregateProgress, '完整备份'),
|
||||
})
|
||||
setImportProgress(importAggregateProgress, 100, '完整备份导入完成')
|
||||
aggregateImportResult.value = result
|
||||
aggregateImportDialogOpen.value = false
|
||||
aggregateMergeModeSelectOpen.value = false
|
||||
aggregateImportResultDialogOpen.value = true
|
||||
success('聚合数据导入成功')
|
||||
success('完整备份导入成功')
|
||||
} catch (err: unknown) {
|
||||
error(parseApiError(err, '导入聚合数据失败'))
|
||||
log.error('导入聚合数据失败:', err)
|
||||
error(parseApiError(err, '导入完整备份失败'))
|
||||
log.error('导入完整备份失败:', err)
|
||||
} finally {
|
||||
importAggregateLoading.value = false
|
||||
importAggregateProgress.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,6 +490,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
importResult,
|
||||
mergeMode,
|
||||
mergeModeSelectOpen,
|
||||
importProgress,
|
||||
handleExportConfig,
|
||||
triggerConfigFileSelect,
|
||||
handleConfigFileSelect,
|
||||
@@ -436,11 +505,12 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
importUsersResult,
|
||||
usersMergeMode,
|
||||
usersMergeModeSelectOpen,
|
||||
importUsersProgress,
|
||||
handleExportUsers,
|
||||
triggerUsersFileSelect,
|
||||
handleUsersFileSelect,
|
||||
confirmImportUsers,
|
||||
// 聚合数据导出/导入
|
||||
// 完整备份导出/导入
|
||||
exportAggregateLoading,
|
||||
importAggregateLoading,
|
||||
aggregateImportDialogOpen,
|
||||
@@ -449,6 +519,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
aggregateImportResult,
|
||||
aggregateMergeMode,
|
||||
aggregateMergeModeSelectOpen,
|
||||
importAggregateProgress,
|
||||
handleExportAggregate,
|
||||
handleAggregateFileSelect,
|
||||
confirmImportAggregate,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user