feat(notify): improve balance notify card UX
- Show system default threshold as placeholder in custom threshold input - Display user's primary email with "Primary" badge - Support adding multiple pending emails before verification - Each pending email has independent send/verify/resend flow - Expose balance_low_notify_threshold in PublicSettings API - Clean up timers on unmount to prevent leaks
This commit is contained in:
@@ -193,6 +193,7 @@ type PublicSettings struct {
|
|||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
||||||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
||||||
|
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OverloadCooldownSettings 529过载冷却配置 DTO
|
// OverloadCooldownSettings 529过载冷却配置 DTO
|
||||||
|
|||||||
@@ -63,5 +63,6 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
|||||||
Version: h.version,
|
Version: h.version,
|
||||||
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
||||||
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
|
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
|
||||||
|
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
SettingKeyOIDCConnectEnabled,
|
SettingKeyOIDCConnectEnabled,
|
||||||
SettingKeyOIDCConnectProviderName,
|
SettingKeyOIDCConnectProviderName,
|
||||||
SettingKeyBalanceLowNotifyEnabled,
|
SettingKeyBalanceLowNotifyEnabled,
|
||||||
|
SettingKeyBalanceLowNotifyThreshold,
|
||||||
SettingKeyAccountQuotaNotifyEnabled,
|
SettingKeyAccountQuotaNotifyEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +223,11 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
settings[SettingKeyTablePageSizeOptions],
|
settings[SettingKeyTablePageSizeOptions],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var balanceLowNotifyThreshold float64
|
||||||
|
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
|
||||||
|
balanceLowNotifyThreshold = v
|
||||||
|
}
|
||||||
|
|
||||||
return &PublicSettings{
|
return &PublicSettings{
|
||||||
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
||||||
EmailVerifyEnabled: emailVerifyEnabled,
|
EmailVerifyEnabled: emailVerifyEnabled,
|
||||||
@@ -253,6 +259,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
OIDCOAuthProviderName: oidcProviderName,
|
OIDCOAuthProviderName: oidcProviderName,
|
||||||
BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
|
BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
|
||||||
AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
|
AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
|
||||||
|
BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +315,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
Version string `json:"version,omitempty"`
|
Version string `json:"version,omitempty"`
|
||||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
||||||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
||||||
|
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
||||||
}{
|
}{
|
||||||
RegistrationEnabled: settings.RegistrationEnabled,
|
RegistrationEnabled: settings.RegistrationEnabled,
|
||||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||||
@@ -340,6 +348,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
Version: s.version,
|
Version: s.version,
|
||||||
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
||||||
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
|
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
|
||||||
|
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,8 +154,9 @@ type PublicSettings struct {
|
|||||||
OIDCOAuthProviderName string
|
OIDCOAuthProviderName string
|
||||||
Version string
|
Version string
|
||||||
|
|
||||||
BalanceLowNotifyEnabled bool
|
BalanceLowNotifyEnabled bool
|
||||||
AccountQuotaNotifyEnabled bool
|
AccountQuotaNotifyEnabled bool
|
||||||
|
BalanceLowNotifyThreshold float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制)
|
// StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制)
|
||||||
|
|||||||
@@ -4,90 +4,113 @@
|
|||||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
{{ t('profile.balanceNotify.title') }}
|
{{ t('profile.balanceNotify.title') }}
|
||||||
</h2>
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('profile.balanceNotify.description') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-6 space-y-6">
|
<div class="px-6 py-6 space-y-6">
|
||||||
<!-- Enable toggle -->
|
<!-- Enable toggle -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<label class="input-label mb-0">{{ t('profile.balanceNotify.enabled') }}</label>
|
||||||
<label class="input-label">{{ t('profile.balanceNotify.enabled') }}</label>
|
|
||||||
</div>
|
|
||||||
<label class="relative inline-flex items-center cursor-pointer">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<input type="checkbox" v-model="notifyEnabled" @change="handleToggle" class="sr-only peer" />
|
<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>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom threshold -->
|
<template v-if="notifyEnabled">
|
||||||
<div v-if="notifyEnabled">
|
<!-- Custom threshold -->
|
||||||
<label class="input-label">
|
<div>
|
||||||
{{ t('profile.balanceNotify.threshold') }}
|
<label class="input-label">
|
||||||
<span class="text-xs text-gray-400 ml-2">{{ t('profile.balanceNotify.thresholdHint') }}</span>
|
{{ t('profile.balanceNotify.threshold') }}
|
||||||
</label>
|
<span class="text-xs text-gray-400 ml-2">{{ t('profile.balanceNotify.thresholdHint') }}</span>
|
||||||
<div class="flex items-center gap-2">
|
</label>
|
||||||
<span class="text-gray-500">$</span>
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<span class="text-gray-500">$</span>
|
||||||
v-model.number="customThreshold"
|
<input
|
||||||
type="number"
|
v-model.number="customThreshold"
|
||||||
min="0"
|
type="number"
|
||||||
step="0.01"
|
min="0"
|
||||||
class="input flex-1"
|
step="0.01"
|
||||||
:placeholder="t('profile.balanceNotify.thresholdPlaceholder')"
|
class="input flex-1"
|
||||||
@blur="handleThresholdUpdate"
|
:placeholder="systemDefaultThreshold > 0 ? `${t('profile.balanceNotify.systemDefault')} $${systemDefaultThreshold}` : 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add new email -->
|
<!-- Primary email (always shown, with toggle) -->
|
||||||
<div class="space-y-2">
|
<div>
|
||||||
|
<label class="input-label">{{ t('profile.balanceNotify.extraEmails') }}</label>
|
||||||
|
<div class="space-y-2 mb-3">
|
||||||
|
<div 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">{{ userEmail }}</span>
|
||||||
|
<span class="text-xs text-gray-400">{{ t('profile.balanceNotify.primaryEmail') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Verified extra emails -->
|
||||||
|
<div v-if="extraEmails.length > 0" class="space-y-2 mb-3">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Pending (unverified) emails -->
|
||||||
|
<div v-if="pendingEmails.length > 0" class="space-y-2 mb-3">
|
||||||
|
<div v-for="(pe, idx) in pendingEmails" :key="pe.email"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-yellow-50 dark:bg-yellow-900/10 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
||||||
|
<span class="flex-1 text-sm text-gray-700 dark:text-gray-300">{{ pe.email }}</span>
|
||||||
|
<div v-if="!pe.codeSent" class="flex items-center gap-1">
|
||||||
|
<button @click="sendCodeFor(idx)" :disabled="pe.sending" class="text-xs text-primary-600 hover:text-primary-700">
|
||||||
|
{{ t('profile.balanceNotify.sendCode') }}
|
||||||
|
</button>
|
||||||
|
<button @click="pendingEmails.splice(idx, 1)" class="text-xs text-red-500 hover:text-red-700 ml-1">
|
||||||
|
{{ t('profile.balanceNotify.removeEmail') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
v-model="pe.code"
|
||||||
|
type="text"
|
||||||
|
maxlength="6"
|
||||||
|
class="w-20 rounded border border-gray-300 px-2 py-1 text-xs dark:border-dark-500 dark:bg-dark-700"
|
||||||
|
:placeholder="t('profile.balanceNotify.codePlaceholder')"
|
||||||
|
/>
|
||||||
|
<button @click="verifyPending(idx)" :disabled="!pe.code || pe.code.length !== 6 || pe.verifying" class="text-xs text-primary-600 hover:text-primary-700">
|
||||||
|
{{ t('profile.balanceNotify.verify') }}
|
||||||
|
</button>
|
||||||
|
<span v-if="pe.countdown > 0" class="text-xs text-gray-400">{{ pe.countdown }}s</span>
|
||||||
|
<button v-else @click="sendCodeFor(idx)" :disabled="pe.sending" class="text-xs text-gray-500 hover:text-gray-700">
|
||||||
|
{{ t('profile.balanceNotify.resend') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add new email input -->
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="newEmail"
|
v-model="newEmail"
|
||||||
type="email"
|
type="email"
|
||||||
class="input flex-1"
|
class="input flex-1"
|
||||||
:placeholder="t('profile.balanceNotify.emailPlaceholder')"
|
:placeholder="t('profile.balanceNotify.emailPlaceholder')"
|
||||||
:disabled="codeSent"
|
@keyup.enter="addPendingEmail"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@click="handleSendCode"
|
@click="addPendingEmail"
|
||||||
:disabled="!newEmail || sendingCode || codeCountdown > 0"
|
:disabled="!newEmail"
|
||||||
class="btn btn-outline whitespace-nowrap"
|
class="btn btn-secondary whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{{ codeCountdown > 0 ? `${codeCountdown}s` : (codeSent ? t('profile.balanceNotify.codeSent') : t('profile.balanceNotify.sendCode')) }}
|
{{ t('common.add') }}
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -100,10 +123,22 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import { userAPI } from '@/api'
|
import { userAPI } from '@/api'
|
||||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||||
|
|
||||||
|
interface PendingEmail {
|
||||||
|
email: string
|
||||||
|
codeSent: boolean
|
||||||
|
code: string
|
||||||
|
sending: boolean
|
||||||
|
verifying: boolean
|
||||||
|
countdown: number
|
||||||
|
timer: ReturnType<typeof setInterval> | null
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
threshold: number | null
|
threshold: number | null
|
||||||
extraEmails: string[]
|
extraEmails: string[]
|
||||||
|
systemDefaultThreshold: number
|
||||||
|
userEmail: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -113,23 +148,19 @@ const appStore = useAppStore()
|
|||||||
const notifyEnabled = ref(props.enabled)
|
const notifyEnabled = ref(props.enabled)
|
||||||
const customThreshold = ref<number | null>(props.threshold)
|
const customThreshold = ref<number | null>(props.threshold)
|
||||||
const extraEmails = ref<string[]>([...props.extraEmails])
|
const extraEmails = ref<string[]>([...props.extraEmails])
|
||||||
|
const pendingEmails = ref<PendingEmail[]>([])
|
||||||
const newEmail = 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<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (countdownTimer) clearInterval(countdownTimer)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.enabled, (val) => { notifyEnabled.value = val })
|
watch(() => props.enabled, (val) => { notifyEnabled.value = val })
|
||||||
watch(() => props.threshold, (val) => { customThreshold.value = val })
|
watch(() => props.threshold, (val) => { customThreshold.value = val })
|
||||||
watch(() => props.extraEmails, (val) => { extraEmails.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 () => {
|
const handleToggle = async () => {
|
||||||
try {
|
try {
|
||||||
const updated = await userAPI.updateProfile({ balance_notify_enabled: notifyEnabled.value })
|
const updated = await userAPI.updateProfile({ balance_notify_enabled: notifyEnabled.value })
|
||||||
@@ -150,47 +181,56 @@ const handleThresholdUpdate = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSendCode = async () => {
|
function addPendingEmail() {
|
||||||
if (!newEmail.value) return
|
const email = newEmail.value.trim()
|
||||||
sendingCode.value = true
|
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 {
|
try {
|
||||||
await userAPI.sendNotifyEmailCode(newEmail.value)
|
await userAPI.sendNotifyEmailCode(pe.email)
|
||||||
codeSent.value = true
|
pe.codeSent = true
|
||||||
codeCountdown.value = 60
|
pe.countdown = 60
|
||||||
countdownTimer = setInterval(() => {
|
pe.timer = setInterval(() => {
|
||||||
codeCountdown.value--
|
pe.countdown--
|
||||||
if (codeCountdown.value <= 0) {
|
if (pe.countdown <= 0 && pe.timer) {
|
||||||
if (countdownTimer) clearInterval(countdownTimer)
|
clearInterval(pe.timer)
|
||||||
countdownTimer = null
|
pe.timer = null
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
appStore.showSuccess(t('profile.balanceNotify.codeSent'))
|
appStore.showSuccess(t('profile.balanceNotify.codeSent'))
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||||
} finally {
|
} finally {
|
||||||
sendingCode.value = false
|
pe.sending = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleVerify = async () => {
|
async function verifyPending(idx: number) {
|
||||||
if (!verifyCode.value || verifyCode.value.length !== 6) return
|
const pe = pendingEmails.value[idx]
|
||||||
verifying.value = true
|
if (!pe || !pe.code || pe.code.length !== 6) return
|
||||||
|
pe.verifying = true
|
||||||
try {
|
try {
|
||||||
await userAPI.verifyNotifyEmail(newEmail.value, verifyCode.value)
|
await userAPI.verifyNotifyEmail(pe.email, pe.code)
|
||||||
extraEmails.value.push(newEmail.value)
|
extraEmails.value.push(pe.email)
|
||||||
newEmail.value = ''
|
if (pe.timer) clearInterval(pe.timer)
|
||||||
verifyCode.value = ''
|
pendingEmails.value.splice(idx, 1)
|
||||||
codeSent.value = false
|
|
||||||
if (countdownTimer) clearInterval(countdownTimer)
|
|
||||||
codeCountdown.value = 0
|
|
||||||
appStore.showSuccess(t('profile.balanceNotify.verifySuccess'))
|
appStore.showSuccess(t('profile.balanceNotify.verifySuccess'))
|
||||||
// Refresh user data
|
|
||||||
const updated = await userAPI.getProfile()
|
const updated = await userAPI.getProfile()
|
||||||
authStore.user = updated
|
authStore.user = updated
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||||
} finally {
|
} finally {
|
||||||
verifying.value = false
|
pe.verifying = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -911,17 +911,19 @@ export default {
|
|||||||
thresholdHint: 'Leave empty to use system default',
|
thresholdHint: 'Leave empty to use system default',
|
||||||
thresholdPlaceholder: 'Enter amount',
|
thresholdPlaceholder: 'Enter amount',
|
||||||
systemDefault: 'System Default',
|
systemDefault: 'System Default',
|
||||||
extraEmails: 'Extra Notification Emails',
|
extraEmails: 'Notification Emails',
|
||||||
|
primaryEmail: 'Primary',
|
||||||
noExtraEmails: 'No extra notification emails',
|
noExtraEmails: 'No extra notification emails',
|
||||||
enterEmail: 'Enter email address',
|
enterEmail: 'Enter email address',
|
||||||
addEmail: 'Add Email',
|
addEmail: 'Add Email',
|
||||||
emailPlaceholder: 'Enter email address',
|
emailPlaceholder: 'Enter email address',
|
||||||
sendCode: 'Send Code',
|
sendCode: 'Send Code',
|
||||||
|
resend: 'Resend',
|
||||||
codeSent: 'Verification code sent',
|
codeSent: 'Verification code sent',
|
||||||
codeSentTo: 'Code sent to {email}',
|
codeSentTo: 'Code sent to {email}',
|
||||||
enterCode: 'Enter verification code',
|
enterCode: 'Enter verification code',
|
||||||
codePlaceholder: '6-digit code',
|
codePlaceholder: '6-digit code',
|
||||||
verify: 'Verify & Add',
|
verify: 'Verify',
|
||||||
emailAdded: 'Email added',
|
emailAdded: 'Email added',
|
||||||
emailRemoved: 'Email removed',
|
emailRemoved: 'Email removed',
|
||||||
verifySuccess: 'Email added successfully',
|
verifySuccess: 'Email added successfully',
|
||||||
|
|||||||
@@ -915,17 +915,19 @@ export default {
|
|||||||
thresholdHint: '留空使用系统默认值',
|
thresholdHint: '留空使用系统默认值',
|
||||||
thresholdPlaceholder: '输入金额',
|
thresholdPlaceholder: '输入金额',
|
||||||
systemDefault: '系统默认值',
|
systemDefault: '系统默认值',
|
||||||
extraEmails: '额外通知邮箱',
|
extraEmails: '通知邮箱',
|
||||||
|
primaryEmail: '主邮箱',
|
||||||
noExtraEmails: '暂无额外通知邮箱',
|
noExtraEmails: '暂无额外通知邮箱',
|
||||||
enterEmail: '输入邮箱地址',
|
enterEmail: '输入邮箱地址',
|
||||||
addEmail: '添加邮箱',
|
addEmail: '添加邮箱',
|
||||||
emailPlaceholder: '输入邮箱地址',
|
emailPlaceholder: '输入邮箱地址',
|
||||||
sendCode: '发送验证码',
|
sendCode: '发送验证码',
|
||||||
|
resend: '重发',
|
||||||
codeSent: '验证码已发送',
|
codeSent: '验证码已发送',
|
||||||
codeSentTo: '验证码已发送到 {email}',
|
codeSentTo: '验证码已发送到 {email}',
|
||||||
enterCode: '输入验证码',
|
enterCode: '输入验证码',
|
||||||
codePlaceholder: '6位验证码',
|
codePlaceholder: '6位验证码',
|
||||||
verify: '确认添加',
|
verify: '验证',
|
||||||
emailAdded: '邮箱已添加',
|
emailAdded: '邮箱已添加',
|
||||||
emailRemoved: '邮箱已移除',
|
emailRemoved: '邮箱已移除',
|
||||||
verifySuccess: '邮箱添加成功',
|
verifySuccess: '邮箱添加成功',
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
version: siteVersion.value,
|
version: siteVersion.value,
|
||||||
balance_low_notify_enabled: false,
|
balance_low_notify_enabled: false,
|
||||||
account_quota_notify_enabled: false,
|
account_quota_notify_enabled: false,
|
||||||
|
balance_low_notify_threshold: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export interface PublicSettings {
|
|||||||
version: string
|
version: string
|
||||||
balance_low_notify_enabled: boolean
|
balance_low_notify_enabled: boolean
|
||||||
account_quota_notify_enabled: boolean
|
account_quota_notify_enabled: boolean
|
||||||
|
balance_low_notify_threshold: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
:enabled="user.balance_notify_enabled ?? true"
|
:enabled="user.balance_notify_enabled ?? true"
|
||||||
:threshold="user.balance_notify_threshold"
|
:threshold="user.balance_notify_threshold"
|
||||||
:extra-emails="user.balance_notify_extra_emails ?? []"
|
:extra-emails="user.balance_notify_extra_emails ?? []"
|
||||||
|
:system-default-threshold="systemDefaultThreshold"
|
||||||
|
:user-email="user.email"
|
||||||
/>
|
/>
|
||||||
<ProfilePasswordForm />
|
<ProfilePasswordForm />
|
||||||
<ProfileTotpCard />
|
<ProfileTotpCard />
|
||||||
@@ -41,11 +43,12 @@ import { Icon } from '@/components/icons'
|
|||||||
const { t } = useI18n(); const authStore = useAuthStore(); const user = computed(() => authStore.user)
|
const { t } = useI18n(); const authStore = useAuthStore(); const user = computed(() => authStore.user)
|
||||||
const contactInfo = ref('')
|
const contactInfo = ref('')
|
||||||
const balanceLowNotifyEnabled = ref(false)
|
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 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 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' })]) }
|
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)}`
|
const formatCurrency = (v: number) => `$${v.toFixed(2)}`
|
||||||
</script>
|
</script>
|
||||||
Reference in New Issue
Block a user