diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index 978c1341..9c2ff263 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -193,6 +193,7 @@ type PublicSettings struct {
Version string `json:"version"`
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
+ BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
}
// OverloadCooldownSettings 529过载冷却配置 DTO
diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go
index 0232ddfb..69ffd287 100644
--- a/backend/internal/handler/setting_handler.go
+++ b/backend/internal/handler/setting_handler.go
@@ -63,5 +63,6 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
Version: h.version,
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
+ BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
})
}
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index abcae9c1..773b84ba 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -183,6 +183,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyOIDCConnectEnabled,
SettingKeyOIDCConnectProviderName,
SettingKeyBalanceLowNotifyEnabled,
+ SettingKeyBalanceLowNotifyThreshold,
SettingKeyAccountQuotaNotifyEnabled,
}
@@ -222,6 +223,11 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
settings[SettingKeyTablePageSizeOptions],
)
+ var balanceLowNotifyThreshold float64
+ if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
+ balanceLowNotifyThreshold = v
+ }
+
return &PublicSettings{
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
EmailVerifyEnabled: emailVerifyEnabled,
@@ -253,6 +259,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
OIDCOAuthProviderName: oidcProviderName,
BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
+ BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
}, nil
}
@@ -308,6 +315,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
Version string `json:"version,omitempty"`
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
+ BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
}{
RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled,
@@ -340,6 +348,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
Version: s.version,
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
+ BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
}, nil
}
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index b79b930a..274ec792 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -154,8 +154,9 @@ type PublicSettings struct {
OIDCOAuthProviderName string
Version string
- BalanceLowNotifyEnabled bool
- AccountQuotaNotifyEnabled bool
+ BalanceLowNotifyEnabled bool
+ AccountQuotaNotifyEnabled bool
+ BalanceLowNotifyThreshold float64
}
// StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制)
diff --git a/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue b/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue
index 758704e0..cfe1b332 100644
--- a/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue
+++ b/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue
@@ -4,90 +4,113 @@
{{ t('profile.balanceNotify.title') }}
+
+ {{ t('profile.balanceNotify.description') }}
+
-
-
-
+
-
-
-
-
- $
-
-
-
-
-
-
-
-
-
-
-
-
{{ email }}
-
+
+
+
+
+
+ $
+
-
-
+
+
+
+
+
+ {{ userEmail }}
+ {{ t('profile.balanceNotify.primaryEmail') }}
+
+
+
+
+
+
+ {{ email }}
+
+
+
+
+
+
+
+
{{ pe.email }}
+
+
+
+
+
+
+
+ {{ pe.countdown }}s
+
+
+
+
+
+
-
-
-
-
-
+
@@ -100,10 +123,22 @@ import { useAppStore } from '@/stores/app'
import { userAPI } from '@/api'
import { extractApiErrorMessage } from '@/utils/apiError'
+interface PendingEmail {
+ email: string
+ codeSent: boolean
+ code: string
+ sending: boolean
+ verifying: boolean
+ countdown: number
+ timer: ReturnType
| null
+}
+
const props = defineProps<{
enabled: boolean
threshold: number | null
extraEmails: string[]
+ systemDefaultThreshold: number
+ userEmail: string
}>()
const { t } = useI18n()
@@ -113,23 +148,19 @@ const appStore = useAppStore()
const notifyEnabled = ref(props.enabled)
const customThreshold = ref(props.threshold)
const extraEmails = ref([...props.extraEmails])
+const pendingEmails = ref([])
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 | null = null
-
-onUnmounted(() => {
- if (countdownTimer) clearInterval(countdownTimer)
-})
watch(() => props.enabled, (val) => { notifyEnabled.value = val })
watch(() => props.threshold, (val) => { customThreshold.value = val })
watch(() => props.extraEmails, (val) => { extraEmails.value = [...val] })
+onUnmounted(() => {
+ for (const pe of pendingEmails.value) {
+ if (pe.timer) clearInterval(pe.timer)
+ }
+})
+
const handleToggle = async () => {
try {
const updated = await userAPI.updateProfile({ balance_notify_enabled: notifyEnabled.value })
@@ -150,47 +181,56 @@ const handleThresholdUpdate = async () => {
}
}
-const handleSendCode = async () => {
- if (!newEmail.value) return
- sendingCode.value = true
+function addPendingEmail() {
+ const email = newEmail.value.trim()
+ if (!email) return
+ if (email === props.userEmail || extraEmails.value.includes(email) || pendingEmails.value.some(p => p.email === email)) {
+ appStore.showError(t('common.error'))
+ return
+ }
+ pendingEmails.value.push({ email, codeSent: false, code: '', sending: false, verifying: false, countdown: 0, timer: null })
+ newEmail.value = ''
+}
+
+async function sendCodeFor(idx: number) {
+ const pe = pendingEmails.value[idx]
+ if (!pe) return
+ pe.sending = 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
+ await userAPI.sendNotifyEmailCode(pe.email)
+ pe.codeSent = true
+ pe.countdown = 60
+ pe.timer = setInterval(() => {
+ pe.countdown--
+ if (pe.countdown <= 0 && pe.timer) {
+ clearInterval(pe.timer)
+ pe.timer = null
}
}, 1000)
appStore.showSuccess(t('profile.balanceNotify.codeSent'))
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
- sendingCode.value = false
+ pe.sending = false
}
}
-const handleVerify = async () => {
- if (!verifyCode.value || verifyCode.value.length !== 6) return
- verifying.value = true
+async function verifyPending(idx: number) {
+ const pe = pendingEmails.value[idx]
+ if (!pe || !pe.code || pe.code.length !== 6) return
+ pe.verifying = 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
+ await userAPI.verifyNotifyEmail(pe.email, pe.code)
+ extraEmails.value.push(pe.email)
+ if (pe.timer) clearInterval(pe.timer)
+ pendingEmails.value.splice(idx, 1)
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
+ pe.verifying = false
}
}
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 6688c1b6..1b4cb9ea 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -911,17 +911,19 @@ export default {
thresholdHint: 'Leave empty to use system default',
thresholdPlaceholder: 'Enter amount',
systemDefault: 'System Default',
- extraEmails: 'Extra Notification Emails',
+ extraEmails: 'Notification Emails',
+ primaryEmail: 'Primary',
noExtraEmails: 'No extra notification emails',
enterEmail: 'Enter email address',
addEmail: 'Add Email',
emailPlaceholder: 'Enter email address',
sendCode: 'Send Code',
+ resend: 'Resend',
codeSent: 'Verification code sent',
codeSentTo: 'Code sent to {email}',
enterCode: 'Enter verification code',
codePlaceholder: '6-digit code',
- verify: 'Verify & Add',
+ verify: 'Verify',
emailAdded: 'Email added',
emailRemoved: 'Email removed',
verifySuccess: 'Email added successfully',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 70d20cc0..ca091dd5 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -915,17 +915,19 @@ export default {
thresholdHint: '留空使用系统默认值',
thresholdPlaceholder: '输入金额',
systemDefault: '系统默认值',
- extraEmails: '额外通知邮箱',
+ extraEmails: '通知邮箱',
+ primaryEmail: '主邮箱',
noExtraEmails: '暂无额外通知邮箱',
enterEmail: '输入邮箱地址',
addEmail: '添加邮箱',
emailPlaceholder: '输入邮箱地址',
sendCode: '发送验证码',
+ resend: '重发',
codeSent: '验证码已发送',
codeSentTo: '验证码已发送到 {email}',
enterCode: '输入验证码',
codePlaceholder: '6位验证码',
- verify: '确认添加',
+ verify: '验证',
emailAdded: '邮箱已添加',
emailRemoved: '邮箱已移除',
verifySuccess: '邮箱添加成功',
diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts
index b69c3648..1995383d 100644
--- a/frontend/src/stores/app.ts
+++ b/frontend/src/stores/app.ts
@@ -342,6 +342,7 @@ export const useAppStore = defineStore('app', () => {
version: siteVersion.value,
balance_low_notify_enabled: false,
account_quota_notify_enabled: false,
+ balance_low_notify_threshold: 0,
}
}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index c6c74354..661c4c79 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -119,6 +119,7 @@ export interface PublicSettings {
version: string
balance_low_notify_enabled: boolean
account_quota_notify_enabled: boolean
+ balance_low_notify_threshold: number
}
export interface AuthResponse {
diff --git a/frontend/src/views/user/ProfileView.vue b/frontend/src/views/user/ProfileView.vue
index f801e20d..e7418ebb 100644
--- a/frontend/src/views/user/ProfileView.vue
+++ b/frontend/src/views/user/ProfileView.vue
@@ -19,6 +19,8 @@
:enabled="user.balance_notify_enabled ?? true"
:threshold="user.balance_notify_threshold"
:extra-emails="user.balance_notify_extra_emails ?? []"
+ :system-default-threshold="systemDefaultThreshold"
+ :user-email="user.email"
/>
@@ -41,11 +43,12 @@ import { Icon } from '@/components/icons'
const { t } = useI18n(); const authStore = useAuthStore(); const user = computed(() => authStore.user)
const contactInfo = ref('')
const balanceLowNotifyEnabled = ref(false)
+const systemDefaultThreshold = ref(0)
const WalletIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12' })]) }
const BoltIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })]) }
const CalendarIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M6.75 3v2.25M17.25 3v2.25' })]) }
-onMounted(async () => { try { const s = await authAPI.getPublicSettings(); contactInfo.value = s.contact_info || ''; balanceLowNotifyEnabled.value = s.balance_low_notify_enabled ?? false } catch (error) { console.error('Failed to load contact info:', error) } })
+onMounted(async () => { try { const s = await authAPI.getPublicSettings(); contactInfo.value = s.contact_info || ''; balanceLowNotifyEnabled.value = s.balance_low_notify_enabled ?? false; systemDefaultThreshold.value = s.balance_low_notify_threshold ?? 0 } catch (error) { console.error('Failed to load settings:', error) } })
const formatCurrency = (v: number) => `$${v.toFixed(2)}`
\ No newline at end of file