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:
erio
2026-04-12 20:29:26 +08:00
parent 79d154ed73
commit 81287e960c
10 changed files with 162 additions and 101 deletions

View File

@@ -4,90 +4,113 @@
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
{{ t('profile.balanceNotify.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.balanceNotify.description') }}
</p>
</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="input-label mb-0">{{ t('profile.balanceNotify.enabled') }}</label>
<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>
<template v-if="notifyEnabled">
<!-- Custom threshold -->
<div>
<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="systemDefaultThreshold > 0 ? `${t('profile.balanceNotify.systemDefault')} $${systemDefaultThreshold}` : t('profile.balanceNotify.thresholdPlaceholder')"
@blur="handleThresholdUpdate"
/>
</div>
</div>
<!-- Add new email -->
<div class="space-y-2">
<!-- Primary email (always shown, with toggle) -->
<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">
<input
v-model="newEmail"
type="email"
class="input flex-1"
:placeholder="t('profile.balanceNotify.emailPlaceholder')"
:disabled="codeSent"
@keyup.enter="addPendingEmail"
/>
<button
@click="handleSendCode"
:disabled="!newEmail || sendingCode || codeCountdown > 0"
class="btn btn-outline whitespace-nowrap"
@click="addPendingEmail"
:disabled="!newEmail"
class="btn btn-secondary 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') }}
{{ t('common.add') }}
</button>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
@@ -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<typeof setInterval> | 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<number | null>(props.threshold)
const extraEmails = ref<string[]>([...props.extraEmails])
const pendingEmails = ref<PendingEmail[]>([])
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.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
}
}