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 }} - +
@@ -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