fix(accounts): unify modal width, add notify props to create, fix quota layout

- EditAccountModal width changed from "normal" to "wide" (match CreateAccountModal)
- CreateAccountModal now passes all quota notify props to QuotaLimitCard
- QuotaLimitCard: when global notify disabled, hide title row, input takes full width
- Quota alert email: show remaining quota + threshold (fixed/$, percentage/%) instead of usage trigger point
This commit is contained in:
erio
2026-04-13 20:01:25 +08:00
parent 6e9146e746
commit a43da62254
5 changed files with 99 additions and 24 deletions

View File

@@ -1 +1 @@
0.1.110.42
0.1.110.44

View File

@@ -257,7 +257,7 @@ func (s *BalanceNotifyService) asyncSendQuotaAlert(adminEmails []string, account
slog.Error("panic in quota notification", "recover", r)
}
}()
s.sendQuotaAlertEmails(adminEmails, accountID, accountName, platform, dim.name, newUsed, dim.limit, effectiveThreshold, siteName)
s.sendQuotaAlertEmails(adminEmails, accountID, accountName, platform, dim, newUsed, siteName)
}()
}
@@ -384,15 +384,25 @@ func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userNam
}
// sendQuotaAlertEmails sends quota alert notification to admin emails.
func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accountID int64, accountName, platform, dimension string, used, limit, threshold float64, siteName string) {
dimLabel := quotaDimLabels[dimension]
func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accountID int64, accountName, platform string, dim quotaDim, used float64, siteName string) {
dimLabel := quotaDimLabels[dim.name]
if dimLabel == "" {
dimLabel = dimension
dimLabel = dim.name
}
// Format the remaining-based threshold for display
thresholdDisplay := fmt.Sprintf("$%.2f", dim.threshold)
if dim.thresholdType == thresholdTypePercentage {
thresholdDisplay = fmt.Sprintf("%.0f%%", dim.threshold)
}
remaining := dim.limit - used
if remaining < 0 {
remaining = 0
}
subject := fmt.Sprintf("[%s] 账号限额告警 / Account Quota Alert - %s", sanitizeEmailHeader(siteName), sanitizeEmailHeader(accountName))
body := s.buildQuotaAlertEmailBody(accountID, html.EscapeString(accountName), html.EscapeString(platform), html.EscapeString(dimLabel), used, limit, threshold, html.EscapeString(siteName))
s.sendEmails(adminEmails, subject, body, "account", accountName, "dimension", dimension)
body := s.buildQuotaAlertEmailBody(accountID, html.EscapeString(accountName), html.EscapeString(platform), html.EscapeString(dimLabel), used, dim.limit, remaining, thresholdDisplay, html.EscapeString(siteName))
s.sendEmails(adminEmails, subject, body, "account", accountName, "dimension", dim.name)
}
// sanitizeEmailHeader removes CR/LF characters to prevent SMTP header injection.
@@ -440,7 +450,7 @@ const balanceLowEmailTemplate = `<!DOCTYPE html>
</html>`
// quotaAlertEmailTemplate is the HTML template for account quota alert notifications.
// Format args: siteName, accountID, accountName, platform, dimLabel, used, limitStr, threshold.
// Format args: siteName, accountID, accountName, platform, dimLabel, used, limitStr, remaining, thresholdDisplay.
const quotaAlertEmailTemplate = `<!DOCTYPE html>
<html>
<head>
@@ -469,10 +479,11 @@ const quotaAlertEmailTemplate = `<!DOCTYPE html>
<div class="metric"><span class="metric-label">维度 / Dimension</span><span class="metric-value">%s</span></div>
<div class="metric"><span class="metric-label">已使用 / Used</span><span class="metric-value">$%.2f</span></div>
<div class="metric"><span class="metric-label">限额 / Limit</span><span class="metric-value">%s</span></div>
<div class="metric"><span class="metric-label">告警阈值 / Threshold</span><span class="metric-value">$%.2f</span></div>
<div class="metric"><span class="metric-label">剩余额度 / Remaining</span><span class="metric-value">$%.2f</span></div>
<div class="metric"><span class="metric-label">提醒阈值 / Alert Threshold</span><span class="metric-value">%s</span></div>
<div class="info">
<p>账号配额用量已达到告警阈值,请及时关注。</p>
<p>Account quota usage has reached the alert threshold.</p>
<p>账号剩余额度已低于提醒阈值,请及时关注。</p>
<p>Account remaining quota has fallen below the alert threshold.</p>
</div>
</div>
<div class="footer"><p>此邮件由系统自动发送,请勿回复。</p></div>
@@ -490,11 +501,11 @@ func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance
}
// buildQuotaAlertEmailBody builds HTML email for account quota alert.
func (s *BalanceNotifyService) buildQuotaAlertEmailBody(accountID int64, accountName, platform, dimLabel string, used, limit, threshold float64, siteName string) string {
func (s *BalanceNotifyService) buildQuotaAlertEmailBody(accountID int64, accountName, platform, dimLabel string, used, limit, remaining float64, thresholdDisplay, siteName string) string {
limitStr := fmt.Sprintf("$%.2f", limit)
if limit <= 0 {
limitStr = "无限制 / Unlimited"
}
return fmt.Sprintf(quotaAlertEmailTemplate, siteName, accountID, accountName, platform, dimLabel, used, limitStr, threshold)
return fmt.Sprintf(quotaAlertEmailTemplate, siteName, accountID, accountName, platform, dimLabel, used, limitStr, remaining, thresholdDisplay)
}

View File

@@ -1493,6 +1493,15 @@
:dailyLimit="editQuotaDailyLimit"
:weeklyLimit="editQuotaWeeklyLimit"
:quotaNotifyGlobalEnabled="quotaNotifyGlobalEnabled"
:quotaNotifyDailyEnabled="quotaNotifyDailyEnabled"
:quotaNotifyDailyThreshold="quotaNotifyDailyThreshold"
:quotaNotifyDailyThresholdType="quotaNotifyDailyThresholdType"
:quotaNotifyWeeklyEnabled="quotaNotifyWeeklyEnabled"
:quotaNotifyWeeklyThreshold="quotaNotifyWeeklyThreshold"
:quotaNotifyWeeklyThresholdType="quotaNotifyWeeklyThresholdType"
:quotaNotifyTotalEnabled="quotaNotifyTotalEnabled"
:quotaNotifyTotalThreshold="quotaNotifyTotalThreshold"
:quotaNotifyTotalThresholdType="quotaNotifyTotalThresholdType"
:dailyResetMode="editDailyResetMode"
:dailyResetHour="editDailyResetHour"
:weeklyResetMode="editWeeklyResetMode"
@@ -1502,6 +1511,15 @@
@update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
@update:quotaNotifyDailyEnabled="quotaNotifyDailyEnabled = $event"
@update:quotaNotifyDailyThreshold="quotaNotifyDailyThreshold = $event"
@update:quotaNotifyDailyThresholdType="quotaNotifyDailyThresholdType = $event"
@update:quotaNotifyWeeklyEnabled="quotaNotifyWeeklyEnabled = $event"
@update:quotaNotifyWeeklyThreshold="quotaNotifyWeeklyThreshold = $event"
@update:quotaNotifyWeeklyThresholdType="quotaNotifyWeeklyThresholdType = $event"
@update:quotaNotifyTotalEnabled="quotaNotifyTotalEnabled = $event"
@update:quotaNotifyTotalThreshold="quotaNotifyTotalThreshold = $event"
@update:quotaNotifyTotalThresholdType="quotaNotifyTotalThresholdType = $event"
@update:dailyResetMode="editDailyResetMode = $event"
@update:dailyResetHour="editDailyResetHour = $event"
@update:weeklyResetMode="editWeeklyResetMode = $event"
@@ -1527,6 +1545,15 @@
:dailyLimit="editQuotaDailyLimit"
:weeklyLimit="editQuotaWeeklyLimit"
:quotaNotifyGlobalEnabled="quotaNotifyGlobalEnabled"
:quotaNotifyDailyEnabled="quotaNotifyDailyEnabled"
:quotaNotifyDailyThreshold="quotaNotifyDailyThreshold"
:quotaNotifyDailyThresholdType="quotaNotifyDailyThresholdType"
:quotaNotifyWeeklyEnabled="quotaNotifyWeeklyEnabled"
:quotaNotifyWeeklyThreshold="quotaNotifyWeeklyThreshold"
:quotaNotifyWeeklyThresholdType="quotaNotifyWeeklyThresholdType"
:quotaNotifyTotalEnabled="quotaNotifyTotalEnabled"
:quotaNotifyTotalThreshold="quotaNotifyTotalThreshold"
:quotaNotifyTotalThresholdType="quotaNotifyTotalThresholdType"
:dailyResetMode="editDailyResetMode"
:dailyResetHour="editDailyResetHour"
:weeklyResetMode="editWeeklyResetMode"
@@ -1536,6 +1563,15 @@
@update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
@update:quotaNotifyDailyEnabled="quotaNotifyDailyEnabled = $event"
@update:quotaNotifyDailyThreshold="quotaNotifyDailyThreshold = $event"
@update:quotaNotifyDailyThresholdType="quotaNotifyDailyThresholdType = $event"
@update:quotaNotifyWeeklyEnabled="quotaNotifyWeeklyEnabled = $event"
@update:quotaNotifyWeeklyThreshold="quotaNotifyWeeklyThreshold = $event"
@update:quotaNotifyWeeklyThresholdType="quotaNotifyWeeklyThresholdType = $event"
@update:quotaNotifyTotalEnabled="quotaNotifyTotalEnabled = $event"
@update:quotaNotifyTotalThreshold="quotaNotifyTotalThreshold = $event"
@update:quotaNotifyTotalThresholdType="quotaNotifyTotalThresholdType = $event"
@update:dailyResetMode="editDailyResetMode = $event"
@update:dailyResetHour="editDailyResetHour = $event"
@update:weeklyResetMode="editWeeklyResetMode = $event"
@@ -3041,6 +3077,15 @@ const anthropicPassthroughEnabled = ref(false)
const webSearchEmulationMode = ref('default')
const webSearchGlobalEnabled = ref(false)
const quotaNotifyGlobalEnabled = ref(false)
const quotaNotifyDailyEnabled = ref<boolean | null>(null)
const quotaNotifyDailyThreshold = ref<number | null>(null)
const quotaNotifyDailyThresholdType = ref<string | null>(null)
const quotaNotifyWeeklyEnabled = ref<boolean | null>(null)
const quotaNotifyWeeklyThreshold = ref<number | null>(null)
const quotaNotifyWeeklyThresholdType = ref<string | null>(null)
const quotaNotifyTotalEnabled = ref<boolean | null>(null)
const quotaNotifyTotalThreshold = ref<number | null>(null)
const quotaNotifyTotalThresholdType = ref<string | null>(null)
// Load global feature states once
adminAPI.settings.getWebSearchEmulationConfig().then(cfg => {
@@ -4153,6 +4198,22 @@ const createAccountAndFinish = async (
if (editDailyResetMode.value === 'fixed' || editWeeklyResetMode.value === 'fixed') {
quotaExtra.quota_reset_timezone = editResetTimezone.value || 'UTC'
}
// Quota notify config
if (quotaNotifyDailyEnabled.value) {
quotaExtra.quota_notify_daily_enabled = true
if (quotaNotifyDailyThreshold.value != null) quotaExtra.quota_notify_daily_threshold = quotaNotifyDailyThreshold.value
quotaExtra.quota_notify_daily_threshold_type = quotaNotifyDailyThresholdType.value || 'fixed'
}
if (quotaNotifyWeeklyEnabled.value) {
quotaExtra.quota_notify_weekly_enabled = true
if (quotaNotifyWeeklyThreshold.value != null) quotaExtra.quota_notify_weekly_threshold = quotaNotifyWeeklyThreshold.value
quotaExtra.quota_notify_weekly_threshold_type = quotaNotifyWeeklyThresholdType.value || 'fixed'
}
if (quotaNotifyTotalEnabled.value) {
quotaExtra.quota_notify_total_enabled = true
if (quotaNotifyTotalThreshold.value != null) quotaExtra.quota_notify_total_threshold = quotaNotifyTotalThreshold.value
quotaExtra.quota_notify_total_threshold_type = quotaNotifyTotalThresholdType.value || 'fixed'
}
if (Object.keys(quotaExtra).length > 0) {
finalExtra = quotaExtra
}

View File

@@ -2,7 +2,7 @@
<BaseDialog
:show="show"
:title="t('admin.accounts.editAccount')"
width="normal"
width="wide"
@close="handleClose"
>
<form

View File

@@ -199,14 +199,15 @@ const onWeeklyModeChange = (e: Event) => {
<div v-if="localEnabled && !collapsed" class="space-y-2 p-4 pt-3">
<!-- 日配额 -->
<div>
<!-- 标题行 -->
<div class="flex items-center gap-2 mb-1">
<!-- 标题行仅全局通知开启时显示 -->
<div v-if="quotaNotifyGlobalEnabled" class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 w-28 flex-shrink-0">{{ t('admin.accounts.quotaDailyLimit') }}</span>
<span v-if="quotaNotifyGlobalEnabled && dailyLimit && dailyLimit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.quotaNotify.alert') }}</span>
<span v-if="dailyLimit && dailyLimit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.quotaNotify.alert') }}</span>
</div>
<label v-else class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 block">{{ t('admin.accounts.quotaDailyLimit') }}</label>
<!-- 输入行 -->
<div class="flex items-center gap-2">
<div class="relative w-28 flex-shrink-0">
<div :class="['relative', quotaNotifyGlobalEnabled ? 'w-28 flex-shrink-0' : 'flex-1']">
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 text-sm">$</span>
<input :value="dailyLimit" @input="onDailyInput" type="number" min="0" step="0.01" class="input pl-6 py-1.5 text-sm" :placeholder="t('admin.accounts.quotaLimitPlaceholder')" />
</div>
@@ -238,12 +239,13 @@ const onWeeklyModeChange = (e: Event) => {
<!-- 周配额 -->
<div>
<div class="flex items-center gap-2 mb-1">
<div v-if="quotaNotifyGlobalEnabled" class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 w-28 flex-shrink-0">{{ t('admin.accounts.quotaWeeklyLimit') }}</span>
<span v-if="quotaNotifyGlobalEnabled && weeklyLimit && weeklyLimit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.quotaNotify.alert') }}</span>
<span v-if="weeklyLimit && weeklyLimit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.quotaNotify.alert') }}</span>
</div>
<label v-else class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 block">{{ t('admin.accounts.quotaWeeklyLimit') }}</label>
<div class="flex items-center gap-2">
<div class="relative w-28 flex-shrink-0">
<div :class="['relative', quotaNotifyGlobalEnabled ? 'w-28 flex-shrink-0' : 'flex-1']">
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 text-sm">$</span>
<input :value="weeklyLimit" @input="onWeeklyInput" type="number" min="0" step="0.01" class="input pl-6 py-1.5 text-sm" :placeholder="t('admin.accounts.quotaLimitPlaceholder')" />
</div>
@@ -287,12 +289,13 @@ const onWeeklyModeChange = (e: Event) => {
<!-- 总配额 -->
<div>
<div class="flex items-center gap-2 mb-1">
<div v-if="quotaNotifyGlobalEnabled" class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 w-28 flex-shrink-0">{{ t('admin.accounts.quotaTotalLimit') }}</span>
<span v-if="quotaNotifyGlobalEnabled && totalLimit && totalLimit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.quotaNotify.alert') }}</span>
<span v-if="totalLimit && totalLimit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.quotaNotify.alert') }}</span>
</div>
<label v-else class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 block">{{ t('admin.accounts.quotaTotalLimit') }}</label>
<div class="flex items-center gap-2">
<div class="relative w-28 flex-shrink-0">
<div :class="['relative', quotaNotifyGlobalEnabled ? 'w-28 flex-shrink-0' : 'flex-1']">
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 text-sm">$</span>
<input :value="totalLimit" @input="onTotalInput" type="number" min="0" step="0.01" class="input pl-6 py-1.5 text-sm" :placeholder="t('admin.accounts.quotaLimitPlaceholder')" />
</div>