feat(notify): convert email lists to NotifyEmailEntry struct with toggle support

- Change balance_notify_extra_emails and account_quota_notify_emails
  from []string to []NotifyEmailEntry{email, disabled, verified}
- Add per-email enable/disable toggle for both user and admin notifications
- Add PUT /user/notify-email/toggle API endpoint
- Fix critical bug: API key auth cache snapshot missing balance notify
  fields (Email, Username, BalanceNotifyEnabled, etc.), causing
  notifications to never fire on cached request paths
- Bump cache snapshot version 3→4 to invalidate stale entries
- Add SQL migration 104 to convert old format data
- Backward compatible: parseNotifyEmails auto-detects old/new format
- User balance notify: max 3 emails (primary + 2 extra)
- Admin quota notify: unlimited emails, each with toggle
This commit is contained in:
erio
2026-04-13 00:52:42 +08:00
parent 61aa197b0b
commit 915b7a4a56
25 changed files with 448 additions and 95 deletions

View File

@@ -45,23 +45,26 @@
</div>
</div>
<!-- Primary email (always shown, with toggle) -->
<!-- Email list with toggles -->
<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 with toggle -->
<div v-if="extraEmails.length > 0" class="space-y-2 mb-3">
<div v-for="email in extraEmails" :key="email"
<!-- All email entries (primary placeholder + extra) -->
<div v-for="(entry, idx) in emailEntries" :key="idx"
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>
<div class="flex items-center gap-2">
<button @click="handleRemoveEmail(email)" class="text-red-500 hover:text-red-700 text-xs">
<div class="flex items-center gap-2 min-w-0 flex-1">
<label class="relative inline-flex items-center cursor-pointer shrink-0">
<input type="checkbox" :checked="!entry.disabled" @change="handleEmailToggle(entry)" class="sr-only peer" />
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-600 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-4 after:w-4 after:transition-all dark:after:border-gray-500 peer-checked:bg-primary-600"></div>
</label>
<span class="text-sm text-gray-700 dark:text-gray-300 truncate">
{{ entry.email === '' ? userEmail : entry.email }}
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<span v-if="entry.email === ''" class="text-xs text-gray-400">{{ t('profile.balanceNotify.primaryEmail') }}</span>
<span v-else-if="!entry.verified" class="text-xs text-yellow-500">{{ t('profile.balanceNotify.unverified') }}</span>
<button v-if="entry.email !== ''" @click="handleRemoveEmail(entry.email)" class="text-red-500 hover:text-red-700 text-xs">
{{ t('profile.balanceNotify.removeEmail') }}
</button>
</div>
@@ -100,8 +103,8 @@
</div>
</div>
<!-- Add new email input -->
<div class="flex gap-2">
<!-- Add new email input (hidden when at limit) -->
<div v-if="canAddMore" class="flex gap-2">
<input
v-model="newEmail"
type="email"
@@ -117,6 +120,9 @@
{{ t('common.add') }}
</button>
</div>
<p v-else class="text-xs text-gray-400">
{{ t('profile.balanceNotify.maxEmailsReached') }}
</p>
</div>
</template>
</div>
@@ -124,12 +130,15 @@
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue'
import { ref, computed, watch, onUnmounted } 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'
import type { NotifyEmailEntry } from '@/types'
const maxTotalEmails = 3 // primary + 2 extra
interface PendingEmail {
email: string
@@ -144,7 +153,7 @@ interface PendingEmail {
const props = defineProps<{
enabled: boolean
threshold: number | null
extraEmails: string[]
extraEmails: NotifyEmailEntry[]
systemDefaultThreshold: number
userEmail: string
}>()
@@ -155,14 +164,18 @@ const appStore = useAppStore()
const notifyEnabled = ref(props.enabled)
const customThreshold = ref<number | null>(props.threshold)
const extraEmails = ref<string[]>([...props.extraEmails])
const emailEntries = ref<NotifyEmailEntry[]>([...props.extraEmails])
const pendingEmails = ref<PendingEmail[]>([])
const newEmail = ref('')
const savingThreshold = ref(false)
const canAddMore = computed(() => {
return emailEntries.value.length + pendingEmails.value.length < maxTotalEmails
})
watch(() => props.enabled, (val) => { notifyEnabled.value = val })
watch(() => props.threshold, (val) => { customThreshold.value = val })
watch(() => props.extraEmails, (val) => { extraEmails.value = [...val] })
watch(() => props.extraEmails, (val) => { emailEntries.value = [...val] })
onUnmounted(() => {
for (const pe of pendingEmails.value) {
@@ -194,10 +207,25 @@ const handleThresholdUpdate = async () => {
}
}
async function handleEmailToggle(entry: NotifyEmailEntry) {
const newDisabled = !entry.disabled
try {
const updated = await userAPI.toggleNotifyEmail(entry.email, newDisabled)
authStore.user = updated
emailEntries.value = [...updated.balance_notify_extra_emails]
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
}
}
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)) {
// Check duplicates against existing entries and pending
const isDuplicate = emailEntries.value.some(e =>
(e.email === '' ? props.userEmail : e.email).toLowerCase() === email.toLowerCase()
) || pendingEmails.value.some(p => p.email.toLowerCase() === email.toLowerCase())
if (isDuplicate) {
appStore.showError(t('profile.balanceNotify.emailDuplicate'))
return
}
@@ -234,12 +262,12 @@ async function verifyPending(idx: number) {
pe.verifying = true
try {
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'))
const updated = await userAPI.getProfile()
authStore.user = updated
emailEntries.value = [...updated.balance_notify_extra_emails]
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
@@ -250,10 +278,10 @@ async function verifyPending(idx: number) {
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
emailEntries.value = [...updated.balance_notify_extra_emails]
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
}