feat: WebSearch tri-state, account stats pricing fix, quota cache fix, usage tooltip
WebSearch tri-state switch: - Account-level web_search_emulation changed from bool to tri-state string: "default" (follow channel) / "enabled" / "disabled" - shouldEmulateWebSearch checks channel config when account is "default" - SQL migration converts old bool values - Frontend select replaces toggle in Edit/CreateAccountModal Account stats pricing: - resolveAccountStatsCost uses upstream model (post-mapping) for matching - Priority: custom rules → model pricing file (when toggle on) → default - Custom rules always configurable, independent of toggle - Account ID field changed to searchable selector filtered by platform - Description updated to reflect new behavior Quota notification cache fix: - CheckAccountQuotaAfterIncrement fetches real-time account from DB - Reconstructs pre-increment usage for accurate threshold crossing detection - New AccountQuotaReader interface (minimal: GetByID only) Usage tooltip: - Per-request/image billing shows per-request price instead of $0 token price - Token billing continues to show input/output price per million tokens
This commit is contained in:
@@ -2337,7 +2337,11 @@
|
||||
{{ t('admin.accounts.anthropic.webSearchEmulationDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="webSearchEmulationEnabled" />
|
||||
<select v-model="webSearchEmulationMode" class="input w-32 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>
|
||||
|
||||
@@ -2846,7 +2850,6 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import Toggle from '@/components/common/Toggle.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
|
||||
@@ -2997,7 +3000,7 @@ 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 webSearchEmulationEnabled = ref(false)
|
||||
const webSearchEmulationMode = ref('default')
|
||||
const webSearchGlobalEnabled = ref(false)
|
||||
|
||||
// Load web search global state once
|
||||
@@ -3331,7 +3334,7 @@ watch(
|
||||
}
|
||||
if (newPlatform !== 'anthropic') {
|
||||
anthropicPassthroughEnabled.value = false
|
||||
webSearchEmulationEnabled.value = false
|
||||
webSearchEmulationMode.value = 'default'
|
||||
}
|
||||
// Reset OAuth states
|
||||
oauth.resetState()
|
||||
@@ -3351,7 +3354,7 @@ watch(
|
||||
}
|
||||
if (platform !== 'anthropic' || category !== 'apikey') {
|
||||
anthropicPassthroughEnabled.value = false
|
||||
webSearchEmulationEnabled.value = false
|
||||
webSearchEmulationMode.value = 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -3716,7 +3719,7 @@ const resetForm = () => {
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
codexCLIOnlyEnabled.value = false
|
||||
anthropicPassthroughEnabled.value = false
|
||||
webSearchEmulationEnabled.value = false
|
||||
webSearchEmulationMode.value = 'default'
|
||||
// Reset quota control state
|
||||
windowCostEnabled.value = false
|
||||
windowCostLimit.value = null
|
||||
@@ -3804,10 +3807,10 @@ const buildAnthropicExtra = (base?: Record<string, unknown>): Record<string, unk
|
||||
} else {
|
||||
delete extra.anthropic_passthrough
|
||||
}
|
||||
if (webSearchEmulationEnabled.value) {
|
||||
extra.web_search_emulation = true
|
||||
} else {
|
||||
if (webSearchEmulationMode.value === 'default') {
|
||||
delete extra.web_search_emulation
|
||||
} else {
|
||||
extra.web_search_emulation = webSearchEmulationMode.value
|
||||
}
|
||||
|
||||
return Object.keys(extra).length > 0 ? extra : undefined
|
||||
|
||||
@@ -1161,7 +1161,11 @@
|
||||
{{ t('admin.accounts.anthropic.webSearchEmulationDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="webSearchEmulationEnabled" />
|
||||
<select v-model="webSearchEmulationMode" class="input w-32 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>
|
||||
|
||||
@@ -1844,7 +1848,6 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import Toggle from '@/components/common/Toggle.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
|
||||
@@ -1986,7 +1989,7 @@ 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 webSearchEmulationEnabled = ref(false)
|
||||
const webSearchEmulationMode = ref('default')
|
||||
const webSearchGlobalEnabled = ref(false)
|
||||
|
||||
// Load web search global state once
|
||||
@@ -2171,7 +2174,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
codexCLIOnlyEnabled.value = false
|
||||
anthropicPassthroughEnabled.value = false
|
||||
webSearchEmulationEnabled.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, {
|
||||
@@ -2192,7 +2195,15 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
}
|
||||
if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') {
|
||||
anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true
|
||||
webSearchEmulationEnabled.value = extra?.web_search_emulation === 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)
|
||||
@@ -3180,10 +3191,10 @@ const handleSubmit = async () => {
|
||||
} else {
|
||||
delete newExtra.anthropic_passthrough
|
||||
}
|
||||
if (webSearchEmulationEnabled.value) {
|
||||
newExtra.web_search_emulation = true
|
||||
} else {
|
||||
if (webSearchEmulationMode.value === 'default') {
|
||||
delete newExtra.web_search_emulation
|
||||
} else {
|
||||
newExtra.web_search_emulation = webSearchEmulationMode.value
|
||||
}
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
@@ -279,13 +279,21 @@
|
||||
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<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>
|
||||
<!-- Token billing: show unit prices per 1M tokens -->
|
||||
<template v-if="!tooltipData?.billing_mode || tooltipData.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>
|
||||
</div>
|
||||
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<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 === '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>
|
||||
|
||||
@@ -774,6 +774,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',
|
||||
@@ -1877,14 +1879,15 @@ export default {
|
||||
pricingEntry: 'Pricing Entry',
|
||||
noModels: 'No models added',
|
||||
applyPricingToAccountStats: 'Apply Pricing to Account Stats',
|
||||
applyPricingToAccountStatsDesc: 'When enabled, custom account stats model pricing rules will be applied.',
|
||||
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: 'Account IDs',
|
||||
ruleAccountsPlaceholder: 'Enter account IDs, comma-separated',
|
||||
ruleAccounts: 'Accounts',
|
||||
searchAccountPlaceholder: 'Search accounts...',
|
||||
ruleAccountsHint: 'Leave empty to match all accounts',
|
||||
ruleModelPricing: 'Model Pricing',
|
||||
noGroupsInChannel: 'No groups selected in platform tabs above'
|
||||
}
|
||||
@@ -2380,6 +2383,9 @@ export default {
|
||||
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.',
|
||||
webSearchDefault: 'Default (follow channel)',
|
||||
webSearchEnabled: 'Enabled',
|
||||
webSearchDisabled: 'Disabled',
|
||||
},
|
||||
modelRestriction: 'Model Restriction (Optional)',
|
||||
modelWhitelist: 'Model Whitelist',
|
||||
|
||||
@@ -778,6 +778,8 @@ export default {
|
||||
inputTokenPrice: '输入单价',
|
||||
outputTokenPrice: '输出单价',
|
||||
perMillionTokens: '/ 1M Token',
|
||||
unitPrice: '单次价格',
|
||||
imageUnitPrice: '单张价格',
|
||||
cacheRead: '读取',
|
||||
cacheWrite: '写入',
|
||||
serviceTier: '服务档位',
|
||||
@@ -1956,14 +1958,15 @@ export default {
|
||||
pricingEntry: '定价配置',
|
||||
noModels: '未添加模型',
|
||||
applyPricingToAccountStats: '应用模型定价到账号统计',
|
||||
applyPricingToAccountStatsDesc: '启用后将支持自定义账号统计的模型价格',
|
||||
applyPricingToAccountStatsDesc: '启用后,未被自定义规则匹配的请求将使用模型定价文件中的标准价格计算账号统计费用',
|
||||
accountStatsPricingRules: '自定义账号统计定价规则',
|
||||
addRule: '添加规则',
|
||||
noRulesConfigured: '未配置自定义规则,将使用上方的模型定价。',
|
||||
ruleName: '规则名称(可选)',
|
||||
ruleGroups: '分组',
|
||||
ruleAccounts: '账号 ID',
|
||||
ruleAccountsPlaceholder: '输入账号 ID,逗号分隔',
|
||||
ruleAccounts: '账号',
|
||||
searchAccountPlaceholder: '搜索账号...',
|
||||
ruleAccountsHint: '留空表示匹配所有账号',
|
||||
ruleModelPricing: '模型定价',
|
||||
noGroupsInChannel: '上方平台标签页中未选择分组'
|
||||
}
|
||||
@@ -2527,6 +2530,9 @@ export default {
|
||||
webSearchEmulation: 'Web Search 模拟',
|
||||
webSearchEmulationDesc:
|
||||
'为该 API Key 账号启用 web search 模拟。客户端发送纯 web_search 请求时,由网关调用第三方搜索 API 并构造响应返回。',
|
||||
webSearchDefault: '默认(跟随渠道)',
|
||||
webSearchEnabled: '开启',
|
||||
webSearchDisabled: '关闭',
|
||||
},
|
||||
modelRestriction: '模型限制(可选)',
|
||||
modelWhitelist: '模型白名单',
|
||||
|
||||
@@ -413,8 +413,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Stats Pricing Rules (per-platform, only when global toggle is on) -->
|
||||
<div v-if="form.apply_pricing_to_account_stats" class="mt-4 border-t border-gray-200 pt-4 dark:border-dark-700 space-y-3">
|
||||
<!-- Account Stats Pricing Rules (per-platform, always visible) -->
|
||||
<div class="mt-4 border-t border-gray-200 pt-4 dark:border-dark-700 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.channels.form.accountStatsPricingRules') }}
|
||||
@@ -474,12 +474,51 @@
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.channels.form.ruleAccounts') }}</label>
|
||||
<input
|
||||
:value="rule.account_ids.join(', ')"
|
||||
@change="rule.account_ids = parseAccountIdsInput(($event.target as HTMLInputElement).value)"
|
||||
:placeholder="t('admin.channels.form.ruleAccountsPlaceholder')"
|
||||
class="input mt-1 text-sm"
|
||||
/>
|
||||
<!-- Selected account chips -->
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="accountId in rule.account_ids"
|
||||
:key="accountId"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-primary-300 bg-primary-50 px-2 py-0.5 text-xs dark:border-primary-700 dark:bg-primary-900/20"
|
||||
>
|
||||
<span>{{ getRuleAccountLabel(accountId) }}</span>
|
||||
<button type="button" @click="removeRuleAccount(rule, accountId)" class="text-gray-400 hover:text-red-500">
|
||||
<Icon name="x" size="xs" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Account search input -->
|
||||
<div class="relative mt-1 rule-account-search-container">
|
||||
<input
|
||||
v-model="ruleAccountSearchKeyword[`${section.platform}-${ruleIndex}`]"
|
||||
type="text"
|
||||
class="input text-sm"
|
||||
:placeholder="t('admin.channels.form.searchAccountPlaceholder')"
|
||||
@input="onRuleAccountSearchInput(section.platform, ruleIndex)"
|
||||
@focus="onRuleAccountSearchFocus(section.platform, ruleIndex)"
|
||||
/>
|
||||
<!-- Search results dropdown -->
|
||||
<div
|
||||
v-if="showRuleAccountDropdown[`${section.platform}-${ruleIndex}`] && (ruleAccountSearchResults[`${section.platform}-${ruleIndex}`]?.length ?? 0) > 0"
|
||||
class="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="account in ruleAccountSearchResults[`${section.platform}-${ruleIndex}`]"
|
||||
:key="account.id"
|
||||
type="button"
|
||||
@click="selectRuleAccount(rule, account, section.platform, ruleIndex)"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||
:class="{ 'opacity-50': rule.account_ids.includes(account.id) }"
|
||||
:disabled="rule.account_ids.includes(account.id)"
|
||||
>
|
||||
<span>{{ account.name }}</span>
|
||||
<span class="ml-2 text-xs text-gray-400">#{{ account.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
{{ t('admin.channels.form.ruleAccountsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -569,6 +608,7 @@ import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
import Toggle from '@/components/common/Toggle.vue'
|
||||
import PricingEntryCard from '@/components/admin/channel/PricingEntryCard.vue'
|
||||
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -852,6 +892,9 @@ function addRulePricingEntry(ruleIndex: number) {
|
||||
|
||||
function removeAccountStatsRule(ruleIndex: number) {
|
||||
form.account_stats_pricing_rules.splice(ruleIndex, 1)
|
||||
// Clear all search state since indices shift after removal
|
||||
ruleAccountSearchRunner.clearAll()
|
||||
clearAllRuleAccountSearchState()
|
||||
}
|
||||
|
||||
function removeRulePricingEntry(ruleIndex: number, pricingIndex: number) {
|
||||
@@ -863,11 +906,78 @@ function getGroupNameById(groupId: number): string {
|
||||
return group ? group.name : `#${groupId}`
|
||||
}
|
||||
|
||||
function parseAccountIdsInput(value: string): number[] {
|
||||
return value
|
||||
.split(',')
|
||||
.map(s => parseInt(s.trim()))
|
||||
.filter(n => !isNaN(n) && n > 0)
|
||||
// ── Account search for pricing rules ──
|
||||
interface SimpleAccount { id: number; name: string }
|
||||
|
||||
const ruleAccountSearchKeyword = ref<Record<string, string>>({})
|
||||
const ruleAccountSearchResults = ref<Record<string, SimpleAccount[]>>({})
|
||||
const showRuleAccountDropdown = ref<Record<string, boolean>>({})
|
||||
// Cache: account ID → name, populated when search results are selected
|
||||
const ruleAccountNameCache = ref<Record<number, string>>({})
|
||||
|
||||
const ruleAccountSearchRunner = useKeyedDebouncedSearch<SimpleAccount[]>({
|
||||
delay: 300,
|
||||
search: async (keyword, { key, signal }) => {
|
||||
const platform = key.split('-')[0]
|
||||
const res = await adminAPI.accounts.list(1, 20, { platform, search: keyword }, { signal })
|
||||
return res.items.map(a => ({ id: a.id, name: a.name }))
|
||||
},
|
||||
onSuccess: (key, result) => { ruleAccountSearchResults.value[key] = result },
|
||||
onError: (key) => { ruleAccountSearchResults.value[key] = [] },
|
||||
})
|
||||
|
||||
function onRuleAccountSearchInput(platform: string, ruleIndex: number) {
|
||||
const key = `${platform}-${ruleIndex}`
|
||||
showRuleAccountDropdown.value[key] = true
|
||||
ruleAccountSearchRunner.trigger(key, ruleAccountSearchKeyword.value[key] || '')
|
||||
}
|
||||
|
||||
function onRuleAccountSearchFocus(platform: string, ruleIndex: number) {
|
||||
const key = `${platform}-${ruleIndex}`
|
||||
showRuleAccountDropdown.value[key] = true
|
||||
if (!ruleAccountSearchResults.value[key]?.length) {
|
||||
ruleAccountSearchRunner.trigger(key, ruleAccountSearchKeyword.value[key] || '')
|
||||
}
|
||||
}
|
||||
|
||||
function selectRuleAccount(
|
||||
rule: { account_ids: number[] },
|
||||
account: SimpleAccount,
|
||||
platform: string,
|
||||
ruleIndex: number,
|
||||
) {
|
||||
if (!rule.account_ids.includes(account.id)) {
|
||||
rule.account_ids.push(account.id)
|
||||
ruleAccountNameCache.value[account.id] = account.name
|
||||
}
|
||||
const key = `${platform}-${ruleIndex}`
|
||||
ruleAccountSearchKeyword.value[key] = ''
|
||||
showRuleAccountDropdown.value[key] = false
|
||||
}
|
||||
|
||||
function removeRuleAccount(rule: { account_ids: number[] }, accountId: number) {
|
||||
const idx = rule.account_ids.indexOf(accountId)
|
||||
if (idx !== -1) rule.account_ids.splice(idx, 1)
|
||||
}
|
||||
|
||||
function getRuleAccountLabel(accountId: number): string {
|
||||
const name = ruleAccountNameCache.value[accountId]
|
||||
return name ? `${name} #${accountId}` : `#${accountId}`
|
||||
}
|
||||
|
||||
function handleRuleAccountClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.rule-account-search-container')) {
|
||||
Object.keys(showRuleAccountDropdown.value).forEach(key => {
|
||||
showRuleAccountDropdown.value[key] = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllRuleAccountSearchState() {
|
||||
ruleAccountSearchKeyword.value = {}
|
||||
ruleAccountSearchResults.value = {}
|
||||
showRuleAccountDropdown.value = {}
|
||||
}
|
||||
|
||||
function accountStatsRulesToAPI(): AccountStatsPricingRule[] {
|
||||
@@ -1093,6 +1203,9 @@ function resetForm() {
|
||||
form.apply_pricing_to_account_stats = false
|
||||
form.account_stats_pricing_rules = []
|
||||
activeTab.value = 'basic'
|
||||
ruleAccountSearchRunner.clearAll()
|
||||
clearAllRuleAccountSearchState()
|
||||
ruleAccountNameCache.value = {}
|
||||
}
|
||||
|
||||
async function openCreateDialog() {
|
||||
@@ -1313,11 +1426,15 @@ onMounted(() => {
|
||||
loadChannels()
|
||||
loadGroups()
|
||||
loadWebSearchGlobalState()
|
||||
document.addEventListener('click', handleRuleAccountClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(searchTimeout)
|
||||
abortController?.abort()
|
||||
document.removeEventListener('click', handleRuleAccountClickOutside)
|
||||
ruleAccountSearchRunner.clearAll()
|
||||
clearAllRuleAccountSearchState()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -447,13 +447,21 @@
|
||||
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<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>
|
||||
<!-- Token billing: show unit prices per 1M tokens -->
|
||||
<template v-if="!tooltipData?.billing_mode || tooltipData.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>
|
||||
</div>
|
||||
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
|
||||
<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 === '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>
|
||||
|
||||
Reference in New Issue
Block a user