Unverified Commit d402e722 authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #1637 from touwaeriol/feat/websearch-notify-pricing

feat: web search emulation, balance/quota notify, account stats pricing, per-provider refund control, Stripe fix / Web 搜索模拟、余额配额通知、渠道统计计费、按服务商退款控制、Stripe 修复
parents e534e9ba 8548a130
......@@ -5,7 +5,7 @@
width="wide"
@close="handleClose"
>
<form id="bulk-edit-account-form" class="space-y-5" @submit.prevent="handleSubmit">
<form id="bulk-edit-account-form" class="space-y-5" @submit.prevent="() => handleSubmit()">
<!-- Info -->
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<p class="text-sm text-blue-700 dark:text-blue-400">
......
<script setup lang="ts">
defineProps<{
colorClass: string
tooltip?: string
current: string | number
max: string | number
suffix?: string
}>()
</script>
<template>
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-px text-[10px] font-medium leading-tight',
colorClass
]"
:title="tooltip"
>
<slot />
<span class="font-mono">{{ current }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ max }}</span>
<span v-if="suffix" class="text-[9px] opacity-60">{{ suffix }}</span>
</span>
</template>
......@@ -1477,10 +1477,65 @@
</div>
</div>
<!-- API Key / Bedrock 账号配额限制 -->
<div v-if="form.type === 'apikey' || form.type === 'bedrock'" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<!-- 配额控制 (Anthropic apikey/bedrock: 配额限制 + 亲和) -->
<div
v-if="form.platform === 'anthropic' && (form.type === 'apikey' || form.type === 'bedrock')"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
>
<div class="mb-3">
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaLimit') }}</h3>
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaControl.hint') }}
</p>
</div>
<QuotaLimitCard
:totalLimit="editQuotaLimit"
:dailyLimit="editQuotaDailyLimit"
:weeklyLimit="editQuotaWeeklyLimit"
:quotaNotifyGlobalEnabled="quotaNotifyGlobalEnabled"
:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled"
:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold"
:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType"
:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled"
:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold"
:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType"
:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled"
:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold"
:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType"
:dailyResetMode="editDailyResetMode"
:dailyResetHour="editDailyResetHour"
:weeklyResetMode="editWeeklyResetMode"
:weeklyResetDay="editWeeklyResetDay"
:weeklyResetHour="editWeeklyResetHour"
:resetTimezone="editResetTimezone"
@update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
@update:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled = $event"
@update:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold = $event"
@update:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType = $event"
@update:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled = $event"
@update:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold = $event"
@update:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType = $event"
@update:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled = $event"
@update:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold = $event"
@update:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType = $event"
@update:dailyResetMode="editDailyResetMode = $event"
@update:dailyResetHour="editDailyResetHour = $event"
@update:weeklyResetMode="editWeeklyResetMode = $event"
@update:weeklyResetDay="editWeeklyResetDay = $event"
@update:weeklyResetHour="editWeeklyResetHour = $event"
@update:resetTimezone="editResetTimezone = $event"
/>
</div>
<!-- 配额控制 ( Anthropic apikey/bedrock) -->
<div
v-else-if="form.type === 'apikey' || form.type === 'bedrock'"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
>
<div class="mb-3">
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaLimitHint') }}
</p>
......@@ -1489,6 +1544,16 @@
:totalLimit="editQuotaLimit"
:dailyLimit="editQuotaDailyLimit"
:weeklyLimit="editQuotaWeeklyLimit"
:quotaNotifyGlobalEnabled="quotaNotifyGlobalEnabled"
:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled"
:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold"
:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType"
:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled"
:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold"
:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType"
:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled"
:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold"
:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType"
:dailyResetMode="editDailyResetMode"
:dailyResetHour="editDailyResetHour"
:weeklyResetMode="editWeeklyResetMode"
......@@ -1498,6 +1563,15 @@
@update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
@update:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled = $event"
@update:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold = $event"
@update:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType = $event"
@update:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled = $event"
@update:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold = $event"
@update:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType = $event"
@update:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled = $event"
@update:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold = $event"
@update:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType = $event"
@update:dailyResetMode="editDailyResetMode = $event"
@update:dailyResetHour="editDailyResetHour = $event"
@update:weeklyResetMode="editWeeklyResetMode = $event"
......@@ -1823,7 +1897,7 @@
</div>
</div>
<!-- Quota Control Section (Anthropic OAuth/SetupToken only) -->
<!-- 配额控制 (Anthropic OAuth/SetupToken: 亲和 + 窗口费用 + 会话 + RPM ) -->
<div
v-if="form.platform === 'anthropic' && accountCategory === 'oauth-based'"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
......@@ -2325,6 +2399,26 @@
</div>
</div>
<!-- Anthropic API Key: Web Search Emulation (hidden when global disabled) -->
<div
v-if="form.platform === 'anthropic' && accountCategory === 'apikey' && webSearchGlobalEnabled"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.anthropic.webSearchEmulation') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.anthropic.webSearchEmulationDesc') }}
</p>
</div>
<select v-model="webSearchEmulationMode" class="input w-24 text-sm">
<option value="default">{{ t('admin.accounts.anthropic.webSearchDefault') }}</option>
<option value="enabled">{{ t('admin.accounts.anthropic.webSearchEnabled') }}</option>
<option value="disabled">{{ t('admin.accounts.anthropic.webSearchDisabled') }}</option>
</select>
</div>
</div>
<!-- OpenAI OAuth Codex 官方客户端限制开关 -->
<div
v-if="form.platform === 'openai' && accountCategory === 'oauth-based'"
......@@ -2809,6 +2903,7 @@ import {
} from '@/composables/useModelWhitelist'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin'
import { useQuotaNotifyState } from '@/composables/useQuotaNotifyState'
import {
useAccountOAuth,
type AddMethod,
......@@ -2980,6 +3075,21 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = ref(false)
const webSearchEmulationMode = ref('default')
const webSearchGlobalEnabled = ref(false)
const {
globalEnabled: quotaNotifyGlobalEnabled,
state: quotaNotifyState,
loadGlobalState: loadQuotaNotifyGlobal,
writeToExtra: writeQuotaNotifyToExtra,
} = useQuotaNotifyState()
// Load global feature states once
adminAPI.settings.getWebSearchEmulationConfig().then(cfg => {
webSearchGlobalEnabled.value = cfg?.enabled === true && (cfg?.providers?.length ?? 0) > 0
}).catch(() => { webSearchGlobalEnabled.value = false })
loadQuotaNotifyGlobal()
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
......@@ -3307,6 +3417,7 @@ watch(
}
if (newPlatform !== 'anthropic') {
anthropicPassthroughEnabled.value = false
webSearchEmulationMode.value = 'default'
}
// Reset OAuth states
oauth.resetState()
......@@ -3326,6 +3437,7 @@ watch(
}
if (platform !== 'anthropic' || category !== 'apikey') {
anthropicPassthroughEnabled.value = false
webSearchEmulationMode.value = 'default'
}
}
)
......@@ -3690,6 +3802,7 @@ const resetForm = () => {
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false
anthropicPassthroughEnabled.value = false
webSearchEmulationMode.value = 'default'
// Reset quota control state
windowCostEnabled.value = false
windowCostLimit.value = null
......@@ -3777,6 +3890,11 @@ const buildAnthropicExtra = (base?: Record<string, unknown>): Record<string, unk
} else {
delete extra.anthropic_passthrough
}
if (webSearchEmulationMode.value === 'default') {
delete extra.web_search_emulation
} else {
extra.web_search_emulation = webSearchEmulationMode.value
}
return Object.keys(extra).length > 0 ? extra : undefined
}
......@@ -4075,6 +4193,8 @@ const createAccountAndFinish = async (
if (editDailyResetMode.value === 'fixed' || editWeeklyResetMode.value === 'fixed') {
quotaExtra.quota_reset_timezone = editResetTimezone.value || 'UTC'
}
// Quota notify config
writeQuotaNotifyToExtra(quotaExtra, 'create')
if (Object.keys(quotaExtra).length > 0) {
finalExtra = quotaExtra
}
......
......@@ -2,7 +2,7 @@
<BaseDialog
:show="show"
:title="t('admin.accounts.editAccount')"
width="normal"
width="wide"
@close="handleClose"
>
<form
......@@ -1149,10 +1149,84 @@
</div>
</div>
<!-- API Key / Bedrock 账号配额限制 -->
<div v-if="account?.type === 'apikey' || account?.type === 'bedrock'" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<!-- Anthropic API Key: Web Search Emulation (hidden when global disabled) -->
<div
v-if="account?.platform === 'anthropic' && account?.type === 'apikey' && webSearchGlobalEnabled"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.anthropic.webSearchEmulation') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.anthropic.webSearchEmulationDesc') }}
</p>
</div>
<select v-model="webSearchEmulationMode" class="input w-24 text-sm">
<option value="default">{{ t('admin.accounts.anthropic.webSearchDefault') }}</option>
<option value="enabled">{{ t('admin.accounts.anthropic.webSearchEnabled') }}</option>
<option value="disabled">{{ t('admin.accounts.anthropic.webSearchDisabled') }}</option>
</select>
</div>
</div>
<!-- 配额控制 (Anthropic apikey/bedrock: 配额限制 + 亲和) -->
<div
v-if="account?.platform === 'anthropic' && (account?.type === 'apikey' || account?.type === 'bedrock')"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
>
<div class="mb-3">
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaLimit') }}</h3>
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaControl.hint') }}
</p>
</div>
<QuotaLimitCard
:totalLimit="editQuotaLimit"
:dailyLimit="editQuotaDailyLimit"
:weeklyLimit="editQuotaWeeklyLimit"
:dailyResetMode="editDailyResetMode"
:dailyResetHour="editDailyResetHour"
:weeklyResetMode="editWeeklyResetMode"
:weeklyResetDay="editWeeklyResetDay"
:weeklyResetHour="editWeeklyResetHour"
:resetTimezone="editResetTimezone"
:quotaNotifyGlobalEnabled="quotaNotifyGlobalEnabled"
:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled"
:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold"
:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType"
:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled"
:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold"
:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType"
:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled"
:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold"
:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType"
@update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
@update:dailyResetMode="editDailyResetMode = $event"
@update:dailyResetHour="editDailyResetHour = $event"
@update:weeklyResetMode="editWeeklyResetMode = $event"
@update:weeklyResetDay="editWeeklyResetDay = $event"
@update:weeklyResetHour="editWeeklyResetHour = $event"
@update:resetTimezone="editResetTimezone = $event"
@update:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled = $event"
@update:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold = $event"
@update:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType = $event"
@update:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled = $event"
@update:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold = $event"
@update:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType = $event"
@update:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled = $event"
@update:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold = $event"
@update:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType = $event"
/>
</div>
<!-- 配额控制 ( Anthropic apikey/bedrock) -->
<div
v-else-if="account?.type === 'apikey' || account?.type === 'bedrock'"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
>
<div class="mb-3">
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaLimitHint') }}
</p>
......@@ -1167,6 +1241,16 @@
:weeklyResetDay="editWeeklyResetDay"
:weeklyResetHour="editWeeklyResetHour"
:resetTimezone="editResetTimezone"
:quotaNotifyGlobalEnabled="quotaNotifyGlobalEnabled"
:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled"
:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold"
:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType"
:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled"
:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold"
:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType"
:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled"
:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold"
:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType"
@update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
......@@ -1176,6 +1260,15 @@
@update:weeklyResetDay="editWeeklyResetDay = $event"
@update:weeklyResetHour="editWeeklyResetHour = $event"
@update:resetTimezone="editResetTimezone = $event"
@update:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled = $event"
@update:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold = $event"
@update:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType = $event"
@update:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled = $event"
@update:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold = $event"
@update:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType = $event"
@update:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled = $event"
@update:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold = $event"
@update:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType = $event"
/>
</div>
......@@ -1237,7 +1330,7 @@
</div>
</div>
<!-- Quota Control Section (Anthropic OAuth/SetupToken only) -->
<!-- 配额控制 (Anthropic OAuth/SetupToken: 亲和 + 窗口费用 + 会话 + RPM ) -->
<div
v-if="account?.platform === 'anthropic' && (account?.type === 'oauth' || account?.type === 'setup-token')"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
......@@ -1751,6 +1844,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin'
import { useQuotaNotifyState } from '@/composables/useQuotaNotifyState'
import type { Account, Proxy, AdminGroup, CheckMixedChannelResponse } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
......@@ -1898,6 +1992,23 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = ref(false)
const webSearchEmulationMode = ref('default')
const webSearchGlobalEnabled = ref(false)
const {
globalEnabled: quotaNotifyGlobalEnabled,
state: quotaNotifyState,
loadGlobalState: loadQuotaNotifyGlobal,
loadFromExtra: loadQuotaNotifyFromExtra,
writeToExtra: writeQuotaNotifyToExtra,
reset: resetQuotaNotify,
} = useQuotaNotifyState()
// Load global feature states once
adminAPI.settings.getWebSearchEmulationConfig().then(cfg => {
webSearchGlobalEnabled.value = cfg?.enabled === true && (cfg?.providers?.length ?? 0) > 0
}).catch(() => { webSearchGlobalEnabled.value = false })
loadQuotaNotifyGlobal()
const editQuotaLimit = ref<number | null>(null)
const editQuotaDailyLimit = ref<number | null>(null)
const editQuotaWeeklyLimit = ref<number | null>(null)
......@@ -2067,6 +2178,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false
anthropicPassthroughEnabled.value = false
webSearchEmulationMode.value = 'default'
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) {
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
......@@ -2087,6 +2199,15 @@ const syncFormFromAccount = (newAccount: Account | null) => {
}
if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') {
anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true
// 三态:string "default"/"enabled"/"disabled",向后兼容旧 bool
const wsVal = extra?.web_search_emulation
if (wsVal === 'enabled' || wsVal === 'disabled') {
webSearchEmulationMode.value = wsVal
} else if (wsVal === true) {
webSearchEmulationMode.value = 'enabled'
} else {
webSearchEmulationMode.value = 'default'
}
}
// Load quota limit for apikey/bedrock accounts (bedrock quota is also loaded in its own branch above)
......@@ -2104,6 +2225,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editWeeklyResetDay.value = (extra?.quota_weekly_reset_day as number) ?? null
editWeeklyResetHour.value = (extra?.quota_weekly_reset_hour as number) ?? null
editResetTimezone.value = (extra?.quota_reset_timezone as string) || null
// Load quota notify config
loadQuotaNotifyFromExtra(extra)
} else {
editQuotaLimit.value = null
editQuotaDailyLimit.value = null
......@@ -2114,6 +2237,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editWeeklyResetDay.value = null
editWeeklyResetHour.value = null
editResetTimezone.value = null
resetQuotaNotify()
}
// Load antigravity model mapping (Antigravity 只支持映射模式)
......@@ -2228,6 +2352,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editQuotaLimit.value = typeof bedrockExtra.quota_limit === 'number' ? bedrockExtra.quota_limit : null
editQuotaDailyLimit.value = typeof bedrockExtra.quota_daily_limit === 'number' ? bedrockExtra.quota_daily_limit : null
editQuotaWeeklyLimit.value = typeof bedrockExtra.quota_weekly_limit === 'number' ? bedrockExtra.quota_weekly_limit : null
// Load quota notify for bedrock
loadQuotaNotifyFromExtra(bedrockExtra)
// Load model mappings for bedrock
const existingMappings = bedrockCreds.model_mapping as Record<string, string> | undefined
......@@ -2522,8 +2648,13 @@ function loadQuotaControlSettings(account: Account) {
customBaseUrlEnabled.value = false
customBaseUrl.value = ''
// Only applies to Anthropic OAuth/SetupToken accounts
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
// Remaining quota control settings only apply to Anthropic accounts
if (account.platform !== 'anthropic') {
return
}
// Window cost / session limit only apply to Anthropic OAuth/SetupToken accounts
if (account.type !== 'oauth' && account.type !== 'setup-token') {
return
}
......@@ -2949,7 +3080,7 @@ const handleSubmit = async () => {
// For Anthropic OAuth/SetupToken accounts, handle quota control settings in extra
if (props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token')) {
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
const currentExtra = (updatePayload.extra as Record<string, unknown>) || (props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra }
// Window cost limit settings
......@@ -3037,15 +3168,20 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra
}
// For Anthropic API Key accounts, handle passthrough mode in extra
// For Anthropic API Key accounts, handle passthrough mode + web search emulation in extra
if (props.account.platform === 'anthropic' && props.account.type === 'apikey') {
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
const currentExtra = (updatePayload.extra as Record<string, unknown>) || (props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra }
if (anthropicPassthroughEnabled.value) {
newExtra.anthropic_passthrough = true
} else {
delete newExtra.anthropic_passthrough
}
if (webSearchEmulationMode.value === 'default') {
delete newExtra.web_search_emulation
} else {
newExtra.web_search_emulation = webSearchEmulationMode.value
}
updatePayload.extra = newExtra
}
......@@ -3089,20 +3225,27 @@ const handleSubmit = async () => {
const currentExtra = (updatePayload.extra as Record<string, unknown>) ||
(props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra }
// Total quota
if (editQuotaLimit.value != null && editQuotaLimit.value > 0) {
newExtra.quota_limit = editQuotaLimit.value
} else {
delete newExtra.quota_limit
}
// Daily quota
if (editQuotaDailyLimit.value != null && editQuotaDailyLimit.value > 0) {
newExtra.quota_daily_limit = editQuotaDailyLimit.value
} else {
delete newExtra.quota_daily_limit
delete newExtra.quota_daily_used
delete newExtra.quota_daily_start
}
// Weekly quota
if (editQuotaWeeklyLimit.value != null && editQuotaWeeklyLimit.value > 0) {
newExtra.quota_weekly_limit = editQuotaWeeklyLimit.value
} else {
delete newExtra.quota_weekly_limit
delete newExtra.quota_weekly_used
delete newExtra.quota_weekly_start
}
// Quota reset mode config
if (editDailyResetMode.value === 'fixed') {
......@@ -3126,6 +3269,8 @@ const handleSubmit = async () => {
} else {
delete newExtra.quota_reset_timezone
}
// Quota notify config
writeQuotaNotifyToExtra(newExtra, 'update')
updatePayload.extra = newExtra
}
......
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import QuotaNotifyToggle from './QuotaNotifyToggle.vue'
import type { QuotaThresholdType, QuotaResetMode } from '@/constants/account'
const { t } = useI18n()
const props = defineProps<{
dim: 'daily' | 'weekly' | 'total'
label: string
limit: number | null
quotaNotifyGlobalEnabled: boolean
notifyEnabled: boolean | null
notifyThreshold: number | null
notifyThresholdType: QuotaThresholdType | null
// Reset mode (only for daily/weekly, null for total)
resetMode: QuotaResetMode | null
resetHour: number | null
resetDay: number | null // weekly only
resetTimezone: string | null
hintRolling: string
hintFixed: string
// Shared options passed from parent
hourOptions: number[]
dayOptions: { value: number; key: string }[]
timezoneOptions?: string[]
}>()
const emit = defineEmits<{
'update:limit': [value: number | null]
'update:notifyEnabled': [value: boolean | null]
'update:notifyThreshold': [value: number | null]
'update:notifyThresholdType': [value: QuotaThresholdType | null]
'update:resetMode': [value: QuotaResetMode | null]
'update:resetHour': [value: number | null]
'update:resetDay': [value: number | null]
'update:resetTimezone': [value: string | null]
}>()
const hasResetMode = props.dim !== 'total'
const onLimitInput = (e: Event) => {
const raw = (e.target as HTMLInputElement).valueAsNumber
emit('update:limit', Number.isNaN(raw) ? null : raw)
}
const onModeChange = (e: Event) => {
const val = (e.target as HTMLSelectElement).value as QuotaResetMode
emit('update:resetMode', val)
if (val === 'fixed') {
if (props.resetHour == null) emit('update:resetHour', 0)
if (props.dim === 'weekly' && props.resetDay == null) emit('update:resetDay', 1)
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
}
}
function getTimezoneOffsetLabel(tz: string): string {
try {
const dtf = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' })
const parts = dtf.formatToParts(new Date())
const tzPart = parts.find(p => p.type === 'timeZoneName')
return tzPart ? (tzPart.value === 'GMT' ? 'GMT+0' : tzPart.value) : ''
} catch {
return ''
}
}
</script>
<template>
<div>
<!-- Title row (only when global notify is enabled) -->
<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 flex-1 min-w-0">{{ label }}</span>
<span v-if="limit && limit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">{{ 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">{{ label }}</label>
<!-- Input row -->
<div class="flex items-center gap-2">
<div :class="['relative', quotaNotifyGlobalEnabled ? 'flex-1 min-w-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="limit" @input="onLimitInput" type="number" min="0" step="0.01" class="input pl-6 py-1.5 text-sm" :placeholder="t('admin.accounts.quotaLimitPlaceholder')" />
</div>
<QuotaNotifyToggle
v-if="quotaNotifyGlobalEnabled && limit && limit > 0"
class="flex-1 min-w-0"
:enabled="notifyEnabled" :threshold="notifyThreshold" :threshold-type="notifyThresholdType"
@update:enabled="emit('update:notifyEnabled', $event)" @update:threshold="emit('update:notifyThreshold', $event)" @update:threshold-type="emit('update:notifyThresholdType', $event)"
/>
</div>
<!-- Reset mode row (daily/weekly only) -->
<div v-if="hasResetMode" class="mt-1 flex items-center gap-2 flex-wrap">
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetMode') }}</label>
<select :value="resetMode || 'rolling'" @change="onModeChange" class="input py-1 text-xs w-auto">
<option value="rolling">{{ t('admin.accounts.quotaResetModeRolling') }}</option>
<option value="fixed">{{ t('admin.accounts.quotaResetModeFixed') }}</option>
</select>
<template v-if="resetMode === 'fixed'">
<!-- Weekly: day of week selector -->
<template v-if="dim === 'weekly'">
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaWeeklyResetDay') }}</label>
<select :value="resetDay ?? 1" @change="emit('update:resetDay', Number(($event.target as HTMLSelectElement).value))" class="input py-1 text-xs w-28">
<option v-for="d in dayOptions" :key="d.value" :value="d.value">{{ t('admin.accounts.dayOfWeek.' + d.key) }}</option>
</select>
</template>
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetHour') }}</label>
<select :value="resetHour ?? 0" @change="emit('update:resetHour', Number(($event.target as HTMLSelectElement).value))" class="input py-1 text-xs w-24">
<option v-for="h in hourOptions" :key="h" :value="h">{{ String(h).padStart(2, '0') }}:00</option>
</select>
<template v-if="timezoneOptions && timezoneOptions.length > 0">
<select :value="resetTimezone || 'UTC'" @change="emit('update:resetTimezone', ($event.target as HTMLSelectElement).value)" class="input py-1 text-xs w-auto">
<option v-for="tz in timezoneOptions" :key="tz" :value="tz">{{ tz }} ({{ getTimezoneOffsetLabel(tz) }})</option>
</select>
</template>
</template>
<span class="text-[11px] text-gray-500 dark:text-gray-400">
<template v-if="resetMode === 'fixed'">{{ hintFixed }}</template>
<template v-else>{{ hintRolling }}</template>
</span>
</div>
<!-- Total dimension hint (no reset mode) -->
<p v-if="!hasResetMode" class="input-hint mb-0 text-[11px]">{{ hintRolling }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import QuotaDimensionRow from './QuotaDimensionRow.vue'
import type { QuotaThresholdType, QuotaResetMode } from '@/constants/account'
const { t } = useI18n()
const props = defineProps<{
const props = withDefaults(defineProps<{
totalLimit: number | null
dailyLimit: number | null
weeklyLimit: number | null
dailyResetMode: 'rolling' | 'fixed' | null
dailyResetMode: QuotaResetMode | null
dailyResetHour: number | null
weeklyResetMode: 'rolling' | 'fixed' | null
weeklyResetMode: QuotaResetMode | null
weeklyResetDay: number | null
weeklyResetHour: number | null
resetTimezone: string | null
}>()
quotaNotifyGlobalEnabled?: boolean
quotaNotifyDailyEnabled?: boolean | null
quotaNotifyDailyThreshold?: number | null
quotaNotifyDailyThresholdType?: QuotaThresholdType | null
quotaNotifyWeeklyEnabled?: boolean | null
quotaNotifyWeeklyThreshold?: number | null
quotaNotifyWeeklyThresholdType?: QuotaThresholdType | null
quotaNotifyTotalEnabled?: boolean | null
quotaNotifyTotalThreshold?: number | null
quotaNotifyTotalThresholdType?: QuotaThresholdType | null
}>(), {
quotaNotifyGlobalEnabled: false,
quotaNotifyDailyEnabled: null,
quotaNotifyDailyThreshold: null,
quotaNotifyDailyThresholdType: null,
quotaNotifyWeeklyEnabled: null,
quotaNotifyWeeklyThreshold: null,
quotaNotifyWeeklyThresholdType: null,
quotaNotifyTotalEnabled: null,
quotaNotifyTotalThreshold: null,
quotaNotifyTotalThresholdType: null,
})
const emit = defineEmits<{
'update:totalLimit': [value: number | null]
'update:dailyLimit': [value: number | null]
'update:weeklyLimit': [value: number | null]
'update:dailyResetMode': [value: 'rolling' | 'fixed' | null]
'update:dailyResetMode': [value: QuotaResetMode | null]
'update:dailyResetHour': [value: number | null]
'update:weeklyResetMode': [value: 'rolling' | 'fixed' | null]
'update:weeklyResetMode': [value: QuotaResetMode | null]
'update:weeklyResetDay': [value: number | null]
'update:weeklyResetHour': [value: number | null]
'update:resetTimezone': [value: string | null]
'update:quotaNotifyDailyEnabled': [value: boolean | null]
'update:quotaNotifyDailyThreshold': [value: number | null]
'update:quotaNotifyDailyThresholdType': [value: QuotaThresholdType | null]
'update:quotaNotifyWeeklyEnabled': [value: boolean | null]
'update:quotaNotifyWeeklyThreshold': [value: number | null]
'update:quotaNotifyWeeklyThresholdType': [value: QuotaThresholdType | null]
'update:quotaNotifyTotalEnabled': [value: boolean | null]
'update:quotaNotifyTotalThreshold': [value: number | null]
'update:quotaNotifyTotalThresholdType': [value: QuotaThresholdType | null]
}>()
const enabled = computed(() =>
......@@ -35,15 +67,17 @@ const enabled = computed(() =>
)
const localEnabled = ref(enabled.value)
const collapsed = ref(false)
// Sync when props change externally
watch(enabled, (val) => {
localEnabled.value = val
})
// When toggle is turned off, clear all values
// When toggle is turned off, clear all values and expand
watch(localEnabled, (val) => {
if (!val) {
collapsed.value = false
emit('update:totalLimit', null)
emit('update:dailyLimit', null)
emit('update:weeklyLimit', null)
......@@ -56,31 +90,12 @@ watch(localEnabled, (val) => {
}
})
// Whether any fixed mode is active (to show timezone selector)
const hasFixedMode = computed(() =>
props.dailyResetMode === 'fixed' || props.weeklyResetMode === 'fixed'
)
// Common timezone options
const timezoneOptions = [
'UTC',
'Asia/Shanghai',
'Asia/Tokyo',
'Asia/Seoul',
'Asia/Singapore',
'Asia/Kolkata',
'Asia/Dubai',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Moscow',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Sao_Paulo',
'Australia/Sydney',
'Pacific/Auckland',
'UTC', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Singapore', 'Asia/Kolkata',
'Asia/Dubai', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Moscow',
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
'America/Sao_Paulo', 'Australia/Sydney', 'Pacific/Auckland',
]
// Hours for dropdown (0-23)
......@@ -97,48 +112,39 @@ const dayOptions = [
{ value: 0, key: 'sunday' },
]
const onTotalInput = (e: Event) => {
const raw = (e.target as HTMLInputElement).valueAsNumber
emit('update:totalLimit', Number.isNaN(raw) ? null : raw)
}
const onDailyInput = (e: Event) => {
const raw = (e.target as HTMLInputElement).valueAsNumber
emit('update:dailyLimit', Number.isNaN(raw) ? null : raw)
}
const onWeeklyInput = (e: Event) => {
const raw = (e.target as HTMLInputElement).valueAsNumber
emit('update:weeklyLimit', Number.isNaN(raw) ? null : raw)
}
const onDailyModeChange = (e: Event) => {
const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed'
emit('update:dailyResetMode', val)
if (val === 'fixed') {
if (props.dailyResetHour == null) emit('update:dailyResetHour', 0)
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
}
}
// Precomputed hint strings for the weekly fixed mode
const weeklyFixedHint = computed(() => {
const dayKey = dayOptions.find(d => d.value === (props.weeklyResetDay ?? 1))?.key || 'monday'
return t('admin.accounts.quotaWeeklyLimitHintFixed', {
day: t('admin.accounts.dayOfWeek.' + dayKey),
hour: String(props.weeklyResetHour ?? 0).padStart(2, '0'),
timezone: props.resetTimezone || 'UTC',
})
})
const onWeeklyModeChange = (e: Event) => {
const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed'
emit('update:weeklyResetMode', val)
if (val === 'fixed') {
if (props.weeklyResetDay == null) emit('update:weeklyResetDay', 1)
if (props.weeklyResetHour == null) emit('update:weeklyResetHour', 0)
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
}
}
const dailyFixedHint = computed(() =>
t('admin.accounts.quotaDailyLimitHintFixed', {
hour: String(props.dailyResetHour ?? 0).padStart(2, '0'),
timezone: props.resetTimezone || 'UTC',
})
)
</script>
<template>
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<div class="rounded-lg border border-gray-200 dark:border-dark-600">
<!-- Header: toggle + collapse -->
<div class="flex items-center justify-between p-4" :class="{ 'pb-0': localEnabled && !collapsed }">
<div class="flex items-center gap-2 flex-1 cursor-pointer" @click="localEnabled && (collapsed = !collapsed)">
<svg v-if="localEnabled" class="h-4 w-4 text-gray-400 transition-transform" :class="{ '-rotate-90': collapsed }" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
</svg>
<div>
<label class="input-label mb-0">{{ t('admin.accounts.quotaLimitToggle') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<label class="input-label mb-0 cursor-pointer">{{ t('admin.accounts.quotaLimitToggle') }}</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaLimitToggleHint') }}
</p>
</div>
</div>
<button
type="button"
@click="localEnabled = !localEnabled"
......@@ -156,140 +162,85 @@ const onWeeklyModeChange = (e: Event) => {
</button>
</div>
<div v-if="localEnabled" class="space-y-3">
<!-- 日配额 -->
<div>
<label class="input-label">{{ t('admin.accounts.quotaDailyLimit') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
<input
:value="dailyLimit"
@input="onDailyInput"
type="number"
min="0"
step="0.01"
class="input pl-7"
:placeholder="t('admin.accounts.quotaLimitPlaceholder')"
<!-- Collapsible content -->
<div v-if="localEnabled && !collapsed" class="space-y-2 p-4 pt-3">
<!-- Daily quota -->
<QuotaDimensionRow
dim="daily"
:label="t('admin.accounts.quotaDailyLimit')"
:limit="dailyLimit"
:quota-notify-global-enabled="quotaNotifyGlobalEnabled"
:notify-enabled="props.quotaNotifyDailyEnabled"
:notify-threshold="props.quotaNotifyDailyThreshold"
:notify-threshold-type="props.quotaNotifyDailyThresholdType"
:reset-mode="dailyResetMode"
:reset-hour="dailyResetHour"
:reset-day="null"
:reset-timezone="resetTimezone"
:hint-rolling="t('admin.accounts.quotaDailyLimitHint')"
:hint-fixed="dailyFixedHint"
:hour-options="hourOptions"
:day-options="dayOptions"
:timezone-options="timezoneOptions"
@update:limit="emit('update:dailyLimit', $event)"
@update:notify-enabled="emit('update:quotaNotifyDailyEnabled', $event)"
@update:notify-threshold="emit('update:quotaNotifyDailyThreshold', $event)"
@update:notify-threshold-type="emit('update:quotaNotifyDailyThresholdType', $event)"
@update:reset-mode="emit('update:dailyResetMode', $event)"
@update:reset-hour="emit('update:dailyResetHour', $event)"
@update:reset-timezone="emit('update:resetTimezone', $event)"
/>
</div>
<!-- 日配额重置模式 -->
<div class="mt-2 flex items-center gap-2">
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetMode') }}</label>
<select
:value="dailyResetMode || 'rolling'"
@change="onDailyModeChange"
class="input py-1 text-xs"
>
<option value="rolling">{{ t('admin.accounts.quotaResetModeRolling') }}</option>
<option value="fixed">{{ t('admin.accounts.quotaResetModeFixed') }}</option>
</select>
</div>
<!-- 固定模式:小时选择 -->
<div v-if="dailyResetMode === 'fixed'" class="mt-2 flex items-center gap-2">
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetHour') }}</label>
<select
:value="dailyResetHour ?? 0"
@change="emit('update:dailyResetHour', Number(($event.target as HTMLSelectElement).value))"
class="input py-1 text-xs w-24"
>
<option v-for="h in hourOptions" :key="h" :value="h">{{ String(h).padStart(2, '0') }}:00</option>
</select>
</div>
<p class="input-hint">
<template v-if="dailyResetMode === 'fixed'">
{{ t('admin.accounts.quotaDailyLimitHintFixed', { hour: String(dailyResetHour ?? 0).padStart(2, '0'), timezone: resetTimezone || 'UTC' }) }}
</template>
<template v-else>
{{ t('admin.accounts.quotaDailyLimitHint') }}
</template>
</p>
</div>
<!-- 周配额 -->
<div>
<label class="input-label">{{ t('admin.accounts.quotaWeeklyLimit') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
<input
:value="weeklyLimit"
@input="onWeeklyInput"
type="number"
min="0"
step="0.01"
class="input pl-7"
:placeholder="t('admin.accounts.quotaLimitPlaceholder')"
<!-- Weekly quota -->
<QuotaDimensionRow
dim="weekly"
:label="t('admin.accounts.quotaWeeklyLimit')"
:limit="weeklyLimit"
:quota-notify-global-enabled="quotaNotifyGlobalEnabled"
:notify-enabled="props.quotaNotifyWeeklyEnabled"
:notify-threshold="props.quotaNotifyWeeklyThreshold"
:notify-threshold-type="props.quotaNotifyWeeklyThresholdType"
:reset-mode="weeklyResetMode"
:reset-hour="weeklyResetHour"
:reset-day="weeklyResetDay"
:reset-timezone="resetTimezone"
:hint-rolling="t('admin.accounts.quotaWeeklyLimitHint')"
:hint-fixed="weeklyFixedHint"
:hour-options="hourOptions"
:day-options="dayOptions"
:timezone-options="timezoneOptions"
@update:limit="emit('update:weeklyLimit', $event)"
@update:notify-enabled="emit('update:quotaNotifyWeeklyEnabled', $event)"
@update:notify-threshold="emit('update:quotaNotifyWeeklyThreshold', $event)"
@update:notify-threshold-type="emit('update:quotaNotifyWeeklyThresholdType', $event)"
@update:reset-mode="emit('update:weeklyResetMode', $event)"
@update:reset-hour="emit('update:weeklyResetHour', $event)"
@update:reset-day="emit('update:weeklyResetDay', $event)"
@update:reset-timezone="emit('update:resetTimezone', $event)"
/>
</div>
<!-- 周配额重置模式 -->
<div class="mt-2 flex items-center gap-2">
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetMode') }}</label>
<select
:value="weeklyResetMode || 'rolling'"
@change="onWeeklyModeChange"
class="input py-1 text-xs"
>
<option value="rolling">{{ t('admin.accounts.quotaResetModeRolling') }}</option>
<option value="fixed">{{ t('admin.accounts.quotaResetModeFixed') }}</option>
</select>
</div>
<!-- 固定模式星期几 + 小时 -->
<div v-if="weeklyResetMode === 'fixed'" class="mt-2 flex items-center gap-2 flex-wrap">
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaWeeklyResetDay') }}</label>
<select
:value="weeklyResetDay ?? 1"
@change="emit('update:weeklyResetDay', Number(($event.target as HTMLSelectElement).value))"
class="input py-1 text-xs w-28"
>
<option v-for="d in dayOptions" :key="d.value" :value="d.value">{{ t('admin.accounts.dayOfWeek.' + d.key) }}</option>
</select>
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetHour') }}</label>
<select
:value="weeklyResetHour ?? 0"
@change="emit('update:weeklyResetHour', Number(($event.target as HTMLSelectElement).value))"
class="input py-1 text-xs w-24"
>
<option v-for="h in hourOptions" :key="h" :value="h">{{ String(h).padStart(2, '0') }}:00</option>
</select>
</div>
<p class="input-hint">
<template v-if="weeklyResetMode === 'fixed'">
{{ t('admin.accounts.quotaWeeklyLimitHintFixed', { day: t('admin.accounts.dayOfWeek.' + (dayOptions.find(d => d.value === (weeklyResetDay ?? 1))?.key || 'monday')), hour: String(weeklyResetHour ?? 0).padStart(2, '0'), timezone: resetTimezone || 'UTC' }) }}
</template>
<template v-else>
{{ t('admin.accounts.quotaWeeklyLimitHint') }}
</template>
</p>
</div>
<!-- 时区选择当任一维度使用固定模式时显示 -->
<div v-if="hasFixedMode">
<label class="input-label">{{ t('admin.accounts.quotaResetTimezone') }}</label>
<select
:value="resetTimezone || 'UTC'"
@change="emit('update:resetTimezone', ($event.target as HTMLSelectElement).value)"
class="input text-sm"
>
<option v-for="tz in timezoneOptions" :key="tz" :value="tz">{{ tz }}</option>
</select>
</div>
<!-- 总配额 -->
<div>
<label class="input-label">{{ t('admin.accounts.quotaTotalLimit') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
<input
:value="totalLimit"
@input="onTotalInput"
type="number"
min="0"
step="0.01"
class="input pl-7"
:placeholder="t('admin.accounts.quotaLimitPlaceholder')"
<!-- Total quota -->
<QuotaDimensionRow
dim="total"
:label="t('admin.accounts.quotaTotalLimit')"
:limit="totalLimit"
:quota-notify-global-enabled="quotaNotifyGlobalEnabled"
:notify-enabled="props.quotaNotifyTotalEnabled"
:notify-threshold="props.quotaNotifyTotalThreshold"
:notify-threshold-type="props.quotaNotifyTotalThresholdType"
:reset-mode="null"
:reset-hour="null"
:reset-day="null"
:reset-timezone="null"
:hint-rolling="t('admin.accounts.quotaTotalLimitHint')"
hint-fixed=""
:hour-options="hourOptions"
:day-options="dayOptions"
@update:limit="emit('update:totalLimit', $event)"
@update:notify-enabled="emit('update:quotaNotifyTotalEnabled', $event)"
@update:notify-threshold="emit('update:quotaNotifyTotalThreshold', $event)"
@update:notify-threshold-type="emit('update:quotaNotifyTotalThresholdType', $event)"
/>
</div>
<p class="input-hint">{{ t('admin.accounts.quotaTotalLimitHint') }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { QUOTA_THRESHOLD_TYPE_FIXED, QUOTA_THRESHOLD_TYPE_PERCENTAGE, type QuotaThresholdType } from '@/constants/account'
defineProps<{
enabled: boolean | null
threshold: number | null
thresholdType: QuotaThresholdType | null
}>()
const emit = defineEmits<{
'update:enabled': [value: boolean | null]
'update:threshold': [value: number | null]
'update:thresholdType': [value: QuotaThresholdType | null]
}>()
</script>
<template>
<div class="flex items-center gap-1.5">
<button
type="button"
@click="emit('update:enabled', !enabled)"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
enabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
enabled ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
<template v-if="enabled">
<input
:value="threshold"
@input="emit('update:threshold', parseFloat(($event.target as HTMLInputElement).value) || null)"
type="number"
min="0"
:max="thresholdType === QUOTA_THRESHOLD_TYPE_PERCENTAGE ? 100 : undefined"
:step="thresholdType === QUOTA_THRESHOLD_TYPE_PERCENTAGE ? 1 : 0.01"
class="input py-1 text-sm flex-1 min-w-0"
/>
<select
:value="thresholdType || QUOTA_THRESHOLD_TYPE_FIXED"
@change="emit('update:thresholdType', ($event.target as HTMLSelectElement).value as QuotaThresholdType)"
class="input py-1 text-xs w-[4.5rem] flex-shrink-0 text-center"
>
<option :value="QUOTA_THRESHOLD_TYPE_FIXED">$</option>
<option :value="QUOTA_THRESHOLD_TYPE_PERCENTAGE">%</option>
</select>
</template>
</div>
</template>
......@@ -87,13 +87,13 @@
<template #cell-billing_mode="{ row }">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getBillingModeBadgeClass(row.billing_mode)">
{{ getBillingModeLabel(row.billing_mode) }}
{{ getBillingModeLabel(row.billing_mode, t) }}
</span>
</template>
<template #cell-tokens="{ row }">
<!-- 图片生成请求(仅按次计费时显示图片格式) -->
<div v-if="row.image_count > 0 && row.billing_mode === 'image'" class="flex items-center gap-1.5">
<div v-if="row.image_count > 0 && row.billing_mode === BILLING_MODE_IMAGE" class="flex items-center gap-1.5">
<svg class="h-4 w-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
......@@ -155,7 +155,7 @@
</div>
</div>
<div v-if="row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-gray-400">
A ${{ (row.total_cost * row.account_rate_multiplier).toFixed(6) }}
A ${{ accountBilled(row).toFixed(6) }}
</div>
</div>
</template>
......@@ -279,6 +279,8 @@
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div>
<!-- Token billing: show unit prices per 1M tokens -->
<template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
......@@ -287,6 +289,12 @@
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<!-- Per-request / image billing: show unit price -->
<div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ tooltipData.billing_mode === BILLING_MODE_IMAGE ? t('usage.imageUnitPrice') : t('usage.unitPrice') }}</span>
<span class="font-medium text-sky-300">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
......@@ -305,10 +313,6 @@
<span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.rate_multiplier || 1) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
......@@ -317,10 +321,19 @@
<span class="text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<!-- Account billing (separated from user billing) -->
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="font-semibold text-green-400">
${{ (((tooltipData?.total_cost || 0) * (tooltipData?.account_rate_multiplier ?? 1)) || 0).toFixed(6) }}
${{ accountBilled({
total_cost: tooltipData?.total_cost,
account_stats_cost: tooltipData?.account_stats_cost,
account_rate_multiplier: tooltipData?.account_rate_multiplier,
}).toFixed(6) }}
</span>
</div>
</div>
......@@ -338,6 +351,15 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType'
import { getBillingModeLabel, getBillingModeBadgeClass, BILLING_MODE_TOKEN, BILLING_MODE_IMAGE } from '@/utils/billingMode'
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
function accountBilled(row: { total_cost?: number | null; account_stats_cost?: number | null; account_rate_multiplier?: number | null }): number {
const base = row.account_stats_cost != null ? row.account_stats_cost : (row.total_cost ?? 0)
const result = base * (row.account_rate_multiplier ?? 1)
return Number.isNaN(result) ? 0 : result
}
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
......@@ -391,17 +413,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 => {
......
......@@ -32,7 +32,8 @@
<!-- Toggles + Payment mode + Supported types (single row) -->
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<ToggleSwitch :label="t('common.enabled')" :checked="form.enabled" @toggle="form.enabled = !form.enabled" />
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="form.refund_enabled" @toggle="form.refund_enabled = !form.refund_enabled" />
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="form.refund_enabled" @toggle="form.refund_enabled = !form.refund_enabled; if (!form.refund_enabled) form.allow_user_refund = false" />
<ToggleSwitch v-if="form.refund_enabled" :label="t('admin.settings.payment.allowUserRefund')" :checked="form.allow_user_refund" @toggle="form.allow_user_refund = !form.allow_user_refund" />
<div v-if="form.provider_key === 'easypay'" class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.paymentMode') }}</span>
<div class="flex gap-1.5">
......@@ -243,6 +244,7 @@ const emit = defineEmits<{
enabled: boolean
payment_mode: string
refund_enabled: boolean
allow_user_refund: boolean
config: Record<string, string>
limits: string
}]
......@@ -258,6 +260,7 @@ const form = reactive({
enabled: true,
payment_mode: PAYMENT_MODE_QRCODE,
refund_enabled: false,
allow_user_refund: false,
})
const config = reactive<Record<string, string>>({})
const limits = reactive<Record<string, Record<string, number>>>({})
......@@ -433,6 +436,7 @@ function handleSave() {
enabled: form.enabled,
payment_mode: form.provider_key === 'easypay' ? form.payment_mode : '',
refund_enabled: form.refund_enabled,
allow_user_refund: form.refund_enabled ? form.allow_user_refund : false,
config: filteredConfig,
limits: serializeLimits(),
})
......@@ -452,6 +456,7 @@ function reset(defaultKey: string) {
form.enabled = true
form.payment_mode = defaultKey === 'easypay' ? PAYMENT_MODE_QRCODE : ''
form.refund_enabled = false
form.allow_user_refund = false
clearConfig()
applyDefaults()
}
......@@ -463,6 +468,7 @@ function loadProvider(provider: ProviderInstance) {
form.enabled = provider.enabled
form.payment_mode = provider.payment_mode || (provider.provider_key === 'easypay' ? PAYMENT_MODE_QRCODE : '')
form.refund_enabled = provider.refund_enabled
form.allow_user_refund = provider.allow_user_refund
clearConfig()
// Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••)
if (provider.config) {
......
......@@ -115,7 +115,7 @@ const emit = defineEmits<{
create: []
edit: [provider: ProviderInstance]
delete: [provider: ProviderInstance]
toggleField: [provider: ProviderInstance, field: 'enabled' | 'refund_enabled']
toggleField: [provider: ProviderInstance, field: 'enabled' | 'refund_enabled' | 'allow_user_refund']
toggleType: [provider: ProviderInstance, type: string]
reorder: [providers: { id: number; sort_order: number }[]]
}>()
......
......@@ -46,6 +46,7 @@
<div class="flex items-center gap-4">
<ToggleSwitch :label="t('common.enabled')" :checked="provider.enabled" @toggle="emit('toggleField', 'enabled')" />
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="provider.refund_enabled" @toggle="emit('toggleField', 'refund_enabled')" />
<ToggleSwitch v-if="provider.refund_enabled" :label="t('admin.settings.payment.allowUserRefund')" :checked="provider.allow_user_refund" @toggle="emit('toggleField', 'allow_user_refund')" />
<div class="flex items-center gap-2 border-l border-gray-200 pl-3 dark:border-dark-600">
<button type="button" @click="emit('edit')" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400">
<Icon name="edit" size="sm" />
......@@ -84,7 +85,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
toggleField: [field: 'enabled' | 'refund_enabled']
toggleField: [field: 'enabled' | 'refund_enabled' | 'allow_user_refund']
toggleType: [type: string]
edit: []
delete: []
......
<template>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<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">
<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>
<template v-if="notifyEnabled">
<!-- Custom threshold with save button -->
<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')"
/>
<button
@click="handleThresholdUpdate"
:disabled="savingThreshold"
class="btn btn-primary btn-sm whitespace-nowrap"
>
{{ savingThreshold ? t('common.saving') : t('common.save') }}
</button>
</div>
</div>
<!-- Email list with toggles -->
<div>
<label class="input-label">{{ t('profile.balanceNotify.extraEmails') }}</label>
<p class="mb-2 text-xs text-yellow-600 dark:text-yellow-400">{{ t('profile.balanceNotify.extraEmailsHint') }}</p>
<!-- Saved email entries -->
<div v-if="emailEntries.length > 0" class="space-y-2 mb-3">
<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">
<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 }}</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<template v-if="!entry.verified">
<!-- Inline verify flow for saved unverified emails -->
<template v-if="verifyingEmail === entry.email">
<input
v-model="verifyCode"
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="verifySavedEmail(entry.email)" :disabled="!verifyCode || verifyCode.length !== 6 || verifyingSaved" class="text-xs text-primary-600 hover:text-primary-700">
{{ t('profile.balanceNotify.verify') }}
</button>
<span v-if="verifyCountdown > 0" class="text-xs text-gray-400">{{ verifyCountdown }}s</span>
<button v-else @click="sendCodeForSaved(entry.email)" :disabled="sendingSavedCode" class="text-xs text-gray-500 hover:text-gray-700">
{{ t('profile.balanceNotify.resend') }}
</button>
<button @click="verifyingEmail = ''" class="text-xs text-gray-400 hover:text-gray-600">
{{ t('common.cancel') }}
</button>
</template>
<template v-else>
<button @click="sendCodeForSaved(entry.email)" :disabled="sendingSavedCode" class="text-xs text-primary-600 hover:text-primary-700">
{{ t('profile.balanceNotify.verify') }}
</button>
<span class="text-xs text-yellow-500">{{ t('profile.balanceNotify.unverified') }}</span>
</template>
</template>
<span v-else class="text-xs text-green-500">{{ t('profile.balanceNotify.verified') }}</span>
<button @click="handleRemoveEmail(entry.email)" class="text-red-500 hover:text-red-700 text-xs">
{{ t('profile.balanceNotify.removeEmail') }}
</button>
</div>
</div>
</div>
<!-- Pending (unverified) emails in verification flow -->
<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 (hidden when at limit) -->
<div v-if="canAddMore" class="flex gap-2">
<input
v-model="newEmail"
type="email"
class="input flex-1"
:placeholder="t('profile.balanceNotify.emailPlaceholder')"
@keyup.enter="addPendingEmail"
/>
<button
@click="addPendingEmail"
:disabled="!newEmail"
class="btn btn-secondary whitespace-nowrap"
>
{{ t('common.add') }}
</button>
</div>
<p v-else class="text-xs text-gray-400">
{{ t('profile.balanceNotify.maxEmailsReached') }}
</p>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, 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
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: NotifyEmailEntry[]
systemDefaultThreshold: number
userEmail: string
}>()
const { t } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
const notifyEnabled = ref(props.enabled)
const customThreshold = ref<number | null>(props.threshold)
const emailEntries = ref<NotifyEmailEntry[]>([...props.extraEmails])
const pendingEmails = ref<PendingEmail[]>([])
const newEmail = ref('')
const savingThreshold = ref(false)
// State for verifying saved unverified emails
const verifyingEmail = ref('')
const verifyCode = ref('')
const verifyingSaved = ref(false)
const sendingSavedCode = ref(false)
const verifyCountdown = ref(0)
let verifyTimer: ReturnType<typeof setInterval> | null = null
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) => { emailEntries.value = [...val] })
// When list is empty on mount, pre-fill the add input with user's email
onMounted(() => {
if (emailEntries.value.length === 0 && props.userEmail) {
newEmail.value = props.userEmail
}
})
onUnmounted(() => {
for (const pe of pendingEmails.value) {
if (pe.timer) clearInterval(pe.timer)
}
if (verifyTimer) clearInterval(verifyTimer)
})
const handleToggle = async () => {
try {
const updated = await userAPI.updateProfile({ balance_notify_enabled: notifyEnabled.value })
authStore.user = updated
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
notifyEnabled.value = !notifyEnabled.value
}
}
const handleThresholdUpdate = async () => {
savingThreshold.value = true
try {
const threshold = customThreshold.value && customThreshold.value > 0 ? customThreshold.value : 0
const updated = await userAPI.updateProfile({ balance_notify_threshold: threshold })
authStore.user = updated
appStore.showSuccess(t('common.saved'))
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
savingThreshold.value = false
}
}
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
// Check duplicates
const isDuplicate = emailEntries.value.some(e => e.email.toLowerCase() === email.toLowerCase())
|| pendingEmails.value.some(p => p.email.toLowerCase() === email.toLowerCase())
if (isDuplicate) {
appStore.showError(t('profile.balanceNotify.emailDuplicate'))
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(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 {
pe.sending = false
}
}
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(pe.email, pe.code)
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 {
pe.verifying = false
}
}
const handleRemoveEmail = async (email: string) => {
try {
await userAPI.removeNotifyEmail(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')))
}
}
// Verify saved unverified emails
async function sendCodeForSaved(email: string) {
sendingSavedCode.value = true
try {
await userAPI.sendNotifyEmailCode(email)
verifyingEmail.value = email
verifyCode.value = ''
verifyCountdown.value = 60
if (verifyTimer) clearInterval(verifyTimer)
verifyTimer = setInterval(() => {
verifyCountdown.value--
if (verifyCountdown.value <= 0 && verifyTimer) {
clearInterval(verifyTimer)
verifyTimer = null
}
}, 1000)
appStore.showSuccess(t('profile.balanceNotify.codeSent'))
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
sendingSavedCode.value = false
}
}
async function verifySavedEmail(email: string) {
if (!verifyCode.value || verifyCode.value.length !== 6) return
verifyingSaved.value = true
try {
await userAPI.verifyNotifyEmail(email, verifyCode.value)
verifyingEmail.value = ''
verifyCode.value = ''
if (verifyTimer) { clearInterval(verifyTimer); verifyTimer = null }
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 {
verifyingSaved.value = false
}
}
</script>
import { reactive, ref } from 'vue'
import { adminAPI } from '@/api/admin'
import { QUOTA_THRESHOLD_TYPE_FIXED, type QuotaThresholdType } from '@/constants/account'
export const QUOTA_NOTIFY_DIMS = ['daily', 'weekly', 'total'] as const
export type QuotaNotifyDim = (typeof QUOTA_NOTIFY_DIMS)[number]
interface DimState {
enabled: boolean | null
threshold: number | null
thresholdType: QuotaThresholdType | null
}
export function useQuotaNotifyState() {
const globalEnabled = ref(false)
const state = reactive<Record<QuotaNotifyDim, DimState>>({
daily: { enabled: null, threshold: null, thresholdType: null },
weekly: { enabled: null, threshold: null, thresholdType: null },
total: { enabled: null, threshold: null, thresholdType: null },
})
function loadGlobalState() {
adminAPI.settings
.getSettings()
.then((settings) => {
globalEnabled.value = settings.account_quota_notify_enabled === true
})
.catch(() => {
globalEnabled.value = false
})
}
function loadFromExtra(extra: Record<string, unknown> | null | undefined) {
for (const d of QUOTA_NOTIFY_DIMS) {
state[d].enabled = (extra?.[`quota_notify_${d}_enabled`] as boolean) ?? null
state[d].threshold = (extra?.[`quota_notify_${d}_threshold`] as number) ?? null
state[d].thresholdType = (extra?.[`quota_notify_${d}_threshold_type`] as QuotaThresholdType) ?? null
}
}
function writeToExtra(extra: Record<string, unknown>, mode: 'create' | 'update') {
for (const d of QUOTA_NOTIFY_DIMS) {
const s = state[d]
if (s.enabled) {
extra[`quota_notify_${d}_enabled`] = true
if (s.threshold != null) {
extra[`quota_notify_${d}_threshold`] = s.threshold
} else if (mode === 'update') {
delete extra[`quota_notify_${d}_threshold`]
}
extra[`quota_notify_${d}_threshold_type`] = s.thresholdType || QUOTA_THRESHOLD_TYPE_FIXED
} else if (mode === 'update') {
delete extra[`quota_notify_${d}_enabled`]
delete extra[`quota_notify_${d}_threshold`]
delete extra[`quota_notify_${d}_threshold_type`]
}
}
}
function reset() {
for (const d of QUOTA_NOTIFY_DIMS) {
state[d].enabled = null
state[d].threshold = null
state[d].thresholdType = null
}
}
return { globalEnabled, state, loadGlobalState, loadFromExtra, writeToExtra, reset }
}
......@@ -76,6 +76,12 @@ export function useTableSelection<T>({ rows, getId }: UseTableSelectionOptions<T
replaceSelectedSet(next)
}
const batchUpdate = (updater: (draft: Set<number>) => void) => {
const draft = new Set(selectedSet.value)
updater(draft)
replaceSelectedSet(draft)
}
const selectVisible = () => {
toggleVisible(true)
}
......@@ -93,6 +99,7 @@ export function useTableSelection<T>({ rows, getId }: UseTableSelectionOptions<T
clear,
removeMany,
toggleVisible,
selectVisible
selectVisible,
batchUpdate
}
}
/** WebSearch emulation mode values (must match backend WebSearchMode* constants in account.go) */
export const WEB_SEARCH_MODE_DEFAULT = 'default' as const
export const WEB_SEARCH_MODE_ENABLED = 'enabled' as const
export const WEB_SEARCH_MODE_DISABLED = 'disabled' as const
export type WebSearchMode = typeof WEB_SEARCH_MODE_DEFAULT | typeof WEB_SEARCH_MODE_ENABLED | typeof WEB_SEARCH_MODE_DISABLED
/** Quota notification threshold type values (must match thresholdType* constants in balance_notify_service.go) */
export const QUOTA_THRESHOLD_TYPE_FIXED = 'fixed' as const
export const QUOTA_THRESHOLD_TYPE_PERCENTAGE = 'percentage' as const
export type QuotaThresholdType = typeof QUOTA_THRESHOLD_TYPE_FIXED | typeof QUOTA_THRESHOLD_TYPE_PERCENTAGE
/** Quota reset mode values */
export const QUOTA_RESET_MODE_ROLLING = 'rolling' as const
export const QUOTA_RESET_MODE_FIXED = 'fixed' as const
export type QuotaResetMode = typeof QUOTA_RESET_MODE_ROLLING | typeof QUOTA_RESET_MODE_FIXED
......@@ -247,6 +247,8 @@ export default {
loading: 'Loading...',
justNow: 'just now',
save: 'Save',
saved: 'Saved successfully',
deleted: 'Deleted successfully',
cancel: 'Cancel',
delete: 'Delete',
edit: 'Edit',
......@@ -304,6 +306,7 @@ export default {
saving: 'Saving...',
selectedCount: '({count} selected)',
refresh: 'Refresh',
view: 'View',
settings: 'Settings',
chooseFile: 'Choose File',
notAvailable: 'N/A',
......@@ -774,6 +777,8 @@ export default {
inputTokenPrice: 'Input price',
outputTokenPrice: 'Output price',
perMillionTokens: '/ 1M tokens',
unitPrice: 'Per-request price',
imageUnitPrice: 'Per-image price',
cacheRead: 'Read',
cacheWrite: 'Write',
serviceTier: 'Service tier',
......@@ -902,6 +907,38 @@ export default {
sendCode: 'Send Code',
codeSent: 'Verification code sent to your email',
sendCodeFailed: 'Failed to send verification code'
},
balanceNotify: {
title: 'Balance Low Notification',
description: 'Send email alert when account balance falls below threshold',
enabled: 'Enable Balance Low Notification',
threshold: 'Custom Threshold',
thresholdHint: 'Leave empty to use system default',
thresholdPlaceholder: 'Enter amount',
systemDefault: 'System Default',
extraEmails: 'Notification Emails',
extraEmailsHint: 'You must add and verify an email address to receive low balance alerts',
primaryEmail: 'Primary',
noExtraEmails: 'No extra notification emails',
enterEmail: 'Enter email address',
addEmail: 'Add Email',
emailPlaceholder: 'Enter email address',
sendCode: 'Send Code',
resend: 'Resend',
codeSent: 'Verification code sent',
codeSentTo: 'Code sent to {email}',
enterCode: 'Enter verification code',
codePlaceholder: '6-digit code',
verify: 'Verify',
emailAdded: 'Email added',
emailRemoved: 'Email removed',
verifySuccess: 'Email added successfully',
removeEmail: 'Remove',
removeSuccess: 'Email removed',
emailDuplicate: 'This email already exists',
maxEmailsReached: 'Maximum number of notification emails reached',
unverified: 'Unverified',
verified: 'Verified',
}
},
......@@ -1836,12 +1873,28 @@ export default {
defaultPerRequestPrice: 'Default per-request price (fallback when no tier matches)',
defaultImagePrice: 'Default image price (fallback when no tier matches)',
platformConfig: 'Platform Configuration',
webSearchEmulation: 'Web Search Emulation',
webSearchEmulationHint: '⚠️ When enabled, all accounts in this channel\'s Anthropic groups will intercept web_search requests. Use with caution.',
webSearchEmulationGlobalDisabled: 'Please enable the global switch first in Settings → Gateway → Web Search Emulation',
basicSettings: 'Basic Settings',
addPlatform: 'Add Platform',
noPlatforms: 'Click "Add Platform" to start configuring the channel',
mappingCount: 'mappings',
pricingEntry: 'Pricing Entry',
noModels: 'No models added'
noModels: 'No models added',
applyPricingToAccountStats: 'Apply Pricing to Account Stats',
applyPricingToAccountStatsDesc: 'When enabled, requests not matched by custom rules will use standard model pricing for account stats calculation',
accountStatsPricingRules: 'Custom Account Stats Pricing Rules',
addRule: 'Add Rule',
noRulesConfigured: 'No custom rules configured. Channel model pricing above will be used.',
ruleName: 'Rule name (optional)',
ruleGroups: 'Groups',
ruleAccounts: 'Accounts',
searchAccountPlaceholder: 'Search accounts...',
ruleAccountsHint: 'Leave empty to match all accounts',
ruleModelPricing: 'Model Pricing',
noGroupsInChannel: 'No groups selected in platform tabs above',
unnamed: 'Unnamed'
}
},
......@@ -2214,6 +2267,12 @@ export default {
},
quotaLimitAmount: 'Total Limit',
quotaLimitAmountHint: 'Cumulative spending limit. Does not auto-reset.',
quotaNotify: {
alert: 'Alert',
enabled: 'Enable Alert',
threshold: 'Alert Amount',
thresholdPlaceholder: 'Enter percentage',
},
testConnection: 'Test Connection',
reAuthorize: 'Re-Authorize',
refreshToken: 'Refresh Token',
......@@ -2325,7 +2384,13 @@ export default {
anthropic: {
apiKeyPassthrough: 'Auto passthrough (auth only)',
apiKeyPassthroughDesc:
'Only applies to Anthropic API Key accounts. When enabled, messages/count_tokens are forwarded in passthrough mode with auth replacement only, while billing/concurrency/audit and safety filtering are preserved. Disable to roll back immediately.'
'Only applies to Anthropic API Key accounts. When enabled, messages/count_tokens are forwarded in passthrough mode with auth replacement only, while billing/concurrency/audit and safety filtering are preserved. Disable to roll back immediately.',
webSearchEmulation: 'Web Search Emulation',
webSearchEmulationDesc:
'Enable web search emulation for this API Key account. When a pure web_search request is detected, the gateway calls a third-party search API and constructs the response locally. Default follows channel config.',
webSearchDefault: 'Default',
webSearchEnabled: 'Enabled',
webSearchDisabled: 'Disabled',
},
modelRestriction: 'Model Restriction (Optional)',
modelWhitelist: 'Model Whitelist',
......@@ -4358,6 +4423,40 @@ export default {
cchSigning: 'CCH Signing',
cchSigningHint: 'Sign the billing header in forwarded requests with CCH hash. When disabled, the placeholder is preserved.',
},
webSearchEmulation: {
title: 'Web Search Emulation',
description: 'Inject web search capability for Anthropic API Key accounts that don\'t natively support it',
enabled: 'Enable Web Search Emulation',
enabledHint: 'Global switch. When disabled, web search emulation is inactive for all channels and accounts.',
providers: 'Search Providers',
addProvider: 'Add Provider',
providerType: 'Provider Type',
apiKey: 'API Key',
apiKeyPlaceholder: 'Enter API Key',
apiKeyConfigured: 'Configured',
showApiKey: 'Show',
hideApiKey: 'Hide',
copyApiKey: 'Copy',
copied: 'Copied',
quotaLimit: 'Quota Limit',
quotaLimitHint: 'Leave empty for unlimited; must be > 0 if set',
quotaLimitMustBePositive: 'Quota limit must be greater than 0',
subscribedAt: 'Subscribed At',
subscribedAtHint: 'Quota resets monthly from this date; leave empty to disable auto-reset',
quotaUsage: 'Usage',
resetUsage: 'Reset',
resetUsageConfirm: 'Reset usage counter for this provider?',
resetUsageSuccess: 'Usage counter reset',
proxy: 'Proxy',
removeProvider: 'Remove',
noProviders: 'No search providers configured',
test: 'Test',
testDefaultQuery: 'Major world events this year',
testing: 'Searching...',
testResultTitle: 'Search Results',
testResultProvider: 'Provider',
testNoResults: 'No results found',
},
site: {
title: 'Site Settings',
description: 'Customize site branding',
......@@ -4550,6 +4649,27 @@ export default {
supportedTypes: 'Supported Payment Types',
supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay',
refundEnabled: 'Allow Refund',
allowUserRefund: 'Allow User Refund',
},
balanceNotify: {
title: 'Balance Low Notification',
description: 'Send email notification when user balance falls below threshold',
enabled: 'Enable Balance Low Notification',
threshold: 'Default Threshold',
thresholdHint: 'Used when user has not set a custom value',
thresholdPlaceholder: 'Enter amount',
rechargeUrl: 'Recharge Page URL',
rechargeUrlPlaceholder: 'https://example.com/payment',
rechargeUrlHint: 'A top-up button will appear in the email when set',
},
quotaNotify: {
title: 'Account Quota Notification',
description: 'Notify admins when account quota usage reaches alert threshold',
enabled: 'Enable Account Quota Notification',
emails: 'Notification Emails',
emailsHint: 'Leave empty to disable notifications',
addEmail: 'Add Email',
emailPlaceholder: 'Enter email address',
},
smtp: {
title: 'SMTP Settings',
......@@ -5370,6 +5490,7 @@ export default {
refundSuccess: 'Refund successful',
refundInfo: 'Refund Info',
refundEnabled: 'Refund Enabled',
allowUserRefund: 'Allow User Refund',
alreadyRefunded: 'Already Refunded',
deductBalance: 'Deduct Balance',
deductBalanceHint: 'Subtract recharged amount from user balance',
......@@ -5439,6 +5560,9 @@ export default {
tabPlanConfig: 'Plan Configuration',
tabUserSubs: 'User Subscriptions',
selectGroup: 'Select a group',
groupRequired: 'Please select a subscription group',
priceRequired: 'Price must be greater than 0',
validityDaysRequired: 'Validity days must be greater than 0',
groupMissing: 'Missing',
groupInfo: 'Group Info',
platform: 'Platform',
......
......@@ -247,6 +247,8 @@ export default {
loading: '加载中...',
justNow: '刚刚',
save: '保存',
saved: '保存成功',
deleted: '删除成功',
cancel: '取消',
delete: '删除',
edit: '编辑',
......@@ -304,6 +306,7 @@ export default {
saving: '保存中...',
selectedCount: '(已选 {count} 个)',
refresh: '刷新',
view: '查看',
settings: '设置',
chooseFile: '选择文件',
notAvailable: '不可用',
......@@ -778,6 +781,8 @@ export default {
inputTokenPrice: '输入单价',
outputTokenPrice: '输出单价',
perMillionTokens: '/ 1M Token',
unitPrice: '单次价格',
imageUnitPrice: '单张价格',
cacheRead: '读取',
cacheWrite: '写入',
serviceTier: '服务档位',
......@@ -906,6 +911,38 @@ export default {
sendCode: '发送验证码',
codeSent: '验证码已发送到您的邮箱',
sendCodeFailed: '发送验证码失败'
},
balanceNotify: {
title: '余额不足提醒',
description: '当账户余额低于阈值时发送邮件提醒',
enabled: '启用余额不足提醒',
threshold: '自定义提醒阈值',
thresholdHint: '留空使用系统默认值',
thresholdPlaceholder: '输入金额',
systemDefault: '系统默认值',
extraEmails: '通知邮箱',
extraEmailsHint: '必须添加并验证邮箱后,余额不足时才能收到提醒邮件',
primaryEmail: '主邮箱',
noExtraEmails: '暂无额外通知邮箱',
enterEmail: '输入邮箱地址',
addEmail: '添加邮箱',
emailPlaceholder: '输入邮箱地址',
sendCode: '发送验证码',
resend: '重发',
codeSent: '验证码已发送',
codeSentTo: '验证码已发送到 {email}',
enterCode: '输入验证码',
codePlaceholder: '6位验证码',
verify: '验证',
emailAdded: '邮箱已添加',
emailRemoved: '邮箱已移除',
verifySuccess: '邮箱添加成功',
removeEmail: '移除',
removeSuccess: '邮箱已移除',
emailDuplicate: '该邮箱已存在',
maxEmailsReached: '已达到通知邮箱数量上限',
unverified: '未验证',
verified: '已验证',
}
},
......@@ -1915,12 +1952,28 @@ export default {
defaultPerRequestPrice: '默认单次价格(未命中层级时使用)',
defaultImagePrice: '默认图片价格(未命中层级时使用)',
platformConfig: '平台配置',
webSearchEmulation: 'Web Search 模拟',
webSearchEmulationHint: '⚠️ 开启后该渠道下所有 Anthropic 分组的账号将自动拦截 web_search 请求,请谨慎操作',
webSearchEmulationGlobalDisabled: '请先在系统设置 → 网关 → Web Search 模拟中启用全局开关',
basicSettings: '基础设置',
addPlatform: '添加平台',
noPlatforms: '点击"添加平台"开始配置渠道',
mappingCount: '条映射',
pricingEntry: '定价配置',
noModels: '未添加模型'
noModels: '未添加模型',
applyPricingToAccountStats: '应用模型定价到账号统计',
applyPricingToAccountStatsDesc: '启用后,未被自定义规则匹配的请求将使用模型定价文件中的标准价格计算账号统计费用',
accountStatsPricingRules: '自定义账号统计定价规则',
addRule: '添加规则',
noRulesConfigured: '未配置自定义规则,将使用上方的模型定价。',
ruleName: '规则名称(可选)',
ruleGroups: '分组',
ruleAccounts: '账号',
searchAccountPlaceholder: '搜索账号...',
ruleAccountsHint: '留空表示匹配所有账号',
ruleModelPricing: '模型定价',
noGroupsInChannel: '上方平台标签页中未选择分组',
unnamed: '未命名'
}
},
......@@ -2212,6 +2265,12 @@ export default {
},
quotaLimitAmount: '总限额',
quotaLimitAmountHint: '累计消费上限,不会自动重置。',
quotaNotify: {
alert: '提醒阈值',
enabled: '启用告警',
threshold: '告警金额',
thresholdPlaceholder: '输入百分比',
},
testConnection: '测试连接',
reAuthorize: '重新授权',
refreshToken: '刷新令牌',
......@@ -2472,7 +2531,13 @@ export default {
anthropic: {
apiKeyPassthrough: '自动透传(仅替换认证)',
apiKeyPassthroughDesc:
'仅对 Anthropic API Key 生效。开启后,messages/count_tokens 请求将透传上游并仅替换认证,保留计费/并发/审计及必要安全过滤;关闭即可回滚到现有兼容链路。'
'仅对 Anthropic API Key 生效。开启后,messages/count_tokens 请求将透传上游并仅替换认证,保留计费/并发/审计及必要安全过滤;关闭即可回滚到现有兼容链路。',
webSearchEmulation: 'Web Search 模拟',
webSearchEmulationDesc:
'为该 API Key 账号启用 web search 模拟。客户端发送纯 web_search 请求时,由网关调用第三方搜索 API 并构造响应返回。默认跟随渠道配置。',
webSearchDefault: '默认',
webSearchEnabled: '开启',
webSearchDisabled: '关闭',
},
modelRestriction: '模型限制(可选)',
modelWhitelist: '模型白名单',
......@@ -4520,6 +4585,40 @@ export default {
cchSigning: 'CCH 签名',
cchSigningHint: '对转发请求的 billing header 进行 CCH 哈希签名。关闭时保留原始占位符。',
},
webSearchEmulation: {
title: 'Web Search 模拟',
description: '为不原生支持搜索的 Anthropic API Key 账号注入 web search 能力',
enabled: '启用 Web Search 模拟',
enabledHint: '全局开关。关闭后所有渠道和账号的 web search 模拟均不生效。',
providers: '搜索服务商',
addProvider: '添加服务商',
providerType: '服务商类型',
apiKey: 'API Key',
apiKeyPlaceholder: '输入 API Key',
apiKeyConfigured: '已配置',
showApiKey: '显示',
hideApiKey: '隐藏',
copyApiKey: '复制',
copied: '已复制',
quotaLimit: '配额上限',
quotaLimitHint: '留空表示无限制;填写时必须大于 0',
quotaLimitMustBePositive: '配额上限必须大于 0',
subscribedAt: '订阅时间',
subscribedAtHint: '配额从此日期起每月自动重置;留空则不自动重置',
quotaUsage: '用量',
resetUsage: '重置',
resetUsageConfirm: '确定要重置此服务商的用量计数吗?',
resetUsageSuccess: '用量已重置',
proxy: '代理',
removeProvider: '删除',
noProviders: '未配置搜索服务商',
test: '测试',
testDefaultQuery: '搜索今年世界大事件',
testing: '搜索中...',
testResultTitle: '搜索结果',
testResultProvider: '服务商',
testNoResults: '无搜索结果',
},
site: {
title: '站点设置',
description: '自定义站点品牌',
......@@ -4714,6 +4813,27 @@ export default {
supportedTypes: '支持的支付方式',
supportedTypesHint: '逗号分隔,如 alipay,wxpay',
refundEnabled: '允许退款',
allowUserRefund: '允许用户退款',
},
balanceNotify: {
title: '余额不足提醒',
description: '当用户余额低于阈值时发送邮件提醒',
enabled: '启用余额不足提醒',
threshold: '默认提醒阈值',
thresholdHint: '用户未自定义时使用此值',
thresholdPlaceholder: '输入金额',
rechargeUrl: '充值页面 URL',
rechargeUrlPlaceholder: 'https://example.com/payment',
rechargeUrlHint: '设置后邮件中将包含充值链接按钮',
},
quotaNotify: {
title: '账号限额通知',
description: '当账号配额用量达到告警阈值时通知管理员',
enabled: '启用账号限额通知',
emails: '通知邮箱',
emailsHint: '留空则不发送通知',
addEmail: '添加邮箱',
emailPlaceholder: '输入邮箱地址',
},
smtp: {
title: 'SMTP 设置',
......@@ -5627,6 +5747,9 @@ export default {
tabPlanConfig: '套餐配置',
tabUserSubs: '用户订阅',
selectGroup: '请选择分组',
groupRequired: '请选择订阅分组',
priceRequired: '价格必须大于 0',
validityDaysRequired: '有效期天数必须大于 0',
groupMissing: '缺失',
groupInfo: '分组信息',
platform: '平台',
......
......@@ -339,7 +339,10 @@ export const useAppStore = defineStore('app', () => {
oidc_oauth_enabled: false,
oidc_oauth_provider_name: 'OIDC',
backend_mode_enabled: false,
version: siteVersion.value
version: siteVersion.value,
balance_low_notify_enabled: false,
account_quota_notify_enabled: false,
balance_low_notify_threshold: 0,
}
}
......
......@@ -66,7 +66,7 @@ export const usePaymentStore = defineStore('payment', () => {
return response.data
}
/** Poll order status by ID */
/** Poll order status by ID (read-only, no upstream check) */
async function pollOrderStatus(orderId: number): Promise<PaymentOrder | null> {
try {
const response = await paymentAPI.getOrder(orderId)
......
......@@ -22,6 +22,16 @@ export interface FetchOptions {
signal?: AbortSignal
}
// ==================== Notification Types ====================
/** Notification email entry with enable/disable and verification state.
* email="" is a placeholder for the primary email (user's registration email or admin email). */
export interface NotifyEmailEntry {
email: string
disabled: boolean
verified: boolean
}
// ==================== User & Auth Types ====================
export interface User {
......@@ -33,6 +43,9 @@ export interface User {
concurrency: number // Allowed concurrent requests
status: 'active' | 'disabled' // Account status
allowed_groups: number[] | null // Allowed group IDs (null = all non-exclusive groups)
balance_notify_enabled: boolean
balance_notify_threshold: number | null
balance_notify_extra_emails: NotifyEmailEntry[]
subscriptions?: UserSubscription[] // User's active subscriptions
created_at: string
updated_at: string
......@@ -114,6 +127,9 @@ export interface PublicSettings {
oidc_oauth_provider_name: string
backend_mode_enabled: boolean
version: string
balance_low_notify_enabled: boolean
account_quota_notify_enabled: boolean
balance_low_notify_threshold: number
}
export interface AuthResponse {
......@@ -413,8 +429,6 @@ export interface AdminGroup extends Group {
// MCP XML 协议注入(仅 antigravity 平台使用)
mcp_xml_inject: boolean
// Claude usage 模拟开关(仅 anthropic 平台使用)
simulate_claude_max_enabled: boolean
// 支持的模型系列(仅 antigravity 平台使用)
supported_model_scopes?: string[]
......@@ -507,7 +521,6 @@ export interface CreateGroupRequest {
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean
simulate_claude_max_enabled?: boolean
supported_model_scopes?: string[]
require_oauth_only?: boolean
require_privacy_set?: boolean
......@@ -533,7 +546,6 @@ export interface UpdateGroupRequest {
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean
simulate_claude_max_enabled?: boolean
supported_model_scopes?: string[]
require_oauth_only?: boolean
require_privacy_set?: boolean
......@@ -675,6 +687,7 @@ export interface Account {
// Extra fields including Codex usage and model-level rate limits (Antigravity smart retry)
extra?: (CodexUsageSnapshot & {
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
antigravity_credits_overages?: Record<string, { activated_at: string; active_until: string }>
} & Record<string, unknown>)
proxy_id: number | null
concurrency: number
......@@ -736,12 +749,6 @@ export interface Account {
custom_base_url_enabled?: boolean | null
custom_base_url?: string | null
// 客户端亲和调度(仅 Anthropic/Antigravity 平台有效)
// 启用后新会话会优先调度到客户端之前使用过的账号
client_affinity_enabled?: boolean | null
affinity_client_count?: number | null
affinity_clients?: string[] | null
// API Key 账号配额限制
quota_limit?: number | null
quota_used?: number | null
......@@ -1050,6 +1057,12 @@ export interface AdminUsageLog extends UsageLog {
// 账号计费倍率(仅管理员可见)
account_rate_multiplier?: number | null
// 自定义定价规则计算的账号统计费用(nil 时使用 total_cost * multiplier)
account_stats_cost?: number | null
// 渠道 ID 和计费等级(仅管理员可见)
channel_id?: number | null
billing_tier?: string | null
// 用户请求 IP(仅管理员可见)
ip_address?: string | null
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment