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 { ...@@ -34,6 +34,14 @@ export interface ChannelModelPricing {
intervals: PricingInterval[] intervals: PricingInterval[]
} }
export interface AccountStatsPricingRule {
id?: number
name: string
group_ids: number[]
account_ids: number[]
pricing: ChannelModelPricing[]
}
export interface Channel { export interface Channel {
id: number id: number
name: string name: string
...@@ -41,9 +49,12 @@ export interface Channel { ...@@ -41,9 +49,12 @@ export interface Channel {
status: string status: string
billing_model_source: string // "requested" | "upstream" billing_model_source: string // "requested" | "upstream"
restrict_models: boolean restrict_models: boolean
features_config?: Record<string, unknown>
group_ids: number[] group_ids: number[]
model_pricing: ChannelModelPricing[] model_pricing: ChannelModelPricing[]
model_mapping: Record<string, Record<string, string>> // platform → {src→dst} model_mapping: Record<string, Record<string, string>> // platform → {src→dst}
apply_pricing_to_account_stats: boolean
account_stats_pricing_rules: AccountStatsPricingRule[]
created_at: string created_at: string
updated_at: string updated_at: string
} }
...@@ -56,6 +67,9 @@ export interface CreateChannelRequest { ...@@ -56,6 +67,9 @@ export interface CreateChannelRequest {
model_mapping?: Record<string, Record<string, string>> model_mapping?: Record<string, Record<string, string>>
billing_model_source?: string billing_model_source?: string
restrict_models?: boolean restrict_models?: boolean
features_config?: Record<string, unknown>
apply_pricing_to_account_stats?: boolean
account_stats_pricing_rules?: AccountStatsPricingRule[]
} }
export interface UpdateChannelRequest { export interface UpdateChannelRequest {
...@@ -67,6 +81,9 @@ export interface UpdateChannelRequest { ...@@ -67,6 +81,9 @@ export interface UpdateChannelRequest {
model_mapping?: Record<string, Record<string, string>> model_mapping?: Record<string, Record<string, string>>
billing_model_source?: string billing_model_source?: string
restrict_models?: boolean restrict_models?: boolean
features_config?: Record<string, unknown>
apply_pricing_to_account_stats?: boolean
account_stats_pricing_rules?: AccountStatsPricingRule[]
} }
interface PaginatedResponse<T> { interface PaginatedResponse<T> {
......
...@@ -23,6 +23,7 @@ export interface AdminPaymentConfig { ...@@ -23,6 +23,7 @@ export interface AdminPaymentConfig {
max_pending_orders: number max_pending_orders: number
enabled_payment_types: string[] enabled_payment_types: string[]
balance_disabled: boolean balance_disabled: boolean
balance_recharge_multiplier: number
load_balance_strategy: string load_balance_strategy: string
product_name_prefix: string product_name_prefix: string
product_name_suffix: string product_name_suffix: string
...@@ -40,6 +41,7 @@ export interface UpdatePaymentConfigRequest { ...@@ -40,6 +41,7 @@ export interface UpdatePaymentConfigRequest {
max_pending_orders?: number max_pending_orders?: number
enabled_payment_types?: string[] enabled_payment_types?: string[]
balance_disabled?: boolean balance_disabled?: boolean
balance_recharge_multiplier?: number
load_balance_strategy?: string load_balance_strategy?: string
product_name_prefix?: string product_name_prefix?: string
product_name_suffix?: string product_name_suffix?: string
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
*/ */
import { apiClient } from '../client' import { apiClient } from '../client'
import type { CustomMenuItem, CustomEndpoint } from '@/types' import type { CustomMenuItem, CustomEndpoint, NotifyEmailEntry } from '@/types'
export interface DefaultSubscriptionSetting { export interface DefaultSubscriptionSetting {
group_id: number group_id: number
...@@ -114,6 +114,7 @@ export interface SystemSettings { ...@@ -114,6 +114,7 @@ export interface SystemSettings {
enable_fingerprint_unification: boolean enable_fingerprint_unification: boolean
enable_metadata_passthrough: boolean enable_metadata_passthrough: boolean
enable_cch_signing: boolean enable_cch_signing: boolean
web_search_emulation_enabled?: boolean
// Payment configuration // Payment configuration
payment_enabled: boolean payment_enabled: boolean
...@@ -124,6 +125,8 @@ export interface SystemSettings { ...@@ -124,6 +125,8 @@ export interface SystemSettings {
payment_max_pending_orders: number payment_max_pending_orders: number
payment_enabled_types: string[] payment_enabled_types: string[]
payment_balance_disabled: boolean payment_balance_disabled: boolean
payment_balance_recharge_multiplier: number
payment_recharge_fee_rate: number
payment_load_balance_strategy: string payment_load_balance_strategy: string
payment_product_name_prefix: string payment_product_name_prefix: string
payment_product_name_suffix: string payment_product_name_suffix: string
...@@ -134,6 +137,13 @@ export interface SystemSettings { ...@@ -134,6 +137,13 @@ export interface SystemSettings {
payment_cancel_rate_limit_window: number payment_cancel_rate_limit_window: number
payment_cancel_rate_limit_unit: string payment_cancel_rate_limit_unit: string
payment_cancel_rate_limit_window_mode: 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 { export interface UpdateSettingsRequest {
...@@ -223,6 +233,8 @@ export interface UpdateSettingsRequest { ...@@ -223,6 +233,8 @@ export interface UpdateSettingsRequest {
payment_max_pending_orders?: number payment_max_pending_orders?: number
payment_enabled_types?: string[] payment_enabled_types?: string[]
payment_balance_disabled?: boolean payment_balance_disabled?: boolean
payment_balance_recharge_multiplier?: number
payment_recharge_fee_rate?: number
payment_load_balance_strategy?: string payment_load_balance_strategy?: string
payment_product_name_prefix?: string payment_product_name_prefix?: string
payment_product_name_suffix?: string payment_product_name_suffix?: string
...@@ -233,6 +245,12 @@ export interface UpdateSettingsRequest { ...@@ -233,6 +245,12 @@ export interface UpdateSettingsRequest {
payment_cancel_rate_limit_window?: number payment_cancel_rate_limit_window?: number
payment_cancel_rate_limit_unit?: string payment_cancel_rate_limit_unit?: string
payment_cancel_rate_limit_window_mode?: 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( ...@@ -482,6 +500,63 @@ export async function updateBetaPolicySettings(
return data 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 = { export const settingsAPI = {
getSettings, getSettings,
updateSettings, updateSettings,
...@@ -497,7 +572,11 @@ export const settingsAPI = { ...@@ -497,7 +572,11 @@ export const settingsAPI = {
getRectifierSettings, getRectifierSettings,
updateRectifierSettings, updateRectifierSettings,
getBetaPolicySettings, getBetaPolicySettings,
updateBetaPolicySettings updateBetaPolicySettings,
getWebSearchEmulationConfig,
updateWebSearchEmulationConfig,
testWebSearchEmulation,
resetWebSearchUsage
} }
export default settingsAPI export default settingsAPI
...@@ -17,7 +17,7 @@ export interface AdminUsageStatsResponse { ...@@ -17,7 +17,7 @@ export interface AdminUsageStatsResponse {
total_tokens: number total_tokens: number
total_cost: number total_cost: number
total_actual_cost: number total_actual_cost: number
total_account_cost?: number total_account_cost: number
average_duration_ms: number average_duration_ms: number
endpoints?: EndpointStat[] endpoints?: EndpointStat[]
upstream_endpoints?: EndpointStat[] upstream_endpoints?: EndpointStat[]
......
...@@ -270,9 +270,9 @@ apiClient.interceptors.response.use( ...@@ -270,9 +270,9 @@ apiClient.interceptors.response.use(
return Promise.reject({ return Promise.reject({
status, status,
code: apiData.code, code: apiData.code,
reason: apiData.reason,
error: apiData.error, error: apiData.error,
message: apiData.message || apiData.detail || error.message, message: apiData.message || apiData.detail || error.message,
reason: apiData.reason,
metadata: apiData.metadata, metadata: apiData.metadata,
}) })
} }
......
...@@ -75,5 +75,10 @@ export const paymentAPI = { ...@@ -75,5 +75,10 @@ export const paymentAPI = {
/** Request a refund for a completed order */ /** Request a refund for a completed order */
requestRefund(id: number, data: { reason: string }) { requestRefund(id: number, data: { reason: string }) {
return apiClient.post(`/payment/orders/${id}/refund-request`, data) 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 @@ ...@@ -4,7 +4,7 @@
*/ */
import { apiClient } from './client' import { apiClient } from './client'
import type { User, ChangePasswordRequest } from '@/types' import type { User, ChangePasswordRequest, NotifyEmailEntry } from '@/types'
/** /**
* Get current user profile * Get current user profile
...@@ -22,6 +22,9 @@ export async function getProfile(): Promise<User> { ...@@ -22,6 +22,9 @@ export async function getProfile(): Promise<User> {
*/ */
export async function updateProfile(profile: { export async function updateProfile(profile: {
username?: string username?: string
balance_notify_enabled?: boolean
balance_notify_threshold?: number | null
balance_notify_extra_emails?: NotifyEmailEntry[]
}): Promise<User> { }): Promise<User> {
const { data } = await apiClient.put<User>('/user', profile) const { data } = await apiClient.put<User>('/user', profile)
return data return data
...@@ -45,10 +48,49 @@ export async function changePassword( ...@@ -45,10 +48,49 @@ export async function changePassword(
return data 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 = { export const userAPI = {
getProfile, getProfile,
updateProfile, updateProfile,
changePassword changePassword,
sendNotifyEmailCode,
verifyNotifyEmail,
removeNotifyEmail,
toggleNotifyEmail
} }
export default userAPI export default userAPI
<template> <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"> <CapacityBadge :color-class="concurrencyClass" :current="currentConcurrency" :max="account.concurrency">
<span <svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
:class="[ <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" />
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium', </svg>
concurrencyClass </CapacityBadge>
]"
> <!-- 5h窗口费用限制 -->
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <CapacityBadge v-if="showWindowCost" :color-class="windowCostClass" :tooltip="windowCostTooltip" :current="'$' + formatCost(currentWindowCost)" :max="'$' + formatCost(account.window_cost_limit)">
<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 class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
</svg> <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" />
<span class="font-mono">{{ currentConcurrency }}</span> </svg>
<span class="text-gray-400 dark:text-gray-500">/</span> </CapacityBadge>
<span class="font-mono">{{ account.concurrency }}</span>
</span> <!-- 会话数量限制 -->
</div> <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">
<!-- 5h窗口费用限制(仅 Anthropic OAuth/SetupToken 且启用时显示) --> <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" />
<div v-if="showWindowCost" class="flex items-center gap-1"> </svg>
<span </CapacityBadge>
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium', <!-- RPM 限制 -->
windowCostClass <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">
:title="windowCostTooltip" <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>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> </CapacityBadge>
<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>
<!-- API Key 账号配额限制 --> <!-- API Key 账号配额限制 -->
<QuotaBadge v-if="showDailyQuota" :used="account.quota_daily_used ?? 0" :limit="account.quota_daily_limit!" label="D" /> <QuotaBadge v-if="showDailyQuota" :used="account.quota_daily_used ?? 0" :limit="account.quota_daily_limit!" label="D" />
...@@ -83,7 +39,8 @@ ...@@ -83,7 +39,8 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { Account } from '@/types' 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<{ const props = defineProps<{
account: Account account: Account
...@@ -91,225 +48,143 @@ const props = defineProps<{ ...@@ -91,225 +48,143 @@ const props = defineProps<{
const { t } = useI18n() const { t } = useI18n()
// 当前并发数 // ====== 并发 ======
const currentConcurrency = computed(() => props.account.current_concurrency || 0) 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 concurrencyClass = computed(() => {
const current = currentConcurrency.value const current = currentConcurrency.value
const max = props.account.concurrency 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 >= max) { if (current > 0) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
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' 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(() => { const windowCostClass = computed(() => {
if (!showWindowCost.value) return '' if (!showWindowCost.value) return ''
const current = currentWindowCost.value const current = currentWindowCost.value
const limit = props.account.window_cost_limit || 0 const limit = props.account.window_cost_limit || 0
const reserve = props.account.window_cost_sticky_reserve || 10 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 + reserve) { if (current >= limit) return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-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) {
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' return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
}) })
// 窗口费用提示文字
const windowCostTooltip = computed(() => { const windowCostTooltip = computed(() => {
if (!showWindowCost.value) return '' if (!showWindowCost.value) return ''
const current = currentWindowCost.value const current = currentWindowCost.value
const limit = props.account.window_cost_limit || 0 const limit = props.account.window_cost_limit || 0
const reserve = props.account.window_cost_sticky_reserve || 10 const reserve = props.account.window_cost_sticky_reserve || 10
if (current >= limit + reserve) return t('admin.accounts.capacity.windowCost.blocked')
if (current >= limit + reserve) { if (current >= limit) return t('admin.accounts.capacity.windowCost.stickyOnly')
return t('admin.accounts.capacity.windowCost.blocked')
}
if (current >= limit) {
return t('admin.accounts.capacity.windowCost.stickyOnly')
}
return t('admin.accounts.capacity.windowCost.normal') 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(() => { const sessionLimitClass = computed(() => {
if (!showSessionLimit.value) return '' if (!showSessionLimit.value) return ''
const current = activeSessions.value const current = activeSessions.value
const max = props.account.max_sessions || 0 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) { if (current >= max * 0.8) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
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' return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
}) })
// 会话限制提示文字
const sessionLimitTooltip = computed(() => { const sessionLimitTooltip = computed(() => {
if (!showSessionLimit.value) return '' if (!showSessionLimit.value) return ''
const current = activeSessions.value const current = activeSessions.value
const max = props.account.max_sessions || 0 const max = props.account.max_sessions || 0
const idle = props.account.session_idle_timeout_minutes || 5 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 }) return t('admin.accounts.capacity.sessions.normal', { idle })
}) })
// 是否显示 RPM 限制 // ====== RPM ======
const showRpmLimit = computed(() => { const showRpmLimit = computed(() =>
return ( isAnthropicOAuthOrSetupToken.value &&
isAnthropicOAuthOrSetupToken.value && props.account.base_rpm != null &&
props.account.base_rpm !== undefined && props.account.base_rpm > 0
props.account.base_rpm !== null && )
props.account.base_rpm > 0
)
})
// 当前 RPM 计数
const currentRPM = computed(() => props.account.current_rpm ?? 0) const currentRPM = computed(() => props.account.current_rpm ?? 0)
// RPM 策略
const rpmStrategy = computed(() => props.account.rpm_strategy || 'tiered') 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 rpmBuffer = computed(() => {
const base = props.account.base_rpm || 0 const base = props.account.base_rpm || 0
return props.account.rpm_sticky_buffer ?? (base > 0 ? Math.max(1, Math.floor(base / 5)) : 0) return props.account.rpm_sticky_buffer ?? (base > 0 ? Math.max(1, Math.floor(base / 5)) : 0)
}) })
// RPM 状态样式
const rpmClass = computed(() => { const rpmClass = computed(() => {
if (!showRpmLimit.value) return '' if (!showRpmLimit.value) return ''
const current = currentRPM.value const current = currentRPM.value
const base = props.account.base_rpm ?? 0 const base = props.account.base_rpm ?? 0
const buffer = rpmBuffer.value const buffer = rpmBuffer.value
if (rpmStrategy.value === 'tiered') { if (rpmStrategy.value === 'tiered') {
if (current >= base + buffer) { if (current >= base + buffer) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
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) {
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
}
} else { } else {
if (current >= base) { if (current >= base) return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
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 * 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' return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
}) })
// RPM 提示文字(增强版:显示策略、区域、缓冲区)
const rpmTooltip = computed(() => { const rpmTooltip = computed(() => {
if (!showRpmLimit.value) return '' if (!showRpmLimit.value) return ''
const current = currentRPM.value const current = currentRPM.value
const base = props.account.base_rpm ?? 0 const base = props.account.base_rpm ?? 0
const buffer = rpmBuffer.value const buffer = rpmBuffer.value
if (rpmStrategy.value === 'tiered') { if (rpmStrategy.value === 'tiered') {
if (current >= base + buffer) { if (current >= base + buffer) return t('admin.accounts.capacity.rpm.tieredBlocked', { 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) {
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') return t('admin.accounts.capacity.rpm.tieredNormal')
} else { } else {
if (current >= base) { if (current >= base) return t('admin.accounts.capacity.rpm.stickyExemptOver')
return t('admin.accounts.capacity.rpm.stickyExemptOver') if (current >= base * 0.8) return t('admin.accounts.capacity.rpm.stickyExemptWarning')
}
if (current >= base * 0.8) {
return t('admin.accounts.capacity.rpm.stickyExemptWarning')
}
return t('admin.accounts.capacity.rpm.stickyExemptNormal') 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) => { const formatCost = (value: number | null | undefined) => {
if (value === null || value === undefined) return '0' if (value === null || value === undefined) return '0'
return value.toFixed(2) 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> </script>
...@@ -165,7 +165,6 @@ ...@@ -165,7 +165,6 @@
<button <button
@click="handleClose" @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" 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') }} {{ t('common.close') }}
</button> </button>
...@@ -249,7 +248,7 @@ const availableModels = ref<ClaudeModel[]>([]) ...@@ -249,7 +248,7 @@ const availableModels = ref<ClaudeModel[]>([])
const selectedModelId = ref('') const selectedModelId = ref('')
const testPrompt = ref('') const testPrompt = ref('')
const loadingModels = ref(false) const loadingModels = ref(false)
let eventSource: EventSource | null = null let abortController: AbortController | null = null
const generatedImages = ref<PreviewImage[]>([]) 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 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(() => { const supportsGeminiImageTest = computed(() => {
...@@ -279,7 +278,7 @@ watch( ...@@ -279,7 +278,7 @@ watch(
resetState() resetState()
await loadAvailableModels() await loadAvailableModels()
} else { } else {
closeEventSource() abortStream()
} }
} }
) )
...@@ -329,18 +328,14 @@ const resetState = () => { ...@@ -329,18 +328,14 @@ const resetState = () => {
} }
const handleClose = () => { const handleClose = () => {
// 防止在连接测试进行中关闭对话框 abortStream()
if (status.value === 'connecting') {
return
}
closeEventSource()
emit('close') emit('close')
} }
const closeEventSource = () => { const abortStream = () => {
if (eventSource) { if (abortController) {
eventSource.close() abortController.abort()
eventSource = null abortController = null
} }
} }
...@@ -365,7 +360,9 @@ const startTest = async () => { ...@@ -365,7 +360,9 @@ const startTest = async () => {
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400') addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
addLine('', 'text-gray-300') addLine('', 'text-gray-300')
closeEventSource() abortStream()
abortController = new AbortController()
try { try {
// Create EventSource for SSE // Create EventSource for SSE
...@@ -381,7 +378,8 @@ const startTest = async () => { ...@@ -381,7 +378,8 @@ const startTest = async () => {
body: JSON.stringify({ body: JSON.stringify({
model_id: selectedModelId.value, model_id: selectedModelId.value,
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : '' prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
}) }),
signal: abortController.signal
}) })
if (!response.ok) { if (!response.ok) {
...@@ -418,10 +416,15 @@ const startTest = async () => { ...@@ -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' status.value = 'error'
errorMessage.value = error.message || 'Unknown error' const msg = error instanceof Error ? error.message : 'Unknown error'
addLine(`Error: ${errorMessage.value}`, 'text-red-400') errorMessage.value = msg
addLine(`Error: ${msg}`, 'text-red-400')
} }
} }
......
...@@ -439,15 +439,20 @@ ...@@ -439,15 +439,20 @@
</template> </template>
<script setup lang="ts"> <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 { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types' import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh' import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
import { formatCompactNumber } from '@/utils/format' import { formatCompactNumber } from '@/utils/format'
import UsageProgressBar from './UsageProgressBar.vue' import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.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( const props = withDefaults(
defineProps<{ defineProps<{
account: Account account: Account
...@@ -465,6 +470,9 @@ const props = withDefaults( ...@@ -465,6 +470,9 @@ const props = withDefaults(
const { t } = useI18n() const { t } = useI18n()
const desktopViewportQuery = '(min-width: 768px)' const desktopViewportQuery = '(min-width: 768px)'
const unmounted = ref(false)
onBeforeUnmount(() => { unmounted.value = true })
const loading = ref(false) const loading = ref(false)
const activeQueryLoading = ref(false) const activeQueryLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
...@@ -941,19 +949,36 @@ const isAnthropicOAuthOrSetupToken = computed(() => { ...@@ -941,19 +949,36 @@ const isAnthropicOAuthOrSetupToken = computed(() => {
return props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token') 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 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 loading.value = true
error.value = null error.value = null
try { 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) { } catch (e: any) {
error.value = t('common.error') if (!unmounted.value) {
console.error('Failed to load usage:', e) error.value = t('common.error')
console.error('Failed to load usage:', e)
}
} finally { } finally {
loading.value = false if (!unmounted.value) loading.value = false
} }
} }
...@@ -962,7 +987,7 @@ const flushPendingAutoLoad = () => { ...@@ -962,7 +987,7 @@ const flushPendingAutoLoad = () => {
const source = pendingAutoLoadSource.value const source = pendingAutoLoadSource.value
pendingAutoLoad.value = false pendingAutoLoad.value = false
pendingAutoLoadSource.value = undefined pendingAutoLoadSource.value = undefined
loadUsage(source).catch((e) => { loadUsage({ source }).catch((e) => {
console.error('Failed to load deferred usage:', e) console.error('Failed to load deferred usage:', e)
}) })
} }
...@@ -974,7 +999,7 @@ const requestAutoLoad = (source?: 'passive' | 'active') => { ...@@ -974,7 +999,7 @@ const requestAutoLoad = (source?: 'passive' | 'active') => {
pendingAutoLoadSource.value = source pendingAutoLoadSource.value = source
return return
} }
loadUsage(source).catch((e) => { loadUsage({ source }).catch((e) => {
console.error('Failed to auto load usage:', e) console.error('Failed to auto load usage:', e)
}) })
} }
...@@ -1138,7 +1163,10 @@ watch( ...@@ -1138,7 +1163,10 @@ watch(
if (!shouldFetchUsage.value) return if (!shouldFetchUsage.value) return
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined 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 @@ ...@@ -5,7 +5,7 @@
width="wide" width="wide"
@close="handleClose" @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 --> <!-- Info -->
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20"> <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"> <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 @@ ...@@ -1477,10 +1477,65 @@
</div> </div>
</div> </div>
<!-- API Key / Bedrock 账号配额限制 --> <!-- 配额控制 (Anthropic apikey/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"> <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"> <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"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaLimitHint') }} {{ t('admin.accounts.quotaLimitHint') }}
</p> </p>
...@@ -1489,6 +1544,16 @@ ...@@ -1489,6 +1544,16 @@
:totalLimit="editQuotaLimit" :totalLimit="editQuotaLimit"
:dailyLimit="editQuotaDailyLimit" :dailyLimit="editQuotaDailyLimit"
:weeklyLimit="editQuotaWeeklyLimit" :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" :dailyResetMode="editDailyResetMode"
:dailyResetHour="editDailyResetHour" :dailyResetHour="editDailyResetHour"
:weeklyResetMode="editWeeklyResetMode" :weeklyResetMode="editWeeklyResetMode"
...@@ -1498,6 +1563,15 @@ ...@@ -1498,6 +1563,15 @@
@update:totalLimit="editQuotaLimit = $event" @update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event" @update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $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:dailyResetMode="editDailyResetMode = $event"
@update:dailyResetHour="editDailyResetHour = $event" @update:dailyResetHour="editDailyResetHour = $event"
@update:weeklyResetMode="editWeeklyResetMode = $event" @update:weeklyResetMode="editWeeklyResetMode = $event"
...@@ -1823,7 +1897,7 @@ ...@@ -1823,7 +1897,7 @@
</div> </div>
</div> </div>
<!-- Quota Control Section (Anthropic OAuth/SetupToken only) --> <!-- 配额控制 (Anthropic OAuth/SetupToken: 亲和 + 窗口费用 + 会话 + RPM ) -->
<div <div
v-if="form.platform === 'anthropic' && accountCategory === 'oauth-based'" v-if="form.platform === 'anthropic' && accountCategory === 'oauth-based'"
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
...@@ -2325,6 +2399,26 @@ ...@@ -2325,6 +2399,26 @@
</div> </div>
</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 官方客户端限制开关 --> <!-- OpenAI OAuth Codex 官方客户端限制开关 -->
<div <div
v-if="form.platform === 'openai' && accountCategory === 'oauth-based'" v-if="form.platform === 'openai' && accountCategory === 'oauth-based'"
...@@ -2809,6 +2903,7 @@ import { ...@@ -2809,6 +2903,7 @@ import {
} from '@/composables/useModelWhitelist' } from '@/composables/useModelWhitelist'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import { useQuotaNotifyState } from '@/composables/useQuotaNotifyState'
import { import {
useAccountOAuth, useAccountOAuth,
type AddMethod, type AddMethod,
...@@ -2980,6 +3075,21 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF ...@@ -2980,6 +3075,21 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF) const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = 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 mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
...@@ -3307,6 +3417,7 @@ watch( ...@@ -3307,6 +3417,7 @@ watch(
} }
if (newPlatform !== 'anthropic') { if (newPlatform !== 'anthropic') {
anthropicPassthroughEnabled.value = false anthropicPassthroughEnabled.value = false
webSearchEmulationMode.value = 'default'
} }
// Reset OAuth states // Reset OAuth states
oauth.resetState() oauth.resetState()
...@@ -3326,6 +3437,7 @@ watch( ...@@ -3326,6 +3437,7 @@ watch(
} }
if (platform !== 'anthropic' || category !== 'apikey') { if (platform !== 'anthropic' || category !== 'apikey') {
anthropicPassthroughEnabled.value = false anthropicPassthroughEnabled.value = false
webSearchEmulationMode.value = 'default'
} }
} }
) )
...@@ -3690,6 +3802,7 @@ const resetForm = () => { ...@@ -3690,6 +3802,7 @@ const resetForm = () => {
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false codexCLIOnlyEnabled.value = false
anthropicPassthroughEnabled.value = false anthropicPassthroughEnabled.value = false
webSearchEmulationMode.value = 'default'
// Reset quota control state // Reset quota control state
windowCostEnabled.value = false windowCostEnabled.value = false
windowCostLimit.value = null windowCostLimit.value = null
...@@ -3777,6 +3890,11 @@ const buildAnthropicExtra = (base?: Record<string, unknown>): Record<string, unk ...@@ -3777,6 +3890,11 @@ const buildAnthropicExtra = (base?: Record<string, unknown>): Record<string, unk
} else { } else {
delete extra.anthropic_passthrough 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 return Object.keys(extra).length > 0 ? extra : undefined
} }
...@@ -4075,6 +4193,8 @@ const createAccountAndFinish = async ( ...@@ -4075,6 +4193,8 @@ const createAccountAndFinish = async (
if (editDailyResetMode.value === 'fixed' || editWeeklyResetMode.value === 'fixed') { if (editDailyResetMode.value === 'fixed' || editWeeklyResetMode.value === 'fixed') {
quotaExtra.quota_reset_timezone = editResetTimezone.value || 'UTC' quotaExtra.quota_reset_timezone = editResetTimezone.value || 'UTC'
} }
// Quota notify config
writeQuotaNotifyToExtra(quotaExtra, 'create')
if (Object.keys(quotaExtra).length > 0) { if (Object.keys(quotaExtra).length > 0) {
finalExtra = quotaExtra finalExtra = quotaExtra
} }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<BaseDialog <BaseDialog
:show="show" :show="show"
:title="t('admin.accounts.editAccount')" :title="t('admin.accounts.editAccount')"
width="normal" width="wide"
@close="handleClose" @close="handleClose"
> >
<form <form
...@@ -1149,10 +1149,84 @@ ...@@ -1149,10 +1149,84 @@
</div> </div>
</div> </div>
<!-- API Key / Bedrock 账号配额限制 --> <!-- Anthropic API Key: Web Search Emulation (hidden when global disabled) -->
<div v-if="account?.type === 'apikey' || account?.type === 'bedrock'" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"> <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"> <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"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaLimitHint') }} {{ t('admin.accounts.quotaLimitHint') }}
</p> </p>
...@@ -1167,6 +1241,16 @@ ...@@ -1167,6 +1241,16 @@
:weeklyResetDay="editWeeklyResetDay" :weeklyResetDay="editWeeklyResetDay"
:weeklyResetHour="editWeeklyResetHour" :weeklyResetHour="editWeeklyResetHour"
:resetTimezone="editResetTimezone" :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:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event" @update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event" @update:weeklyLimit="editQuotaWeeklyLimit = $event"
...@@ -1176,6 +1260,15 @@ ...@@ -1176,6 +1260,15 @@
@update:weeklyResetDay="editWeeklyResetDay = $event" @update:weeklyResetDay="editWeeklyResetDay = $event"
@update:weeklyResetHour="editWeeklyResetHour = $event" @update:weeklyResetHour="editWeeklyResetHour = $event"
@update:resetTimezone="editResetTimezone = $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> </div>
...@@ -1237,7 +1330,7 @@ ...@@ -1237,7 +1330,7 @@
</div> </div>
</div> </div>
<!-- Quota Control Section (Anthropic OAuth/SetupToken only) --> <!-- 配额控制 (Anthropic OAuth/SetupToken: 亲和 + 窗口费用 + 会话 + RPM ) -->
<div <div
v-if="account?.platform === 'anthropic' && (account?.type === 'oauth' || account?.type === 'setup-token')" 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" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
...@@ -1751,6 +1844,7 @@ import { useI18n } from 'vue-i18n' ...@@ -1751,6 +1844,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import { useQuotaNotifyState } from '@/composables/useQuotaNotifyState'
import type { Account, Proxy, AdminGroup, CheckMixedChannelResponse } from '@/types' import type { Account, Proxy, AdminGroup, CheckMixedChannelResponse } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
...@@ -1898,6 +1992,23 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF ...@@ -1898,6 +1992,23 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF) const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = 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 editQuotaLimit = ref<number | null>(null)
const editQuotaDailyLimit = ref<number | null>(null) const editQuotaDailyLimit = ref<number | null>(null)
const editQuotaWeeklyLimit = ref<number | null>(null) const editQuotaWeeklyLimit = ref<number | null>(null)
...@@ -2067,6 +2178,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { ...@@ -2067,6 +2178,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false codexCLIOnlyEnabled.value = false
anthropicPassthroughEnabled.value = false anthropicPassthroughEnabled.value = false
webSearchEmulationMode.value = 'default'
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) { if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) {
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, { openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
...@@ -2087,6 +2199,15 @@ const syncFormFromAccount = (newAccount: Account | null) => { ...@@ -2087,6 +2199,15 @@ const syncFormFromAccount = (newAccount: Account | null) => {
} }
if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') { if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') {
anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true 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) // 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) => { ...@@ -2104,6 +2225,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editWeeklyResetDay.value = (extra?.quota_weekly_reset_day as number) ?? null editWeeklyResetDay.value = (extra?.quota_weekly_reset_day as number) ?? null
editWeeklyResetHour.value = (extra?.quota_weekly_reset_hour as number) ?? null editWeeklyResetHour.value = (extra?.quota_weekly_reset_hour as number) ?? null
editResetTimezone.value = (extra?.quota_reset_timezone as string) || null editResetTimezone.value = (extra?.quota_reset_timezone as string) || null
// Load quota notify config
loadQuotaNotifyFromExtra(extra)
} else { } else {
editQuotaLimit.value = null editQuotaLimit.value = null
editQuotaDailyLimit.value = null editQuotaDailyLimit.value = null
...@@ -2114,6 +2237,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { ...@@ -2114,6 +2237,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editWeeklyResetDay.value = null editWeeklyResetDay.value = null
editWeeklyResetHour.value = null editWeeklyResetHour.value = null
editResetTimezone.value = null editResetTimezone.value = null
resetQuotaNotify()
} }
// Load antigravity model mapping (Antigravity 只支持映射模式) // Load antigravity model mapping (Antigravity 只支持映射模式)
...@@ -2228,6 +2352,8 @@ const syncFormFromAccount = (newAccount: Account | null) => { ...@@ -2228,6 +2352,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editQuotaLimit.value = typeof bedrockExtra.quota_limit === 'number' ? bedrockExtra.quota_limit : 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 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 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 // Load model mappings for bedrock
const existingMappings = bedrockCreds.model_mapping as Record<string, string> | undefined const existingMappings = bedrockCreds.model_mapping as Record<string, string> | undefined
...@@ -2522,8 +2648,13 @@ function loadQuotaControlSettings(account: Account) { ...@@ -2522,8 +2648,13 @@ function loadQuotaControlSettings(account: Account) {
customBaseUrlEnabled.value = false customBaseUrlEnabled.value = false
customBaseUrl.value = '' customBaseUrl.value = ''
// Only applies to Anthropic OAuth/SetupToken accounts // Remaining quota control settings only apply to Anthropic accounts
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) { 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 return
} }
...@@ -2949,7 +3080,7 @@ const handleSubmit = async () => { ...@@ -2949,7 +3080,7 @@ const handleSubmit = async () => {
// For Anthropic OAuth/SetupToken accounts, handle quota control settings in extra // 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')) { 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 } const newExtra: Record<string, unknown> = { ...currentExtra }
// Window cost limit settings // Window cost limit settings
...@@ -3037,15 +3168,20 @@ const handleSubmit = async () => { ...@@ -3037,15 +3168,20 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra 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') { 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 } const newExtra: Record<string, unknown> = { ...currentExtra }
if (anthropicPassthroughEnabled.value) { if (anthropicPassthroughEnabled.value) {
newExtra.anthropic_passthrough = true newExtra.anthropic_passthrough = true
} else { } else {
delete newExtra.anthropic_passthrough delete newExtra.anthropic_passthrough
} }
if (webSearchEmulationMode.value === 'default') {
delete newExtra.web_search_emulation
} else {
newExtra.web_search_emulation = webSearchEmulationMode.value
}
updatePayload.extra = newExtra updatePayload.extra = newExtra
} }
...@@ -3089,20 +3225,27 @@ const handleSubmit = async () => { ...@@ -3089,20 +3225,27 @@ const handleSubmit = async () => {
const currentExtra = (updatePayload.extra as Record<string, unknown>) || const currentExtra = (updatePayload.extra as Record<string, unknown>) ||
(props.account.extra as Record<string, unknown>) || {} (props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra } const newExtra: Record<string, unknown> = { ...currentExtra }
// Total quota
if (editQuotaLimit.value != null && editQuotaLimit.value > 0) { if (editQuotaLimit.value != null && editQuotaLimit.value > 0) {
newExtra.quota_limit = editQuotaLimit.value newExtra.quota_limit = editQuotaLimit.value
} else { } else {
delete newExtra.quota_limit delete newExtra.quota_limit
} }
// Daily quota
if (editQuotaDailyLimit.value != null && editQuotaDailyLimit.value > 0) { if (editQuotaDailyLimit.value != null && editQuotaDailyLimit.value > 0) {
newExtra.quota_daily_limit = editQuotaDailyLimit.value newExtra.quota_daily_limit = editQuotaDailyLimit.value
} else { } else {
delete newExtra.quota_daily_limit delete newExtra.quota_daily_limit
delete newExtra.quota_daily_used
delete newExtra.quota_daily_start
} }
// Weekly quota
if (editQuotaWeeklyLimit.value != null && editQuotaWeeklyLimit.value > 0) { if (editQuotaWeeklyLimit.value != null && editQuotaWeeklyLimit.value > 0) {
newExtra.quota_weekly_limit = editQuotaWeeklyLimit.value newExtra.quota_weekly_limit = editQuotaWeeklyLimit.value
} else { } else {
delete newExtra.quota_weekly_limit delete newExtra.quota_weekly_limit
delete newExtra.quota_weekly_used
delete newExtra.quota_weekly_start
} }
// Quota reset mode config // Quota reset mode config
if (editDailyResetMode.value === 'fixed') { if (editDailyResetMode.value === 'fixed') {
...@@ -3126,6 +3269,8 @@ const handleSubmit = async () => { ...@@ -3126,6 +3269,8 @@ const handleSubmit = async () => {
} else { } else {
delete newExtra.quota_reset_timezone delete newExtra.quota_reset_timezone
} }
// Quota notify config
writeQuotaNotifyToExtra(newExtra, 'update')
updatePayload.extra = newExtra 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"> <script setup lang="ts">
import { ref, watch, computed } from 'vue' import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import QuotaDimensionRow from './QuotaDimensionRow.vue'
import type { QuotaThresholdType, QuotaResetMode } from '@/constants/account'
const { t } = useI18n() const { t } = useI18n()
const props = defineProps<{ const props = withDefaults(defineProps<{
totalLimit: number | null totalLimit: number | null
dailyLimit: number | null dailyLimit: number | null
weeklyLimit: number | null weeklyLimit: number | null
dailyResetMode: 'rolling' | 'fixed' | null dailyResetMode: QuotaResetMode | null
dailyResetHour: number | null dailyResetHour: number | null
weeklyResetMode: 'rolling' | 'fixed' | null weeklyResetMode: QuotaResetMode | null
weeklyResetDay: number | null weeklyResetDay: number | null
weeklyResetHour: number | null weeklyResetHour: number | null
resetTimezone: string | 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<{ const emit = defineEmits<{
'update:totalLimit': [value: number | null] 'update:totalLimit': [value: number | null]
'update:dailyLimit': [value: number | null] 'update:dailyLimit': [value: number | null]
'update:weeklyLimit': [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:dailyResetHour': [value: number | null]
'update:weeklyResetMode': [value: 'rolling' | 'fixed' | null] 'update:weeklyResetMode': [value: QuotaResetMode | null]
'update:weeklyResetDay': [value: number | null] 'update:weeklyResetDay': [value: number | null]
'update:weeklyResetHour': [value: number | null] 'update:weeklyResetHour': [value: number | null]
'update:resetTimezone': [value: string | 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(() => const enabled = computed(() =>
...@@ -35,15 +67,17 @@ const enabled = computed(() => ...@@ -35,15 +67,17 @@ const enabled = computed(() =>
) )
const localEnabled = ref(enabled.value) const localEnabled = ref(enabled.value)
const collapsed = ref(false)
// Sync when props change externally // Sync when props change externally
watch(enabled, (val) => { watch(enabled, (val) => {
localEnabled.value = 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) => { watch(localEnabled, (val) => {
if (!val) { if (!val) {
collapsed.value = false
emit('update:totalLimit', null) emit('update:totalLimit', null)
emit('update:dailyLimit', null) emit('update:dailyLimit', null)
emit('update:weeklyLimit', null) emit('update:weeklyLimit', null)
...@@ -56,31 +90,12 @@ watch(localEnabled, (val) => { ...@@ -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 // Common timezone options
const timezoneOptions = [ const timezoneOptions = [
'UTC', 'UTC', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Singapore', 'Asia/Kolkata',
'Asia/Shanghai', 'Asia/Dubai', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Moscow',
'Asia/Tokyo', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
'Asia/Seoul', 'America/Sao_Paulo', 'Australia/Sydney', 'Pacific/Auckland',
'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) // Hours for dropdown (0-23)
...@@ -97,47 +112,38 @@ const dayOptions = [ ...@@ -97,47 +112,38 @@ const dayOptions = [
{ value: 0, key: 'sunday' }, { value: 0, key: 'sunday' },
] ]
const onTotalInput = (e: Event) => { // Precomputed hint strings for the weekly fixed mode
const raw = (e.target as HTMLInputElement).valueAsNumber const weeklyFixedHint = computed(() => {
emit('update:totalLimit', Number.isNaN(raw) ? null : raw) const dayKey = dayOptions.find(d => d.value === (props.weeklyResetDay ?? 1))?.key || 'monday'
} return t('admin.accounts.quotaWeeklyLimitHintFixed', {
const onDailyInput = (e: Event) => { day: t('admin.accounts.dayOfWeek.' + dayKey),
const raw = (e.target as HTMLInputElement).valueAsNumber hour: String(props.weeklyResetHour ?? 0).padStart(2, '0'),
emit('update:dailyLimit', Number.isNaN(raw) ? null : raw) timezone: props.resetTimezone || 'UTC',
} })
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')
}
}
const onWeeklyModeChange = (e: Event) => { const dailyFixedHint = computed(() =>
const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed' t('admin.accounts.quotaDailyLimitHintFixed', {
emit('update:weeklyResetMode', val) hour: String(props.dailyResetHour ?? 0).padStart(2, '0'),
if (val === 'fixed') { timezone: props.resetTimezone || 'UTC',
if (props.weeklyResetDay == null) emit('update:weeklyResetDay', 1) })
if (props.weeklyResetHour == null) emit('update:weeklyResetHour', 0) )
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
}
}
</script> </script>
<template> <template>
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600"> <div class="rounded-lg border border-gray-200 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between"> <!-- Header: toggle + collapse -->
<div> <div class="flex items-center justify-between p-4" :class="{ 'pb-0': localEnabled && !collapsed }">
<label class="input-label mb-0">{{ t('admin.accounts.quotaLimitToggle') }}</label> <div class="flex items-center gap-2 flex-1 cursor-pointer" @click="localEnabled && (collapsed = !collapsed)">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <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">
{{ t('admin.accounts.quotaLimitToggleHint') }} <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" />
</p> </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> </div>
<button <button
type="button" type="button"
...@@ -156,140 +162,85 @@ const onWeeklyModeChange = (e: Event) => { ...@@ -156,140 +162,85 @@ const onWeeklyModeChange = (e: Event) => {
</button> </button>
</div> </div>
<div v-if="localEnabled" class="space-y-3"> <!-- Collapsible content -->
<!-- 日配额 --> <div v-if="localEnabled && !collapsed" class="space-y-2 p-4 pt-3">
<div> <!-- Daily quota -->
<label class="input-label">{{ t('admin.accounts.quotaDailyLimit') }}</label> <QuotaDimensionRow
<div class="relative"> dim="daily"
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span> :label="t('admin.accounts.quotaDailyLimit')"
<input :limit="dailyLimit"
:value="dailyLimit" :quota-notify-global-enabled="quotaNotifyGlobalEnabled"
@input="onDailyInput" :notify-enabled="props.quotaNotifyDailyEnabled"
type="number" :notify-threshold="props.quotaNotifyDailyThreshold"
min="0" :notify-threshold-type="props.quotaNotifyDailyThresholdType"
step="0.01" :reset-mode="dailyResetMode"
class="input pl-7" :reset-hour="dailyResetHour"
:placeholder="t('admin.accounts.quotaLimitPlaceholder')" :reset-day="null"
/> :reset-timezone="resetTimezone"
</div> :hint-rolling="t('admin.accounts.quotaDailyLimitHint')"
<!-- 日配额重置模式 --> :hint-fixed="dailyFixedHint"
<div class="mt-2 flex items-center gap-2"> :hour-options="hourOptions"
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetMode') }}</label> :day-options="dayOptions"
<select :timezone-options="timezoneOptions"
:value="dailyResetMode || 'rolling'" @update:limit="emit('update:dailyLimit', $event)"
@change="onDailyModeChange" @update:notify-enabled="emit('update:quotaNotifyDailyEnabled', $event)"
class="input py-1 text-xs" @update:notify-threshold="emit('update:quotaNotifyDailyThreshold', $event)"
> @update:notify-threshold-type="emit('update:quotaNotifyDailyThresholdType', $event)"
<option value="rolling">{{ t('admin.accounts.quotaResetModeRolling') }}</option> @update:reset-mode="emit('update:dailyResetMode', $event)"
<option value="fixed">{{ t('admin.accounts.quotaResetModeFixed') }}</option> @update:reset-hour="emit('update:dailyResetHour', $event)"
</select> @update:reset-timezone="emit('update:resetTimezone', $event)"
</div> />
<!-- 固定模式:小时选择 -->
<div v-if="dailyResetMode === 'fixed'" class="mt-2 flex items-center gap-2"> <!-- Weekly quota -->
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetHour') }}</label> <QuotaDimensionRow
<select dim="weekly"
:value="dailyResetHour ?? 0" :label="t('admin.accounts.quotaWeeklyLimit')"
@change="emit('update:dailyResetHour', Number(($event.target as HTMLSelectElement).value))" :limit="weeklyLimit"
class="input py-1 text-xs w-24" :quota-notify-global-enabled="quotaNotifyGlobalEnabled"
> :notify-enabled="props.quotaNotifyWeeklyEnabled"
<option v-for="h in hourOptions" :key="h" :value="h">{{ String(h).padStart(2, '0') }}:00</option> :notify-threshold="props.quotaNotifyWeeklyThreshold"
</select> :notify-threshold-type="props.quotaNotifyWeeklyThresholdType"
</div> :reset-mode="weeklyResetMode"
<p class="input-hint"> :reset-hour="weeklyResetHour"
<template v-if="dailyResetMode === 'fixed'"> :reset-day="weeklyResetDay"
{{ t('admin.accounts.quotaDailyLimitHintFixed', { hour: String(dailyResetHour ?? 0).padStart(2, '0'), timezone: resetTimezone || 'UTC' }) }} :reset-timezone="resetTimezone"
</template> :hint-rolling="t('admin.accounts.quotaWeeklyLimitHint')"
<template v-else> :hint-fixed="weeklyFixedHint"
{{ t('admin.accounts.quotaDailyLimitHint') }} :hour-options="hourOptions"
</template> :day-options="dayOptions"
</p> :timezone-options="timezoneOptions"
</div> @update:limit="emit('update:weeklyLimit', $event)"
@update:notify-enabled="emit('update:quotaNotifyWeeklyEnabled', $event)"
<!-- 周配额 --> @update:notify-threshold="emit('update:quotaNotifyWeeklyThreshold', $event)"
<div> @update:notify-threshold-type="emit('update:quotaNotifyWeeklyThresholdType', $event)"
<label class="input-label">{{ t('admin.accounts.quotaWeeklyLimit') }}</label> @update:reset-mode="emit('update:weeklyResetMode', $event)"
<div class="relative"> @update:reset-hour="emit('update:weeklyResetHour', $event)"
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span> @update:reset-day="emit('update:weeklyResetDay', $event)"
<input @update:reset-timezone="emit('update:resetTimezone', $event)"
:value="weeklyLimit" />
@input="onWeeklyInput"
type="number" <!-- Total quota -->
min="0" <QuotaDimensionRow
step="0.01" dim="total"
class="input pl-7" :label="t('admin.accounts.quotaTotalLimit')"
:placeholder="t('admin.accounts.quotaLimitPlaceholder')" :limit="totalLimit"
/> :quota-notify-global-enabled="quotaNotifyGlobalEnabled"
</div> :notify-enabled="props.quotaNotifyTotalEnabled"
<!-- 周配额重置模式 --> :notify-threshold="props.quotaNotifyTotalThreshold"
<div class="mt-2 flex items-center gap-2"> :notify-threshold-type="props.quotaNotifyTotalThresholdType"
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetMode') }}</label> :reset-mode="null"
<select :reset-hour="null"
:value="weeklyResetMode || 'rolling'" :reset-day="null"
@change="onWeeklyModeChange" :reset-timezone="null"
class="input py-1 text-xs" :hint-rolling="t('admin.accounts.quotaTotalLimitHint')"
> hint-fixed=""
<option value="rolling">{{ t('admin.accounts.quotaResetModeRolling') }}</option> :hour-options="hourOptions"
<option value="fixed">{{ t('admin.accounts.quotaResetModeFixed') }}</option> :day-options="dayOptions"
</select> @update:limit="emit('update:totalLimit', $event)"
</div> @update:notify-enabled="emit('update:quotaNotifyTotalEnabled', $event)"
<!-- 固定模式星期几 + 小时 --> @update:notify-threshold="emit('update:quotaNotifyTotalThreshold', $event)"
<div v-if="weeklyResetMode === 'fixed'" class="mt-2 flex items-center gap-2 flex-wrap"> @update:notify-threshold-type="emit('update:quotaNotifyTotalThresholdType', $event)"
<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>
</div> </div>
</div> </div>
</template> </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 @@ ...@@ -165,7 +165,6 @@
<button <button
@click="handleClose" @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" 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') }} {{ t('common.close') }}
</button> </button>
...@@ -249,7 +248,7 @@ const availableModels = ref<ClaudeModel[]>([]) ...@@ -249,7 +248,7 @@ const availableModels = ref<ClaudeModel[]>([])
const selectedModelId = ref('') const selectedModelId = ref('')
const testPrompt = ref('') const testPrompt = ref('')
const loadingModels = ref(false) const loadingModels = ref(false)
let eventSource: EventSource | null = null let abortController: AbortController | null = null
const generatedImages = ref<PreviewImage[]>([]) 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 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(() => { const supportsGeminiImageTest = computed(() => {
...@@ -279,7 +278,7 @@ watch( ...@@ -279,7 +278,7 @@ watch(
resetState() resetState()
await loadAvailableModels() await loadAvailableModels()
} else { } else {
closeEventSource() abortStream()
} }
} }
) )
...@@ -329,18 +328,14 @@ const resetState = () => { ...@@ -329,18 +328,14 @@ const resetState = () => {
} }
const handleClose = () => { const handleClose = () => {
// 防止在连接测试进行中关闭对话框 abortStream()
if (status.value === 'connecting') {
return
}
closeEventSource()
emit('close') emit('close')
} }
const closeEventSource = () => { const abortStream = () => {
if (eventSource) { if (abortController) {
eventSource.close() abortController.abort()
eventSource = null abortController = null
} }
} }
...@@ -365,7 +360,9 @@ const startTest = async () => { ...@@ -365,7 +360,9 @@ const startTest = async () => {
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400') addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
addLine('', 'text-gray-300') addLine('', 'text-gray-300')
closeEventSource() abortStream()
abortController = new AbortController()
try { try {
// Create EventSource for SSE // Create EventSource for SSE
...@@ -381,7 +378,8 @@ const startTest = async () => { ...@@ -381,7 +378,8 @@ const startTest = async () => {
body: JSON.stringify({ body: JSON.stringify({
model_id: selectedModelId.value, model_id: selectedModelId.value,
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : '' prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
}) }),
signal: abortController.signal
}) })
if (!response.ok) { if (!response.ok) {
...@@ -418,10 +416,15 @@ const startTest = async () => { ...@@ -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' status.value = 'error'
errorMessage.value = error.message || 'Unknown error' const msg = error instanceof Error ? error.message : 'Unknown error'
addLine(`Error: ${errorMessage.value}`, 'text-red-400') errorMessage.value = msg
addLine(`Error: ${msg}`, 'text-red-400')
} }
} }
......
...@@ -18,12 +18,20 @@ ...@@ -18,12 +18,20 @@
</span> </span>
</div> </div>
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</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">${{ order.amount.toFixed(2) }}</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>
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</p> <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>
<div> <div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</p> <p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</p>
...@@ -31,10 +39,6 @@ ...@@ -31,10 +39,6 @@
{{ t('payment.methods.' + order.payment_type, order.payment_type) }} {{ t('payment.methods.' + order.payment_type, order.payment_type) }}
</p> </p>
</div> </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> <div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.orderType') }}</p> <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"> <p class="text-sm text-gray-700 dark:text-gray-300">
...@@ -73,7 +77,7 @@ ...@@ -73,7 +77,7 @@
<div class="grid grid-cols-2 gap-2 text-sm"> <div class="grid grid-cols-2 gap-2 text-sm">
<div> <div>
<span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundAmount') }}:</span> <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>
<div v-if="order.refund_reason" class="col-span-2"> <div v-if="order.refund_reason" class="col-span-2">
<span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundReason') }}:</span> <span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundReason') }}:</span>
...@@ -110,6 +114,7 @@ ...@@ -110,6 +114,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import type { PaymentOrder } from '@/types/payment' import type { PaymentOrder } from '@/types/payment'
...@@ -117,11 +122,24 @@ import { statusBadgeClass, canRefund as canRefundStatus, formatOrderDateTime } f ...@@ -117,11 +122,24 @@ import { statusBadgeClass, canRefund as canRefundStatus, formatOrderDateTime } f
const { t } = useI18n() const { t } = useI18n()
defineProps<{ const props = defineProps<{
show: boolean show: boolean
order: PaymentOrder | null 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<{ const emit = defineEmits<{
(e: 'close'): void (e: 'close'): void
(e: 'cancel', order: PaymentOrder): void (e: 'cancel', order: PaymentOrder): void
......
...@@ -51,12 +51,15 @@ ...@@ -51,12 +51,15 @@
<span class="text-sm text-gray-600 dark:text-gray-400">#{{ value }}</span> <span class="text-sm text-gray-600 dark:text-gray-400">#{{ value }}</span>
</template> </template>
<template #cell-amount="{ value, row }"> <template #cell-pay_amount="{ value, row }">
<div class="text-sm"> <div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span> <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"> <span v-if="row.fee_rate > 0" class="ml-1 text-xs text-gray-400" :title="t('payment.orders.fee') + ': ' + row.fee_rate + '%'">
({{ t('payment.orders.payAmount') }}: ${{ row.pay_amount.toFixed(2) }}) ({{ row.fee_rate }}%)
</span> </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> </div>
</template> </template>
...@@ -183,7 +186,7 @@ function emitFiltersChanged() { ...@@ -183,7 +186,7 @@ function emitFiltersChanged() {
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'id', label: t('payment.orders.orderId') }, { key: 'id', label: t('payment.orders.orderId') },
{ key: 'user_id', label: t('payment.orders.userId') }, { 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: 'payment_type', label: t('payment.orders.paymentMethod') },
{ key: 'status', label: t('payment.orders.status') }, { key: 'status', label: t('payment.orders.status') },
{ key: 'order_type', label: t('payment.orders.orderType') }, { 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