Commit 0b746501 authored by 陈曦's avatar 陈曦
Browse files

1. merge upstream v0.1.113 2.提交migration相关文件

parents 45061102 be7551b9
......@@ -34,6 +34,14 @@ export interface ChannelModelPricing {
intervals: PricingInterval[]
}
export interface AccountStatsPricingRule {
id?: number
name: string
group_ids: number[]
account_ids: number[]
pricing: ChannelModelPricing[]
}
export interface Channel {
id: number
name: string
......@@ -41,9 +49,12 @@ export interface Channel {
status: string
billing_model_source: string // "requested" | "upstream"
restrict_models: boolean
features_config?: Record<string, unknown>
group_ids: number[]
model_pricing: ChannelModelPricing[]
model_mapping: Record<string, Record<string, string>> // platform → {src→dst}
apply_pricing_to_account_stats: boolean
account_stats_pricing_rules: AccountStatsPricingRule[]
created_at: string
updated_at: string
}
......@@ -56,6 +67,9 @@ export interface CreateChannelRequest {
model_mapping?: Record<string, Record<string, string>>
billing_model_source?: string
restrict_models?: boolean
features_config?: Record<string, unknown>
apply_pricing_to_account_stats?: boolean
account_stats_pricing_rules?: AccountStatsPricingRule[]
}
export interface UpdateChannelRequest {
......@@ -67,6 +81,9 @@ export interface UpdateChannelRequest {
model_mapping?: Record<string, Record<string, string>>
billing_model_source?: string
restrict_models?: boolean
features_config?: Record<string, unknown>
apply_pricing_to_account_stats?: boolean
account_stats_pricing_rules?: AccountStatsPricingRule[]
}
interface PaginatedResponse<T> {
......
......@@ -23,6 +23,7 @@ export interface AdminPaymentConfig {
max_pending_orders: number
enabled_payment_types: string[]
balance_disabled: boolean
balance_recharge_multiplier: number
load_balance_strategy: string
product_name_prefix: string
product_name_suffix: string
......@@ -40,6 +41,7 @@ export interface UpdatePaymentConfigRequest {
max_pending_orders?: number
enabled_payment_types?: string[]
balance_disabled?: boolean
balance_recharge_multiplier?: number
load_balance_strategy?: string
product_name_prefix?: string
product_name_suffix?: string
......
......@@ -4,7 +4,7 @@
*/
import { apiClient } from '../client'
import type { CustomMenuItem, CustomEndpoint } from '@/types'
import type { CustomMenuItem, CustomEndpoint, NotifyEmailEntry } from '@/types'
export interface DefaultSubscriptionSetting {
group_id: number
......@@ -114,6 +114,7 @@ export interface SystemSettings {
enable_fingerprint_unification: boolean
enable_metadata_passthrough: boolean
enable_cch_signing: boolean
web_search_emulation_enabled?: boolean
// Payment configuration
payment_enabled: boolean
......@@ -124,6 +125,8 @@ export interface SystemSettings {
payment_max_pending_orders: number
payment_enabled_types: string[]
payment_balance_disabled: boolean
payment_balance_recharge_multiplier: number
payment_recharge_fee_rate: number
payment_load_balance_strategy: string
payment_product_name_prefix: string
payment_product_name_suffix: string
......@@ -134,6 +137,13 @@ export interface SystemSettings {
payment_cancel_rate_limit_window: number
payment_cancel_rate_limit_unit: string
payment_cancel_rate_limit_window_mode: string
// Balance & quota notification
balance_low_notify_enabled: boolean
balance_low_notify_threshold: number
balance_low_notify_recharge_url: string
account_quota_notify_enabled: boolean
account_quota_notify_emails: NotifyEmailEntry[]
}
export interface UpdateSettingsRequest {
......@@ -223,6 +233,8 @@ export interface UpdateSettingsRequest {
payment_max_pending_orders?: number
payment_enabled_types?: string[]
payment_balance_disabled?: boolean
payment_balance_recharge_multiplier?: number
payment_recharge_fee_rate?: number
payment_load_balance_strategy?: string
payment_product_name_prefix?: string
payment_product_name_suffix?: string
......@@ -233,6 +245,12 @@ export interface UpdateSettingsRequest {
payment_cancel_rate_limit_window?: number
payment_cancel_rate_limit_unit?: string
payment_cancel_rate_limit_window_mode?: string
// Balance & quota notification
balance_low_notify_enabled?: boolean
balance_low_notify_threshold?: number
balance_low_notify_recharge_url?: string
account_quota_notify_enabled?: boolean
account_quota_notify_emails?: NotifyEmailEntry[]
}
/**
......@@ -482,6 +500,63 @@ export async function updateBetaPolicySettings(
return data
}
// --- Web Search Emulation Config ---
export interface WebSearchProviderConfig {
type: 'brave' | 'tavily'
api_key: string
api_key_configured: boolean
quota_limit: number | null
subscribed_at: number | null
quota_used?: number
proxy_id: number | null
expires_at: number | null
}
export interface WebSearchEmulationConfig {
enabled: boolean
providers: WebSearchProviderConfig[]
}
export interface WebSearchTestResult {
provider: string
results: { url: string; title: string; snippet: string; page_age?: string }[]
query: string
}
export async function getWebSearchEmulationConfig(): Promise<WebSearchEmulationConfig> {
const { data } = await apiClient.get<WebSearchEmulationConfig>(
'/admin/settings/web-search-emulation'
)
return data
}
export async function updateWebSearchEmulationConfig(
config: WebSearchEmulationConfig
): Promise<WebSearchEmulationConfig> {
const { data } = await apiClient.put<WebSearchEmulationConfig>(
'/admin/settings/web-search-emulation',
config
)
return data
}
export async function testWebSearchEmulation(
query: string
): Promise<WebSearchTestResult> {
const { data } = await apiClient.post<WebSearchTestResult>(
'/admin/settings/web-search-emulation/test',
{ query }
)
return data
}
export async function resetWebSearchUsage(
payload: { provider_type: string }
): Promise<void> {
await apiClient.post('/admin/settings/web-search-emulation/reset-usage', payload)
}
export const settingsAPI = {
getSettings,
updateSettings,
......@@ -497,7 +572,11 @@ export const settingsAPI = {
getRectifierSettings,
updateRectifierSettings,
getBetaPolicySettings,
updateBetaPolicySettings
updateBetaPolicySettings,
getWebSearchEmulationConfig,
updateWebSearchEmulationConfig,
testWebSearchEmulation,
resetWebSearchUsage
}
export default settingsAPI
......@@ -17,7 +17,7 @@ export interface AdminUsageStatsResponse {
total_tokens: number
total_cost: number
total_actual_cost: number
total_account_cost?: number
total_account_cost: number
average_duration_ms: number
endpoints?: EndpointStat[]
upstream_endpoints?: EndpointStat[]
......
......@@ -270,9 +270,9 @@ apiClient.interceptors.response.use(
return Promise.reject({
status,
code: apiData.code,
reason: apiData.reason,
error: apiData.error,
message: apiData.message || apiData.detail || error.message,
reason: apiData.reason,
metadata: apiData.metadata,
})
}
......
......@@ -75,5 +75,10 @@ export const paymentAPI = {
/** Request a refund for a completed order */
requestRefund(id: number, data: { reason: string }) {
return apiClient.post(`/payment/orders/${id}/refund-request`, data)
},
/** Get provider instance IDs that allow user refund */
getRefundEligibleProviders() {
return apiClient.get<{ provider_instance_ids: string[] }>('/payment/orders/refund-eligible-providers')
}
}
......@@ -4,7 +4,7 @@
*/
import { apiClient } from './client'
import type { User, ChangePasswordRequest } from '@/types'
import type { User, ChangePasswordRequest, NotifyEmailEntry } from '@/types'
/**
* Get current user profile
......@@ -22,6 +22,9 @@ export async function getProfile(): Promise<User> {
*/
export async function updateProfile(profile: {
username?: string
balance_notify_enabled?: boolean
balance_notify_threshold?: number | null
balance_notify_extra_emails?: NotifyEmailEntry[]
}): Promise<User> {
const { data } = await apiClient.put<User>('/user', profile)
return data
......@@ -45,10 +48,49 @@ export async function changePassword(
return data
}
/**
* Send verification code for adding a notify email
* @param email - Email address to verify
*/
export async function sendNotifyEmailCode(email: string): Promise<void> {
await apiClient.post('/user/notify-email/send-code', { email })
}
/**
* Verify and add a notify email
* @param email - Email address to add
* @param code - Verification code
*/
export async function verifyNotifyEmail(email: string, code: string): Promise<void> {
await apiClient.post('/user/notify-email/verify', { email, code })
}
/**
* Remove a notify email
* @param email - Email address to remove
*/
export async function removeNotifyEmail(email: string): Promise<void> {
await apiClient.delete('/user/notify-email', { data: { email } })
}
/**
* Toggle a notify email's disabled state
* @param email - Email address (empty string for primary email placeholder)
* @param disabled - Whether to disable the email
*/
export async function toggleNotifyEmail(email: string, disabled: boolean): Promise<User> {
const { data } = await apiClient.put<User>('/user/notify-email/toggle', { email, disabled })
return data
}
export const userAPI = {
getProfile,
updateProfile,
changePassword
changePassword,
sendNotifyEmailCode,
verifyNotifyEmail,
removeNotifyEmail,
toggleNotifyEmail
}
export default userAPI
<template>
<div class="flex flex-col gap-1.5">
<div class="flex flex-col gap-0.5">
<!-- 并发槽位 -->
<div class="flex items-center gap-1.5">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
concurrencyClass
]"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
<span class="font-mono">{{ currentConcurrency }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ account.concurrency }}</span>
</span>
</div>
<!-- 5h窗口费用限制(仅 Anthropic OAuth/SetupToken 且启用时显示) -->
<div v-if="showWindowCost" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
windowCostClass
]"
:title="windowCostTooltip"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-mono">${{ formatCost(currentWindowCost) }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">${{ formatCost(account.window_cost_limit) }}</span>
</span>
</div>
<!-- 会话数量限制(仅 Anthropic OAuth/SetupToken 且启用时显示) -->
<div v-if="showSessionLimit" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
sessionLimitClass
]"
:title="sessionLimitTooltip"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
<span class="font-mono">{{ activeSessions }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ account.max_sessions }}</span>
</span>
</div>
<!-- RPM 限制(仅 Anthropic OAuth/SetupToken 且启用时显示) -->
<div v-if="showRpmLimit" class="flex items-center gap-1">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
rpmClass
]"
:title="rpmTooltip"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span class="font-mono">{{ currentRPM }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ account.base_rpm }}</span>
<span class="text-[9px] opacity-60">{{ rpmStrategyTag }}</span>
</span>
</div>
<CapacityBadge :color-class="concurrencyClass" :current="currentConcurrency" :max="account.concurrency">
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
</CapacityBadge>
<!-- 5h窗口费用限制 -->
<CapacityBadge v-if="showWindowCost" :color-class="windowCostClass" :tooltip="windowCostTooltip" :current="'$' + formatCost(currentWindowCost)" :max="'$' + formatCost(account.window_cost_limit)">
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</CapacityBadge>
<!-- 会话数量限制 -->
<CapacityBadge v-if="showSessionLimit" :color-class="sessionLimitClass" :tooltip="sessionLimitTooltip" :current="activeSessions" :max="account.max_sessions!">
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
</CapacityBadge>
<!-- RPM 限制 -->
<CapacityBadge v-if="showRpmLimit" :color-class="rpmClass" :tooltip="rpmTooltip" :current="currentRPM" :max="account.base_rpm!" :suffix="rpmStrategyTag">
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</CapacityBadge>
<!-- API Key 账号配额限制 -->
<QuotaBadge v-if="showDailyQuota" :used="account.quota_daily_used ?? 0" :limit="account.quota_daily_limit!" label="D" />
......@@ -83,7 +39,8 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account } from '@/types'
import QuotaBadge from './QuotaBadge.vue'
import CapacityBadge from '@/components/account/CapacityBadge.vue'
import QuotaBadge from '@/components/account/QuotaBadge.vue'
const props = defineProps<{
account: Account
......@@ -91,225 +48,143 @@ const props = defineProps<{
const { t } = useI18n()
// 当前并发数
// ====== 并发 ======
const currentConcurrency = computed(() => props.account.current_concurrency || 0)
// 是否为 Anthropic OAuth/SetupToken 账号
const isAnthropicOAuthOrSetupToken = computed(() => {
return (
props.account.platform === 'anthropic' &&
(props.account.type === 'oauth' || props.account.type === 'setup-token')
)
})
// 是否显示窗口费用限制
const showWindowCost = computed(() => {
return (
isAnthropicOAuthOrSetupToken.value &&
props.account.window_cost_limit !== undefined &&
props.account.window_cost_limit !== null &&
props.account.window_cost_limit > 0
)
})
// 当前窗口费用
const currentWindowCost = computed(() => props.account.current_window_cost ?? 0)
// 是否显示会话限制
const showSessionLimit = computed(() => {
return (
isAnthropicOAuthOrSetupToken.value &&
props.account.max_sessions !== undefined &&
props.account.max_sessions !== null &&
props.account.max_sessions > 0
)
})
// 当前活跃会话数
const activeSessions = computed(() => props.account.active_sessions ?? 0)
// 并发状态样式
const concurrencyClass = computed(() => {
const current = currentConcurrency.value
const max = props.account.concurrency
if (current >= max) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
if (current > 0) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
if (current >= max) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
if (current > 0) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
})
// 窗口费用状态样式
// ====== 窗口费用 ======
const isAnthropicOAuthOrSetupToken = computed(() =>
props.account.platform === 'anthropic' &&
(props.account.type === 'oauth' || props.account.type === 'setup-token')
)
const showWindowCost = computed(() =>
isAnthropicOAuthOrSetupToken.value &&
props.account.window_cost_limit != null &&
props.account.window_cost_limit > 0
)
const currentWindowCost = computed(() => props.account.current_window_cost ?? 0)
const windowCostClass = computed(() => {
if (!showWindowCost.value) return ''
const current = currentWindowCost.value
const limit = props.account.window_cost_limit || 0
const reserve = props.account.window_cost_sticky_reserve || 10
if (current >= limit + reserve) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
if (current >= limit) {
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
}
if (current >= limit * 0.8) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
if (current >= limit + reserve) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
if (current >= limit) return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
if (current >= limit * 0.8) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
})
// 窗口费用提示文字
const windowCostTooltip = computed(() => {
if (!showWindowCost.value) return ''
const current = currentWindowCost.value
const limit = props.account.window_cost_limit || 0
const reserve = props.account.window_cost_sticky_reserve || 10
if (current >= limit + reserve) {
return t('admin.accounts.capacity.windowCost.blocked')
}
if (current >= limit) {
return t('admin.accounts.capacity.windowCost.stickyOnly')
}
if (current >= limit + reserve) return t('admin.accounts.capacity.windowCost.blocked')
if (current >= limit) return t('admin.accounts.capacity.windowCost.stickyOnly')
return t('admin.accounts.capacity.windowCost.normal')
})
// 会话限制状态样式
// ====== 会话限制 ======
const showSessionLimit = computed(() =>
isAnthropicOAuthOrSetupToken.value &&
props.account.max_sessions != null &&
props.account.max_sessions > 0
)
const activeSessions = computed(() => props.account.active_sessions ?? 0)
const sessionLimitClass = computed(() => {
if (!showSessionLimit.value) return ''
const current = activeSessions.value
const max = props.account.max_sessions || 0
if (current >= max) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
if (current >= max * 0.8) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}
if (current >= max) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
if (current >= max * 0.8) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
})
// 会话限制提示文字
const sessionLimitTooltip = computed(() => {
if (!showSessionLimit.value) return ''
const current = activeSessions.value
const max = props.account.max_sessions || 0
const idle = props.account.session_idle_timeout_minutes || 5
if (current >= max) {
return t('admin.accounts.capacity.sessions.full', { idle })
}
if (current >= max) return t('admin.accounts.capacity.sessions.full', { idle })
return t('admin.accounts.capacity.sessions.normal', { idle })
})
// 是否显示 RPM 限制
const showRpmLimit = computed(() => {
return (
isAnthropicOAuthOrSetupToken.value &&
props.account.base_rpm !== undefined &&
props.account.base_rpm !== null &&
props.account.base_rpm > 0
)
})
// ====== RPM ======
const showRpmLimit = computed(() =>
isAnthropicOAuthOrSetupToken.value &&
props.account.base_rpm != null &&
props.account.base_rpm > 0
)
// 当前 RPM 计数
const currentRPM = computed(() => props.account.current_rpm ?? 0)
// RPM 策略
const rpmStrategy = computed(() => props.account.rpm_strategy || 'tiered')
const rpmStrategyTag = computed(() => rpmStrategy.value === 'sticky_exempt' ? '[S]' : '[T]')
// RPM 策略标签
const rpmStrategyTag = computed(() => {
return rpmStrategy.value === 'sticky_exempt' ? '[S]' : '[T]'
})
// RPM buffer 计算(与后端一致:base <= 0 buffer 0
const rpmBuffer = computed(() => {
const base = props.account.base_rpm || 0
return props.account.rpm_sticky_buffer ?? (base > 0 ? Math.max(1, Math.floor(base / 5)) : 0)
})
// RPM 状态样式
const rpmClass = computed(() => {
if (!showRpmLimit.value) return ''
const current = currentRPM.value
const base = props.account.base_rpm ?? 0
const buffer = rpmBuffer.value
if (rpmStrategy.value === 'tiered') {
if (current >= base + buffer) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
if (current >= base) {
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
}
if (current >= base + buffer) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
if (current >= base) return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
} else {
if (current >= base) {
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
}
}
if (current >= base * 0.8) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
if (current >= base) return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
}
if (current >= base * 0.8) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
})
// RPM 提示文字(增强版:显示策略、区域、缓冲区)
const rpmTooltip = computed(() => {
if (!showRpmLimit.value) return ''
const current = currentRPM.value
const base = props.account.base_rpm ?? 0
const buffer = rpmBuffer.value
if (rpmStrategy.value === 'tiered') {
if (current >= base + buffer) {
return t('admin.accounts.capacity.rpm.tieredBlocked', { buffer })
}
if (current >= base) {
return t('admin.accounts.capacity.rpm.tieredStickyOnly', { buffer })
}
if (current >= base * 0.8) {
return t('admin.accounts.capacity.rpm.tieredWarning')
}
if (current >= base + buffer) return t('admin.accounts.capacity.rpm.tieredBlocked', { buffer })
if (current >= base) return t('admin.accounts.capacity.rpm.tieredStickyOnly', { buffer })
if (current >= base * 0.8) return t('admin.accounts.capacity.rpm.tieredWarning')
return t('admin.accounts.capacity.rpm.tieredNormal')
} else {
if (current >= base) {
return t('admin.accounts.capacity.rpm.stickyExemptOver')
}
if (current >= base * 0.8) {
return t('admin.accounts.capacity.rpm.stickyExemptWarning')
}
if (current >= base) return t('admin.accounts.capacity.rpm.stickyExemptOver')
if (current >= base * 0.8) return t('admin.accounts.capacity.rpm.stickyExemptWarning')
return t('admin.accounts.capacity.rpm.stickyExemptNormal')
}
})
// 是否显示各维度配额(apikey / bedrock 类型)
const isQuotaEligible = computed(() => props.account.type === 'apikey' || props.account.type === 'bedrock')
const showDailyQuota = computed(() => {
return isQuotaEligible.value && (props.account.quota_daily_limit ?? 0) > 0
})
const showWeeklyQuota = computed(() => {
return isQuotaEligible.value && (props.account.quota_weekly_limit ?? 0) > 0
})
const showTotalQuota = computed(() => {
return isQuotaEligible.value && (props.account.quota_limit ?? 0) > 0
})
// 格式化费用显示
const formatCost = (value: number | null | undefined) => {
if (value === null || value === undefined) return '0'
return value.toFixed(2)
}
// ====== 配额 ======
const isQuotaEligible = computed(() => props.account.type === 'apikey' || props.account.type === 'bedrock')
const showDailyQuota = computed(() =>
isQuotaEligible.value && props.account.quota_daily_limit != null && props.account.quota_daily_limit > 0
)
const showWeeklyQuota = computed(() =>
isQuotaEligible.value && props.account.quota_weekly_limit != null && props.account.quota_weekly_limit > 0
)
const showTotalQuota = computed(() =>
isQuotaEligible.value && props.account.quota_limit != null && props.account.quota_limit > 0
)
</script>
......@@ -165,7 +165,6 @@
<button
@click="handleClose"
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
:disabled="status === 'connecting'"
>
{{ t('common.close') }}
</button>
......@@ -249,7 +248,7 @@ const availableModels = ref<ClaudeModel[]>([])
const selectedModelId = ref('')
const testPrompt = ref('')
const loadingModels = ref(false)
let eventSource: EventSource | null = null
let abortController: AbortController | null = null
const generatedImages = ref<PreviewImage[]>([])
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
const supportsGeminiImageTest = computed(() => {
......@@ -279,7 +278,7 @@ watch(
resetState()
await loadAvailableModels()
} else {
closeEventSource()
abortStream()
}
}
)
......@@ -329,18 +328,14 @@ const resetState = () => {
}
const handleClose = () => {
// 防止在连接测试进行中关闭对话框
if (status.value === 'connecting') {
return
}
closeEventSource()
abortStream()
emit('close')
}
const closeEventSource = () => {
if (eventSource) {
eventSource.close()
eventSource = null
const abortStream = () => {
if (abortController) {
abortController.abort()
abortController = null
}
}
......@@ -365,7 +360,9 @@ const startTest = async () => {
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
addLine('', 'text-gray-300')
closeEventSource()
abortStream()
abortController = new AbortController()
try {
// Create EventSource for SSE
......@@ -381,7 +378,8 @@ const startTest = async () => {
body: JSON.stringify({
model_id: selectedModelId.value,
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
})
}),
signal: abortController.signal
})
if (!response.ok) {
......@@ -418,10 +416,15 @@ const startTest = async () => {
}
}
}
} catch (error: any) {
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'AbortError') {
status.value = 'idle'
return
}
status.value = 'error'
errorMessage.value = error.message || 'Unknown error'
addLine(`Error: ${errorMessage.value}`, 'text-red-400')
const msg = error instanceof Error ? error.message : 'Unknown error'
errorMessage.value = msg
addLine(`Error: ${msg}`, 'text-red-400')
}
}
......
......@@ -439,15 +439,20 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
import { formatCompactNumber } from '@/utils/format'
import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue'
// Module-level cache shared across all AccountUsageCell instances
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
const USAGE_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
const props = withDefaults(
defineProps<{
account: Account
......@@ -465,6 +470,9 @@ const props = withDefaults(
const { t } = useI18n()
const desktopViewportQuery = '(min-width: 768px)'
const unmounted = ref(false)
onBeforeUnmount(() => { unmounted.value = true })
const loading = ref(false)
const activeQueryLoading = ref(false)
const error = ref<string | null>(null)
......@@ -941,19 +949,36 @@ const isAnthropicOAuthOrSetupToken = computed(() => {
return props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token')
})
const loadUsage = async (source?: 'passive' | 'active') => {
const loadUsage = async (options?: { source?: 'passive' | 'active'; bypassCache?: boolean }) => {
if (!shouldFetchUsage.value) return
// Check cache
if (!options?.bypassCache) {
const cached = _usageCache.get(props.account.id)
if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL) {
usageInfo.value = cached.data
loading.value = false
return
}
}
loading.value = true
error.value = null
try {
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id, source)
const fetchFn = () => adminAPI.accounts.getUsage(props.account.id, options?.source)
const result = await enqueueUsageRequest(props.account, fetchFn)
if (!unmounted.value) {
usageInfo.value = result
_usageCache.set(props.account.id, { data: result, ts: Date.now() })
}
} catch (e: any) {
error.value = t('common.error')
console.error('Failed to load usage:', e)
if (!unmounted.value) {
error.value = t('common.error')
console.error('Failed to load usage:', e)
}
} finally {
loading.value = false
if (!unmounted.value) loading.value = false
}
}
......@@ -962,7 +987,7 @@ const flushPendingAutoLoad = () => {
const source = pendingAutoLoadSource.value
pendingAutoLoad.value = false
pendingAutoLoadSource.value = undefined
loadUsage(source).catch((e) => {
loadUsage({ source }).catch((e) => {
console.error('Failed to load deferred usage:', e)
})
}
......@@ -974,7 +999,7 @@ const requestAutoLoad = (source?: 'passive' | 'active') => {
pendingAutoLoadSource.value = source
return
}
loadUsage(source).catch((e) => {
loadUsage({ source }).catch((e) => {
console.error('Failed to auto load usage:', e)
})
}
......@@ -1138,7 +1163,10 @@ watch(
if (!shouldFetchUsage.value) return
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
requestAutoLoad(source)
_usageCache.delete(props.account.id)
loadUsage({ source, bypassCache: true }).catch((e) => {
console.error('Failed to refresh usage after manual refresh:', e)
})
}
)
......
......@@ -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.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.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.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,47 +112,38 @@ 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>
<label class="input-label mb-0">{{ t('admin.accounts.quotaLimitToggle') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaLimitToggleHint') }}
</p>
<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 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"
......@@ -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')"
/>
</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')"
/>
</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')"
/>
</div>
<p class="input-hint">{{ t('admin.accounts.quotaTotalLimitHint') }}</p>
</div>
<!-- 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)"
/>
<!-- 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)"
/>
<!-- 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>
</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>
......@@ -165,7 +165,6 @@
<button
@click="handleClose"
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
:disabled="status === 'connecting'"
>
{{ t('common.close') }}
</button>
......@@ -249,7 +248,7 @@ const availableModels = ref<ClaudeModel[]>([])
const selectedModelId = ref('')
const testPrompt = ref('')
const loadingModels = ref(false)
let eventSource: EventSource | null = null
let abortController: AbortController | null = null
const generatedImages = ref<PreviewImage[]>([])
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
const supportsGeminiImageTest = computed(() => {
......@@ -279,7 +278,7 @@ watch(
resetState()
await loadAvailableModels()
} else {
closeEventSource()
abortStream()
}
}
)
......@@ -329,18 +328,14 @@ const resetState = () => {
}
const handleClose = () => {
// 防止在连接测试进行中关闭对话框
if (status.value === 'connecting') {
return
}
closeEventSource()
abortStream()
emit('close')
}
const closeEventSource = () => {
if (eventSource) {
eventSource.close()
eventSource = null
const abortStream = () => {
if (abortController) {
abortController.abort()
abortController = null
}
}
......@@ -365,7 +360,9 @@ const startTest = async () => {
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
addLine('', 'text-gray-300')
closeEventSource()
abortStream()
abortController = new AbortController()
try {
// Create EventSource for SSE
......@@ -381,7 +378,8 @@ const startTest = async () => {
body: JSON.stringify({
model_id: selectedModelId.value,
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
})
}),
signal: abortController.signal
})
if (!response.ok) {
......@@ -418,10 +416,15 @@ const startTest = async () => {
}
}
}
} catch (error: any) {
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'AbortError') {
status.value = 'idle'
return
}
status.value = 'error'
errorMessage.value = error.message || 'Unknown error'
addLine(`Error: ${errorMessage.value}`, 'text-red-400')
const msg = error instanceof Error ? error.message : 'Unknown error'
errorMessage.value = msg
addLine(`Error: ${msg}`, 'text-red-400')
}
}
......
......@@ -18,12 +18,20 @@
</span>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">${{ order.amount.toFixed(2) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.baseAmount') }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">¥{{ baseAmount.toFixed(2) }}</p>
</div>
<div v-if="order.fee_rate > 0">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.fee') }} ({{ order.fee_rate }}%)</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">¥{{ feeAmount.toFixed(2) }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">${{ order.pay_amount.toFixed(2) }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">¥{{ order.pay_amount.toFixed(2) }}</p>
</div>
<div v-if="order.amount !== order.pay_amount">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.creditedAmount') }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ order.order_type === 'balance' ? '$' : '¥' }}{{ order.amount.toFixed(2) }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</p>
......@@ -31,10 +39,6 @@
{{ t('payment.methods.' + order.payment_type, order.payment_type) }}
</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.feeRate') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ (order.fee_rate * 100).toFixed(1) }}%</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.orderType') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">
......@@ -73,7 +77,7 @@
<div class="grid grid-cols-2 gap-2 text-sm">
<div>
<span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundAmount') }}:</span>
<span class="ml-1 font-medium text-red-700 dark:text-red-300">${{ order.refund_amount.toFixed(2) }}</span>
<span class="ml-1 font-medium text-red-700 dark:text-red-300">{{ order.order_type === 'balance' ? '$' : '¥' }}{{ order.refund_amount.toFixed(2) }}</span>
</div>
<div v-if="order.refund_reason" class="col-span-2">
<span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundReason') }}:</span>
......@@ -110,6 +114,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import type { PaymentOrder } from '@/types/payment'
......@@ -117,11 +122,24 @@ import { statusBadgeClass, canRefund as canRefundStatus, formatOrderDateTime } f
const { t } = useI18n()
defineProps<{
const props = defineProps<{
show: boolean
order: PaymentOrder | null
}>()
/** 充值金额 (base amount before fee) = pay_amount - fee = pay_amount / (1 + fee_rate/100) */
const baseAmount = computed(() => {
if (!props.order) return 0
if (props.order.fee_rate <= 0) return props.order.pay_amount
return props.order.pay_amount / (1 + props.order.fee_rate / 100)
})
/** 手续费 = pay_amount - baseAmount */
const feeAmount = computed(() => {
if (!props.order || props.order.fee_rate <= 0) return 0
return props.order.pay_amount - baseAmount.value
})
const emit = defineEmits<{
(e: 'close'): void
(e: 'cancel', order: PaymentOrder): void
......
......@@ -51,12 +51,15 @@
<span class="text-sm text-gray-600 dark:text-gray-400">#{{ value }}</span>
</template>
<template #cell-amount="{ value, row }">
<template #cell-pay_amount="{ value, row }">
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
<span v-if="row.pay_amount !== value" class="ml-1 text-xs text-gray-500">
({{ t('payment.orders.payAmount') }}: ${{ row.pay_amount.toFixed(2) }})
<span class="font-medium text-gray-900 dark:text-white">¥{{ value.toFixed(2) }}</span>
<span v-if="row.fee_rate > 0" class="ml-1 text-xs text-gray-400" :title="t('payment.orders.fee') + ': ' + row.fee_rate + '%'">
({{ row.fee_rate }}%)
</span>
<div v-if="row.amount !== row.pay_amount" class="text-xs text-gray-500">
{{ t('payment.orders.creditedAmount') }}: {{ row.order_type === 'balance' ? '$' : '¥' }}{{ row.amount.toFixed(2) }}
</div>
</div>
</template>
......@@ -183,7 +186,7 @@ function emitFiltersChanged() {
const columns = computed<Column[]>(() => [
{ key: 'id', label: t('payment.orders.orderId') },
{ key: 'user_id', label: t('payment.orders.userId') },
{ key: 'amount', label: t('payment.orders.amount') },
{ key: 'pay_amount', label: t('payment.orders.payAmount') },
{ key: 'payment_type', label: t('payment.orders.paymentMethod') },
{ key: 'status', label: t('payment.orders.status') },
{ key: 'order_type', label: t('payment.orders.orderType') },
......
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