mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
feat: 重要通知模块、Server 酱独立配置与额度提醒
- 新增重要通知统一模块(邮件 + Server 酱)作为后台任务通知出口
- 拆出独立的 Server 酱 配置页(SendKey + Markdown 模板,支持 {title}/{body} 变量替换),通过仪表盘内置工具入口进入
- 新增提供商额度提醒后台 worker:余额低于阈值时通过重要通知推送,提供商配置页加入额度提醒开关与阈值
- 重要通知页加入配置可用性守卫:未配置任一通道时禁用总开关,未配置邮件/SendKey 时禁用对应通道开关
- 测试通知端点支持 channel 过滤(all/email/server_chan),并绕过总开关与通道开关,便于配置阶段先验证通道
- 修复:测试通知路由未在 buffered-body 白名单导致 channel 参数丢失、测试时邮件分支被误触发
- 修复:sub2api 验证响应中 username 为 null 时正确回退到 email,避免误报"验证响应缺少: 用户信息"
Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d51b44d642
commit
6c16f399d4
@@ -239,6 +239,47 @@
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">
|
||||
额度提醒
|
||||
</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
余额低于阈值时通过重要通知发送提醒
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="quotaAlert.enabled" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="quotaAlert.enabled"
|
||||
class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label>提醒阈值</Label>
|
||||
<Input
|
||||
v-model.number="quotaAlert.threshold_amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.0001"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>获取频率(秒)</Label>
|
||||
<Input
|
||||
v-model.number="quotaAlert.fetch_interval_seconds"
|
||||
type="number"
|
||||
min="30"
|
||||
max="86400"
|
||||
step="1"
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -305,6 +346,7 @@ import {
|
||||
getProviderOpsConfig,
|
||||
deleteProviderOpsConfig,
|
||||
type ArchitectureInfo,
|
||||
type QuotaAlertConfig,
|
||||
} from '@/api/providerOps'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -375,6 +417,12 @@ const architecturesLoaded = ref(false)
|
||||
const selectedArchitectureId = ref('new_api')
|
||||
const selectedAuthType = ref('')
|
||||
const formData = ref<Record<string, unknown>>({})
|
||||
const quotaAlert = ref<QuotaAlertConfig>({
|
||||
enabled: false,
|
||||
threshold_amount: 0,
|
||||
fetch_interval_seconds: 30,
|
||||
})
|
||||
const savedQuotaAlertSignature = ref(quotaAlertSignature(quotaAlert.value))
|
||||
|
||||
// 当前架构支持的认证方式
|
||||
const currentAuthTypes = computed(() => {
|
||||
@@ -421,8 +469,15 @@ const canVerify = computed(() => {
|
||||
})
|
||||
|
||||
// 保存按钮是否可用:验证成功且表单未变动
|
||||
const quotaAlertChanged = computed(() => {
|
||||
return quotaAlertSignature(quotaAlert.value) !== savedQuotaAlertSignature.value
|
||||
})
|
||||
|
||||
const canSave = computed(() => {
|
||||
return verifyStatus.value === 'success' && !formChanged.value
|
||||
return (
|
||||
(verifyStatus.value === 'success' && !formChanged.value)
|
||||
|| (hasExistingConfig.value && quotaAlertChanged.value && !formChanged.value)
|
||||
)
|
||||
})
|
||||
|
||||
// 字段分组
|
||||
@@ -495,6 +550,11 @@ function formatQuota(quota: number): string {
|
||||
return quota.toLocaleString()
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
const numberValue = Number(value)
|
||||
return Number.isFinite(numberValue) ? numberValue : null
|
||||
}
|
||||
|
||||
async function handleVerify() {
|
||||
const schema = currentSchema.value
|
||||
if (!schema) return
|
||||
@@ -565,8 +625,10 @@ async function handleVerify() {
|
||||
const displayName = result.data?.display_name || result.data?.username
|
||||
const extra = result.data?.extra
|
||||
let balanceText = `余额: ${formatQuota(quota)}`
|
||||
if (extra && extra.balance !== undefined && extra.points !== undefined) {
|
||||
balanceText = `余额: ${formatQuota(extra.balance)} | 积分: ${formatQuota(extra.points)}`
|
||||
const extraBalance = finiteNumber(extra?.balance)
|
||||
const extraPoints = finiteNumber(extra?.points)
|
||||
if (extraBalance !== null && extraPoints !== null) {
|
||||
balanceText = `余额: ${formatQuota(extraBalance)} | 积分: ${formatQuota(extraPoints)}`
|
||||
}
|
||||
showSuccess(`用户: ${displayName} | ${balanceText}`, '验证成功')
|
||||
}
|
||||
@@ -624,8 +686,10 @@ async function handleSave() {
|
||||
formData.value,
|
||||
props.providerWebsite,
|
||||
)
|
||||
request.quota_alert = normalizedQuotaAlert()
|
||||
const result = await saveProviderOpsConfig(props.providerId, request)
|
||||
if (result.success) {
|
||||
savedQuotaAlertSignature.value = quotaAlertSignature(quotaAlert.value)
|
||||
showSuccess(result.message || '配置已保存', '保存成功')
|
||||
emit('saved')
|
||||
emit('update:open', false)
|
||||
@@ -660,6 +724,7 @@ async function handleClear() {
|
||||
formChanged.value = false
|
||||
selectedArchitectureId.value = 'new_api'
|
||||
selectedAuthType.value = ''
|
||||
loadQuotaAlert(null)
|
||||
resetFormData()
|
||||
emit('saved')
|
||||
emit('update:open', false)
|
||||
@@ -673,18 +738,27 @@ async function handleClear() {
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function stringOrDefault(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && value.trim() ? value : fallback
|
||||
}
|
||||
|
||||
function loadFromConfig(config: Record<string, unknown>) {
|
||||
if (!config?.connector) return
|
||||
const connector = isRecord(config.connector) ? config.connector : null
|
||||
if (!connector) return
|
||||
|
||||
hasExistingConfig.value = true
|
||||
|
||||
// 根据已保存的 architecture_id 选择对应架构
|
||||
const architectureId = config.architecture_id || 'new_api'
|
||||
const architectureId = stringOrDefault(config.architecture_id, 'new_api')
|
||||
const archExists = architectures.value.some((a) => a.architecture_id === architectureId)
|
||||
selectedArchitectureId.value = archExists ? architectureId : 'new_api'
|
||||
|
||||
// 从已保存的 connector auth_type 恢复认证方式选择
|
||||
const savedAuthType = config.connector?.auth_type
|
||||
const savedAuthType = stringOrDefault(connector.auth_type, '')
|
||||
const authTypes = currentAuthTypes.value
|
||||
if (savedAuthType && authTypes.some((t) => t.type === savedAuthType)) {
|
||||
selectedAuthType.value = savedAuthType
|
||||
@@ -694,7 +768,10 @@ function loadFromConfig(config: Record<string, unknown>) {
|
||||
|
||||
const schema = currentSchema.value
|
||||
if (schema) {
|
||||
const parsedData = parseConfigFromSchema(schema, config)
|
||||
const parsedData = parseConfigFromSchema(schema, {
|
||||
...config,
|
||||
connector,
|
||||
})
|
||||
|
||||
// 敏感字段:脱敏值放到 placeholder,表单值设为空
|
||||
sensitivePlaceholders.value = {}
|
||||
@@ -707,6 +784,46 @@ function loadFromConfig(config: Record<string, unknown>) {
|
||||
|
||||
formData.value = parsedData
|
||||
}
|
||||
loadQuotaAlert(config.quota_alert)
|
||||
}
|
||||
|
||||
function defaultQuotaAlert(): QuotaAlertConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
threshold_amount: 0,
|
||||
fetch_interval_seconds: 30,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeQuotaAlert(value: unknown): QuotaAlertConfig {
|
||||
if (!value || typeof value !== 'object') return defaultQuotaAlert()
|
||||
const item = value as Record<string, unknown>
|
||||
const threshold = Number(item.threshold_amount)
|
||||
const interval = Number(item.fetch_interval_seconds)
|
||||
return {
|
||||
enabled: item.enabled === true,
|
||||
threshold_amount: Number.isFinite(threshold) && threshold >= 0 ? threshold : 0,
|
||||
fetch_interval_seconds: Number.isFinite(interval) && interval >= 30 ? Math.min(Math.floor(interval), 86400) : 30,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedQuotaAlert(): QuotaAlertConfig {
|
||||
return normalizeQuotaAlert(quotaAlert.value)
|
||||
}
|
||||
|
||||
function quotaAlertSignature(value: QuotaAlertConfig): string {
|
||||
const normalized = normalizeQuotaAlert(value)
|
||||
return JSON.stringify([
|
||||
normalized.enabled,
|
||||
normalized.threshold_amount,
|
||||
normalized.fetch_interval_seconds,
|
||||
])
|
||||
}
|
||||
|
||||
function loadQuotaAlert(value: unknown) {
|
||||
const normalized = normalizeQuotaAlert(value)
|
||||
quotaAlert.value = normalized
|
||||
savedQuotaAlertSignature.value = quotaAlertSignature(normalized)
|
||||
}
|
||||
|
||||
/** 确保架构列表已加载 */
|
||||
@@ -747,11 +864,13 @@ watch(
|
||||
architecture_id: config.architecture_id,
|
||||
base_url: config.base_url,
|
||||
connector: config.connector,
|
||||
quota_alert: config.quota_alert,
|
||||
}
|
||||
loadFromConfig(configData)
|
||||
} else {
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
loadQuotaAlert(null)
|
||||
selectedArchitectureId.value = 'new_api'
|
||||
selectedAuthType.value = ''
|
||||
resetFormData()
|
||||
@@ -759,6 +878,7 @@ watch(
|
||||
} catch {
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
loadQuotaAlert(null)
|
||||
selectedArchitectureId.value = 'new_api'
|
||||
selectedAuthType.value = ''
|
||||
resetFormData()
|
||||
@@ -768,6 +888,7 @@ watch(
|
||||
} else {
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
loadQuotaAlert(null)
|
||||
selectedArchitectureId.value = 'new_api'
|
||||
selectedAuthType.value = ''
|
||||
resetFormData()
|
||||
|
||||
Reference in New Issue
Block a user