diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index e88f7f8c..cbe9c76c 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -57,7 +57,8 @@ func tryModelFilePricing(billingService *BillingService, model string, tokens Us cost := float64(tokens.InputTokens)*pricing.InputPricePerToken + float64(tokens.OutputTokens)*pricing.OutputPricePerToken + float64(tokens.CacheCreationTokens)*pricing.CacheCreationPricePerToken + - float64(tokens.CacheReadTokens)*pricing.CacheReadPricePerToken + float64(tokens.CacheReadTokens)*pricing.CacheReadPricePerToken + + float64(tokens.ImageOutputTokens)*pricing.ImageOutputPricePerToken if cost <= 0 { return nil } @@ -194,7 +195,7 @@ func calculateTokenStatsCost(pricing *ChannelModelPricing, tokens UsageTokens) * float64(tokens.CacheCreationTokens)*deref(pricing.CacheWritePrice) + float64(tokens.CacheReadTokens)*deref(pricing.CacheReadPrice) + float64(tokens.ImageOutputTokens)*deref(pricing.ImageOutputPrice) - if cost == 0 { + if cost <= 0 { return nil } return &cost diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index bc3db251..bf9da978 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -428,3 +428,102 @@ func TestTryCustomRules_RuleMatchesButModelNot_ContinuesToNext(t *testing.T) { require.NotNil(t, result) require.InDelta(t, 5.0, *result, 1e-12) // 使用规则2 } + +// --------------------------------------------------------------------------- +// tryModelFilePricing +// --------------------------------------------------------------------------- + +// newTestBillingServiceWithPrices creates a BillingService with pre-populated +// fallback prices for testing. No config or pricing service is needed. +// The key must match what getFallbackPricing resolves to for a given model name. +// E.g., model "claude-sonnet-4" resolves to key "claude-sonnet-4". +func newTestBillingServiceWithPrices(prices map[string]*ModelPricing) *BillingService { + return &BillingService{ + fallbackPrices: prices, + } +} + +func TestTryModelFilePricing_Success(t *testing.T) { + bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ + "claude-sonnet-4": { + InputPricePerToken: 0.001, + OutputPricePerToken: 0.002, + }, + }) + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens) + require.NotNil(t, result) + // 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2 + require.InDelta(t, 0.2, *result, 1e-12) +} + +func TestTryModelFilePricing_PricingNotFound(t *testing.T) { + // "nonexistent-model" does not match any fallback pattern + bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{}) + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + result := tryModelFilePricing(bs, "nonexistent-model", tokens) + require.Nil(t, result) +} + +func TestTryModelFilePricing_NilFallback(t *testing.T) { + // getFallbackPricing returns nil when key maps to nil + bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ + "claude-sonnet-4": nil, + }) + tokens := UsageTokens{InputTokens: 100} + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens) + require.Nil(t, result) +} + +func TestTryModelFilePricing_ZeroCost(t *testing.T) { + bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ + "claude-sonnet-4": { + InputPricePerToken: 0.001, + OutputPricePerToken: 0.002, + }, + }) + tokens := UsageTokens{} // all zero tokens → cost = 0 → nil + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens) + require.Nil(t, result) +} + +func TestTryModelFilePricing_WithImageOutput(t *testing.T) { + bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ + "claude-sonnet-4": { + InputPricePerToken: 0.001, + OutputPricePerToken: 0.002, + ImageOutputPricePerToken: 0.01, + }, + }) + tokens := UsageTokens{ + InputTokens: 100, + OutputTokens: 50, + ImageOutputTokens: 10, + } + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens) + require.NotNil(t, result) + // 100*0.001 + 50*0.002 + 10*0.01 = 0.1 + 0.1 + 0.1 = 0.3 + require.InDelta(t, 0.3, *result, 1e-12) +} + +func TestTryModelFilePricing_WithCacheTokens(t *testing.T) { + bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ + "claude-sonnet-4": { + InputPricePerToken: 0.001, + OutputPricePerToken: 0.002, + CacheCreationPricePerToken: 0.003, + CacheReadPricePerToken: 0.0005, + }, + }) + tokens := UsageTokens{ + InputTokens: 100, + OutputTokens: 50, + CacheCreationTokens: 200, + CacheReadTokens: 300, + } + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens) + require.NotNil(t, result) + // 100*0.001 + 50*0.002 + 200*0.003 + 300*0.0005 + // = 0.1 + 0.1 + 0.6 + 0.15 = 0.95 + require.InDelta(t, 0.95, *result, 1e-12) +} diff --git a/backend/internal/service/account_websearch_test.go b/backend/internal/service/account_websearch_test.go index fe742ebf..b4d23c6b 100644 --- a/backend/internal/service/account_websearch_test.go +++ b/backend/internal/service/account_websearch_test.go @@ -1,3 +1,5 @@ +//go:build unit + package service import ( @@ -6,66 +8,98 @@ import ( "github.com/stretchr/testify/require" ) -func TestAccount_IsWebSearchEmulationEnabled_Enabled(t *testing.T) { +func TestGetWebSearchEmulationMode_Enabled(t *testing.T) { + a := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Extra: map[string]any{featureKeyWebSearchEmulation: "enabled"}, + } + require.Equal(t, WebSearchModeEnabled, a.GetWebSearchEmulationMode()) +} + +func TestGetWebSearchEmulationMode_Disabled(t *testing.T) { + a := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Extra: map[string]any{featureKeyWebSearchEmulation: "disabled"}, + } + require.Equal(t, WebSearchModeDisabled, a.GetWebSearchEmulationMode()) +} + +func TestGetWebSearchEmulationMode_Default(t *testing.T) { + a := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Extra: map[string]any{featureKeyWebSearchEmulation: "default"}, + } + require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode()) +} + +func TestGetWebSearchEmulationMode_UnknownString(t *testing.T) { + a := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Extra: map[string]any{featureKeyWebSearchEmulation: "unknown"}, + } + require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode()) +} + +func TestGetWebSearchEmulationMode_OldBoolTrue(t *testing.T) { a := &Account{ Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Extra: map[string]any{featureKeyWebSearchEmulation: true}, } - require.True(t, a.IsWebSearchEmulationEnabled()) + // bool is not a string, type assertion fails → default + require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode()) } -func TestAccount_IsWebSearchEmulationEnabled_Disabled(t *testing.T) { +func TestGetWebSearchEmulationMode_OldBoolFalse(t *testing.T) { a := &Account{ Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Extra: map[string]any{featureKeyWebSearchEmulation: false}, } - require.False(t, a.IsWebSearchEmulationEnabled()) + require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode()) } -func TestAccount_IsWebSearchEmulationEnabled_MissingField(t *testing.T) { +func TestGetWebSearchEmulationMode_NilAccount(t *testing.T) { + var a *Account + require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode()) +} + +func TestGetWebSearchEmulationMode_NilExtra(t *testing.T) { + a := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Extra: nil, + } + require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode()) +} + +func TestGetWebSearchEmulationMode_MissingField(t *testing.T) { a := &Account{ Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Extra: map[string]any{}, } - require.False(t, a.IsWebSearchEmulationEnabled()) + require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode()) } -func TestAccount_IsWebSearchEmulationEnabled_WrongType(t *testing.T) { - a := &Account{ - Platform: PlatformAnthropic, - Type: AccountTypeAPIKey, - Extra: map[string]any{featureKeyWebSearchEmulation: "true"}, - } - require.False(t, a.IsWebSearchEmulationEnabled()) -} - -func TestAccount_IsWebSearchEmulationEnabled_NilExtra(t *testing.T) { - a := &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Extra: nil} - require.False(t, a.IsWebSearchEmulationEnabled()) -} - -func TestAccount_IsWebSearchEmulationEnabled_NilAccount(t *testing.T) { - var a *Account - require.False(t, a.IsWebSearchEmulationEnabled()) -} - -func TestAccount_IsWebSearchEmulationEnabled_NonAnthropicPlatform(t *testing.T) { +func TestGetWebSearchEmulationMode_NonAnthropicPlatform(t *testing.T) { a := &Account{ Platform: PlatformOpenAI, Type: AccountTypeAPIKey, - Extra: map[string]any{featureKeyWebSearchEmulation: true}, + Extra: map[string]any{featureKeyWebSearchEmulation: "enabled"}, } - require.False(t, a.IsWebSearchEmulationEnabled()) + require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode()) } -func TestAccount_IsWebSearchEmulationEnabled_NonAPIKeyType(t *testing.T) { +func TestGetWebSearchEmulationMode_NonAPIKeyType(t *testing.T) { a := &Account{ Platform: PlatformAnthropic, Type: AccountTypeOAuth, - Extra: map[string]any{featureKeyWebSearchEmulation: true}, + Extra: map[string]any{featureKeyWebSearchEmulation: "enabled"}, } - require.False(t, a.IsWebSearchEmulationEnabled()) + require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode()) } diff --git a/backend/internal/service/balance_notify_service.go b/backend/internal/service/balance_notify_service.go index 23411ed5..1e4d8ff6 100644 --- a/backend/internal/service/balance_notify_service.go +++ b/backend/internal/service/balance_notify_service.go @@ -2,7 +2,6 @@ package service import ( "context" - "encoding/json" "fmt" "html" "log/slog" @@ -14,6 +13,10 @@ import ( const ( emailSendTimeout = 30 * time.Second + // Threshold type values + thresholdTypeFixed = "fixed" + thresholdTypePercentage = "percentage" + // Quota dimension labels quotaDimDaily = "daily" quotaDimWeekly = "weekly" @@ -48,6 +51,15 @@ func NewBalanceNotifyService(emailService *EmailService, settingRepo SettingRepo } } +// resolveBalanceThreshold returns the effective balance threshold. +// For percentage type, it computes threshold = totalRecharged * percentage / 100. +func resolveBalanceThreshold(threshold float64, thresholdType string, totalRecharged float64) float64 { + if thresholdType == thresholdTypePercentage && totalRecharged > 0 { + return totalRecharged * threshold / 100 + } + return threshold +} + // CheckBalanceAfterDeduction checks if balance crossed below threshold after deduction. // oldBalance is the balance before deduction, cost is the amount deducted. // Notification is sent only on first crossing: oldBalance >= threshold && newBalance < threshold. @@ -73,8 +85,13 @@ func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, u return } + effectiveThreshold := resolveBalanceThreshold(threshold, user.BalanceNotifyThresholdType, user.TotalRecharged) + if effectiveThreshold <= 0 { + return + } + newBalance := oldBalance - cost - if oldBalance >= threshold && newBalance < threshold { + if oldBalance >= effectiveThreshold && newBalance < effectiveThreshold { siteName := s.getSiteName(ctx) recipients := s.collectBalanceNotifyRecipients(user) go func() { @@ -83,7 +100,7 @@ func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, u slog.Error("panic in balance notification", "recover", r) } }() - s.sendBalanceLowEmails(recipients, user.Username, user.Email, newBalance, threshold, siteName) + s.sendBalanceLowEmails(recipients, user.Username, user.Email, newBalance, effectiveThreshold, siteName) }() } } @@ -94,14 +111,14 @@ type quotaDim struct { enabled bool threshold float64 thresholdType string // "fixed" (default) or "percentage" - oldUsed float64 + currentUsed float64 limit float64 } // resolvedThreshold returns the effective threshold value. // For percentage type, it computes threshold = limit * percentage / 100. func (d quotaDim) resolvedThreshold() float64 { - if d.thresholdType == "percentage" && d.limit > 0 { + if d.thresholdType == thresholdTypePercentage && d.limit > 0 { return d.limit * d.threshold / 100 } return d.threshold @@ -150,7 +167,7 @@ func (s *BalanceNotifyService) fetchFreshAccount(ctx context.Context, snapshot * } // checkQuotaDimCrossings iterates quota dimensions and sends alerts for threshold crossings. -// freshAccount has post-increment values; oldUsed is reconstructed as freshUsed - cost. +// freshAccount has post-increment values; pre-increment is reconstructed as currentUsed - cost. func (s *BalanceNotifyService) checkQuotaDimCrossings(freshAccount *Account, cost float64, adminEmails []string, siteName string) { for _, dim := range buildQuotaDims(freshAccount) { if !dim.enabled || dim.threshold <= 0 { @@ -160,10 +177,10 @@ func (s *BalanceNotifyService) checkQuotaDimCrossings(freshAccount *Account, cos if effectiveThreshold <= 0 { continue } - // dim.oldUsed is actually the post-increment value from fresh DB data; + // currentUsed is the post-increment value from fresh DB data; // reconstruct pre-increment value to detect threshold crossing. - newUsed := dim.oldUsed - oldUsed := dim.oldUsed - cost + newUsed := dim.currentUsed + oldUsed := dim.currentUsed - cost if oldUsed < effectiveThreshold && newUsed >= effectiveThreshold { s.asyncSendQuotaAlert(adminEmails, freshAccount.Name, dim, newUsed, effectiveThreshold, siteName) } @@ -309,10 +326,9 @@ func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accoun s.sendEmails(adminEmails, subject, body, "account", accountName, "dimension", dimension) } -// buildBalanceLowEmailBody builds HTML email for balance low notification. -// Lines exceed 30 due to inline HTML template (not splittable). -func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance, threshold float64, siteName string) string { - return fmt.Sprintf(` +// balanceLowEmailTemplate is the HTML template for balance low notifications. +// Format args: siteName, userName, userName, balance, threshold, threshold. +const balanceLowEmailTemplate = ` @@ -344,17 +360,11 @@ func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance -`, siteName, userName, userName, balance, threshold, threshold) -} +` -// buildQuotaAlertEmailBody builds HTML email for account quota alert. -// Lines exceed 30 due to inline HTML template (not splittable). -func (s *BalanceNotifyService) buildQuotaAlertEmailBody(accountName, dimLabel string, used, limit, threshold float64, siteName string) string { - limitStr := fmt.Sprintf("$%.2f", limit) - if limit <= 0 { - limitStr = "无限制 / Unlimited" - } - return fmt.Sprintf(` +// quotaAlertEmailTemplate is the HTML template for account quota alert notifications. +// Format args: siteName, accountName, dimLabel, used, limitStr, threshold. +const quotaAlertEmailTemplate = ` @@ -389,18 +399,19 @@ func (s *BalanceNotifyService) buildQuotaAlertEmailBody(accountName, dimLabel st -`, siteName, accountName, dimLabel, used, limitStr, threshold) +` + +// buildBalanceLowEmailBody builds HTML email for balance low notification. +func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance, threshold float64, siteName string) string { + return fmt.Sprintf(balanceLowEmailTemplate, siteName, userName, userName, balance, threshold, threshold) } -// parseJSONStringArray parses a JSON string array, returns nil on error. -func parseJSONStringArray(raw string) []string { - raw = strings.TrimSpace(raw) - if raw == "" || raw == "[]" { - return nil +// buildQuotaAlertEmailBody builds HTML email for account quota alert. +func (s *BalanceNotifyService) buildQuotaAlertEmailBody(accountName, dimLabel string, used, limit, threshold float64, siteName string) string { + limitStr := fmt.Sprintf("$%.2f", limit) + if limit <= 0 { + limitStr = "无限制 / Unlimited" } - var result []string - if err := json.Unmarshal([]byte(raw), &result); err != nil { - return nil - } - return result + return fmt.Sprintf(quotaAlertEmailTemplate, siteName, accountName, dimLabel, used, limitStr, threshold) } + diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 0267040d..28eb3a70 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -627,11 +627,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled) updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64) updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled) - accountQuotaNotifyEmailsJSON, err := json.Marshal(settings.AccountQuotaNotifyEmails) - if err != nil { - return fmt.Errorf("marshal account quota notify emails: %w", err) - } - updates[SettingKeyAccountQuotaNotifyEmails] = string(accountQuotaNotifyEmailsJSON) + updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails) err = s.settingRepo.SetMultiple(ctx, updates) if err == nil { diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index 92c8dd34..c405e66b 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -87,7 +87,7 @@ @@ -346,6 +346,7 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters' import { formatTokenPricePerMillion } from '@/utils/usagePricing' import { getUsageServiceTierLabel } from '@/utils/usageServiceTier' import { resolveUsageRequestType } from '@/utils/usageRequestType' +import { getBillingModeLabel, getBillingModeBadgeClass } from '@/utils/billingMode' import DataTable from '@/components/common/DataTable.vue' import EmptyState from '@/components/common/EmptyState.vue' import Icon from '@/components/icons/Icon.vue' @@ -399,17 +400,6 @@ const getRequestTypeBadgeClass = (row: AdminUsageLog): string => { return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200' } -const getBillingModeLabel = (mode: string | null | undefined): string => { - if (mode === 'per_request') return t('admin.usage.billingModePerRequest') - if (mode === 'image') return t('admin.usage.billingModeImage') - return t('admin.usage.billingModeToken') -} - -const getBillingModeBadgeClass = (mode: string | null | undefined): string => { - if (mode === 'per_request') return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' - if (mode === 'image') return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' - return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' -} const formatUserAgent = (ua: string): string => { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 056bdde0..1f9ba3e1 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1889,7 +1889,8 @@ export default { searchAccountPlaceholder: 'Search accounts...', ruleAccountsHint: 'Leave empty to match all accounts', ruleModelPricing: 'Model Pricing', - noGroupsInChannel: 'No groups selected in platform tabs above' + noGroupsInChannel: 'No groups selected in platform tabs above', + unnamed: 'Unnamed' } }, diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 47a1f8d4..fa5d970c 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1968,7 +1968,8 @@ export default { searchAccountPlaceholder: '搜索账号...', ruleAccountsHint: '留空表示匹配所有账号', ruleModelPricing: '模型定价', - noGroupsInChannel: '上方平台标签页中未选择分组' + noGroupsInChannel: '上方平台标签页中未选择分组', + unnamed: '未命名' } }, diff --git a/frontend/src/utils/billingMode.ts b/frontend/src/utils/billingMode.ts new file mode 100644 index 00000000..152dadc4 --- /dev/null +++ b/frontend/src/utils/billingMode.ts @@ -0,0 +1,19 @@ +export const BILLING_MODE_TOKEN = 'token' +export const BILLING_MODE_PER_REQUEST = 'per_request' +export const BILLING_MODE_IMAGE = 'image' + +export function getBillingModeLabel(mode: string | null | undefined, t: (key: string) => string): string { + switch (mode) { + case BILLING_MODE_PER_REQUEST: return t('admin.usage.billingModePerRequest') + case BILLING_MODE_IMAGE: return t('admin.usage.billingModeImage') + default: return t('admin.usage.billingModeToken') + } +} + +export function getBillingModeBadgeClass(mode: string | null | undefined): string { + switch (mode) { + case BILLING_MODE_PER_REQUEST: return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' + case BILLING_MODE_IMAGE: return 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300' + default: return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' + } +} diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index 5be45cb5..b714ca30 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -980,26 +980,38 @@ function clearAllRuleAccountSearchState() { showRuleAccountDropdown.value = {} } +function inferRulePlatform(groupIds: number[]): string { + const platforms = new Set() + for (const gid of groupIds) { + const group = allGroups.value.find(g => g.id === gid) + if (group) platforms.add(group.platform) + } + return platforms.size === 1 ? [...platforms][0] : '' +} + function accountStatsRulesToAPI(): AccountStatsPricingRule[] { - return form.account_stats_pricing_rules.map(rule => ({ - name: rule.name, - group_ids: rule.group_ids, - account_ids: rule.account_ids, - pricing: rule.pricing - .filter(p => p.models.length > 0) - .map(p => ({ - platform: '', - models: p.models, - billing_mode: p.billing_mode, - input_price: mTokToPerToken(p.input_price), - output_price: mTokToPerToken(p.output_price), - cache_write_price: mTokToPerToken(p.cache_write_price), - cache_read_price: mTokToPerToken(p.cache_read_price), - image_output_price: mTokToPerToken(p.image_output_price), - per_request_price: p.per_request_price != null && p.per_request_price !== '' ? Number(p.per_request_price) : null, - intervals: formIntervalsToAPI(p.intervals || []) - })) - })) + return form.account_stats_pricing_rules.map(rule => { + const platform = inferRulePlatform(rule.group_ids) + return { + name: rule.name, + group_ids: rule.group_ids, + account_ids: rule.account_ids, + pricing: rule.pricing + .filter(p => p.models.length > 0) + .map(p => ({ + platform, + models: p.models, + billing_mode: p.billing_mode, + input_price: mTokToPerToken(p.input_price), + output_price: mTokToPerToken(p.output_price), + cache_write_price: mTokToPerToken(p.cache_write_price), + cache_read_price: mTokToPerToken(p.cache_read_price), + image_output_price: mTokToPerToken(p.image_output_price), + per_request_price: p.per_request_price != null && p.per_request_price !== '' ? Number(p.per_request_price) : null, + intervals: formIntervalsToAPI(p.intervals || []) + })) + } + }) } // ── Form ↔ API conversion ── @@ -1329,7 +1341,7 @@ async function handleSubmit() { const intervalErr = validateIntervals(entry.intervals) if (intervalErr) { const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform) - const modelLabel = entry.models.join(', ') || '未命名' + const modelLabel = entry.models.join(', ') || t('admin.channels.form.unnamed') appStore.showError(`${platformLabel} - ${modelLabel}: ${intervalErr}`) activeTab.value = section.platform return diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index c687f0db..d2e17440 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -2804,7 +2804,7 @@ import type { WebSearchProviderConfig, WebSearchTestResult, } from '@/api/admin/settings' -import type { AdminGroup, Proxy } from '@/types' +import type { AdminGroup, Proxy, NotifyEmailEntry } from '@/types' import type { ProviderInstance } from '@/types/payment' import AppLayout from '@/components/layout/AppLayout.vue' import Icon from '@/components/icons/Icon.vue' @@ -3028,7 +3028,7 @@ const form = reactive({ balance_low_notify_enabled: false, balance_low_notify_threshold: 0, account_quota_notify_enabled: false, - account_quota_notify_emails: [] as { email: string; disabled: boolean; verified: boolean }[] + account_quota_notify_emails: [] as NotifyEmailEntry[] }) // Proxies for web search emulation ProxySelector diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index 2ec0fea5..08298d89 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -192,7 +192,7 @@ @@ -524,6 +524,7 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters' import { formatTokenPricePerMillion } from '@/utils/usagePricing' import { getUsageServiceTierLabel } from '@/utils/usageServiceTier' import { resolveUsageRequestType } from '@/utils/usageRequestType' +import { getBillingModeLabel, getBillingModeBadgeClass } from '@/utils/billingMode' const { t } = useI18n() const appStore = useAppStore() @@ -644,17 +645,6 @@ const getRequestTypeBadgeClass = (log: UsageLog): string => { return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200' } -const getBillingModeLabel = (mode: string | null | undefined): string => { - if (mode === 'per_request') return t('admin.usage.billingModePerRequest') - if (mode === 'image') return t('admin.usage.billingModeImage') - return t('admin.usage.billingModeToken') -} - -const getBillingModeBadgeClass = (mode: string | null | undefined): string => { - if (mode === 'per_request') return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200' - if (mode === 'image') return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' - return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' -} const getRequestTypeExportText = (log: UsageLog): string => { const requestType = resolveUsageRequestType(log) @@ -866,7 +856,7 @@ const exportToCSV = async () => { formatReasoningEffort(log.reasoning_effort), log.inbound_endpoint || '', getRequestTypeExportText(log), - getBillingModeLabel(log.billing_mode), + getBillingModeLabel(log.billing_mode, t), log.input_tokens, log.output_tokens, log.cache_read_tokens,