Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
0b746501
Commit
0b746501
authored
Apr 16, 2026
by
陈曦
Browse files
1. merge upstream v0.1.113 2.提交migration相关文件
parents
45061102
be7551b9
Changes
225
Hide whitespace changes
Inline
Side-by-side
frontend/src/api/admin/channels.ts
View file @
0b746501
...
...
@@ -34,6 +34,14 @@ export interface ChannelModelPricing {
intervals
:
PricingInterval
[]
}
export
interface
AccountStatsPricingRule
{
id
?:
number
name
:
string
group_ids
:
number
[]
account_ids
:
number
[]
pricing
:
ChannelModelPricing
[]
}
export
interface
Channel
{
id
:
number
name
:
string
...
...
@@ -41,9 +49,12 @@ export interface Channel {
status
:
string
billing_model_source
:
string
// "requested" | "upstream"
restrict_models
:
boolean
features_config
?:
Record
<
string
,
unknown
>
group_ids
:
number
[]
model_pricing
:
ChannelModelPricing
[]
model_mapping
:
Record
<
string
,
Record
<
string
,
string
>>
// platform → {src→dst}
apply_pricing_to_account_stats
:
boolean
account_stats_pricing_rules
:
AccountStatsPricingRule
[]
created_at
:
string
updated_at
:
string
}
...
...
@@ -56,6 +67,9 @@ export interface CreateChannelRequest {
model_mapping
?:
Record
<
string
,
Record
<
string
,
string
>>
billing_model_source
?:
string
restrict_models
?:
boolean
features_config
?:
Record
<
string
,
unknown
>
apply_pricing_to_account_stats
?:
boolean
account_stats_pricing_rules
?:
AccountStatsPricingRule
[]
}
export
interface
UpdateChannelRequest
{
...
...
@@ -67,6 +81,9 @@ export interface UpdateChannelRequest {
model_mapping
?:
Record
<
string
,
Record
<
string
,
string
>>
billing_model_source
?:
string
restrict_models
?:
boolean
features_config
?:
Record
<
string
,
unknown
>
apply_pricing_to_account_stats
?:
boolean
account_stats_pricing_rules
?:
AccountStatsPricingRule
[]
}
interface
PaginatedResponse
<
T
>
{
...
...
frontend/src/api/admin/payment.ts
View file @
0b746501
...
...
@@ -23,6 +23,7 @@ export interface AdminPaymentConfig {
max_pending_orders
:
number
enabled_payment_types
:
string
[]
balance_disabled
:
boolean
balance_recharge_multiplier
:
number
load_balance_strategy
:
string
product_name_prefix
:
string
product_name_suffix
:
string
...
...
@@ -40,6 +41,7 @@ export interface UpdatePaymentConfigRequest {
max_pending_orders
?:
number
enabled_payment_types
?:
string
[]
balance_disabled
?:
boolean
balance_recharge_multiplier
?:
number
load_balance_strategy
?:
string
product_name_prefix
?:
string
product_name_suffix
?:
string
...
...
frontend/src/api/admin/settings.ts
View file @
0b746501
...
...
@@ -4,7 +4,7 @@
*/
import
{
apiClient
}
from
'
../client
'
import
type
{
CustomMenuItem
,
CustomEndpoint
}
from
'
@/types
'
import
type
{
CustomMenuItem
,
CustomEndpoint
,
NotifyEmailEntry
}
from
'
@/types
'
export
interface
DefaultSubscriptionSetting
{
group_id
:
number
...
...
@@ -114,6 +114,7 @@ export interface SystemSettings {
enable_fingerprint_unification
:
boolean
enable_metadata_passthrough
:
boolean
enable_cch_signing
:
boolean
web_search_emulation_enabled
?:
boolean
// Payment configuration
payment_enabled
:
boolean
...
...
@@ -124,6 +125,8 @@ export interface SystemSettings {
payment_max_pending_orders
:
number
payment_enabled_types
:
string
[]
payment_balance_disabled
:
boolean
payment_balance_recharge_multiplier
:
number
payment_recharge_fee_rate
:
number
payment_load_balance_strategy
:
string
payment_product_name_prefix
:
string
payment_product_name_suffix
:
string
...
...
@@ -134,6 +137,13 @@ export interface SystemSettings {
payment_cancel_rate_limit_window
:
number
payment_cancel_rate_limit_unit
:
string
payment_cancel_rate_limit_window_mode
:
string
// Balance & quota notification
balance_low_notify_enabled
:
boolean
balance_low_notify_threshold
:
number
balance_low_notify_recharge_url
:
string
account_quota_notify_enabled
:
boolean
account_quota_notify_emails
:
NotifyEmailEntry
[]
}
export
interface
UpdateSettingsRequest
{
...
...
@@ -223,6 +233,8 @@ export interface UpdateSettingsRequest {
payment_max_pending_orders
?:
number
payment_enabled_types
?:
string
[]
payment_balance_disabled
?:
boolean
payment_balance_recharge_multiplier
?:
number
payment_recharge_fee_rate
?:
number
payment_load_balance_strategy
?:
string
payment_product_name_prefix
?:
string
payment_product_name_suffix
?:
string
...
...
@@ -233,6 +245,12 @@ export interface UpdateSettingsRequest {
payment_cancel_rate_limit_window
?:
number
payment_cancel_rate_limit_unit
?:
string
payment_cancel_rate_limit_window_mode
?:
string
// Balance & quota notification
balance_low_notify_enabled
?:
boolean
balance_low_notify_threshold
?:
number
balance_low_notify_recharge_url
?:
string
account_quota_notify_enabled
?:
boolean
account_quota_notify_emails
?:
NotifyEmailEntry
[]
}
/**
...
...
@@ -482,6 +500,63 @@ export async function updateBetaPolicySettings(
return
data
}
// --- Web Search Emulation Config ---
export
interface
WebSearchProviderConfig
{
type
:
'
brave
'
|
'
tavily
'
api_key
:
string
api_key_configured
:
boolean
quota_limit
:
number
|
null
subscribed_at
:
number
|
null
quota_used
?:
number
proxy_id
:
number
|
null
expires_at
:
number
|
null
}
export
interface
WebSearchEmulationConfig
{
enabled
:
boolean
providers
:
WebSearchProviderConfig
[]
}
export
interface
WebSearchTestResult
{
provider
:
string
results
:
{
url
:
string
;
title
:
string
;
snippet
:
string
;
page_age
?:
string
}[]
query
:
string
}
export
async
function
getWebSearchEmulationConfig
():
Promise
<
WebSearchEmulationConfig
>
{
const
{
data
}
=
await
apiClient
.
get
<
WebSearchEmulationConfig
>
(
'
/admin/settings/web-search-emulation
'
)
return
data
}
export
async
function
updateWebSearchEmulationConfig
(
config
:
WebSearchEmulationConfig
):
Promise
<
WebSearchEmulationConfig
>
{
const
{
data
}
=
await
apiClient
.
put
<
WebSearchEmulationConfig
>
(
'
/admin/settings/web-search-emulation
'
,
config
)
return
data
}
export
async
function
testWebSearchEmulation
(
query
:
string
):
Promise
<
WebSearchTestResult
>
{
const
{
data
}
=
await
apiClient
.
post
<
WebSearchTestResult
>
(
'
/admin/settings/web-search-emulation/test
'
,
{
query
}
)
return
data
}
export
async
function
resetWebSearchUsage
(
payload
:
{
provider_type
:
string
}
):
Promise
<
void
>
{
await
apiClient
.
post
(
'
/admin/settings/web-search-emulation/reset-usage
'
,
payload
)
}
export
const
settingsAPI
=
{
getSettings
,
updateSettings
,
...
...
@@ -497,7 +572,11 @@ export const settingsAPI = {
getRectifierSettings
,
updateRectifierSettings
,
getBetaPolicySettings
,
updateBetaPolicySettings
updateBetaPolicySettings
,
getWebSearchEmulationConfig
,
updateWebSearchEmulationConfig
,
testWebSearchEmulation
,
resetWebSearchUsage
}
export
default
settingsAPI
frontend/src/api/admin/usage.ts
View file @
0b746501
...
...
@@ -17,7 +17,7 @@ export interface AdminUsageStatsResponse {
total_tokens
:
number
total_cost
:
number
total_actual_cost
:
number
total_account_cost
?
:
number
total_account_cost
:
number
average_duration_ms
:
number
endpoints
?:
EndpointStat
[]
upstream_endpoints
?:
EndpointStat
[]
...
...
frontend/src/api/client.ts
View file @
0b746501
...
...
@@ -270,9 +270,9 @@ apiClient.interceptors.response.use(
return
Promise
.
reject
({
status
,
code
:
apiData
.
code
,
reason
:
apiData
.
reason
,
error
:
apiData
.
error
,
message
:
apiData
.
message
||
apiData
.
detail
||
error
.
message
,
reason
:
apiData
.
reason
,
metadata
:
apiData
.
metadata
,
})
}
...
...
frontend/src/api/payment.ts
View file @
0b746501
...
...
@@ -75,5 +75,10 @@ export const paymentAPI = {
/** Request a refund for a completed order */
requestRefund
(
id
:
number
,
data
:
{
reason
:
string
})
{
return
apiClient
.
post
(
`/payment/orders/
${
id
}
/refund-request`
,
data
)
},
/** Get provider instance IDs that allow user refund */
getRefundEligibleProviders
()
{
return
apiClient
.
get
<
{
provider_instance_ids
:
string
[]
}
>
(
'
/payment/orders/refund-eligible-providers
'
)
}
}
frontend/src/api/user.ts
View file @
0b746501
...
...
@@ -4,7 +4,7 @@
*/
import
{
apiClient
}
from
'
./client
'
import
type
{
User
,
ChangePasswordRequest
}
from
'
@/types
'
import
type
{
User
,
ChangePasswordRequest
,
NotifyEmailEntry
}
from
'
@/types
'
/**
* Get current user profile
...
...
@@ -22,6 +22,9 @@ export async function getProfile(): Promise<User> {
*/
export
async
function
updateProfile
(
profile
:
{
username
?:
string
balance_notify_enabled
?:
boolean
balance_notify_threshold
?:
number
|
null
balance_notify_extra_emails
?:
NotifyEmailEntry
[]
}):
Promise
<
User
>
{
const
{
data
}
=
await
apiClient
.
put
<
User
>
(
'
/user
'
,
profile
)
return
data
...
...
@@ -45,10 +48,49 @@ export async function changePassword(
return
data
}
/**
* Send verification code for adding a notify email
* @param email - Email address to verify
*/
export
async
function
sendNotifyEmailCode
(
email
:
string
):
Promise
<
void
>
{
await
apiClient
.
post
(
'
/user/notify-email/send-code
'
,
{
email
})
}
/**
* Verify and add a notify email
* @param email - Email address to add
* @param code - Verification code
*/
export
async
function
verifyNotifyEmail
(
email
:
string
,
code
:
string
):
Promise
<
void
>
{
await
apiClient
.
post
(
'
/user/notify-email/verify
'
,
{
email
,
code
})
}
/**
* Remove a notify email
* @param email - Email address to remove
*/
export
async
function
removeNotifyEmail
(
email
:
string
):
Promise
<
void
>
{
await
apiClient
.
delete
(
'
/user/notify-email
'
,
{
data
:
{
email
}
})
}
/**
* Toggle a notify email's disabled state
* @param email - Email address (empty string for primary email placeholder)
* @param disabled - Whether to disable the email
*/
export
async
function
toggleNotifyEmail
(
email
:
string
,
disabled
:
boolean
):
Promise
<
User
>
{
const
{
data
}
=
await
apiClient
.
put
<
User
>
(
'
/user/notify-email/toggle
'
,
{
email
,
disabled
})
return
data
}
export
const
userAPI
=
{
getProfile
,
updateProfile
,
changePassword
changePassword
,
sendNotifyEmailCode
,
verifyNotifyEmail
,
removeNotifyEmail
,
toggleNotifyEmail
}
export
default
userAPI
frontend/src/components/account/AccountCapacityCell.vue
View file @
0b746501
<
template
>
<div
class=
"flex flex-col gap-
1
.5"
>
<div
class=
"flex flex-col gap-
0
.5"
>
<!-- 并发槽位 -->
<div
class=
"flex items-center gap-1.5"
>
<span
:class=
"[
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
concurrencyClass
]"
>
<svg
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"
/>
</svg>
<span
class=
"font-mono"
>
{{
currentConcurrency
}}
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
>
/
</span>
<span
class=
"font-mono"
>
{{
account
.
concurrency
}}
</span>
</span>
</div>
<!-- 5h窗口费用限制(仅 Anthropic OAuth/SetupToken 且启用时显示) -->
<div
v-if=
"showWindowCost"
class=
"flex items-center gap-1"
>
<span
:class=
"[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
windowCostClass
]"
:title=
"windowCostTooltip"
>
<svg
class=
"h-2.5 w-2.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
class=
"font-mono"
>
$
{{
formatCost
(
currentWindowCost
)
}}
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
>
/
</span>
<span
class=
"font-mono"
>
$
{{
formatCost
(
account
.
window_cost_limit
)
}}
</span>
</span>
</div>
<!-- 会话数量限制(仅 Anthropic OAuth/SetupToken 且启用时显示) -->
<div
v-if=
"showSessionLimit"
class=
"flex items-center gap-1"
>
<span
:class=
"[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
sessionLimitClass
]"
:title=
"sessionLimitTooltip"
>
<svg
class=
"h-2.5 w-2.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
<span
class=
"font-mono"
>
{{
activeSessions
}}
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
>
/
</span>
<span
class=
"font-mono"
>
{{
account
.
max_sessions
}}
</span>
</span>
</div>
<!-- RPM 限制(仅 Anthropic OAuth/SetupToken 且启用时显示) -->
<div
v-if=
"showRpmLimit"
class=
"flex items-center gap-1"
>
<span
:class=
"[
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
rpmClass
]"
:title=
"rpmTooltip"
>
<svg
class=
"h-2.5 w-2.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<span
class=
"font-mono"
>
{{
currentRPM
}}
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
>
/
</span>
<span
class=
"font-mono"
>
{{
account
.
base_rpm
}}
</span>
<span
class=
"text-[9px] opacity-60"
>
{{
rpmStrategyTag
}}
</span>
</span>
</div>
<CapacityBadge
:color-class=
"concurrencyClass"
:current=
"currentConcurrency"
:max=
"account.concurrency"
>
<svg
class=
"h-2.5 w-2.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"
/>
</svg>
</CapacityBadge>
<!-- 5h窗口费用限制 -->
<CapacityBadge
v-if=
"showWindowCost"
:color-class=
"windowCostClass"
:tooltip=
"windowCostTooltip"
:current=
"'$' + formatCost(currentWindowCost)"
:max=
"'$' + formatCost(account.window_cost_limit)"
>
<svg
class=
"h-2.5 w-2.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</CapacityBadge>
<!-- 会话数量限制 -->
<CapacityBadge
v-if=
"showSessionLimit"
:color-class=
"sessionLimitClass"
:tooltip=
"sessionLimitTooltip"
:current=
"activeSessions"
:max=
"account.max_sessions!"
>
<svg
class=
"h-2.5 w-2.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
</CapacityBadge>
<!-- RPM 限制 -->
<CapacityBadge
v-if=
"showRpmLimit"
:color-class=
"rpmClass"
:tooltip=
"rpmTooltip"
:current=
"currentRPM"
:max=
"account.base_rpm!"
:suffix=
"rpmStrategyTag"
>
<svg
class=
"h-2.5 w-2.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke-width=
"1.5"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</CapacityBadge>
<!-- API Key 账号配额限制 -->
<QuotaBadge
v-if=
"showDailyQuota"
:used=
"account.quota_daily_used ?? 0"
:limit=
"account.quota_daily_limit!"
label=
"D"
/>
...
...
@@ -83,7 +39,8 @@
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
Account
}
from
'
@/types
'
import
QuotaBadge
from
'
./QuotaBadge.vue
'
import
CapacityBadge
from
'
@/components/account/CapacityBadge.vue
'
import
QuotaBadge
from
'
@/components/account/QuotaBadge.vue
'
const
props
=
defineProps
<
{
account
:
Account
...
...
@@ -91,225 +48,143 @@ const props = defineProps<{
const
{
t
}
=
useI18n
()
//
当前并发数
//
====== 并发 ======
const
currentConcurrency
=
computed
(()
=>
props
.
account
.
current_concurrency
||
0
)
// 是否为 Anthropic OAuth/SetupToken 账号
const
isAnthropicOAuthOrSetupToken
=
computed
(()
=>
{
return
(
props
.
account
.
platform
===
'
anthropic
'
&&
(
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
)
)
})
// 是否显示窗口费用限制
const
showWindowCost
=
computed
(()
=>
{
return
(
isAnthropicOAuthOrSetupToken
.
value
&&
props
.
account
.
window_cost_limit
!==
undefined
&&
props
.
account
.
window_cost_limit
!==
null
&&
props
.
account
.
window_cost_limit
>
0
)
})
// 当前窗口费用
const
currentWindowCost
=
computed
(()
=>
props
.
account
.
current_window_cost
??
0
)
// 是否显示会话限制
const
showSessionLimit
=
computed
(()
=>
{
return
(
isAnthropicOAuthOrSetupToken
.
value
&&
props
.
account
.
max_sessions
!==
undefined
&&
props
.
account
.
max_sessions
!==
null
&&
props
.
account
.
max_sessions
>
0
)
})
// 当前活跃会话数
const
activeSessions
=
computed
(()
=>
props
.
account
.
active_sessions
??
0
)
// 并发状态样式
const
concurrencyClass
=
computed
(()
=>
{
const
current
=
currentConcurrency
.
value
const
max
=
props
.
account
.
concurrency
if
(
current
>=
max
)
{
return
'
bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
'
}
if
(
current
>
0
)
{
return
'
bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400
'
}
if
(
current
>=
max
)
return
'
bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
'
if
(
current
>
0
)
return
'
bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400
'
return
'
bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400
'
})
// 窗口费用状态样式
// ====== 窗口费用 ======
const
isAnthropicOAuthOrSetupToken
=
computed
(()
=>
props
.
account
.
platform
===
'
anthropic
'
&&
(
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
)
)
const
showWindowCost
=
computed
(()
=>
isAnthropicOAuthOrSetupToken
.
value
&&
props
.
account
.
window_cost_limit
!=
null
&&
props
.
account
.
window_cost_limit
>
0
)
const
currentWindowCost
=
computed
(()
=>
props
.
account
.
current_window_cost
??
0
)
const
windowCostClass
=
computed
(()
=>
{
if
(
!
showWindowCost
.
value
)
return
''
const
current
=
currentWindowCost
.
value
const
limit
=
props
.
account
.
window_cost_limit
||
0
const
reserve
=
props
.
account
.
window_cost_sticky_reserve
||
10
if
(
current
>=
limit
+
reserve
)
{
return
'
bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
'
}
if
(
current
>=
limit
)
{
return
'
bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400
'
}
if
(
current
>=
limit
*
0.8
)
{
return
'
bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400
'
}
if
(
current
>=
limit
+
reserve
)
return
'
bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
'
if
(
current
>=
limit
)
return
'
bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400
'
if
(
current
>=
limit
*
0.8
)
return
'
bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400
'
return
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400
'
})
// 窗口费用提示文字
const
windowCostTooltip
=
computed
(()
=>
{
if
(
!
showWindowCost
.
value
)
return
''
const
current
=
currentWindowCost
.
value
const
limit
=
props
.
account
.
window_cost_limit
||
0
const
reserve
=
props
.
account
.
window_cost_sticky_reserve
||
10
if
(
current
>=
limit
+
reserve
)
{
return
t
(
'
admin.accounts.capacity.windowCost.blocked
'
)
}
if
(
current
>=
limit
)
{
return
t
(
'
admin.accounts.capacity.windowCost.stickyOnly
'
)
}
if
(
current
>=
limit
+
reserve
)
return
t
(
'
admin.accounts.capacity.windowCost.blocked
'
)
if
(
current
>=
limit
)
return
t
(
'
admin.accounts.capacity.windowCost.stickyOnly
'
)
return
t
(
'
admin.accounts.capacity.windowCost.normal
'
)
})
// 会话限制状态样式
// ====== 会话限制 ======
const
showSessionLimit
=
computed
(()
=>
isAnthropicOAuthOrSetupToken
.
value
&&
props
.
account
.
max_sessions
!=
null
&&
props
.
account
.
max_sessions
>
0
)
const
activeSessions
=
computed
(()
=>
props
.
account
.
active_sessions
??
0
)
const
sessionLimitClass
=
computed
(()
=>
{
if
(
!
showSessionLimit
.
value
)
return
''
const
current
=
activeSessions
.
value
const
max
=
props
.
account
.
max_sessions
||
0
if
(
current
>=
max
)
{
return
'
bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
'
}
if
(
current
>=
max
*
0.8
)
{
return
'
bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400
'
}
if
(
current
>=
max
)
return
'
bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
'
if
(
current
>=
max
*
0.8
)
return
'
bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400
'
return
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400
'
})
// 会话限制提示文字
const
sessionLimitTooltip
=
computed
(()
=>
{
if
(
!
showSessionLimit
.
value
)
return
''
const
current
=
activeSessions
.
value
const
max
=
props
.
account
.
max_sessions
||
0
const
idle
=
props
.
account
.
session_idle_timeout_minutes
||
5
if
(
current
>=
max
)
{
return
t
(
'
admin.accounts.capacity.sessions.full
'
,
{
idle
})
}
if
(
current
>=
max
)
return
t
(
'
admin.accounts.capacity.sessions.full
'
,
{
idle
})
return
t
(
'
admin.accounts.capacity.sessions.normal
'
,
{
idle
})
})
// 是否显示 RPM 限制
const
showRpmLimit
=
computed
(()
=>
{
return
(
isAnthropicOAuthOrSetupToken
.
value
&&
props
.
account
.
base_rpm
!==
undefined
&&
props
.
account
.
base_rpm
!==
null
&&
props
.
account
.
base_rpm
>
0
)
})
// ====== RPM ======
const
showRpmLimit
=
computed
(()
=>
isAnthropicOAuthOrSetupToken
.
value
&&
props
.
account
.
base_rpm
!=
null
&&
props
.
account
.
base_rpm
>
0
)
// 当前 RPM 计数
const
currentRPM
=
computed
(()
=>
props
.
account
.
current_rpm
??
0
)
// RPM 策略
const
rpmStrategy
=
computed
(()
=>
props
.
account
.
rpm_strategy
||
'
tiered
'
)
const
rpmStrategyTag
=
computed
(()
=>
rpmStrategy
.
value
===
'
sticky_exempt
'
?
'
[S]
'
:
'
[T]
'
)
// RPM 策略标签
const
rpmStrategyTag
=
computed
(()
=>
{
return
rpmStrategy
.
value
===
'
sticky_exempt
'
?
'
[S]
'
:
'
[T]
'
})
// RPM buffer 计算(与后端一致:base
<=
0
时
buffer
为
0
)
const
rpmBuffer
=
computed
(()
=>
{
const
base
=
props
.
account
.
base_rpm
||
0
return
props
.
account
.
rpm_sticky_buffer
??
(
base
>
0
?
Math
.
max
(
1
,
Math
.
floor
(
base
/
5
))
:
0
)
})
// RPM 状态样式
const
rpmClass
=
computed
(()
=>
{
if
(
!
showRpmLimit
.
value
)
return
''
const
current
=
currentRPM
.
value
const
base
=
props
.
account
.
base_rpm
??
0
const
buffer
=
rpmBuffer
.
value
if
(
rpmStrategy
.
value
===
'
tiered
'
)
{
if
(
current
>=
base
+
buffer
)
{
return
'
bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
'
}
if
(
current
>=
base
)
{
return
'
bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400
'
}
if
(
current
>=
base
+
buffer
)
return
'
bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
'
if
(
current
>=
base
)
return
'
bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400
'
}
else
{
if
(
current
>=
base
)
{
return
'
bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400
'
}
}
if
(
current
>=
base
*
0.8
)
{
return
'
bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400
'
if
(
current
>=
base
)
return
'
bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400
'
}
if
(
current
>=
base
*
0.8
)
return
'
bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400
'
return
'
bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400
'
})
// RPM 提示文字(增强版:显示策略、区域、缓冲区)
const
rpmTooltip
=
computed
(()
=>
{
if
(
!
showRpmLimit
.
value
)
return
''
const
current
=
currentRPM
.
value
const
base
=
props
.
account
.
base_rpm
??
0
const
buffer
=
rpmBuffer
.
value
if
(
rpmStrategy
.
value
===
'
tiered
'
)
{
if
(
current
>=
base
+
buffer
)
{
return
t
(
'
admin.accounts.capacity.rpm.tieredBlocked
'
,
{
buffer
})
}
if
(
current
>=
base
)
{
return
t
(
'
admin.accounts.capacity.rpm.tieredStickyOnly
'
,
{
buffer
})
}
if
(
current
>=
base
*
0.8
)
{
return
t
(
'
admin.accounts.capacity.rpm.tieredWarning
'
)
}
if
(
current
>=
base
+
buffer
)
return
t
(
'
admin.accounts.capacity.rpm.tieredBlocked
'
,
{
buffer
})
if
(
current
>=
base
)
return
t
(
'
admin.accounts.capacity.rpm.tieredStickyOnly
'
,
{
buffer
})
if
(
current
>=
base
*
0.8
)
return
t
(
'
admin.accounts.capacity.rpm.tieredWarning
'
)
return
t
(
'
admin.accounts.capacity.rpm.tieredNormal
'
)
}
else
{
if
(
current
>=
base
)
{
return
t
(
'
admin.accounts.capacity.rpm.stickyExemptOver
'
)
}
if
(
current
>=
base
*
0.8
)
{
return
t
(
'
admin.accounts.capacity.rpm.stickyExemptWarning
'
)
}
if
(
current
>=
base
)
return
t
(
'
admin.accounts.capacity.rpm.stickyExemptOver
'
)
if
(
current
>=
base
*
0.8
)
return
t
(
'
admin.accounts.capacity.rpm.stickyExemptWarning
'
)
return
t
(
'
admin.accounts.capacity.rpm.stickyExemptNormal
'
)
}
})
// 是否显示各维度配额(apikey / bedrock 类型)
const
isQuotaEligible
=
computed
(()
=>
props
.
account
.
type
===
'
apikey
'
||
props
.
account
.
type
===
'
bedrock
'
)
const
showDailyQuota
=
computed
(()
=>
{
return
isQuotaEligible
.
value
&&
(
props
.
account
.
quota_daily_limit
??
0
)
>
0
})
const
showWeeklyQuota
=
computed
(()
=>
{
return
isQuotaEligible
.
value
&&
(
props
.
account
.
quota_weekly_limit
??
0
)
>
0
})
const
showTotalQuota
=
computed
(()
=>
{
return
isQuotaEligible
.
value
&&
(
props
.
account
.
quota_limit
??
0
)
>
0
})
// 格式化费用显示
const
formatCost
=
(
value
:
number
|
null
|
undefined
)
=>
{
if
(
value
===
null
||
value
===
undefined
)
return
'
0
'
return
value
.
toFixed
(
2
)
}
// ====== 配额 ======
const
isQuotaEligible
=
computed
(()
=>
props
.
account
.
type
===
'
apikey
'
||
props
.
account
.
type
===
'
bedrock
'
)
const
showDailyQuota
=
computed
(()
=>
isQuotaEligible
.
value
&&
props
.
account
.
quota_daily_limit
!=
null
&&
props
.
account
.
quota_daily_limit
>
0
)
const
showWeeklyQuota
=
computed
(()
=>
isQuotaEligible
.
value
&&
props
.
account
.
quota_weekly_limit
!=
null
&&
props
.
account
.
quota_weekly_limit
>
0
)
const
showTotalQuota
=
computed
(()
=>
isQuotaEligible
.
value
&&
props
.
account
.
quota_limit
!=
null
&&
props
.
account
.
quota_limit
>
0
)
</
script
>
frontend/src/components/account/AccountTestModal.vue
View file @
0b746501
...
...
@@ -165,7 +165,6 @@
<button
@
click=
"handleClose"
class=
"rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
:disabled=
"status === 'connecting'"
>
{{
t
(
'
common.close
'
)
}}
</button>
...
...
@@ -249,7 +248,7 @@ const availableModels = ref<ClaudeModel[]>([])
const
selectedModelId
=
ref
(
''
)
const
testPrompt
=
ref
(
''
)
const
loadingModels
=
ref
(
false
)
let
eventSource
:
EventSource
|
null
=
null
let
abortController
:
AbortController
|
null
=
null
const
generatedImages
=
ref
<
PreviewImage
[]
>
([])
const
prioritizedGeminiModels
=
[
'
gemini-3.1-flash-image
'
,
'
gemini-2.5-flash-image
'
,
'
gemini-2.5-flash
'
,
'
gemini-2.5-pro
'
,
'
gemini-3-flash-preview
'
,
'
gemini-3-pro-preview
'
,
'
gemini-2.0-flash
'
]
const
supportsGeminiImageTest
=
computed
(()
=>
{
...
...
@@ -279,7 +278,7 @@ watch(
resetState
()
await
loadAvailableModels
()
}
else
{
closeEventSource
()
abortStream
()
}
}
)
...
...
@@ -329,18 +328,14 @@ const resetState = () => {
}
const
handleClose
=
()
=>
{
// 防止在连接测试进行中关闭对话框
if
(
status
.
value
===
'
connecting
'
)
{
return
}
closeEventSource
()
abortStream
()
emit
(
'
close
'
)
}
const
closeEventSource
=
()
=>
{
if
(
eventSource
)
{
eventSource
.
close
()
eventSource
=
null
const
abortStream
=
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
abortController
=
null
}
}
...
...
@@ -365,7 +360,9 @@ const startTest = async () => {
addLine
(
t
(
'
admin.accounts.testAccountTypeLabel
'
,
{
type
:
props
.
account
.
type
}),
'
text-gray-400
'
)
addLine
(
''
,
'
text-gray-300
'
)
closeEventSource
()
abortStream
()
abortController
=
new
AbortController
()
try
{
// Create EventSource for SSE
...
...
@@ -381,7 +378,8 @@ const startTest = async () => {
body
:
JSON
.
stringify
({
model_id
:
selectedModelId
.
value
,
prompt
:
supportsGeminiImageTest
.
value
?
testPrompt
.
value
.
trim
()
:
''
})
}),
signal
:
abortController
.
signal
})
if
(
!
response
.
ok
)
{
...
...
@@ -418,10 +416,15 @@ const startTest = async () => {
}
}
}
}
catch
(
error
:
any
)
{
}
catch
(
error
:
unknown
)
{
if
(
error
instanceof
DOMException
&&
error
.
name
===
'
AbortError
'
)
{
status
.
value
=
'
idle
'
return
}
status
.
value
=
'
error
'
errorMessage
.
value
=
error
.
message
||
'
Unknown error
'
addLine
(
`Error:
${
errorMessage
.
value
}
`
,
'
text-red-400
'
)
const
msg
=
error
instanceof
Error
?
error
.
message
:
'
Unknown error
'
errorMessage
.
value
=
msg
addLine
(
`Error:
${
msg
}
`
,
'
text-red-400
'
)
}
}
...
...
frontend/src/components/account/AccountUsageCell.vue
View file @
0b746501
...
...
@@ -439,15 +439,20 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
,
onUnmounted
,
watch
}
from
'
vue
'
import
{
ref
,
computed
,
onMounted
,
onBeforeUnmount
,
onUnmounted
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
AccountUsageInfo
,
GeminiCredentials
,
WindowStats
}
from
'
@/types
'
import
{
buildOpenAIUsageRefreshKey
}
from
'
@/utils/accountUsageRefresh
'
import
{
enqueueUsageRequest
}
from
'
@/utils/usageLoadQueue
'
import
{
formatCompactNumber
}
from
'
@/utils/format
'
import
UsageProgressBar
from
'
./UsageProgressBar.vue
'
import
AccountQuotaInfo
from
'
./AccountQuotaInfo.vue
'
// Module-level cache shared across all AccountUsageCell instances
const
_usageCache
=
new
Map
<
number
,
{
data
:
AccountUsageInfo
;
ts
:
number
}
>
()
const
USAGE_CACHE_TTL
=
5
*
60
*
1000
// 5 minutes
const
props
=
withDefaults
(
defineProps
<
{
account
:
Account
...
...
@@ -465,6 +470,9 @@ const props = withDefaults(
const
{
t
}
=
useI18n
()
const
desktopViewportQuery
=
'
(min-width: 768px)
'
const
unmounted
=
ref
(
false
)
onBeforeUnmount
(()
=>
{
unmounted
.
value
=
true
})
const
loading
=
ref
(
false
)
const
activeQueryLoading
=
ref
(
false
)
const
error
=
ref
<
string
|
null
>
(
null
)
...
...
@@ -941,19 +949,36 @@ const isAnthropicOAuthOrSetupToken = computed(() => {
return
props
.
account
.
platform
===
'
anthropic
'
&&
(
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
)
})
const
loadUsage
=
async
(
source
?:
'
passive
'
|
'
active
'
)
=>
{
const
loadUsage
=
async
(
options
?:
{
source
?:
'
passive
'
|
'
active
'
;
bypassCache
?:
boolean
}
)
=>
{
if
(
!
shouldFetchUsage
.
value
)
return
// Check cache
if
(
!
options
?.
bypassCache
)
{
const
cached
=
_usageCache
.
get
(
props
.
account
.
id
)
if
(
cached
&&
Date
.
now
()
-
cached
.
ts
<
USAGE_CACHE_TTL
)
{
usageInfo
.
value
=
cached
.
data
loading
.
value
=
false
return
}
}
loading
.
value
=
true
error
.
value
=
null
try
{
usageInfo
.
value
=
await
adminAPI
.
accounts
.
getUsage
(
props
.
account
.
id
,
source
)
const
fetchFn
=
()
=>
adminAPI
.
accounts
.
getUsage
(
props
.
account
.
id
,
options
?.
source
)
const
result
=
await
enqueueUsageRequest
(
props
.
account
,
fetchFn
)
if
(
!
unmounted
.
value
)
{
usageInfo
.
value
=
result
_usageCache
.
set
(
props
.
account
.
id
,
{
data
:
result
,
ts
:
Date
.
now
()
})
}
}
catch
(
e
:
any
)
{
error
.
value
=
t
(
'
common.error
'
)
console
.
error
(
'
Failed to load usage:
'
,
e
)
if
(
!
unmounted
.
value
)
{
error
.
value
=
t
(
'
common.error
'
)
console
.
error
(
'
Failed to load usage:
'
,
e
)
}
}
finally
{
loading
.
value
=
false
if
(
!
unmounted
.
value
)
loading
.
value
=
false
}
}
...
...
@@ -962,7 +987,7 @@ const flushPendingAutoLoad = () => {
const
source
=
pendingAutoLoadSource
.
value
pendingAutoLoad
.
value
=
false
pendingAutoLoadSource
.
value
=
undefined
loadUsage
(
source
).
catch
((
e
)
=>
{
loadUsage
(
{
source
}
).
catch
((
e
)
=>
{
console
.
error
(
'
Failed to load deferred usage:
'
,
e
)
})
}
...
...
@@ -974,7 +999,7 @@ const requestAutoLoad = (source?: 'passive' | 'active') => {
pendingAutoLoadSource
.
value
=
source
return
}
loadUsage
(
source
).
catch
((
e
)
=>
{
loadUsage
(
{
source
}
).
catch
((
e
)
=>
{
console
.
error
(
'
Failed to auto load usage:
'
,
e
)
})
}
...
...
@@ -1138,7 +1163,10 @@ watch(
if
(
!
shouldFetchUsage
.
value
)
return
const
source
=
isAnthropicOAuthOrSetupToken
.
value
?
'
passive
'
:
undefined
requestAutoLoad
(
source
)
_usageCache
.
delete
(
props
.
account
.
id
)
loadUsage
({
source
,
bypassCache
:
true
}).
catch
((
e
)
=>
{
console
.
error
(
'
Failed to refresh usage after manual refresh:
'
,
e
)
})
}
)
...
...
frontend/src/components/account/BulkEditAccountModal.vue
View file @
0b746501
...
...
@@ -5,7 +5,7 @@
width=
"wide"
@
close=
"handleClose"
>
<form
id=
"bulk-edit-account-form"
class=
"space-y-5"
@
submit.prevent=
"handleSubmit"
>
<form
id=
"bulk-edit-account-form"
class=
"space-y-5"
@
submit.prevent=
"
() =>
handleSubmit
()
"
>
<!-- Info -->
<div
class=
"rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20"
>
<p
class=
"text-sm text-blue-700 dark:text-blue-400"
>
...
...
frontend/src/components/account/CapacityBadge.vue
0 → 100644
View file @
0b746501
<
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
>
frontend/src/components/account/CreateAccountModal.vue
View file @
0b746501
...
...
@@ -1477,10 +1477,65 @@
<
/div
>
<
/div
>
<!--
API
Key
/
Bedrock
账号配额限制
-->
<
div
v
-
if
=
"
form.type === 'apikey' || form.type === 'bedrock'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
>
<!--
配额控制
(
Anthropic
apikey
/
bedrock
:
配额限制
+
亲和
)
-->
<
div
v
-
if
=
"
form.platform === 'anthropic' && (form.type === 'apikey' || form.type === 'bedrock')
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
>
<
div
class
=
"
mb-3
"
>
<
h3
class
=
"
input-label mb-0 text-base font-semibold
"
>
{{
t
(
'
admin.accounts.quotaLimit
'
)
}}
<
/h3
>
<
h3
class
=
"
input-label mb-0 text-base font-semibold
"
>
{{
t
(
'
admin.accounts.quotaControl.title
'
)
}}
<
/h3
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.hint
'
)
}}
<
/p
>
<
/div
>
<
QuotaLimitCard
:
totalLimit
=
"
editQuotaLimit
"
:
dailyLimit
=
"
editQuotaDailyLimit
"
:
weeklyLimit
=
"
editQuotaWeeklyLimit
"
:
quotaNotifyGlobalEnabled
=
"
quotaNotifyGlobalEnabled
"
:
quotaNotifyDailyEnabled
=
"
quotaNotifyState.daily.enabled
"
:
quotaNotifyDailyThreshold
=
"
quotaNotifyState.daily.threshold
"
:
quotaNotifyDailyThresholdType
=
"
quotaNotifyState.daily.thresholdType
"
:
quotaNotifyWeeklyEnabled
=
"
quotaNotifyState.weekly.enabled
"
:
quotaNotifyWeeklyThreshold
=
"
quotaNotifyState.weekly.threshold
"
:
quotaNotifyWeeklyThresholdType
=
"
quotaNotifyState.weekly.thresholdType
"
:
quotaNotifyTotalEnabled
=
"
quotaNotifyState.total.enabled
"
:
quotaNotifyTotalThreshold
=
"
quotaNotifyState.total.threshold
"
:
quotaNotifyTotalThresholdType
=
"
quotaNotifyState.total.thresholdType
"
:
dailyResetMode
=
"
editDailyResetMode
"
:
dailyResetHour
=
"
editDailyResetHour
"
:
weeklyResetMode
=
"
editWeeklyResetMode
"
:
weeklyResetDay
=
"
editWeeklyResetDay
"
:
weeklyResetHour
=
"
editWeeklyResetHour
"
:
resetTimezone
=
"
editResetTimezone
"
@
update
:
totalLimit
=
"
editQuotaLimit = $event
"
@
update
:
dailyLimit
=
"
editQuotaDailyLimit = $event
"
@
update
:
weeklyLimit
=
"
editQuotaWeeklyLimit = $event
"
@
update
:
quotaNotifyDailyEnabled
=
"
quotaNotifyState.daily.enabled = $event
"
@
update
:
quotaNotifyDailyThreshold
=
"
quotaNotifyState.daily.threshold = $event
"
@
update
:
quotaNotifyDailyThresholdType
=
"
quotaNotifyState.daily.thresholdType = $event
"
@
update
:
quotaNotifyWeeklyEnabled
=
"
quotaNotifyState.weekly.enabled = $event
"
@
update
:
quotaNotifyWeeklyThreshold
=
"
quotaNotifyState.weekly.threshold = $event
"
@
update
:
quotaNotifyWeeklyThresholdType
=
"
quotaNotifyState.weekly.thresholdType = $event
"
@
update
:
quotaNotifyTotalEnabled
=
"
quotaNotifyState.total.enabled = $event
"
@
update
:
quotaNotifyTotalThreshold
=
"
quotaNotifyState.total.threshold = $event
"
@
update
:
quotaNotifyTotalThresholdType
=
"
quotaNotifyState.total.thresholdType = $event
"
@
update
:
dailyResetMode
=
"
editDailyResetMode = $event
"
@
update
:
dailyResetHour
=
"
editDailyResetHour = $event
"
@
update
:
weeklyResetMode
=
"
editWeeklyResetMode = $event
"
@
update
:
weeklyResetDay
=
"
editWeeklyResetDay = $event
"
@
update
:
weeklyResetHour
=
"
editWeeklyResetHour = $event
"
@
update
:
resetTimezone
=
"
editResetTimezone = $event
"
/>
<
/div
>
<!--
配额控制
(
非
Anthropic
apikey
/
bedrock
)
-->
<
div
v
-
else
-
if
=
"
form.type === 'apikey' || form.type === 'bedrock'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
>
<
div
class
=
"
mb-3
"
>
<
h3
class
=
"
input-label mb-0 text-base font-semibold
"
>
{{
t
(
'
admin.accounts.quotaControl.title
'
)
}}
<
/h3
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaLimitHint
'
)
}}
<
/p
>
...
...
@@ -1489,6 +1544,16 @@
:
totalLimit
=
"
editQuotaLimit
"
:
dailyLimit
=
"
editQuotaDailyLimit
"
:
weeklyLimit
=
"
editQuotaWeeklyLimit
"
:
quotaNotifyGlobalEnabled
=
"
quotaNotifyGlobalEnabled
"
:
quotaNotifyDailyEnabled
=
"
quotaNotifyState.daily.enabled
"
:
quotaNotifyDailyThreshold
=
"
quotaNotifyState.daily.threshold
"
:
quotaNotifyDailyThresholdType
=
"
quotaNotifyState.daily.thresholdType
"
:
quotaNotifyWeeklyEnabled
=
"
quotaNotifyState.weekly.enabled
"
:
quotaNotifyWeeklyThreshold
=
"
quotaNotifyState.weekly.threshold
"
:
quotaNotifyWeeklyThresholdType
=
"
quotaNotifyState.weekly.thresholdType
"
:
quotaNotifyTotalEnabled
=
"
quotaNotifyState.total.enabled
"
:
quotaNotifyTotalThreshold
=
"
quotaNotifyState.total.threshold
"
:
quotaNotifyTotalThresholdType
=
"
quotaNotifyState.total.thresholdType
"
:
dailyResetMode
=
"
editDailyResetMode
"
:
dailyResetHour
=
"
editDailyResetHour
"
:
weeklyResetMode
=
"
editWeeklyResetMode
"
...
...
@@ -1498,6 +1563,15 @@
@
update
:
totalLimit
=
"
editQuotaLimit = $event
"
@
update
:
dailyLimit
=
"
editQuotaDailyLimit = $event
"
@
update
:
weeklyLimit
=
"
editQuotaWeeklyLimit = $event
"
@
update
:
quotaNotifyDailyEnabled
=
"
quotaNotifyState.daily.enabled = $event
"
@
update
:
quotaNotifyDailyThreshold
=
"
quotaNotifyState.daily.threshold = $event
"
@
update
:
quotaNotifyDailyThresholdType
=
"
quotaNotifyState.daily.thresholdType = $event
"
@
update
:
quotaNotifyWeeklyEnabled
=
"
quotaNotifyState.weekly.enabled = $event
"
@
update
:
quotaNotifyWeeklyThreshold
=
"
quotaNotifyState.weekly.threshold = $event
"
@
update
:
quotaNotifyWeeklyThresholdType
=
"
quotaNotifyState.weekly.thresholdType = $event
"
@
update
:
quotaNotifyTotalEnabled
=
"
quotaNotifyState.total.enabled = $event
"
@
update
:
quotaNotifyTotalThreshold
=
"
quotaNotifyState.total.threshold = $event
"
@
update
:
quotaNotifyTotalThresholdType
=
"
quotaNotifyState.total.thresholdType = $event
"
@
update
:
dailyResetMode
=
"
editDailyResetMode = $event
"
@
update
:
dailyResetHour
=
"
editDailyResetHour = $event
"
@
update
:
weeklyResetMode
=
"
editWeeklyResetMode = $event
"
...
...
@@ -1823,7 +1897,7 @@
<
/div
>
<
/div
>
<!--
Quota
Control
Section
(
Anthropic
OAuth
/
SetupToken
only
)
-->
<!--
配额控制
(
Anthropic
OAuth
/
SetupToken
:
亲和
+
窗口费用
+
会话
+
RPM
等
)
-->
<
div
v
-
if
=
"
form.platform === 'anthropic' && accountCategory === 'oauth-based'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
...
...
@@ -2325,6 +2399,26 @@
<
/div
>
<
/div
>
<!--
Anthropic
API
Key
:
Web
Search
Emulation
(
hidden
when
global
disabled
)
-->
<
div
v
-
if
=
"
form.platform === 'anthropic' && accountCategory === 'apikey' && webSearchGlobalEnabled
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.anthropic.webSearchEmulation
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.anthropic.webSearchEmulationDesc
'
)
}}
<
/p
>
<
/div
>
<
select
v
-
model
=
"
webSearchEmulationMode
"
class
=
"
input w-24 text-sm
"
>
<
option
value
=
"
default
"
>
{{
t
(
'
admin.accounts.anthropic.webSearchDefault
'
)
}}
<
/option
>
<
option
value
=
"
enabled
"
>
{{
t
(
'
admin.accounts.anthropic.webSearchEnabled
'
)
}}
<
/option
>
<
option
value
=
"
disabled
"
>
{{
t
(
'
admin.accounts.anthropic.webSearchDisabled
'
)
}}
<
/option
>
<
/select
>
<
/div
>
<
/div
>
<!--
OpenAI
OAuth
Codex
官方客户端限制开关
-->
<
div
v
-
if
=
"
form.platform === 'openai' && accountCategory === 'oauth-based'
"
...
...
@@ -2809,6 +2903,7 @@ import {
}
from
'
@/composables/useModelWhitelist
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
useQuotaNotifyState
}
from
'
@/composables/useQuotaNotifyState
'
import
{
useAccountOAuth
,
type
AddMethod
,
...
...
@@ -2980,6 +3075,21 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF
const
openaiAPIKeyResponsesWebSocketV2Mode
=
ref
<
OpenAIWSMode
>
(
OPENAI_WS_MODE_OFF
)
const
codexCLIOnlyEnabled
=
ref
(
false
)
const
anthropicPassthroughEnabled
=
ref
(
false
)
const
webSearchEmulationMode
=
ref
(
'
default
'
)
const
webSearchGlobalEnabled
=
ref
(
false
)
const
{
globalEnabled
:
quotaNotifyGlobalEnabled
,
state
:
quotaNotifyState
,
loadGlobalState
:
loadQuotaNotifyGlobal
,
writeToExtra
:
writeQuotaNotifyToExtra
,
}
=
useQuotaNotifyState
()
// Load global feature states once
adminAPI
.
settings
.
getWebSearchEmulationConfig
().
then
(
cfg
=>
{
webSearchGlobalEnabled
.
value
=
cfg
?.
enabled
===
true
&&
(
cfg
?.
providers
?.
length
??
0
)
>
0
}
).
catch
(()
=>
{
webSearchGlobalEnabled
.
value
=
false
}
)
loadQuotaNotifyGlobal
()
const
mixedScheduling
=
ref
(
false
)
// For antigravity accounts: enable mixed scheduling
const
allowOverages
=
ref
(
false
)
// For antigravity accounts: enable AI Credits overages
const
antigravityAccountType
=
ref
<
'
oauth
'
|
'
upstream
'
>
(
'
oauth
'
)
// For antigravity: oauth or upstream
...
...
@@ -3307,6 +3417,7 @@ watch(
}
if
(
newPlatform
!==
'
anthropic
'
)
{
anthropicPassthroughEnabled
.
value
=
false
webSearchEmulationMode
.
value
=
'
default
'
}
// Reset OAuth states
oauth
.
resetState
()
...
...
@@ -3326,6 +3437,7 @@ watch(
}
if
(
platform
!==
'
anthropic
'
||
category
!==
'
apikey
'
)
{
anthropicPassthroughEnabled
.
value
=
false
webSearchEmulationMode
.
value
=
'
default
'
}
}
)
...
...
@@ -3690,6 +3802,7 @@ const resetForm = () => {
openaiAPIKeyResponsesWebSocketV2Mode
.
value
=
OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled
.
value
=
false
anthropicPassthroughEnabled
.
value
=
false
webSearchEmulationMode
.
value
=
'
default
'
// Reset quota control state
windowCostEnabled
.
value
=
false
windowCostLimit
.
value
=
null
...
...
@@ -3777,6 +3890,11 @@ const buildAnthropicExtra = (base?: Record<string, unknown>): Record<string, unk
}
else
{
delete
extra
.
anthropic_passthrough
}
if
(
webSearchEmulationMode
.
value
===
'
default
'
)
{
delete
extra
.
web_search_emulation
}
else
{
extra
.
web_search_emulation
=
webSearchEmulationMode
.
value
}
return
Object
.
keys
(
extra
).
length
>
0
?
extra
:
undefined
}
...
...
@@ -4075,6 +4193,8 @@ const createAccountAndFinish = async (
if
(
editDailyResetMode
.
value
===
'
fixed
'
||
editWeeklyResetMode
.
value
===
'
fixed
'
)
{
quotaExtra
.
quota_reset_timezone
=
editResetTimezone
.
value
||
'
UTC
'
}
// Quota notify config
writeQuotaNotifyToExtra
(
quotaExtra
,
'
create
'
)
if
(
Object
.
keys
(
quotaExtra
).
length
>
0
)
{
finalExtra
=
quotaExtra
}
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
0b746501
...
...
@@ -2,7 +2,7 @@
<BaseDialog
:show=
"show"
:title=
"t('admin.accounts.editAccount')"
width=
"
normal
"
width=
"
wide
"
@
close=
"handleClose"
>
<form
...
...
@@ -1149,10 +1149,84 @@
<
/div
>
<
/div
>
<!--
API
Key
/
Bedrock
账号配额限制
-->
<
div
v
-
if
=
"
account?.type === 'apikey' || account?.type === 'bedrock'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
>
<!--
Anthropic
API
Key
:
Web
Search
Emulation
(
hidden
when
global
disabled
)
-->
<
div
v
-
if
=
"
account?.platform === 'anthropic' && account?.type === 'apikey' && webSearchGlobalEnabled
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.anthropic.webSearchEmulation
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.anthropic.webSearchEmulationDesc
'
)
}}
<
/p
>
<
/div
>
<
select
v
-
model
=
"
webSearchEmulationMode
"
class
=
"
input w-24 text-sm
"
>
<
option
value
=
"
default
"
>
{{
t
(
'
admin.accounts.anthropic.webSearchDefault
'
)
}}
<
/option
>
<
option
value
=
"
enabled
"
>
{{
t
(
'
admin.accounts.anthropic.webSearchEnabled
'
)
}}
<
/option
>
<
option
value
=
"
disabled
"
>
{{
t
(
'
admin.accounts.anthropic.webSearchDisabled
'
)
}}
<
/option
>
<
/select
>
<
/div
>
<
/div
>
<!--
配额控制
(
Anthropic
apikey
/
bedrock
:
配额限制
+
亲和
)
-->
<
div
v
-
if
=
"
account?.platform === 'anthropic' && (account?.type === 'apikey' || account?.type === 'bedrock')
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
>
<
div
class
=
"
mb-3
"
>
<
h3
class
=
"
input-label mb-0 text-base font-semibold
"
>
{{
t
(
'
admin.accounts.quotaControl.title
'
)
}}
<
/h3
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.hint
'
)
}}
<
/p
>
<
/div
>
<
QuotaLimitCard
:
totalLimit
=
"
editQuotaLimit
"
:
dailyLimit
=
"
editQuotaDailyLimit
"
:
weeklyLimit
=
"
editQuotaWeeklyLimit
"
:
dailyResetMode
=
"
editDailyResetMode
"
:
dailyResetHour
=
"
editDailyResetHour
"
:
weeklyResetMode
=
"
editWeeklyResetMode
"
:
weeklyResetDay
=
"
editWeeklyResetDay
"
:
weeklyResetHour
=
"
editWeeklyResetHour
"
:
resetTimezone
=
"
editResetTimezone
"
:
quotaNotifyGlobalEnabled
=
"
quotaNotifyGlobalEnabled
"
:
quotaNotifyDailyEnabled
=
"
quotaNotifyState.daily.enabled
"
:
quotaNotifyDailyThreshold
=
"
quotaNotifyState.daily.threshold
"
:
quotaNotifyDailyThresholdType
=
"
quotaNotifyState.daily.thresholdType
"
:
quotaNotifyWeeklyEnabled
=
"
quotaNotifyState.weekly.enabled
"
:
quotaNotifyWeeklyThreshold
=
"
quotaNotifyState.weekly.threshold
"
:
quotaNotifyWeeklyThresholdType
=
"
quotaNotifyState.weekly.thresholdType
"
:
quotaNotifyTotalEnabled
=
"
quotaNotifyState.total.enabled
"
:
quotaNotifyTotalThreshold
=
"
quotaNotifyState.total.threshold
"
:
quotaNotifyTotalThresholdType
=
"
quotaNotifyState.total.thresholdType
"
@
update
:
totalLimit
=
"
editQuotaLimit = $event
"
@
update
:
dailyLimit
=
"
editQuotaDailyLimit = $event
"
@
update
:
weeklyLimit
=
"
editQuotaWeeklyLimit = $event
"
@
update
:
dailyResetMode
=
"
editDailyResetMode = $event
"
@
update
:
dailyResetHour
=
"
editDailyResetHour = $event
"
@
update
:
weeklyResetMode
=
"
editWeeklyResetMode = $event
"
@
update
:
weeklyResetDay
=
"
editWeeklyResetDay = $event
"
@
update
:
weeklyResetHour
=
"
editWeeklyResetHour = $event
"
@
update
:
resetTimezone
=
"
editResetTimezone = $event
"
@
update
:
quotaNotifyDailyEnabled
=
"
quotaNotifyState.daily.enabled = $event
"
@
update
:
quotaNotifyDailyThreshold
=
"
quotaNotifyState.daily.threshold = $event
"
@
update
:
quotaNotifyDailyThresholdType
=
"
quotaNotifyState.daily.thresholdType = $event
"
@
update
:
quotaNotifyWeeklyEnabled
=
"
quotaNotifyState.weekly.enabled = $event
"
@
update
:
quotaNotifyWeeklyThreshold
=
"
quotaNotifyState.weekly.threshold = $event
"
@
update
:
quotaNotifyWeeklyThresholdType
=
"
quotaNotifyState.weekly.thresholdType = $event
"
@
update
:
quotaNotifyTotalEnabled
=
"
quotaNotifyState.total.enabled = $event
"
@
update
:
quotaNotifyTotalThreshold
=
"
quotaNotifyState.total.threshold = $event
"
@
update
:
quotaNotifyTotalThresholdType
=
"
quotaNotifyState.total.thresholdType = $event
"
/>
<
/div
>
<!--
配额控制
(
非
Anthropic
apikey
/
bedrock
)
-->
<
div
v
-
else
-
if
=
"
account?.type === 'apikey' || account?.type === 'bedrock'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
>
<
div
class
=
"
mb-3
"
>
<
h3
class
=
"
input-label mb-0 text-base font-semibold
"
>
{{
t
(
'
admin.accounts.quota
Limit
'
)
}}
<
/h3
>
<
h3
class
=
"
input-label mb-0 text-base font-semibold
"
>
{{
t
(
'
admin.accounts.quota
Control.title
'
)
}}
<
/h3
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaLimitHint
'
)
}}
<
/p
>
...
...
@@ -1167,6 +1241,16 @@
:
weeklyResetDay
=
"
editWeeklyResetDay
"
:
weeklyResetHour
=
"
editWeeklyResetHour
"
:
resetTimezone
=
"
editResetTimezone
"
:
quotaNotifyGlobalEnabled
=
"
quotaNotifyGlobalEnabled
"
:
quotaNotifyDailyEnabled
=
"
quotaNotifyState.daily.enabled
"
:
quotaNotifyDailyThreshold
=
"
quotaNotifyState.daily.threshold
"
:
quotaNotifyDailyThresholdType
=
"
quotaNotifyState.daily.thresholdType
"
:
quotaNotifyWeeklyEnabled
=
"
quotaNotifyState.weekly.enabled
"
:
quotaNotifyWeeklyThreshold
=
"
quotaNotifyState.weekly.threshold
"
:
quotaNotifyWeeklyThresholdType
=
"
quotaNotifyState.weekly.thresholdType
"
:
quotaNotifyTotalEnabled
=
"
quotaNotifyState.total.enabled
"
:
quotaNotifyTotalThreshold
=
"
quotaNotifyState.total.threshold
"
:
quotaNotifyTotalThresholdType
=
"
quotaNotifyState.total.thresholdType
"
@
update
:
totalLimit
=
"
editQuotaLimit = $event
"
@
update
:
dailyLimit
=
"
editQuotaDailyLimit = $event
"
@
update
:
weeklyLimit
=
"
editQuotaWeeklyLimit = $event
"
...
...
@@ -1176,6 +1260,15 @@
@
update
:
weeklyResetDay
=
"
editWeeklyResetDay = $event
"
@
update
:
weeklyResetHour
=
"
editWeeklyResetHour = $event
"
@
update
:
resetTimezone
=
"
editResetTimezone = $event
"
@
update
:
quotaNotifyDailyEnabled
=
"
quotaNotifyState.daily.enabled = $event
"
@
update
:
quotaNotifyDailyThreshold
=
"
quotaNotifyState.daily.threshold = $event
"
@
update
:
quotaNotifyDailyThresholdType
=
"
quotaNotifyState.daily.thresholdType = $event
"
@
update
:
quotaNotifyWeeklyEnabled
=
"
quotaNotifyState.weekly.enabled = $event
"
@
update
:
quotaNotifyWeeklyThreshold
=
"
quotaNotifyState.weekly.threshold = $event
"
@
update
:
quotaNotifyWeeklyThresholdType
=
"
quotaNotifyState.weekly.thresholdType = $event
"
@
update
:
quotaNotifyTotalEnabled
=
"
quotaNotifyState.total.enabled = $event
"
@
update
:
quotaNotifyTotalThreshold
=
"
quotaNotifyState.total.threshold = $event
"
@
update
:
quotaNotifyTotalThresholdType
=
"
quotaNotifyState.total.thresholdType = $event
"
/>
<
/div
>
...
...
@@ -1237,7 +1330,7 @@
<
/div
>
<
/div
>
<!--
Quota
Control
Section
(
Anthropic
OAuth
/
SetupToken
only
)
-->
<!--
配额控制
(
Anthropic
OAuth
/
SetupToken
:
亲和
+
窗口费用
+
会话
+
RPM
等
)
-->
<
div
v
-
if
=
"
account?.platform === 'anthropic' && (account?.type === 'oauth' || account?.type === 'setup-token')
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
...
...
@@ -1751,6 +1844,7 @@ import { useI18n } from 'vue-i18n'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
useQuotaNotifyState
}
from
'
@/composables/useQuotaNotifyState
'
import
type
{
Account
,
Proxy
,
AdminGroup
,
CheckMixedChannelResponse
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
...
...
@@ -1898,6 +1992,23 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF
const
openaiAPIKeyResponsesWebSocketV2Mode
=
ref
<
OpenAIWSMode
>
(
OPENAI_WS_MODE_OFF
)
const
codexCLIOnlyEnabled
=
ref
(
false
)
const
anthropicPassthroughEnabled
=
ref
(
false
)
const
webSearchEmulationMode
=
ref
(
'
default
'
)
const
webSearchGlobalEnabled
=
ref
(
false
)
const
{
globalEnabled
:
quotaNotifyGlobalEnabled
,
state
:
quotaNotifyState
,
loadGlobalState
:
loadQuotaNotifyGlobal
,
loadFromExtra
:
loadQuotaNotifyFromExtra
,
writeToExtra
:
writeQuotaNotifyToExtra
,
reset
:
resetQuotaNotify
,
}
=
useQuotaNotifyState
()
// Load global feature states once
adminAPI
.
settings
.
getWebSearchEmulationConfig
().
then
(
cfg
=>
{
webSearchGlobalEnabled
.
value
=
cfg
?.
enabled
===
true
&&
(
cfg
?.
providers
?.
length
??
0
)
>
0
}
).
catch
(()
=>
{
webSearchGlobalEnabled
.
value
=
false
}
)
loadQuotaNotifyGlobal
()
const
editQuotaLimit
=
ref
<
number
|
null
>
(
null
)
const
editQuotaDailyLimit
=
ref
<
number
|
null
>
(
null
)
const
editQuotaWeeklyLimit
=
ref
<
number
|
null
>
(
null
)
...
...
@@ -2067,6 +2178,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
openaiAPIKeyResponsesWebSocketV2Mode
.
value
=
OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled
.
value
=
false
anthropicPassthroughEnabled
.
value
=
false
webSearchEmulationMode
.
value
=
'
default
'
if
(
newAccount
.
platform
===
'
openai
'
&&
(
newAccount
.
type
===
'
oauth
'
||
newAccount
.
type
===
'
apikey
'
))
{
openaiPassthroughEnabled
.
value
=
extra
?.
openai_passthrough
===
true
||
extra
?.
openai_oauth_passthrough
===
true
openaiOAuthResponsesWebSocketV2Mode
.
value
=
resolveOpenAIWSModeFromExtra
(
extra
,
{
...
...
@@ -2087,6 +2199,15 @@ const syncFormFromAccount = (newAccount: Account | null) => {
}
if
(
newAccount
.
platform
===
'
anthropic
'
&&
newAccount
.
type
===
'
apikey
'
)
{
anthropicPassthroughEnabled
.
value
=
extra
?.
anthropic_passthrough
===
true
// 三态:string "default"/"enabled"/"disabled",向后兼容旧 bool
const
wsVal
=
extra
?.
web_search_emulation
if
(
wsVal
===
'
enabled
'
||
wsVal
===
'
disabled
'
)
{
webSearchEmulationMode
.
value
=
wsVal
}
else
if
(
wsVal
===
true
)
{
webSearchEmulationMode
.
value
=
'
enabled
'
}
else
{
webSearchEmulationMode
.
value
=
'
default
'
}
}
// Load quota limit for apikey/bedrock accounts (bedrock quota is also loaded in its own branch above)
...
...
@@ -2104,6 +2225,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editWeeklyResetDay
.
value
=
(
extra
?.
quota_weekly_reset_day
as
number
)
??
null
editWeeklyResetHour
.
value
=
(
extra
?.
quota_weekly_reset_hour
as
number
)
??
null
editResetTimezone
.
value
=
(
extra
?.
quota_reset_timezone
as
string
)
||
null
// Load quota notify config
loadQuotaNotifyFromExtra
(
extra
)
}
else
{
editQuotaLimit
.
value
=
null
editQuotaDailyLimit
.
value
=
null
...
...
@@ -2114,6 +2237,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editWeeklyResetDay
.
value
=
null
editWeeklyResetHour
.
value
=
null
editResetTimezone
.
value
=
null
resetQuotaNotify
()
}
// Load antigravity model mapping (Antigravity 只支持映射模式)
...
...
@@ -2228,6 +2352,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editQuotaLimit
.
value
=
typeof
bedrockExtra
.
quota_limit
===
'
number
'
?
bedrockExtra
.
quota_limit
:
null
editQuotaDailyLimit
.
value
=
typeof
bedrockExtra
.
quota_daily_limit
===
'
number
'
?
bedrockExtra
.
quota_daily_limit
:
null
editQuotaWeeklyLimit
.
value
=
typeof
bedrockExtra
.
quota_weekly_limit
===
'
number
'
?
bedrockExtra
.
quota_weekly_limit
:
null
// Load quota notify for bedrock
loadQuotaNotifyFromExtra
(
bedrockExtra
)
// Load model mappings for bedrock
const
existingMappings
=
bedrockCreds
.
model_mapping
as
Record
<
string
,
string
>
|
undefined
...
...
@@ -2522,8 +2648,13 @@ function loadQuotaControlSettings(account: Account) {
customBaseUrlEnabled
.
value
=
false
customBaseUrl
.
value
=
''
// Only applies to Anthropic OAuth/SetupToken accounts
if
(
account
.
platform
!==
'
anthropic
'
||
(
account
.
type
!==
'
oauth
'
&&
account
.
type
!==
'
setup-token
'
))
{
// Remaining quota control settings only apply to Anthropic accounts
if
(
account
.
platform
!==
'
anthropic
'
)
{
return
}
// Window cost / session limit only apply to Anthropic OAuth/SetupToken accounts
if
(
account
.
type
!==
'
oauth
'
&&
account
.
type
!==
'
setup-token
'
)
{
return
}
...
...
@@ -2949,7 +3080,7 @@ const handleSubmit = async () => {
// For Anthropic OAuth/SetupToken accounts, handle quota control settings in extra
if
(
props
.
account
.
platform
===
'
anthropic
'
&&
(
props
.
account
.
type
===
'
oauth
'
||
props
.
account
.
type
===
'
setup-token
'
))
{
const
currentExtra
=
(
props
.
account
.
extra
as
Record
<
string
,
unknown
>
)
||
{
}
const
currentExtra
=
(
updatePayload
.
extra
as
Record
<
string
,
unknown
>
)
||
(
props
.
account
.
extra
as
Record
<
string
,
unknown
>
)
||
{
}
const
newExtra
:
Record
<
string
,
unknown
>
=
{
...
currentExtra
}
// Window cost limit settings
...
...
@@ -3037,15 +3168,20 @@ const handleSubmit = async () => {
updatePayload
.
extra
=
newExtra
}
// For Anthropic API Key accounts, handle passthrough mode in extra
// For Anthropic API Key accounts, handle passthrough mode
+ web search emulation
in extra
if
(
props
.
account
.
platform
===
'
anthropic
'
&&
props
.
account
.
type
===
'
apikey
'
)
{
const
currentExtra
=
(
props
.
account
.
extra
as
Record
<
string
,
unknown
>
)
||
{
}
const
currentExtra
=
(
updatePayload
.
extra
as
Record
<
string
,
unknown
>
)
||
(
props
.
account
.
extra
as
Record
<
string
,
unknown
>
)
||
{
}
const
newExtra
:
Record
<
string
,
unknown
>
=
{
...
currentExtra
}
if
(
anthropicPassthroughEnabled
.
value
)
{
newExtra
.
anthropic_passthrough
=
true
}
else
{
delete
newExtra
.
anthropic_passthrough
}
if
(
webSearchEmulationMode
.
value
===
'
default
'
)
{
delete
newExtra
.
web_search_emulation
}
else
{
newExtra
.
web_search_emulation
=
webSearchEmulationMode
.
value
}
updatePayload
.
extra
=
newExtra
}
...
...
@@ -3089,20 +3225,27 @@ const handleSubmit = async () => {
const
currentExtra
=
(
updatePayload
.
extra
as
Record
<
string
,
unknown
>
)
||
(
props
.
account
.
extra
as
Record
<
string
,
unknown
>
)
||
{
}
const
newExtra
:
Record
<
string
,
unknown
>
=
{
...
currentExtra
}
// Total quota
if
(
editQuotaLimit
.
value
!=
null
&&
editQuotaLimit
.
value
>
0
)
{
newExtra
.
quota_limit
=
editQuotaLimit
.
value
}
else
{
delete
newExtra
.
quota_limit
}
// Daily quota
if
(
editQuotaDailyLimit
.
value
!=
null
&&
editQuotaDailyLimit
.
value
>
0
)
{
newExtra
.
quota_daily_limit
=
editQuotaDailyLimit
.
value
}
else
{
delete
newExtra
.
quota_daily_limit
delete
newExtra
.
quota_daily_used
delete
newExtra
.
quota_daily_start
}
// Weekly quota
if
(
editQuotaWeeklyLimit
.
value
!=
null
&&
editQuotaWeeklyLimit
.
value
>
0
)
{
newExtra
.
quota_weekly_limit
=
editQuotaWeeklyLimit
.
value
}
else
{
delete
newExtra
.
quota_weekly_limit
delete
newExtra
.
quota_weekly_used
delete
newExtra
.
quota_weekly_start
}
// Quota reset mode config
if
(
editDailyResetMode
.
value
===
'
fixed
'
)
{
...
...
@@ -3126,6 +3269,8 @@ const handleSubmit = async () => {
}
else
{
delete
newExtra
.
quota_reset_timezone
}
// Quota notify config
writeQuotaNotifyToExtra
(
newExtra
,
'
update
'
)
updatePayload
.
extra
=
newExtra
}
...
...
frontend/src/components/account/QuotaDimensionRow.vue
0 → 100644
View file @
0b746501
<
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>
frontend/src/components/account/QuotaLimitCard.vue
View file @
0b746501
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
,
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
QuotaDimensionRow
from
'
./QuotaDimensionRow.vue
'
import
type
{
QuotaThresholdType
,
QuotaResetMode
}
from
'
@/constants/account
'
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
const
props
=
withDefaults
(
defineProps
<
{
totalLimit
:
number
|
null
dailyLimit
:
number
|
null
weeklyLimit
:
number
|
null
dailyResetMode
:
'
rolling
'
|
'
fixed
'
|
null
dailyResetMode
:
QuotaResetMode
|
null
dailyResetHour
:
number
|
null
weeklyResetMode
:
'
rolling
'
|
'
fixed
'
|
null
weeklyResetMode
:
QuotaResetMode
|
null
weeklyResetDay
:
number
|
null
weeklyResetHour
:
number
|
null
resetTimezone
:
string
|
null
}
>
()
quotaNotifyGlobalEnabled
?:
boolean
quotaNotifyDailyEnabled
?:
boolean
|
null
quotaNotifyDailyThreshold
?:
number
|
null
quotaNotifyDailyThresholdType
?:
QuotaThresholdType
|
null
quotaNotifyWeeklyEnabled
?:
boolean
|
null
quotaNotifyWeeklyThreshold
?:
number
|
null
quotaNotifyWeeklyThresholdType
?:
QuotaThresholdType
|
null
quotaNotifyTotalEnabled
?:
boolean
|
null
quotaNotifyTotalThreshold
?:
number
|
null
quotaNotifyTotalThresholdType
?:
QuotaThresholdType
|
null
}
>
(),
{
quotaNotifyGlobalEnabled
:
false
,
quotaNotifyDailyEnabled
:
null
,
quotaNotifyDailyThreshold
:
null
,
quotaNotifyDailyThresholdType
:
null
,
quotaNotifyWeeklyEnabled
:
null
,
quotaNotifyWeeklyThreshold
:
null
,
quotaNotifyWeeklyThresholdType
:
null
,
quotaNotifyTotalEnabled
:
null
,
quotaNotifyTotalThreshold
:
null
,
quotaNotifyTotalThresholdType
:
null
,
})
const
emit
=
defineEmits
<
{
'
update:totalLimit
'
:
[
value
:
number
|
null
]
'
update:dailyLimit
'
:
[
value
:
number
|
null
]
'
update:weeklyLimit
'
:
[
value
:
number
|
null
]
'
update:dailyResetMode
'
:
[
value
:
'
rolling
'
|
'
fixed
'
|
null
]
'
update:dailyResetMode
'
:
[
value
:
QuotaResetMode
|
null
]
'
update:dailyResetHour
'
:
[
value
:
number
|
null
]
'
update:weeklyResetMode
'
:
[
value
:
'
rolling
'
|
'
fixed
'
|
null
]
'
update:weeklyResetMode
'
:
[
value
:
QuotaResetMode
|
null
]
'
update:weeklyResetDay
'
:
[
value
:
number
|
null
]
'
update:weeklyResetHour
'
:
[
value
:
number
|
null
]
'
update:resetTimezone
'
:
[
value
:
string
|
null
]
'
update:quotaNotifyDailyEnabled
'
:
[
value
:
boolean
|
null
]
'
update:quotaNotifyDailyThreshold
'
:
[
value
:
number
|
null
]
'
update:quotaNotifyDailyThresholdType
'
:
[
value
:
QuotaThresholdType
|
null
]
'
update:quotaNotifyWeeklyEnabled
'
:
[
value
:
boolean
|
null
]
'
update:quotaNotifyWeeklyThreshold
'
:
[
value
:
number
|
null
]
'
update:quotaNotifyWeeklyThresholdType
'
:
[
value
:
QuotaThresholdType
|
null
]
'
update:quotaNotifyTotalEnabled
'
:
[
value
:
boolean
|
null
]
'
update:quotaNotifyTotalThreshold
'
:
[
value
:
number
|
null
]
'
update:quotaNotifyTotalThresholdType
'
:
[
value
:
QuotaThresholdType
|
null
]
}
>
()
const
enabled
=
computed
(()
=>
...
...
@@ -35,15 +67,17 @@ const enabled = computed(() =>
)
const
localEnabled
=
ref
(
enabled
.
value
)
const
collapsed
=
ref
(
false
)
// Sync when props change externally
watch
(
enabled
,
(
val
)
=>
{
localEnabled
.
value
=
val
})
// When toggle is turned off, clear all values
// When toggle is turned off, clear all values
and expand
watch
(
localEnabled
,
(
val
)
=>
{
if
(
!
val
)
{
collapsed
.
value
=
false
emit
(
'
update:totalLimit
'
,
null
)
emit
(
'
update:dailyLimit
'
,
null
)
emit
(
'
update:weeklyLimit
'
,
null
)
...
...
@@ -56,31 +90,12 @@ watch(localEnabled, (val) => {
}
})
// Whether any fixed mode is active (to show timezone selector)
const
hasFixedMode
=
computed
(()
=>
props
.
dailyResetMode
===
'
fixed
'
||
props
.
weeklyResetMode
===
'
fixed
'
)
// Common timezone options
const
timezoneOptions
=
[
'
UTC
'
,
'
Asia/Shanghai
'
,
'
Asia/Tokyo
'
,
'
Asia/Seoul
'
,
'
Asia/Singapore
'
,
'
Asia/Kolkata
'
,
'
Asia/Dubai
'
,
'
Europe/London
'
,
'
Europe/Paris
'
,
'
Europe/Berlin
'
,
'
Europe/Moscow
'
,
'
America/New_York
'
,
'
America/Chicago
'
,
'
America/Denver
'
,
'
America/Los_Angeles
'
,
'
America/Sao_Paulo
'
,
'
Australia/Sydney
'
,
'
Pacific/Auckland
'
,
'
UTC
'
,
'
Asia/Shanghai
'
,
'
Asia/Tokyo
'
,
'
Asia/Seoul
'
,
'
Asia/Singapore
'
,
'
Asia/Kolkata
'
,
'
Asia/Dubai
'
,
'
Europe/London
'
,
'
Europe/Paris
'
,
'
Europe/Berlin
'
,
'
Europe/Moscow
'
,
'
America/New_York
'
,
'
America/Chicago
'
,
'
America/Denver
'
,
'
America/Los_Angeles
'
,
'
America/Sao_Paulo
'
,
'
Australia/Sydney
'
,
'
Pacific/Auckland
'
,
]
// Hours for dropdown (0-23)
...
...
@@ -97,47 +112,38 @@ const dayOptions = [
{
value
:
0
,
key
:
'
sunday
'
},
]
const
onTotalInput
=
(
e
:
Event
)
=>
{
const
raw
=
(
e
.
target
as
HTMLInputElement
).
valueAsNumber
emit
(
'
update:totalLimit
'
,
Number
.
isNaN
(
raw
)
?
null
:
raw
)
}
const
onDailyInput
=
(
e
:
Event
)
=>
{
const
raw
=
(
e
.
target
as
HTMLInputElement
).
valueAsNumber
emit
(
'
update:dailyLimit
'
,
Number
.
isNaN
(
raw
)
?
null
:
raw
)
}
const
onWeeklyInput
=
(
e
:
Event
)
=>
{
const
raw
=
(
e
.
target
as
HTMLInputElement
).
valueAsNumber
emit
(
'
update:weeklyLimit
'
,
Number
.
isNaN
(
raw
)
?
null
:
raw
)
}
const
onDailyModeChange
=
(
e
:
Event
)
=>
{
const
val
=
(
e
.
target
as
HTMLSelectElement
).
value
as
'
rolling
'
|
'
fixed
'
emit
(
'
update:dailyResetMode
'
,
val
)
if
(
val
===
'
fixed
'
)
{
if
(
props
.
dailyResetHour
==
null
)
emit
(
'
update:dailyResetHour
'
,
0
)
if
(
!
props
.
resetTimezone
)
emit
(
'
update:resetTimezone
'
,
'
UTC
'
)
}
}
// Precomputed hint strings for the weekly fixed mode
const
weeklyFixedHint
=
computed
(()
=>
{
const
dayKey
=
dayOptions
.
find
(
d
=>
d
.
value
===
(
props
.
weeklyResetDay
??
1
))?.
key
||
'
monday
'
return
t
(
'
admin.accounts.quotaWeeklyLimitHintFixed
'
,
{
day
:
t
(
'
admin.accounts.dayOfWeek.
'
+
dayKey
),
hour
:
String
(
props
.
weeklyResetHour
??
0
).
padStart
(
2
,
'
0
'
),
timezone
:
props
.
resetTimezone
||
'
UTC
'
,
})
})
const
onWeeklyModeChange
=
(
e
:
Event
)
=>
{
const
val
=
(
e
.
target
as
HTMLSelectElement
).
value
as
'
rolling
'
|
'
fixed
'
emit
(
'
update:weeklyResetMode
'
,
val
)
if
(
val
===
'
fixed
'
)
{
if
(
props
.
weeklyResetDay
==
null
)
emit
(
'
update:weeklyResetDay
'
,
1
)
if
(
props
.
weeklyResetHour
==
null
)
emit
(
'
update:weeklyResetHour
'
,
0
)
if
(
!
props
.
resetTimezone
)
emit
(
'
update:resetTimezone
'
,
'
UTC
'
)
}
}
const
dailyFixedHint
=
computed
(()
=>
t
(
'
admin.accounts.quotaDailyLimitHintFixed
'
,
{
hour
:
String
(
props
.
dailyResetHour
??
0
).
padStart
(
2
,
'
0
'
),
timezone
:
props
.
resetTimezone
||
'
UTC
'
,
})
)
</
script
>
<
template
>
<div
class=
"rounded-lg border border-gray-200 p-4 dark:border-dark-600"
>
<div
class=
"mb-3 flex items-center justify-between"
>
<div>
<label
class=
"input-label mb-0"
>
{{
t
(
'
admin.accounts.quotaLimitToggle
'
)
}}
</label>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.quotaLimitToggleHint
'
)
}}
</p>
<div
class=
"rounded-lg border border-gray-200 dark:border-dark-600"
>
<!-- Header: toggle + collapse -->
<div
class=
"flex items-center justify-between p-4"
:class=
"
{ 'pb-0': localEnabled
&&
!collapsed }">
<div
class=
"flex items-center gap-2 flex-1 cursor-pointer"
@
click=
"localEnabled && (collapsed = !collapsed)"
>
<svg
v-if=
"localEnabled"
class=
"h-4 w-4 text-gray-400 transition-transform"
:class=
"
{ '-rotate-90': collapsed }" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule=
"evenodd"
d=
"M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule=
"evenodd"
/>
</svg>
<div>
<label
class=
"input-label mb-0 cursor-pointer"
>
{{
t
(
'
admin.accounts.quotaLimitToggle
'
)
}}
</label>
<p
class=
"mt-0.5 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.quotaLimitToggleHint
'
)
}}
</p>
</div>
</div>
<button
type=
"button"
...
...
@@ -156,140 +162,85 @@ const onWeeklyModeChange = (e: Event) => {
</button>
</div>
<div
v-if=
"localEnabled"
class=
"space-y-3"
>
<!-- 日配额 -->
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.quotaDailyLimit
'
)
}}
</label>
<div
class=
"relative"
>
<span
class=
"absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400"
>
$
</span>
<input
:value=
"dailyLimit"
@
input=
"onDailyInput"
type=
"number"
min=
"0"
step=
"0.01"
class=
"input pl-7"
:placeholder=
"t('admin.accounts.quotaLimitPlaceholder')"
/>
</div>
<!-- 日配额重置模式 -->
<div
class=
"mt-2 flex items-center gap-2"
>
<label
class=
"text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"
>
{{
t
(
'
admin.accounts.quotaResetMode
'
)
}}
</label>
<select
:value=
"dailyResetMode || 'rolling'"
@
change=
"onDailyModeChange"
class=
"input py-1 text-xs"
>
<option
value=
"rolling"
>
{{
t
(
'
admin.accounts.quotaResetModeRolling
'
)
}}
</option>
<option
value=
"fixed"
>
{{
t
(
'
admin.accounts.quotaResetModeFixed
'
)
}}
</option>
</select>
</div>
<!-- 固定模式:小时选择 -->
<div
v-if=
"dailyResetMode === 'fixed'"
class=
"mt-2 flex items-center gap-2"
>
<label
class=
"text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"
>
{{
t
(
'
admin.accounts.quotaResetHour
'
)
}}
</label>
<select
:value=
"dailyResetHour ?? 0"
@
change=
"emit('update:dailyResetHour', Number(($event.target as HTMLSelectElement).value))"
class=
"input py-1 text-xs w-24"
>
<option
v-for=
"h in hourOptions"
:key=
"h"
:value=
"h"
>
{{
String
(
h
).
padStart
(
2
,
'
0
'
)
}}
:00
</option>
</select>
</div>
<p
class=
"input-hint"
>
<template
v-if=
"dailyResetMode === 'fixed'"
>
{{
t
(
'
admin.accounts.quotaDailyLimitHintFixed
'
,
{
hour
:
String
(
dailyResetHour
??
0
).
padStart
(
2
,
'
0
'
),
timezone
:
resetTimezone
||
'
UTC
'
}
)
}}
<
/template
>
<
template
v
-
else
>
{{
t
(
'
admin.accounts.quotaDailyLimitHint
'
)
}}
<
/template
>
<
/p
>
<
/div
>
<!--
周配额
-->
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaWeeklyLimit
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
span
class
=
"
absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400
"
>
$
<
/span
>
<
input
:
value
=
"
weeklyLimit
"
@
input
=
"
onWeeklyInput
"
type
=
"
number
"
min
=
"
0
"
step
=
"
0.01
"
class
=
"
input pl-7
"
:
placeholder
=
"
t('admin.accounts.quotaLimitPlaceholder')
"
/>
<
/div
>
<!--
周配额重置模式
-->
<
div
class
=
"
mt-2 flex items-center gap-2
"
>
<
label
class
=
"
text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap
"
>
{{
t
(
'
admin.accounts.quotaResetMode
'
)
}}
<
/label
>
<
select
:
value
=
"
weeklyResetMode || 'rolling'
"
@
change
=
"
onWeeklyModeChange
"
class
=
"
input py-1 text-xs
"
>
<
option
value
=
"
rolling
"
>
{{
t
(
'
admin.accounts.quotaResetModeRolling
'
)
}}
<
/option
>
<
option
value
=
"
fixed
"
>
{{
t
(
'
admin.accounts.quotaResetModeFixed
'
)
}}
<
/option
>
<
/select
>
<
/div
>
<!--
固定模式
:
星期几
+
小时
-->
<
div
v
-
if
=
"
weeklyResetMode === 'fixed'
"
class
=
"
mt-2 flex items-center gap-2 flex-wrap
"
>
<
label
class
=
"
text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap
"
>
{{
t
(
'
admin.accounts.quotaWeeklyResetDay
'
)
}}
<
/label
>
<
select
:
value
=
"
weeklyResetDay ?? 1
"
@
change
=
"
emit('update:weeklyResetDay', Number(($event.target as HTMLSelectElement).value))
"
class
=
"
input py-1 text-xs w-28
"
>
<
option
v
-
for
=
"
d in dayOptions
"
:
key
=
"
d.value
"
:
value
=
"
d.value
"
>
{{
t
(
'
admin.accounts.dayOfWeek.
'
+
d
.
key
)
}}
<
/option
>
<
/select
>
<
label
class
=
"
text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap
"
>
{{
t
(
'
admin.accounts.quotaResetHour
'
)
}}
<
/label
>
<
select
:
value
=
"
weeklyResetHour ?? 0
"
@
change
=
"
emit('update:weeklyResetHour', Number(($event.target as HTMLSelectElement).value))
"
class
=
"
input py-1 text-xs w-24
"
>
<
option
v
-
for
=
"
h in hourOptions
"
:
key
=
"
h
"
:
value
=
"
h
"
>
{{
String
(
h
).
padStart
(
2
,
'
0
'
)
}}
:
00
<
/option
>
<
/select
>
<
/div
>
<
p
class
=
"
input-hint
"
>
<
template
v
-
if
=
"
weeklyResetMode === 'fixed'
"
>
{{
t
(
'
admin.accounts.quotaWeeklyLimitHintFixed
'
,
{
day
:
t
(
'
admin.accounts.dayOfWeek.
'
+
(
dayOptions
.
find
(
d
=>
d
.
value
===
(
weeklyResetDay
??
1
))?.
key
||
'
monday
'
)),
hour
:
String
(
weeklyResetHour
??
0
).
padStart
(
2
,
'
0
'
),
timezone
:
resetTimezone
||
'
UTC
'
}
)
}}
<
/template
>
<
template
v
-
else
>
{{
t
(
'
admin.accounts.quotaWeeklyLimitHint
'
)
}}
<
/template
>
<
/p
>
<
/div
>
<!--
时区选择
(
当任一维度使用固定模式时显示
)
-->
<
div
v
-
if
=
"
hasFixedMode
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaResetTimezone
'
)
}}
<
/label
>
<
select
:
value
=
"
resetTimezone || 'UTC'
"
@
change
=
"
emit('update:resetTimezone', ($event.target as HTMLSelectElement).value)
"
class
=
"
input text-sm
"
>
<
option
v
-
for
=
"
tz in timezoneOptions
"
:
key
=
"
tz
"
:
value
=
"
tz
"
>
{{
tz
}}
<
/option
>
<
/select
>
<
/div
>
<!--
总配额
-->
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaTotalLimit
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
span
class
=
"
absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400
"
>
$
<
/span
>
<
input
:
value
=
"
totalLimit
"
@
input
=
"
onTotalInput
"
type
=
"
number
"
min
=
"
0
"
step
=
"
0.01
"
class
=
"
input pl-7
"
:
placeholder
=
"
t('admin.accounts.quotaLimitPlaceholder')
"
/>
<
/div
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaTotalLimitHint
'
)
}}
<
/p
>
<
/div
>
<!-- Collapsible content -->
<div
v-if=
"localEnabled && !collapsed"
class=
"space-y-2 p-4 pt-3"
>
<!-- Daily quota -->
<QuotaDimensionRow
dim=
"daily"
:label=
"t('admin.accounts.quotaDailyLimit')"
:limit=
"dailyLimit"
:quota-notify-global-enabled=
"quotaNotifyGlobalEnabled"
:notify-enabled=
"props.quotaNotifyDailyEnabled"
:notify-threshold=
"props.quotaNotifyDailyThreshold"
:notify-threshold-type=
"props.quotaNotifyDailyThresholdType"
:reset-mode=
"dailyResetMode"
:reset-hour=
"dailyResetHour"
:reset-day=
"null"
:reset-timezone=
"resetTimezone"
:hint-rolling=
"t('admin.accounts.quotaDailyLimitHint')"
:hint-fixed=
"dailyFixedHint"
:hour-options=
"hourOptions"
:day-options=
"dayOptions"
:timezone-options=
"timezoneOptions"
@
update:limit=
"emit('update:dailyLimit', $event)"
@
update:notify-enabled=
"emit('update:quotaNotifyDailyEnabled', $event)"
@
update:notify-threshold=
"emit('update:quotaNotifyDailyThreshold', $event)"
@
update:notify-threshold-type=
"emit('update:quotaNotifyDailyThresholdType', $event)"
@
update:reset-mode=
"emit('update:dailyResetMode', $event)"
@
update:reset-hour=
"emit('update:dailyResetHour', $event)"
@
update:reset-timezone=
"emit('update:resetTimezone', $event)"
/>
<!-- Weekly quota -->
<QuotaDimensionRow
dim=
"weekly"
:label=
"t('admin.accounts.quotaWeeklyLimit')"
:limit=
"weeklyLimit"
:quota-notify-global-enabled=
"quotaNotifyGlobalEnabled"
:notify-enabled=
"props.quotaNotifyWeeklyEnabled"
:notify-threshold=
"props.quotaNotifyWeeklyThreshold"
:notify-threshold-type=
"props.quotaNotifyWeeklyThresholdType"
:reset-mode=
"weeklyResetMode"
:reset-hour=
"weeklyResetHour"
:reset-day=
"weeklyResetDay"
:reset-timezone=
"resetTimezone"
:hint-rolling=
"t('admin.accounts.quotaWeeklyLimitHint')"
:hint-fixed=
"weeklyFixedHint"
:hour-options=
"hourOptions"
:day-options=
"dayOptions"
:timezone-options=
"timezoneOptions"
@
update:limit=
"emit('update:weeklyLimit', $event)"
@
update:notify-enabled=
"emit('update:quotaNotifyWeeklyEnabled', $event)"
@
update:notify-threshold=
"emit('update:quotaNotifyWeeklyThreshold', $event)"
@
update:notify-threshold-type=
"emit('update:quotaNotifyWeeklyThresholdType', $event)"
@
update:reset-mode=
"emit('update:weeklyResetMode', $event)"
@
update:reset-hour=
"emit('update:weeklyResetHour', $event)"
@
update:reset-day=
"emit('update:weeklyResetDay', $event)"
@
update:reset-timezone=
"emit('update:resetTimezone', $event)"
/>
<!-- Total quota -->
<QuotaDimensionRow
dim=
"total"
:label=
"t('admin.accounts.quotaTotalLimit')"
:limit=
"totalLimit"
:quota-notify-global-enabled=
"quotaNotifyGlobalEnabled"
:notify-enabled=
"props.quotaNotifyTotalEnabled"
:notify-threshold=
"props.quotaNotifyTotalThreshold"
:notify-threshold-type=
"props.quotaNotifyTotalThresholdType"
:reset-mode=
"null"
:reset-hour=
"null"
:reset-day=
"null"
:reset-timezone=
"null"
:hint-rolling=
"t('admin.accounts.quotaTotalLimitHint')"
hint-fixed=
""
:hour-options=
"hourOptions"
:day-options=
"dayOptions"
@
update:limit=
"emit('update:totalLimit', $event)"
@
update:notify-enabled=
"emit('update:quotaNotifyTotalEnabled', $event)"
@
update:notify-threshold=
"emit('update:quotaNotifyTotalThreshold', $event)"
@
update:notify-threshold-type=
"emit('update:quotaNotifyTotalThresholdType', $event)"
/>
</div>
</div>
</
template
>
frontend/src/components/account/QuotaNotifyToggle.vue
0 → 100644
View file @
0b746501
<
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>
frontend/src/components/admin/account/AccountTestModal.vue
View file @
0b746501
...
...
@@ -165,7 +165,6 @@
<button
@
click=
"handleClose"
class=
"rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
:disabled=
"status === 'connecting'"
>
{{
t
(
'
common.close
'
)
}}
</button>
...
...
@@ -249,7 +248,7 @@ const availableModels = ref<ClaudeModel[]>([])
const
selectedModelId
=
ref
(
''
)
const
testPrompt
=
ref
(
''
)
const
loadingModels
=
ref
(
false
)
let
eventSource
:
EventSource
|
null
=
null
let
abortController
:
AbortController
|
null
=
null
const
generatedImages
=
ref
<
PreviewImage
[]
>
([])
const
prioritizedGeminiModels
=
[
'
gemini-3.1-flash-image
'
,
'
gemini-2.5-flash-image
'
,
'
gemini-2.5-flash
'
,
'
gemini-2.5-pro
'
,
'
gemini-3-flash-preview
'
,
'
gemini-3-pro-preview
'
,
'
gemini-2.0-flash
'
]
const
supportsGeminiImageTest
=
computed
(()
=>
{
...
...
@@ -279,7 +278,7 @@ watch(
resetState
()
await
loadAvailableModels
()
}
else
{
closeEventSource
()
abortStream
()
}
}
)
...
...
@@ -329,18 +328,14 @@ const resetState = () => {
}
const
handleClose
=
()
=>
{
// 防止在连接测试进行中关闭对话框
if
(
status
.
value
===
'
connecting
'
)
{
return
}
closeEventSource
()
abortStream
()
emit
(
'
close
'
)
}
const
closeEventSource
=
()
=>
{
if
(
eventSource
)
{
eventSource
.
close
()
eventSource
=
null
const
abortStream
=
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
abortController
=
null
}
}
...
...
@@ -365,7 +360,9 @@ const startTest = async () => {
addLine
(
t
(
'
admin.accounts.testAccountTypeLabel
'
,
{
type
:
props
.
account
.
type
}),
'
text-gray-400
'
)
addLine
(
''
,
'
text-gray-300
'
)
closeEventSource
()
abortStream
()
abortController
=
new
AbortController
()
try
{
// Create EventSource for SSE
...
...
@@ -381,7 +378,8 @@ const startTest = async () => {
body
:
JSON
.
stringify
({
model_id
:
selectedModelId
.
value
,
prompt
:
supportsGeminiImageTest
.
value
?
testPrompt
.
value
.
trim
()
:
''
})
}),
signal
:
abortController
.
signal
})
if
(
!
response
.
ok
)
{
...
...
@@ -418,10 +416,15 @@ const startTest = async () => {
}
}
}
}
catch
(
error
:
any
)
{
}
catch
(
error
:
unknown
)
{
if
(
error
instanceof
DOMException
&&
error
.
name
===
'
AbortError
'
)
{
status
.
value
=
'
idle
'
return
}
status
.
value
=
'
error
'
errorMessage
.
value
=
error
.
message
||
'
Unknown error
'
addLine
(
`Error:
${
errorMessage
.
value
}
`
,
'
text-red-400
'
)
const
msg
=
error
instanceof
Error
?
error
.
message
:
'
Unknown error
'
errorMessage
.
value
=
msg
addLine
(
`Error:
${
msg
}
`
,
'
text-red-400
'
)
}
}
...
...
frontend/src/components/admin/payment/AdminOrderDetail.vue
View file @
0b746501
...
...
@@ -18,12 +18,20 @@
</span>
</div>
<div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.amount
'
)
}}
</p>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
$
{{
order
.
amount
.
toFixed
(
2
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.baseAmount
'
)
}}
</p>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
¥
{{
baseAmount
.
toFixed
(
2
)
}}
</p>
</div>
<div
v-if=
"order.fee_rate > 0"
>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.fee
'
)
}}
(
{{
order
.
fee_rate
}}
%)
</p>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
¥
{{
feeAmount
.
toFixed
(
2
)
}}
</p>
</div>
<div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.payAmount
'
)
}}
</p>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
$
{{
order
.
pay_amount
.
toFixed
(
2
)
}}
</p>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
¥
{{
order
.
pay_amount
.
toFixed
(
2
)
}}
</p>
</div>
<div
v-if=
"order.amount !== order.pay_amount"
>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.creditedAmount
'
)
}}
</p>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
order
.
order_type
===
'
balance
'
?
'
$
'
:
'
¥
'
}}{{
order
.
amount
.
toFixed
(
2
)
}}
</p>
</div>
<div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.paymentMethod
'
)
}}
</p>
...
...
@@ -31,10 +39,6 @@
{{
t
(
'
payment.methods.
'
+
order
.
payment_type
,
order
.
payment_type
)
}}
</p>
</div>
<div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.feeRate
'
)
}}
</p>
<p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
(
order
.
fee_rate
*
100
).
toFixed
(
1
)
}}
%
</p>
</div>
<div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.orderType
'
)
}}
</p>
<p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
...
...
@@ -73,7 +77,7 @@
<div
class=
"grid grid-cols-2 gap-2 text-sm"
>
<div>
<span
class=
"text-red-600 dark:text-red-400"
>
{{
t
(
'
payment.admin.refundAmount
'
)
}}
:
</span>
<span
class=
"ml-1 font-medium text-red-700 dark:text-red-300"
>
$
{{
order
.
refund_amount
.
toFixed
(
2
)
}}
</span>
<span
class=
"ml-1 font-medium text-red-700 dark:text-red-300"
>
{{
order
.
order_type
===
'
balance
'
?
'
$
'
:
'
¥
'
}}
{{
order
.
refund_amount
.
toFixed
(
2
)
}}
</span>
</div>
<div
v-if=
"order.refund_reason"
class=
"col-span-2"
>
<span
class=
"text-red-600 dark:text-red-400"
>
{{
t
(
'
payment.admin.refundReason
'
)
}}
:
</span>
...
...
@@ -110,6 +114,7 @@
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
type
{
PaymentOrder
}
from
'
@/types/payment
'
...
...
@@ -117,11 +122,24 @@ import { statusBadgeClass, canRefund as canRefundStatus, formatOrderDateTime } f
const
{
t
}
=
useI18n
()
defineProps
<
{
const
props
=
defineProps
<
{
show
:
boolean
order
:
PaymentOrder
|
null
}
>
()
/** 充值金额 (base amount before fee) = pay_amount - fee = pay_amount / (1 + fee_rate/100) */
const
baseAmount
=
computed
(()
=>
{
if
(
!
props
.
order
)
return
0
if
(
props
.
order
.
fee_rate
<=
0
)
return
props
.
order
.
pay_amount
return
props
.
order
.
pay_amount
/
(
1
+
props
.
order
.
fee_rate
/
100
)
})
/** 手续费 = pay_amount - baseAmount */
const
feeAmount
=
computed
(()
=>
{
if
(
!
props
.
order
||
props
.
order
.
fee_rate
<=
0
)
return
0
return
props
.
order
.
pay_amount
-
baseAmount
.
value
})
const
emit
=
defineEmits
<
{
(
e
:
'
close
'
):
void
(
e
:
'
cancel
'
,
order
:
PaymentOrder
):
void
...
...
frontend/src/components/admin/payment/AdminOrderTable.vue
View file @
0b746501
...
...
@@ -51,12 +51,15 @@
<span
class=
"text-sm text-gray-600 dark:text-gray-400"
>
#
{{
value
}}
</span>
</
template
>
<
template
#cell-amount=
"{ value, row }"
>
<
template
#cell-
pay_
amount=
"{ value, row }"
>
<div
class=
"text-sm"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
$
{{
value
.
toFixed
(
2
)
}}
</span>
<span
v-if=
"row.
pay_amount !== value
"
class=
"ml-1 text-xs text-gray-
5
00"
>
(
{{
t
(
'
payment.orders.payAmount
'
)
}}
: $
{{
row
.
pay_amount
.
toFixed
(
2
)
}}
)
<span
class=
"font-medium text-gray-900 dark:text-white"
>
¥
{{
value
.
toFixed
(
2
)
}}
</span>
<span
v-if=
"row.
fee_rate > 0
"
class=
"ml-1 text-xs text-gray-
4
00"
:title=
"t('payment.orders.fee') + ': ' + row.fee_rate + '%'"
>
(
{{
row
.
fee_rate
}}
%
)
</span>
<div
v-if=
"row.amount !== row.pay_amount"
class=
"text-xs text-gray-500"
>
{{
t
(
'
payment.orders.creditedAmount
'
)
}}
:
{{
row
.
order_type
===
'
balance
'
?
'
$
'
:
'
¥
'
}}{{
row
.
amount
.
toFixed
(
2
)
}}
</div>
</div>
</
template
>
...
...
@@ -183,7 +186,7 @@ function emitFiltersChanged() {
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
id
'
,
label
:
t
(
'
payment.orders.orderId
'
)
},
{
key
:
'
user_id
'
,
label
:
t
(
'
payment.orders.userId
'
)
},
{
key
:
'
amount
'
,
label
:
t
(
'
payment.orders.
a
mount
'
)
},
{
key
:
'
pay_
amount
'
,
label
:
t
(
'
payment.orders.
payA
mount
'
)
},
{
key
:
'
payment_type
'
,
label
:
t
(
'
payment.orders.paymentMethod
'
)
},
{
key
:
'
status
'
,
label
:
t
(
'
payment.orders.status
'
)
},
{
key
:
'
order_type
'
,
label
:
t
(
'
payment.orders.orderType
'
)
},
...
...
Prev
1
…
5
6
7
8
9
10
11
12
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment