feat(notify): add balance low & account quota notification system
- User balance low notification: email alert when balance drops below configurable threshold (user email + verified extra emails) - Account quota notification: broadcast email to admin-configured recipients when daily/weekly/total quota usage exceeds alert threshold - Admin settings: global enable/disable, default threshold, quota notification email list (Email Settings tab) - User profile: enable/disable, custom threshold, add/remove extra notification emails with verification code flow - Account quota: per-dimension alert toggle and threshold in quota control card - Trigger logic: first-crossing only (old >= threshold && new < threshold for balance; old < threshold && new >= threshold for quota), naturally prevents duplicate notifications without Redis dedup
This commit is contained in:
@@ -134,6 +134,11 @@ export interface SystemSettings {
|
||||
payment_cancel_rate_limit_window: number
|
||||
payment_cancel_rate_limit_unit: string
|
||||
payment_cancel_rate_limit_window_mode: string
|
||||
|
||||
// Balance & quota notification
|
||||
balance_low_notify_enabled: boolean
|
||||
balance_low_notify_threshold: number
|
||||
account_quota_notify_emails: string[]
|
||||
}
|
||||
|
||||
export interface UpdateSettingsRequest {
|
||||
@@ -233,6 +238,10 @@ export interface UpdateSettingsRequest {
|
||||
payment_cancel_rate_limit_window?: number
|
||||
payment_cancel_rate_limit_unit?: string
|
||||
payment_cancel_rate_limit_window_mode?: string
|
||||
// Balance & quota notification
|
||||
balance_low_notify_enabled?: boolean
|
||||
balance_low_notify_threshold?: number
|
||||
account_quota_notify_emails?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,9 @@ export async function getProfile(): Promise<User> {
|
||||
*/
|
||||
export async function updateProfile(profile: {
|
||||
username?: string
|
||||
balance_notify_enabled?: boolean
|
||||
balance_notify_threshold?: number | null
|
||||
balance_notify_extra_emails?: string[]
|
||||
}): Promise<User> {
|
||||
const { data } = await apiClient.put<User>('/user', profile)
|
||||
return data
|
||||
@@ -45,10 +48,38 @@ export async function changePassword(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Send verification code for adding a notify email
|
||||
* @param email - Email address to verify
|
||||
*/
|
||||
export async function sendNotifyEmailCode(email: string): Promise<void> {
|
||||
await apiClient.post('/user/notify-email/send-code', { email })
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and add a notify email
|
||||
* @param email - Email address to add
|
||||
* @param code - Verification code
|
||||
*/
|
||||
export async function verifyNotifyEmail(email: string, code: string): Promise<void> {
|
||||
await apiClient.post('/user/notify-email/verify', { email, code })
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a notify email
|
||||
* @param email - Email address to remove
|
||||
*/
|
||||
export async function removeNotifyEmail(email: string): Promise<void> {
|
||||
await apiClient.delete('/user/notify-email', { data: { email } })
|
||||
}
|
||||
|
||||
export const userAPI = {
|
||||
getProfile,
|
||||
updateProfile,
|
||||
changePassword
|
||||
changePassword,
|
||||
sendNotifyEmailCode,
|
||||
verifyNotifyEmail,
|
||||
removeNotifyEmail
|
||||
}
|
||||
|
||||
export default userAPI
|
||||
|
||||
@@ -1186,6 +1186,12 @@
|
||||
:weeklyResetDay="editWeeklyResetDay"
|
||||
:weeklyResetHour="editWeeklyResetHour"
|
||||
:resetTimezone="editResetTimezone"
|
||||
:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled"
|
||||
:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold"
|
||||
:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled"
|
||||
:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold"
|
||||
:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled"
|
||||
:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold"
|
||||
@update:totalLimit="editQuotaLimit = $event"
|
||||
@update:dailyLimit="editQuotaDailyLimit = $event"
|
||||
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
|
||||
@@ -1195,6 +1201,12 @@
|
||||
@update:weeklyResetDay="editWeeklyResetDay = $event"
|
||||
@update:weeklyResetHour="editWeeklyResetHour = $event"
|
||||
@update:resetTimezone="editResetTimezone = $event"
|
||||
@update:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled = $event"
|
||||
@update:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold = $event"
|
||||
@update:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled = $event"
|
||||
@update:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold = $event"
|
||||
@update:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled = $event"
|
||||
@update:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold = $event"
|
||||
/>
|
||||
</div>
|
||||
<!-- 配额控制 (非 Anthropic apikey/bedrock) -->
|
||||
@@ -1218,6 +1230,12 @@
|
||||
:weeklyResetDay="editWeeklyResetDay"
|
||||
:weeklyResetHour="editWeeklyResetHour"
|
||||
:resetTimezone="editResetTimezone"
|
||||
:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled"
|
||||
:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold"
|
||||
:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled"
|
||||
:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold"
|
||||
:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled"
|
||||
:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold"
|
||||
@update:totalLimit="editQuotaLimit = $event"
|
||||
@update:dailyLimit="editQuotaDailyLimit = $event"
|
||||
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
|
||||
@@ -1227,6 +1245,12 @@
|
||||
@update:weeklyResetDay="editWeeklyResetDay = $event"
|
||||
@update:weeklyResetHour="editWeeklyResetHour = $event"
|
||||
@update:resetTimezone="editResetTimezone = $event"
|
||||
@update:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled = $event"
|
||||
@update:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold = $event"
|
||||
@update:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled = $event"
|
||||
@update:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold = $event"
|
||||
@update:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled = $event"
|
||||
@update:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1960,6 +1984,12 @@ const editWeeklyResetMode = ref<'rolling' | 'fixed' | null>(null)
|
||||
const editWeeklyResetDay = ref<number | null>(null)
|
||||
const editWeeklyResetHour = ref<number | null>(null)
|
||||
const editResetTimezone = ref<string | null>(null)
|
||||
const editQuotaNotifyDailyEnabled = ref<boolean | null>(null)
|
||||
const editQuotaNotifyDailyThreshold = ref<number | null>(null)
|
||||
const editQuotaNotifyWeeklyEnabled = ref<boolean | null>(null)
|
||||
const editQuotaNotifyWeeklyThreshold = ref<number | null>(null)
|
||||
const editQuotaNotifyTotalEnabled = ref<boolean | null>(null)
|
||||
const editQuotaNotifyTotalThreshold = ref<number | null>(null)
|
||||
const openAIWSModeOptions = computed(() => [
|
||||
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
|
||||
// TODO: ctx_pool 选项暂时隐藏,待测试完成后恢复
|
||||
@@ -2159,6 +2189,13 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
editWeeklyResetDay.value = (extra?.quota_weekly_reset_day as number) ?? null
|
||||
editWeeklyResetHour.value = (extra?.quota_weekly_reset_hour as number) ?? null
|
||||
editResetTimezone.value = (extra?.quota_reset_timezone as string) || null
|
||||
// Load quota notify config
|
||||
editQuotaNotifyDailyEnabled.value = (extra?.quota_notify_daily_enabled as boolean) ?? null
|
||||
editQuotaNotifyDailyThreshold.value = (extra?.quota_notify_daily_threshold as number) ?? null
|
||||
editQuotaNotifyWeeklyEnabled.value = (extra?.quota_notify_weekly_enabled as boolean) ?? null
|
||||
editQuotaNotifyWeeklyThreshold.value = (extra?.quota_notify_weekly_threshold as number) ?? null
|
||||
editQuotaNotifyTotalEnabled.value = (extra?.quota_notify_total_enabled as boolean) ?? null
|
||||
editQuotaNotifyTotalThreshold.value = (extra?.quota_notify_total_threshold as number) ?? null
|
||||
} else {
|
||||
editQuotaLimit.value = null
|
||||
editQuotaDailyLimit.value = null
|
||||
@@ -2169,6 +2206,12 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
editWeeklyResetDay.value = null
|
||||
editWeeklyResetHour.value = null
|
||||
editResetTimezone.value = null
|
||||
editQuotaNotifyDailyEnabled.value = null
|
||||
editQuotaNotifyDailyThreshold.value = null
|
||||
editQuotaNotifyWeeklyEnabled.value = null
|
||||
editQuotaNotifyWeeklyThreshold.value = null
|
||||
editQuotaNotifyTotalEnabled.value = null
|
||||
editQuotaNotifyTotalThreshold.value = null
|
||||
}
|
||||
|
||||
// Load antigravity model mapping (Antigravity 只支持映射模式)
|
||||
@@ -2283,6 +2326,13 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
editQuotaLimit.value = typeof bedrockExtra.quota_limit === 'number' ? bedrockExtra.quota_limit : null
|
||||
editQuotaDailyLimit.value = typeof bedrockExtra.quota_daily_limit === 'number' ? bedrockExtra.quota_daily_limit : null
|
||||
editQuotaWeeklyLimit.value = typeof bedrockExtra.quota_weekly_limit === 'number' ? bedrockExtra.quota_weekly_limit : null
|
||||
// Load quota notify for bedrock
|
||||
editQuotaNotifyDailyEnabled.value = (bedrockExtra.quota_notify_daily_enabled as boolean) ?? null
|
||||
editQuotaNotifyDailyThreshold.value = (bedrockExtra.quota_notify_daily_threshold as number) ?? null
|
||||
editQuotaNotifyWeeklyEnabled.value = (bedrockExtra.quota_notify_weekly_enabled as boolean) ?? null
|
||||
editQuotaNotifyWeeklyThreshold.value = (bedrockExtra.quota_notify_weekly_threshold as number) ?? null
|
||||
editQuotaNotifyTotalEnabled.value = (bedrockExtra.quota_notify_total_enabled as boolean) ?? null
|
||||
editQuotaNotifyTotalThreshold.value = (bedrockExtra.quota_notify_total_threshold as number) ?? null
|
||||
|
||||
// Load model mappings for bedrock
|
||||
const existingMappings = bedrockCreds.model_mapping as Record<string, string> | undefined
|
||||
@@ -3198,6 +3248,40 @@ const handleSubmit = async () => {
|
||||
} else {
|
||||
delete newExtra.quota_reset_timezone
|
||||
}
|
||||
// Quota notify config
|
||||
if (editQuotaNotifyDailyEnabled.value) {
|
||||
newExtra.quota_notify_daily_enabled = true
|
||||
if (editQuotaNotifyDailyThreshold.value != null) {
|
||||
newExtra.quota_notify_daily_threshold = editQuotaNotifyDailyThreshold.value
|
||||
} else {
|
||||
delete newExtra.quota_notify_daily_threshold
|
||||
}
|
||||
} else {
|
||||
delete newExtra.quota_notify_daily_enabled
|
||||
delete newExtra.quota_notify_daily_threshold
|
||||
}
|
||||
if (editQuotaNotifyWeeklyEnabled.value) {
|
||||
newExtra.quota_notify_weekly_enabled = true
|
||||
if (editQuotaNotifyWeeklyThreshold.value != null) {
|
||||
newExtra.quota_notify_weekly_threshold = editQuotaNotifyWeeklyThreshold.value
|
||||
} else {
|
||||
delete newExtra.quota_notify_weekly_threshold
|
||||
}
|
||||
} else {
|
||||
delete newExtra.quota_notify_weekly_enabled
|
||||
delete newExtra.quota_notify_weekly_threshold
|
||||
}
|
||||
if (editQuotaNotifyTotalEnabled.value) {
|
||||
newExtra.quota_notify_total_enabled = true
|
||||
if (editQuotaNotifyTotalThreshold.value != null) {
|
||||
newExtra.quota_notify_total_threshold = editQuotaNotifyTotalThreshold.value
|
||||
} else {
|
||||
delete newExtra.quota_notify_total_threshold
|
||||
}
|
||||
} else {
|
||||
delete newExtra.quota_notify_total_enabled
|
||||
delete newExtra.quota_notify_total_threshold
|
||||
}
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
totalLimit: number | null
|
||||
dailyLimit: number | null
|
||||
weeklyLimit: number | null
|
||||
@@ -14,7 +14,20 @@ const props = defineProps<{
|
||||
weeklyResetDay: number | null
|
||||
weeklyResetHour: number | null
|
||||
resetTimezone: string | null
|
||||
}>()
|
||||
quotaNotifyDailyEnabled?: boolean | null
|
||||
quotaNotifyDailyThreshold?: number | null
|
||||
quotaNotifyWeeklyEnabled?: boolean | null
|
||||
quotaNotifyWeeklyThreshold?: number | null
|
||||
quotaNotifyTotalEnabled?: boolean | null
|
||||
quotaNotifyTotalThreshold?: number | null
|
||||
}>(), {
|
||||
quotaNotifyDailyEnabled: null,
|
||||
quotaNotifyDailyThreshold: null,
|
||||
quotaNotifyWeeklyEnabled: null,
|
||||
quotaNotifyWeeklyThreshold: null,
|
||||
quotaNotifyTotalEnabled: null,
|
||||
quotaNotifyTotalThreshold: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:totalLimit': [value: number | null]
|
||||
@@ -26,6 +39,12 @@ const emit = defineEmits<{
|
||||
'update:weeklyResetDay': [value: number | null]
|
||||
'update:weeklyResetHour': [value: number | null]
|
||||
'update:resetTimezone': [value: string | null]
|
||||
'update:quotaNotifyDailyEnabled': [value: boolean | null]
|
||||
'update:quotaNotifyDailyThreshold': [value: number | null]
|
||||
'update:quotaNotifyWeeklyEnabled': [value: boolean | null]
|
||||
'update:quotaNotifyWeeklyThreshold': [value: number | null]
|
||||
'update:quotaNotifyTotalEnabled': [value: boolean | null]
|
||||
'update:quotaNotifyTotalThreshold': [value: number | null]
|
||||
}>()
|
||||
|
||||
const enabled = computed(() =>
|
||||
@@ -203,6 +222,36 @@ const onWeeklyModeChange = (e: Event) => {
|
||||
{{ t('admin.accounts.quotaDailyLimitHint') }}
|
||||
</template>
|
||||
</p>
|
||||
<!-- 日配额告警 -->
|
||||
<div v-if="dailyLimit && dailyLimit > 0" class="ml-4 mt-2 flex items-center gap-3">
|
||||
<label class="text-sm text-gray-500 whitespace-nowrap">{{ t('admin.accounts.quotaNotify.alert') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('update:quotaNotifyDailyEnabled', !(props.quotaNotifyDailyEnabled))"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
||||
props.quotaNotifyDailyEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
props.quotaNotifyDailyEnabled ? 'translate-x-4' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="props.quotaNotifyDailyEnabled" class="relative flex-1">
|
||||
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
|
||||
<input
|
||||
:value="props.quotaNotifyDailyThreshold"
|
||||
@input="emit('update:quotaNotifyDailyThreshold', parseFloat(($event.target as HTMLInputElement).value) || null)"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="input pl-6 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 周配额 -->
|
||||
@@ -259,6 +308,36 @@ const onWeeklyModeChange = (e: Event) => {
|
||||
{{ t('admin.accounts.quotaWeeklyLimitHint') }}
|
||||
</template>
|
||||
</p>
|
||||
<!-- 周配额告警 -->
|
||||
<div v-if="weeklyLimit && weeklyLimit > 0" class="ml-4 mt-2 flex items-center gap-3">
|
||||
<label class="text-sm text-gray-500 whitespace-nowrap">{{ t('admin.accounts.quotaNotify.alert') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('update:quotaNotifyWeeklyEnabled', !(props.quotaNotifyWeeklyEnabled))"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
||||
props.quotaNotifyWeeklyEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
props.quotaNotifyWeeklyEnabled ? 'translate-x-4' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="props.quotaNotifyWeeklyEnabled" class="relative flex-1">
|
||||
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
|
||||
<input
|
||||
:value="props.quotaNotifyWeeklyThreshold"
|
||||
@input="emit('update:quotaNotifyWeeklyThreshold', parseFloat(($event.target as HTMLInputElement).value) || null)"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="input pl-6 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时区选择(当任一维度使用固定模式时显示) -->
|
||||
@@ -289,6 +368,36 @@ const onWeeklyModeChange = (e: Event) => {
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaTotalLimitHint') }}</p>
|
||||
<!-- 总配额告警 -->
|
||||
<div v-if="totalLimit && totalLimit > 0" class="ml-4 mt-2 flex items-center gap-3">
|
||||
<label class="text-sm text-gray-500 whitespace-nowrap">{{ t('admin.accounts.quotaNotify.alert') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('update:quotaNotifyTotalEnabled', !(props.quotaNotifyTotalEnabled))"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
||||
props.quotaNotifyTotalEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
props.quotaNotifyTotalEnabled ? 'translate-x-4' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="props.quotaNotifyTotalEnabled" class="relative flex-1">
|
||||
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
|
||||
<input
|
||||
:value="props.quotaNotifyTotalThreshold"
|
||||
@input="emit('update:quotaNotifyTotalThreshold', parseFloat(($event.target as HTMLInputElement).value) || null)"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="input pl-6 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ t('profile.balanceNotify.title') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="px-6 py-6 space-y-6">
|
||||
<!-- Enable toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label">{{ t('profile.balanceNotify.enabled') }}</label>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" v-model="notifyEnabled" @change="handleToggle" class="sr-only peer" />
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 dark:peer-focus:ring-primary-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:after:border-gray-600 peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Custom threshold -->
|
||||
<div v-if="notifyEnabled">
|
||||
<label class="input-label">
|
||||
{{ t('profile.balanceNotify.threshold') }}
|
||||
<span class="text-xs text-gray-400 ml-2">{{ t('profile.balanceNotify.thresholdHint') }}</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-500">$</span>
|
||||
<input
|
||||
v-model.number="customThreshold"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="input flex-1"
|
||||
:placeholder="t('profile.balanceNotify.thresholdPlaceholder')"
|
||||
@blur="handleThresholdUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extra emails -->
|
||||
<div v-if="notifyEnabled">
|
||||
<label class="input-label">{{ t('profile.balanceNotify.extraEmails') }}</label>
|
||||
|
||||
<!-- Existing emails list -->
|
||||
<div v-if="extraEmails.length > 0" class="space-y-2 mb-4">
|
||||
<div v-for="email in extraEmails" :key="email"
|
||||
class="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ email }}</span>
|
||||
<button @click="handleRemoveEmail(email)" class="text-red-500 hover:text-red-700 text-sm">
|
||||
{{ t('profile.balanceNotify.removeEmail') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add new email -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newEmail"
|
||||
type="email"
|
||||
class="input flex-1"
|
||||
:placeholder="t('profile.balanceNotify.emailPlaceholder')"
|
||||
:disabled="codeSent"
|
||||
/>
|
||||
<button
|
||||
@click="handleSendCode"
|
||||
:disabled="!newEmail || sendingCode || codeCountdown > 0"
|
||||
class="btn btn-outline whitespace-nowrap"
|
||||
>
|
||||
{{ codeCountdown > 0 ? `${codeCountdown}s` : (codeSent ? t('profile.balanceNotify.codeSent') : t('profile.balanceNotify.sendCode')) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="codeSent" class="flex gap-2">
|
||||
<input
|
||||
v-model="verifyCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
class="input flex-1"
|
||||
:placeholder="t('profile.balanceNotify.codePlaceholder')"
|
||||
/>
|
||||
<button
|
||||
@click="handleVerify"
|
||||
:disabled="!verifyCode || verifyCode.length !== 6 || verifying"
|
||||
class="btn btn-primary whitespace-nowrap"
|
||||
>
|
||||
{{ t('profile.balanceNotify.verify') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { userAPI } from '@/api'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
|
||||
const props = defineProps<{
|
||||
enabled: boolean
|
||||
threshold: number | null
|
||||
extraEmails: string[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const notifyEnabled = ref(props.enabled)
|
||||
const customThreshold = ref<number | null>(props.threshold)
|
||||
const extraEmails = ref<string[]>([...props.extraEmails])
|
||||
const newEmail = ref('')
|
||||
const verifyCode = ref('')
|
||||
const codeSent = ref(false)
|
||||
const sendingCode = ref(false)
|
||||
const verifying = ref(false)
|
||||
const codeCountdown = ref(0)
|
||||
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
watch(() => props.enabled, (val) => { notifyEnabled.value = val })
|
||||
watch(() => props.threshold, (val) => { customThreshold.value = val })
|
||||
watch(() => props.extraEmails, (val) => { extraEmails.value = [...val] })
|
||||
|
||||
const handleToggle = async () => {
|
||||
try {
|
||||
const updated = await userAPI.updateProfile({ balance_notify_enabled: notifyEnabled.value })
|
||||
authStore.user = updated
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
notifyEnabled.value = !notifyEnabled.value
|
||||
}
|
||||
}
|
||||
|
||||
const handleThresholdUpdate = async () => {
|
||||
try {
|
||||
const threshold = customThreshold.value && customThreshold.value > 0 ? customThreshold.value : 0
|
||||
const updated = await userAPI.updateProfile({ balance_notify_threshold: threshold })
|
||||
authStore.user = updated
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!newEmail.value) return
|
||||
sendingCode.value = true
|
||||
try {
|
||||
await userAPI.sendNotifyEmailCode(newEmail.value)
|
||||
codeSent.value = true
|
||||
codeCountdown.value = 60
|
||||
countdownTimer = setInterval(() => {
|
||||
codeCountdown.value--
|
||||
if (codeCountdown.value <= 0) {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
appStore.showSuccess(t('profile.balanceNotify.codeSent'))
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!verifyCode.value || verifyCode.value.length !== 6) return
|
||||
verifying.value = true
|
||||
try {
|
||||
await userAPI.verifyNotifyEmail(newEmail.value, verifyCode.value)
|
||||
extraEmails.value.push(newEmail.value)
|
||||
newEmail.value = ''
|
||||
verifyCode.value = ''
|
||||
codeSent.value = false
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
codeCountdown.value = 0
|
||||
appStore.showSuccess(t('profile.balanceNotify.verifySuccess'))
|
||||
// Refresh user data
|
||||
const updated = await userAPI.getProfile()
|
||||
authStore.user = updated
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveEmail = async (email: string) => {
|
||||
try {
|
||||
await userAPI.removeNotifyEmail(email)
|
||||
extraEmails.value = extraEmails.value.filter(e => e !== email)
|
||||
appStore.showSuccess(t('profile.balanceNotify.removeSuccess'))
|
||||
const updated = await userAPI.getProfile()
|
||||
authStore.user = updated
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -902,6 +902,31 @@ export default {
|
||||
sendCode: 'Send Code',
|
||||
codeSent: 'Verification code sent to your email',
|
||||
sendCodeFailed: 'Failed to send verification code'
|
||||
},
|
||||
balanceNotify: {
|
||||
title: 'Balance Low Notification',
|
||||
description: 'Send email alert when account balance falls below threshold',
|
||||
enabled: 'Enable Balance Low Notification',
|
||||
threshold: 'Custom Threshold',
|
||||
thresholdHint: 'Leave empty to use system default',
|
||||
thresholdPlaceholder: 'Enter amount',
|
||||
systemDefault: 'System Default',
|
||||
extraEmails: 'Extra Notification Emails',
|
||||
noExtraEmails: 'No extra notification emails',
|
||||
enterEmail: 'Enter email address',
|
||||
addEmail: 'Add Email',
|
||||
emailPlaceholder: 'Enter email address',
|
||||
sendCode: 'Send Code',
|
||||
codeSent: 'Verification code sent',
|
||||
codeSentTo: 'Code sent to {email}',
|
||||
enterCode: 'Enter verification code',
|
||||
codePlaceholder: '6-digit code',
|
||||
verify: 'Verify & Add',
|
||||
emailAdded: 'Email added',
|
||||
emailRemoved: 'Email removed',
|
||||
verifySuccess: 'Email added successfully',
|
||||
removeEmail: 'Remove',
|
||||
removeSuccess: 'Email removed',
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2228,6 +2253,12 @@ export default {
|
||||
},
|
||||
quotaLimitAmount: 'Total Limit',
|
||||
quotaLimitAmountHint: 'Cumulative spending limit. Does not auto-reset.',
|
||||
quotaNotify: {
|
||||
alert: 'Alert Threshold',
|
||||
enabled: 'Enable Alert',
|
||||
threshold: 'Alert Amount',
|
||||
thresholdPlaceholder: 'Enter alert amount',
|
||||
},
|
||||
testConnection: 'Test Connection',
|
||||
reAuthorize: 'Re-Authorize',
|
||||
refreshToken: 'Refresh Token',
|
||||
@@ -4593,6 +4624,22 @@ export default {
|
||||
supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay',
|
||||
refundEnabled: 'Allow Refund',
|
||||
},
|
||||
balanceNotify: {
|
||||
title: 'Balance Low Notification',
|
||||
description: 'Send email notification when user balance falls below threshold',
|
||||
enabled: 'Enable Balance Low Notification',
|
||||
threshold: 'Default Threshold',
|
||||
thresholdHint: 'Used when user has not set a custom value',
|
||||
thresholdPlaceholder: 'Enter amount',
|
||||
},
|
||||
quotaNotify: {
|
||||
title: 'Account Quota Notification',
|
||||
description: 'Notify admins when account quota usage reaches alert threshold',
|
||||
emails: 'Notification Emails',
|
||||
emailsHint: 'Leave empty to disable notifications',
|
||||
addEmail: 'Add Email',
|
||||
emailPlaceholder: 'Enter email address',
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP Settings',
|
||||
description: 'Configure email sending for verification codes',
|
||||
|
||||
@@ -906,6 +906,31 @@ export default {
|
||||
sendCode: '发送验证码',
|
||||
codeSent: '验证码已发送到您的邮箱',
|
||||
sendCodeFailed: '发送验证码失败'
|
||||
},
|
||||
balanceNotify: {
|
||||
title: '余额不足提醒',
|
||||
description: '当账户余额低于阈值时发送邮件提醒',
|
||||
enabled: '启用余额不足提醒',
|
||||
threshold: '自定义提醒阈值',
|
||||
thresholdHint: '留空使用系统默认值',
|
||||
thresholdPlaceholder: '输入金额',
|
||||
systemDefault: '系统默认值',
|
||||
extraEmails: '额外通知邮箱',
|
||||
noExtraEmails: '暂无额外通知邮箱',
|
||||
enterEmail: '输入邮箱地址',
|
||||
addEmail: '添加邮箱',
|
||||
emailPlaceholder: '输入邮箱地址',
|
||||
sendCode: '发送验证码',
|
||||
codeSent: '验证码已发送',
|
||||
codeSentTo: '验证码已发送到 {email}',
|
||||
enterCode: '输入验证码',
|
||||
codePlaceholder: '6位验证码',
|
||||
verify: '确认添加',
|
||||
emailAdded: '邮箱已添加',
|
||||
emailRemoved: '邮箱已移除',
|
||||
verifySuccess: '邮箱添加成功',
|
||||
removeEmail: '移除',
|
||||
removeSuccess: '邮箱已移除',
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2226,6 +2251,12 @@ export default {
|
||||
},
|
||||
quotaLimitAmount: '总限额',
|
||||
quotaLimitAmountHint: '累计消费上限,不会自动重置。',
|
||||
quotaNotify: {
|
||||
alert: '告警阈值',
|
||||
enabled: '启用告警',
|
||||
threshold: '告警金额',
|
||||
thresholdPlaceholder: '输入告警金额',
|
||||
},
|
||||
testConnection: '测试连接',
|
||||
reAuthorize: '重新授权',
|
||||
refreshToken: '刷新令牌',
|
||||
@@ -4757,6 +4788,22 @@ export default {
|
||||
supportedTypesHint: '逗号分隔,如 alipay,wxpay',
|
||||
refundEnabled: '允许退款',
|
||||
},
|
||||
balanceNotify: {
|
||||
title: '余额不足提醒',
|
||||
description: '当用户余额低于阈值时发送邮件提醒',
|
||||
enabled: '启用余额不足提醒',
|
||||
threshold: '默认提醒阈值',
|
||||
thresholdHint: '用户未自定义时使用此值',
|
||||
thresholdPlaceholder: '输入金额',
|
||||
},
|
||||
quotaNotify: {
|
||||
title: '账号限额通知',
|
||||
description: '当账号配额用量达到告警阈值时通知管理员',
|
||||
emails: '通知邮箱',
|
||||
emailsHint: '留空则不发送通知',
|
||||
addEmail: '添加邮箱',
|
||||
emailPlaceholder: '输入邮箱地址',
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP 设置',
|
||||
description: '配置用于发送验证码的邮件服务',
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface User {
|
||||
concurrency: number // Allowed concurrent requests
|
||||
status: 'active' | 'disabled' // Account status
|
||||
allowed_groups: number[] | null // Allowed group IDs (null = all non-exclusive groups)
|
||||
balance_notify_enabled: boolean
|
||||
balance_notify_threshold: number | null
|
||||
balance_notify_extra_emails: string[]
|
||||
subscriptions?: UserSubscription[] // User's active subscriptions
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
@@ -2562,6 +2562,60 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Balance Low Notification -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h3 class="text-base font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.balanceNotify.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.balanceNotify.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="mb-0 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.balanceNotify.enabled') }}</label>
|
||||
<Toggle v-model="form.balance_low_notify_enabled" />
|
||||
</div>
|
||||
<div v-if="form.balance_low_notify_enabled">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.balanceNotify.threshold') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">$</span>
|
||||
<input v-model.number="form.balance_low_notify_threshold" type="number" min="0" step="0.01" class="input pl-7" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.balanceNotify.thresholdHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Quota Notification -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h3 class="text-base font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.quotaNotify.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.quotaNotify.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-6 space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.quotaNotify.emails') }}</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(_, index) in (form.account_quota_notify_emails || [])" :key="index" class="flex items-center gap-2">
|
||||
<input v-model="form.account_quota_notify_emails[index]" type="email" class="input flex-1" />
|
||||
<button @click="form.account_quota_notify_emails.splice(index, 1)" class="btn btn-secondary px-2" type="button">
|
||||
<Icon name="x" size="xs" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button @click="addQuotaNotifyEmail" class="btn btn-secondary btn-sm" type="button">
|
||||
+ {{ t('admin.settings.quotaNotify.addEmail') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.quotaNotify.emailsHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /Tab: Email -->
|
||||
|
||||
<!-- Tab: Backup -->
|
||||
@@ -2840,7 +2894,11 @@ const form = reactive<SettingsForm>({
|
||||
// Gateway forwarding behavior
|
||||
enable_fingerprint_unification: true,
|
||||
enable_metadata_passthrough: false,
|
||||
enable_cch_signing: false
|
||||
enable_cch_signing: false,
|
||||
// Balance & quota notification
|
||||
balance_low_notify_enabled: false,
|
||||
balance_low_notify_threshold: 0,
|
||||
account_quota_notify_emails: [] as string[]
|
||||
})
|
||||
|
||||
// Web Search Emulation config (loaded/saved separately)
|
||||
@@ -2972,6 +3030,14 @@ function handleRegistrationEmailSuffixWhitelistPaste(event: ClipboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// Quota notify email helpers
|
||||
const addQuotaNotifyEmail = () => {
|
||||
if (!form.account_quota_notify_emails) {
|
||||
form.account_quota_notify_emails = []
|
||||
}
|
||||
form.account_quota_notify_emails.push('')
|
||||
}
|
||||
|
||||
// LinuxDo OAuth redirect URL suggestion
|
||||
const linuxdoRedirectUrlSuggestion = computed(() => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
@@ -3311,6 +3377,10 @@ async function saveSettings() {
|
||||
payment_cancel_rate_limit_window: Number(form.payment_cancel_rate_limit_window) || 1,
|
||||
payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit,
|
||||
payment_cancel_rate_limit_window_mode: form.payment_cancel_rate_limit_window_mode,
|
||||
// Balance & quota notification
|
||||
balance_low_notify_enabled: form.balance_low_notify_enabled,
|
||||
balance_low_notify_threshold: Number(form.balance_low_notify_threshold) || 0,
|
||||
account_quota_notify_emails: (form.account_quota_notify_emails || []).filter((e: string) => e.trim() !== ''),
|
||||
}
|
||||
|
||||
const updated = await adminAPI.settings.updateSettings(payload)
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<ProfileEditForm :initial-username="user?.username || ''" />
|
||||
<ProfileBalanceNotifyCard
|
||||
v-if="user"
|
||||
:enabled="user.balance_notify_enabled ?? true"
|
||||
:threshold="user.balance_notify_threshold"
|
||||
:extra-emails="user.balance_notify_extra_emails ?? []"
|
||||
/>
|
||||
<ProfilePasswordForm />
|
||||
<ProfileTotpCard />
|
||||
</div>
|
||||
@@ -27,6 +33,7 @@ import { authAPI } from '@/api'; import AppLayout from '@/components/layout/AppL
|
||||
import StatCard from '@/components/common/StatCard.vue'
|
||||
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
|
||||
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
|
||||
import ProfileBalanceNotifyCard from '@/components/user/profile/ProfileBalanceNotifyCard.vue'
|
||||
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
|
||||
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
|
||||
import { Icon } from '@/components/icons'
|
||||
|
||||
Reference in New Issue
Block a user