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
6901b64f
Commit
6901b64f
authored
Jan 17, 2026
by
cyhhao
Browse files
merge: sync upstream changes
parents
32c47b15
dae0d532
Changes
189
Show whitespace changes
Inline
Side-by-side
frontend/src/__tests__/setup.ts
0 → 100644
View file @
6901b64f
/**
* Vitest 测试环境设置
* 提供全局 mock 和测试工具
*/
import
{
config
}
from
'
@vue/test-utils
'
import
{
vi
}
from
'
vitest
'
// Mock requestIdleCallback (Safari < 15 不支持)
if
(
typeof
globalThis
.
requestIdleCallback
===
'
undefined
'
)
{
globalThis
.
requestIdleCallback
=
((
callback
:
IdleRequestCallback
)
=>
{
return
window
.
setTimeout
(()
=>
callback
({
didTimeout
:
false
,
timeRemaining
:
()
=>
50
}),
1
)
})
as
unknown
as
typeof
requestIdleCallback
}
if
(
typeof
globalThis
.
cancelIdleCallback
===
'
undefined
'
)
{
globalThis
.
cancelIdleCallback
=
((
id
:
number
)
=>
{
window
.
clearTimeout
(
id
)
})
as
unknown
as
typeof
cancelIdleCallback
}
// Mock IntersectionObserver
class
MockIntersectionObserver
{
observe
=
vi
.
fn
()
disconnect
=
vi
.
fn
()
unobserve
=
vi
.
fn
()
}
globalThis
.
IntersectionObserver
=
MockIntersectionObserver
as
unknown
as
typeof
IntersectionObserver
// Mock ResizeObserver
class
MockResizeObserver
{
observe
=
vi
.
fn
()
disconnect
=
vi
.
fn
()
unobserve
=
vi
.
fn
()
}
globalThis
.
ResizeObserver
=
MockResizeObserver
as
unknown
as
typeof
ResizeObserver
// Vue Test Utils 全局配置
config
.
global
.
stubs
=
{
// 可以在这里添加全局 stub
}
// 设置全局测试超时
vi
.
setConfig
({
testTimeout
:
10000
})
frontend/src/api/admin/dashboard.ts
View file @
6901b64f
...
...
@@ -46,6 +46,10 @@ export interface TrendParams {
granularity
?:
'
day
'
|
'
hour
'
user_id
?:
number
api_key_id
?:
number
model
?:
string
account_id
?:
number
group_id
?:
number
stream
?:
boolean
}
export
interface
TrendResponse
{
...
...
@@ -70,6 +74,10 @@ export interface ModelStatsParams {
end_date
?:
string
user_id
?:
number
api_key_id
?:
number
model
?:
string
account_id
?:
number
group_id
?:
number
stream
?:
boolean
}
export
interface
ModelStatsResponse
{
...
...
frontend/src/api/admin/ops.ts
View file @
6901b64f
...
...
@@ -17,6 +17,47 @@ export interface OpsRequestOptions {
export
interface
OpsRetryRequest
{
mode
:
OpsRetryMode
pinned_account_id
?:
number
force
?:
boolean
}
export
interface
OpsRetryAttempt
{
id
:
number
created_at
:
string
requested_by_user_id
:
number
source_error_id
:
number
mode
:
string
pinned_account_id
?:
number
|
null
pinned_account_name
?:
string
status
:
string
started_at
?:
string
|
null
finished_at
?:
string
|
null
duration_ms
?:
number
|
null
success
?:
boolean
|
null
http_status_code
?:
number
|
null
upstream_request_id
?:
string
|
null
used_account_id
?:
number
|
null
used_account_name
?:
string
response_preview
?:
string
|
null
response_truncated
?:
boolean
|
null
result_request_id
?:
string
|
null
result_error_id
?:
number
|
null
error_message
?:
string
|
null
}
export
type
OpsUpstreamErrorEvent
=
{
at_unix_ms
?:
number
platform
?:
string
account_id
?:
number
account_name
?:
string
upstream_status_code
?:
number
upstream_request_id
?:
string
upstream_request_body
?:
string
kind
?:
string
message
?:
string
detail
?:
string
}
export
interface
OpsRetryResult
{
...
...
@@ -252,6 +293,7 @@ export interface OpsJobHeartbeat {
last_error_at
?:
string
|
null
last_error
?:
string
|
null
last_duration_ms
?:
number
|
null
last_result
?:
string
|
null
updated_at
:
string
}
...
...
@@ -626,8 +668,6 @@ export type MetricType =
|
'
success_rate
'
|
'
error_rate
'
|
'
upstream_error_rate
'
|
'
p95_latency_ms
'
|
'
p99_latency_ms
'
|
'
cpu_usage_percent
'
|
'
memory_usage_percent
'
|
'
concurrency_queue_depth
'
...
...
@@ -663,7 +703,7 @@ export interface AlertEvent {
id
:
number
rule_id
:
number
severity
:
OpsSeverity
|
string
status
:
'
firing
'
|
'
resolved
'
|
string
status
:
'
firing
'
|
'
resolved
'
|
'
manual_resolved
'
|
string
title
?:
string
description
?:
string
metric_value
?:
number
...
...
@@ -702,7 +742,6 @@ export interface EmailNotificationConfig {
export
interface
OpsMetricThresholds
{
sla_percent_min
?:
number
|
null
// SLA低于此值变红
latency_p99_ms_max
?:
number
|
null
// 延迟P99高于此值变红
ttft_p99_ms_max
?:
number
|
null
// TTFT P99高于此值变红
request_error_rate_percent_max
?:
number
|
null
// 请求错误率高于此值变红
upstream_error_rate_percent_max
?:
number
|
null
// 上游错误率高于此值变红
...
...
@@ -735,6 +774,8 @@ export interface OpsAdvancedSettings {
data_retention
:
OpsDataRetentionSettings
aggregation
:
OpsAggregationSettings
ignore_count_tokens_errors
:
boolean
ignore_context_canceled
:
boolean
ignore_no_available_accounts
:
boolean
auto_refresh_enabled
:
boolean
auto_refresh_interval_seconds
:
number
}
...
...
@@ -754,21 +795,37 @@ export interface OpsAggregationSettings {
export
interface
OpsErrorLog
{
id
:
number
created_at
:
string
// Standardized classification
phase
:
OpsPhase
type
:
string
error_owner
:
'
client
'
|
'
provider
'
|
'
platform
'
|
string
error_source
:
'
client_request
'
|
'
upstream_http
'
|
'
gateway
'
|
string
severity
:
OpsSeverity
status_code
:
number
platform
:
string
model
:
string
latency_ms
?:
number
|
null
is_retryable
:
boolean
retry_count
:
number
resolved
:
boolean
resolved_at
?:
string
|
null
resolved_by_user_id
?:
number
|
null
resolved_retry_id
?:
number
|
null
client_request_id
:
string
request_id
:
string
message
:
string
user_id
?:
number
|
null
user_email
:
string
api_key_id
?:
number
|
null
account_id
?:
number
|
null
account_name
:
string
group_id
?:
number
|
null
group_name
:
string
client_ip
?:
string
|
null
request_path
?:
string
...
...
@@ -890,7 +947,9 @@ export async function getErrorDistribution(
return
data
}
export
async
function
listErrorLogs
(
params
:
{
export
type
OpsErrorListView
=
'
errors
'
|
'
excluded
'
|
'
all
'
export
type
OpsErrorListQueryParams
=
{
page
?:
number
page_size
?:
number
time_range
?:
string
...
...
@@ -899,10 +958,20 @@ export async function listErrorLogs(params: {
platform
?:
string
group_id
?:
number
|
null
account_id
?:
number
|
null
phase
?:
string
error_owner
?:
string
error_source
?:
string
resolved
?:
string
view
?:
OpsErrorListView
q
?:
string
status_codes
?:
string
}):
Promise
<
OpsErrorLogsResponse
>
{
status_codes_other
?:
string
}
// Legacy unified endpoints
export
async
function
listErrorLogs
(
params
:
OpsErrorListQueryParams
):
Promise
<
OpsErrorLogsResponse
>
{
const
{
data
}
=
await
apiClient
.
get
<
OpsErrorLogsResponse
>
(
'
/admin/ops/errors
'
,
{
params
})
return
data
}
...
...
@@ -917,6 +986,70 @@ export async function retryErrorRequest(id: number, req: OpsRetryRequest): Promi
return
data
}
export
async
function
listRetryAttempts
(
errorId
:
number
,
limit
=
50
):
Promise
<
OpsRetryAttempt
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
OpsRetryAttempt
[]
>
(
`/admin/ops/errors/
${
errorId
}
/retries`
,
{
params
:
{
limit
}
})
return
data
}
export
async
function
updateErrorResolved
(
errorId
:
number
,
resolved
:
boolean
):
Promise
<
void
>
{
await
apiClient
.
put
(
`/admin/ops/errors/
${
errorId
}
/resolve`
,
{
resolved
})
}
// New split endpoints
export
async
function
listRequestErrors
(
params
:
OpsErrorListQueryParams
):
Promise
<
OpsErrorLogsResponse
>
{
const
{
data
}
=
await
apiClient
.
get
<
OpsErrorLogsResponse
>
(
'
/admin/ops/request-errors
'
,
{
params
})
return
data
}
export
async
function
listUpstreamErrors
(
params
:
OpsErrorListQueryParams
):
Promise
<
OpsErrorLogsResponse
>
{
const
{
data
}
=
await
apiClient
.
get
<
OpsErrorLogsResponse
>
(
'
/admin/ops/upstream-errors
'
,
{
params
})
return
data
}
export
async
function
getRequestErrorDetail
(
id
:
number
):
Promise
<
OpsErrorDetail
>
{
const
{
data
}
=
await
apiClient
.
get
<
OpsErrorDetail
>
(
`/admin/ops/request-errors/
${
id
}
`
)
return
data
}
export
async
function
getUpstreamErrorDetail
(
id
:
number
):
Promise
<
OpsErrorDetail
>
{
const
{
data
}
=
await
apiClient
.
get
<
OpsErrorDetail
>
(
`/admin/ops/upstream-errors/
${
id
}
`
)
return
data
}
export
async
function
retryRequestErrorClient
(
id
:
number
):
Promise
<
OpsRetryResult
>
{
const
{
data
}
=
await
apiClient
.
post
<
OpsRetryResult
>
(
`/admin/ops/request-errors/
${
id
}
/retry-client`
,
{})
return
data
}
export
async
function
retryRequestErrorUpstreamEvent
(
id
:
number
,
idx
:
number
):
Promise
<
OpsRetryResult
>
{
const
{
data
}
=
await
apiClient
.
post
<
OpsRetryResult
>
(
`/admin/ops/request-errors/
${
id
}
/upstream-errors/
${
idx
}
/retry`
,
{})
return
data
}
export
async
function
retryUpstreamError
(
id
:
number
):
Promise
<
OpsRetryResult
>
{
const
{
data
}
=
await
apiClient
.
post
<
OpsRetryResult
>
(
`/admin/ops/upstream-errors/
${
id
}
/retry`
,
{})
return
data
}
export
async
function
updateRequestErrorResolved
(
errorId
:
number
,
resolved
:
boolean
):
Promise
<
void
>
{
await
apiClient
.
put
(
`/admin/ops/request-errors/
${
errorId
}
/resolve`
,
{
resolved
})
}
export
async
function
updateUpstreamErrorResolved
(
errorId
:
number
,
resolved
:
boolean
):
Promise
<
void
>
{
await
apiClient
.
put
(
`/admin/ops/upstream-errors/
${
errorId
}
/resolve`
,
{
resolved
})
}
export
async
function
listRequestErrorUpstreamErrors
(
id
:
number
,
params
:
OpsErrorListQueryParams
=
{},
options
:
{
include_detail
?:
boolean
}
=
{}
):
Promise
<
PaginatedResponse
<
OpsErrorDetail
>>
{
const
query
:
Record
<
string
,
any
>
=
{
...
params
}
if
(
options
.
include_detail
)
query
.
include_detail
=
'
1
'
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
OpsErrorDetail
>>
(
`/admin/ops/request-errors/
${
id
}
/upstream-errors`
,
{
params
:
query
})
return
data
}
export
async
function
listRequestDetails
(
params
:
OpsRequestDetailsParams
):
Promise
<
OpsRequestDetailsResponse
>
{
const
{
data
}
=
await
apiClient
.
get
<
OpsRequestDetailsResponse
>
(
'
/admin/ops/requests
'
,
{
params
})
return
data
...
...
@@ -942,11 +1075,45 @@ export async function deleteAlertRule(id: number): Promise<void> {
await
apiClient
.
delete
(
`/admin/ops/alert-rules/
${
id
}
`
)
}
export
async
function
listAlertEvents
(
limit
=
100
):
Promise
<
AlertEvent
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
AlertEvent
[]
>
(
'
/admin/ops/alert-events
'
,
{
params
:
{
limit
}
})
export
interface
AlertEventsQuery
{
limit
?:
number
status
?:
string
severity
?:
string
email_sent
?:
boolean
time_range
?:
string
start_time
?:
string
end_time
?:
string
before_fired_at
?:
string
before_id
?:
number
platform
?:
string
group_id
?:
number
}
export
async
function
listAlertEvents
(
params
:
AlertEventsQuery
=
{}):
Promise
<
AlertEvent
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
AlertEvent
[]
>
(
'
/admin/ops/alert-events
'
,
{
params
})
return
data
}
export
async
function
getAlertEvent
(
id
:
number
):
Promise
<
AlertEvent
>
{
const
{
data
}
=
await
apiClient
.
get
<
AlertEvent
>
(
`/admin/ops/alert-events/
${
id
}
`
)
return
data
}
export
async
function
updateAlertEventStatus
(
id
:
number
,
status
:
'
resolved
'
|
'
manual_resolved
'
):
Promise
<
void
>
{
await
apiClient
.
put
(
`/admin/ops/alert-events/
${
id
}
/status`
,
{
status
})
}
export
async
function
createAlertSilence
(
payload
:
{
rule_id
:
number
platform
:
string
group_id
?:
number
|
null
region
?:
string
|
null
until
:
string
reason
?:
string
}):
Promise
<
void
>
{
await
apiClient
.
post
(
'
/admin/ops/alert-silences
'
,
payload
)
}
// Email notification config
export
async
function
getEmailNotificationConfig
():
Promise
<
EmailNotificationConfig
>
{
const
{
data
}
=
await
apiClient
.
get
<
EmailNotificationConfig
>
(
'
/admin/ops/email-notification/config
'
)
...
...
@@ -1001,15 +1168,35 @@ export const opsAPI = {
getAccountAvailabilityStats
,
getRealtimeTrafficSummary
,
subscribeQPS
,
// Legacy unified endpoints
listErrorLogs
,
getErrorLogDetail
,
retryErrorRequest
,
listRetryAttempts
,
updateErrorResolved
,
// New split endpoints
listRequestErrors
,
listUpstreamErrors
,
getRequestErrorDetail
,
getUpstreamErrorDetail
,
retryRequestErrorClient
,
retryRequestErrorUpstreamEvent
,
retryUpstreamError
,
updateRequestErrorResolved
,
updateUpstreamErrorResolved
,
listRequestErrorUpstreamErrors
,
listRequestDetails
,
listAlertRules
,
createAlertRule
,
updateAlertRule
,
deleteAlertRule
,
listAlertEvents
,
getAlertEvent
,
updateAlertEventStatus
,
createAlertSilence
,
getEmailNotificationConfig
,
updateEmailNotificationConfig
,
getAlertRuntimeSettings
,
...
...
frontend/src/api/admin/proxies.ts
View file @
6901b64f
...
...
@@ -4,7 +4,13 @@
*/
import
{
apiClient
}
from
'
../client
'
import
type
{
Proxy
,
CreateProxyRequest
,
UpdateProxyRequest
,
PaginatedResponse
}
from
'
@/types
'
import
type
{
Proxy
,
ProxyAccountSummary
,
CreateProxyRequest
,
UpdateProxyRequest
,
PaginatedResponse
}
from
'
@/types
'
/**
* List all proxies with pagination
...
...
@@ -120,6 +126,7 @@ export async function testProxy(id: number): Promise<{
city
?:
string
region
?:
string
country
?:
string
country_code
?:
string
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
success
:
boolean
...
...
@@ -129,6 +136,7 @@ export async function testProxy(id: number): Promise<{
city
?:
string
region
?:
string
country
?:
string
country_code
?:
string
}
>
(
`/admin/proxies/
${
id
}
/test`
)
return
data
}
...
...
@@ -160,8 +168,8 @@ export async function getStats(id: number): Promise<{
* @param id - Proxy ID
* @returns List of accounts using the proxy
*/
export
async
function
getProxyAccounts
(
id
:
number
):
Promise
<
P
aginatedResponse
<
any
>
>
{
const
{
data
}
=
await
apiClient
.
get
<
P
aginatedResponse
<
any
>
>
(
`/admin/proxies/
${
id
}
/accounts`
)
export
async
function
getProxyAccounts
(
id
:
number
):
Promise
<
P
roxyAccountSummary
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
P
roxyAccountSummary
[]
>
(
`/admin/proxies/
${
id
}
/accounts`
)
return
data
}
...
...
@@ -189,6 +197,17 @@ export async function batchCreate(
return
data
}
export
async
function
batchDelete
(
ids
:
number
[]):
Promise
<
{
deleted_ids
:
number
[]
skipped
:
Array
<
{
id
:
number
;
reason
:
string
}
>
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
deleted_ids
:
number
[]
skipped
:
Array
<
{
id
:
number
;
reason
:
string
}
>
}
>
(
'
/admin/proxies/batch-delete
'
,
{
ids
})
return
data
}
export
const
proxiesAPI
=
{
list
,
getAll
,
...
...
@@ -201,7 +220,8 @@ export const proxiesAPI = {
testProxy
,
getStats
,
getProxyAccounts
,
batchCreate
batchCreate
,
batchDelete
}
export
default
proxiesAPI
frontend/src/api/admin/usage.ts
View file @
6901b64f
...
...
@@ -16,6 +16,7 @@ export interface AdminUsageStatsResponse {
total_tokens
:
number
total_cost
:
number
total_actual_cost
:
number
total_account_cost
?:
number
average_duration_ms
:
number
}
...
...
frontend/src/components/account/AccountCapacityCell.vue
0 → 100644
View file @
6901b64f
<
template
>
<div
class=
"flex flex-col gap-1.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>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
Account
}
from
'
@/types
'
const
props
=
defineProps
<
{
account
:
Account
}
>
()
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
'
}
return
'
bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400
'
})
// 窗口费用状态样式
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
'
}
// >= 80% 阈值: 警告 (黄色)
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
'
)
}
return
t
(
'
admin.accounts.capacity.windowCost.normal
'
)
})
// 会话限制状态样式
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
'
}
// >= 80%: 警告 (黄色)
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
})
}
return
t
(
'
admin.accounts.capacity.sessions.normal
'
,
{
idle
})
})
// 格式化费用显示
const
formatCost
=
(
value
:
number
|
null
|
undefined
)
=>
{
if
(
value
===
null
||
value
===
undefined
)
return
'
0
'
return
value
.
toFixed
(
2
)
}
</
script
>
frontend/src/components/account/AccountStatsModal.vue
View file @
6901b64f
...
...
@@ -73,11 +73,12 @@
</p>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.accumulatedCost
'
)
}}
<span
class=
"text-gray-400 dark:text-gray-500"
>
(
{{
t
(
'
admin.accounts.stats.standardCost
'
)
}}
: $
{{
<span
class=
"text-gray-400 dark:text-gray-500"
>
(
{{
t
(
'
usage.userBilled
'
)
}}
: $
{{
formatCost
(
stats
.
summary
.
total_user_cost
)
}}
·
{{
t
(
'
admin.accounts.stats.standardCost
'
)
}}
: $
{{
formatCost
(
stats
.
summary
.
total_standard_cost
)
}}
)
</span
>
}}
)
</span
>
</p>
</div>
...
...
@@ -127,6 +128,9 @@
days
:
stats
.
summary
.
actual_days_used
}
)
}}
<
span
class
=
"
text-gray-400 dark:text-gray-500
"
>
({{
t
(
'
usage.userBilled
'
)
}}
:
$
{{
formatCost
(
stats
.
summary
.
avg_daily_user_cost
)
}}
)
<
/span
>
<
/p
>
<
/div
>
...
...
@@ -189,13 +193,17 @@
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
<
/span
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.accountBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.userBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
user_cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
...
...
@@ -240,13 +248,17 @@
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
<
/span
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.accountBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-orange-600 dark:text-orange-400
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_cost_day
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.userBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_cost_day
?.
user_cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
...
...
@@ -291,13 +303,17 @@
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
<
/span
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.accountBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_request_day
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.userBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_request_day
?.
user_cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
...
...
@@ -397,13 +413,17 @@
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.todayCost
'
)
}}
<
/span
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.accountBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.userBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
user_cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
...
...
@@ -517,14 +537,24 @@ const trendChartData = computed(() => {
labels
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
label
),
datasets
:
[
{
label
:
t
(
'
admin
.account
s.stats.cost
'
)
+
'
(USD)
'
,
data
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
cost
),
label
:
t
(
'
usage
.account
Billed
'
)
+
'
(USD)
'
,
data
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
actual_
cost
),
borderColor
:
'
#3b82f6
'
,
backgroundColor
:
'
rgba(59, 130, 246, 0.1)
'
,
fill
:
true
,
tension
:
0.3
,
yAxisID
:
'
y
'
}
,
{
label
:
t
(
'
usage.userBilled
'
)
+
'
(USD)
'
,
data
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
user_cost
),
borderColor
:
'
#10b981
'
,
backgroundColor
:
'
rgba(16, 185, 129, 0.08)
'
,
fill
:
false
,
tension
:
0.3
,
borderDash
:
[
5
,
5
],
yAxisID
:
'
y
'
}
,
{
label
:
t
(
'
admin.accounts.stats.requests
'
),
data
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
requests
),
...
...
@@ -602,7 +632,7 @@ const lineChartOptions = computed(() => ({
}
,
title
:
{
display
:
true
,
text
:
t
(
'
admin
.account
s.stats.cost
'
)
+
'
(USD)
'
,
text
:
t
(
'
usage
.account
Billed
'
)
+
'
(USD)
'
,
color
:
'
#3b82f6
'
,
font
:
{
size
:
11
...
...
frontend/src/components/account/AccountTodayStatsCell.vue
View file @
6901b64f
...
...
@@ -32,15 +32,20 @@
formatTokens
(
stats
.
tokens
)
}}
</span>
</div>
<!-- Cost -->
<!-- Cost
(Account)
-->
<div
class=
"flex items-center gap-1"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
:
</span
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.accountBilled
'
)
}}
:
</span>
<span
class=
"font-medium text-emerald-600 dark:text-emerald-400"
>
{{
formatCurrency
(
stats
.
cost
)
}}
</span>
</div>
<!-- Cost (User/API Key) -->
<div
v-if=
"stats.user_cost != null"
class=
"flex items-center gap-1"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.userBilled
'
)
}}
:
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
formatCurrency
(
stats
.
user_cost
)
}}
</span>
</div>
</div>
<!-- No data -->
...
...
frontend/src/components/account/BulkEditAccountModal.vue
View file @
6901b64f
...
...
@@ -459,7 +459,7 @@
<
/div
>
<!--
Concurrency
&
Priority
-->
<
div
class
=
"
grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600
lg:grid-cols-3
"
>
<
div
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
label
...
...
@@ -516,6 +516,36 @@
aria
-
labelledby
=
"
bulk-edit-priority-label
"
/>
<
/div
>
<
div
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
label
id
=
"
bulk-edit-rate-multiplier-label
"
class
=
"
input-label mb-0
"
for
=
"
bulk-edit-rate-multiplier-enabled
"
>
{{
t
(
'
admin.accounts.billingRateMultiplier
'
)
}}
<
/label
>
<
input
v
-
model
=
"
enableRateMultiplier
"
id
=
"
bulk-edit-rate-multiplier-enabled
"
type
=
"
checkbox
"
aria
-
controls
=
"
bulk-edit-rate-multiplier
"
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
<
/div
>
<
input
v
-
model
.
number
=
"
rateMultiplier
"
id
=
"
bulk-edit-rate-multiplier
"
type
=
"
number
"
min
=
"
0
"
step
=
"
0.01
"
:
disabled
=
"
!enableRateMultiplier
"
class
=
"
input
"
:
class
=
"
!enableRateMultiplier && 'cursor-not-allowed opacity-50'
"
aria
-
labelledby
=
"
bulk-edit-rate-multiplier-label
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.billingRateMultiplierHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<!--
Status
-->
...
...
@@ -655,6 +685,7 @@ const enableInterceptWarmup = ref(false)
const
enableProxy
=
ref
(
false
)
const
enableConcurrency
=
ref
(
false
)
const
enablePriority
=
ref
(
false
)
const
enableRateMultiplier
=
ref
(
false
)
const
enableStatus
=
ref
(
false
)
const
enableGroups
=
ref
(
false
)
...
...
@@ -670,6 +701,7 @@ const interceptWarmupRequests = ref(false)
const
proxyId
=
ref
<
number
|
null
>
(
null
)
const
concurrency
=
ref
(
1
)
const
priority
=
ref
(
1
)
const
rateMultiplier
=
ref
(
1
)
const
status
=
ref
<
'
active
'
|
'
inactive
'
>
(
'
active
'
)
const
groupIds
=
ref
<
number
[]
>
([])
...
...
@@ -863,6 +895,10 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
updates
.
priority
=
priority
.
value
}
if
(
enableRateMultiplier
.
value
)
{
updates
.
rate_multiplier
=
rateMultiplier
.
value
}
if
(
enableStatus
.
value
)
{
updates
.
status
=
status
.
value
}
...
...
@@ -923,6 +959,7 @@ const handleSubmit = async () => {
enableProxy
.
value
||
enableConcurrency
.
value
||
enablePriority
.
value
||
enableRateMultiplier
.
value
||
enableStatus
.
value
||
enableGroups
.
value
...
...
@@ -977,6 +1014,7 @@ watch(
enableProxy
.
value
=
false
enableConcurrency
.
value
=
false
enablePriority
.
value
=
false
enableRateMultiplier
.
value
=
false
enableStatus
.
value
=
false
enableGroups
.
value
=
false
...
...
@@ -991,6 +1029,7 @@ watch(
proxyId
.
value
=
null
concurrency
.
value
=
1
priority
.
value
=
1
rateMultiplier
.
value
=
1
status
.
value
=
'
active
'
groupIds
.
value
=
[]
}
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
6901b64f
...
...
@@ -1196,7 +1196,7 @@
<
ProxySelector
v
-
model
=
"
form.proxy_id
"
:
proxies
=
"
proxies
"
/>
<
/div
>
<
div
class
=
"
grid grid-cols-2 gap-4
"
>
<
div
class
=
"
grid grid-cols-2 gap-4
lg:grid-cols-3
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.concurrency
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.concurrency
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
/>
...
...
@@ -1212,6 +1212,11 @@
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.priorityHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.billingRateMultiplier
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.rate_multiplier
"
type
=
"
number
"
min
=
"
0
"
step
=
"
0.01
"
class
=
"
input
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.billingRateMultiplierHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
div
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.expiresAt
'
)
}}
<
/label
>
...
...
@@ -1832,6 +1837,7 @@ const form = reactive({
proxy_id
:
null
as
number
|
null
,
concurrency
:
10
,
priority
:
1
,
rate_multiplier
:
1
,
group_ids
:
[]
as
number
[],
expires_at
:
null
as
number
|
null
}
)
...
...
@@ -2119,6 +2125,7 @@ const resetForm = () => {
form
.
proxy_id
=
null
form
.
concurrency
=
10
form
.
priority
=
1
form
.
rate_multiplier
=
1
form
.
group_ids
=
[]
form
.
expires_at
=
null
accountCategory
.
value
=
'
oauth-based
'
...
...
@@ -2272,6 +2279,7 @@ const createAccountAndFinish = async (
proxy_id
:
form
.
proxy_id
,
concurrency
:
form
.
concurrency
,
priority
:
form
.
priority
,
rate_multiplier
:
form
.
rate_multiplier
,
group_ids
:
form
.
group_ids
,
expires_at
:
form
.
expires_at
,
auto_pause_on_expired
:
autoPauseOnExpired
.
value
...
...
@@ -2490,6 +2498,7 @@ const handleCookieAuth = async (sessionKey: string) => {
proxy_id
:
form
.
proxy_id
,
concurrency
:
form
.
concurrency
,
priority
:
form
.
priority
,
rate_multiplier
:
form
.
rate_multiplier
,
group_ids
:
form
.
group_ids
,
expires_at
:
form
.
expires_at
,
auto_pause_on_expired
:
autoPauseOnExpired
.
value
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
6901b64f
...
...
@@ -549,7 +549,7 @@
<
ProxySelector
v
-
model
=
"
form.proxy_id
"
:
proxies
=
"
proxies
"
/>
<
/div
>
<
div
class
=
"
grid grid-cols-2 gap-4
"
>
<
div
class
=
"
grid grid-cols-2 gap-4
lg:grid-cols-3
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.concurrency
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.concurrency
"
type
=
"
number
"
min
=
"
1
"
class
=
"
input
"
/>
...
...
@@ -564,6 +564,11 @@
data
-
tour
=
"
account-form-priority
"
/>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.billingRateMultiplier
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.rate_multiplier
"
type
=
"
number
"
min
=
"
0
"
step
=
"
0.01
"
class
=
"
input
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.billingRateMultiplierHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
div
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.expiresAt
'
)
}}
<
/label
>
...
...
@@ -599,6 +604,136 @@
<
/div
>
<
/div
>
<!--
Quota
Control
Section
(
Anthropic
OAuth
/
SetupToken
only
)
-->
<
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
"
>
<
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
>
<!--
Window
Cost
Limit
-->
<
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.quotaControl.windowCost.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
windowCostEnabled = !windowCostEnabled
"
:
class
=
"
[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
windowCostEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
windowCostEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
div
v
-
if
=
"
windowCostEnabled
"
class
=
"
grid grid-cols-2 gap-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.limit
'
)
}}
<
/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
v
-
model
.
number
=
"
windowCostLimit
"
type
=
"
number
"
min
=
"
0
"
step
=
"
1
"
class
=
"
input pl-7
"
:
placeholder
=
"
t('admin.accounts.quotaControl.windowCost.limitPlaceholder')
"
/>
<
/div
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.limitHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.stickyReserve
'
)
}}
<
/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
v
-
model
.
number
=
"
windowCostStickyReserve
"
type
=
"
number
"
min
=
"
0
"
step
=
"
1
"
class
=
"
input pl-7
"
:
placeholder
=
"
t('admin.accounts.quotaControl.windowCost.stickyReservePlaceholder')
"
/>
<
/div
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.stickyReserveHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<!--
Session
Limit
-->
<
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.quotaControl.sessionLimit.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
sessionLimitEnabled = !sessionLimitEnabled
"
:
class
=
"
[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
sessionLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
sessionLimitEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
div
v
-
if
=
"
sessionLimitEnabled
"
class
=
"
grid grid-cols-2 gap-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.maxSessions
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
maxSessions
"
type
=
"
number
"
min
=
"
1
"
step
=
"
1
"
class
=
"
input
"
:
placeholder
=
"
t('admin.accounts.quotaControl.sessionLimit.maxSessionsPlaceholder')
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.maxSessionsHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.idleTimeout
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
input
v
-
model
.
number
=
"
sessionIdleTimeout
"
type
=
"
number
"
min
=
"
1
"
step
=
"
1
"
class
=
"
input pr-12
"
:
placeholder
=
"
t('admin.accounts.quotaControl.sessionLimit.idleTimeoutPlaceholder')
"
/>
<
span
class
=
"
absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
common.minutes
'
)
}}
<
/span
>
<
/div
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.idleTimeoutHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
div
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
common.status
'
)
}}
<
/label
>
...
...
@@ -762,6 +897,14 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch
const
tempUnschedEnabled
=
ref
(
false
)
const
tempUnschedRules
=
ref
<
TempUnschedRuleForm
[]
>
([])
// Quota control state (Anthropic OAuth/SetupToken only)
const
windowCostEnabled
=
ref
(
false
)
const
windowCostLimit
=
ref
<
number
|
null
>
(
null
)
const
windowCostStickyReserve
=
ref
<
number
|
null
>
(
null
)
const
sessionLimitEnabled
=
ref
(
false
)
const
maxSessions
=
ref
<
number
|
null
>
(
null
)
const
sessionIdleTimeout
=
ref
<
number
|
null
>
(
null
)
// Computed: current preset mappings based on platform
const
presetMappings
=
computed
(()
=>
getPresetMappingsByPlatform
(
props
.
account
?.
platform
||
'
anthropic
'
))
const
tempUnschedPresets
=
computed
(()
=>
[
...
...
@@ -807,6 +950,7 @@ const form = reactive({
proxy_id
:
null
as
number
|
null
,
concurrency
:
1
,
priority
:
1
,
rate_multiplier
:
1
,
status
:
'
active
'
as
'
active
'
|
'
inactive
'
,
group_ids
:
[]
as
number
[],
expires_at
:
null
as
number
|
null
...
...
@@ -834,6 +978,7 @@ watch(
form
.
proxy_id
=
newAccount
.
proxy_id
form
.
concurrency
=
newAccount
.
concurrency
form
.
priority
=
newAccount
.
priority
form
.
rate_multiplier
=
newAccount
.
rate_multiplier
??
1
form
.
status
=
newAccount
.
status
as
'
active
'
|
'
inactive
'
form
.
group_ids
=
newAccount
.
group_ids
||
[]
form
.
expires_at
=
newAccount
.
expires_at
??
null
...
...
@@ -847,6 +992,9 @@ watch(
const
extra
=
newAccount
.
extra
as
Record
<
string
,
unknown
>
|
undefined
mixedScheduling
.
value
=
extra
?.
mixed_scheduling
===
true
// Load quota control settings (Anthropic OAuth/SetupToken only)
loadQuotaControlSettings
(
newAccount
)
loadTempUnschedRules
(
credentials
)
// Initialize API Key fields for apikey type
...
...
@@ -1080,6 +1228,35 @@ function loadTempUnschedRules(credentials?: Record<string, unknown>) {
}
)
}
// Load quota control settings from account (Anthropic OAuth/SetupToken only)
function
loadQuotaControlSettings
(
account
:
Account
)
{
// Reset all quota control state first
windowCostEnabled
.
value
=
false
windowCostLimit
.
value
=
null
windowCostStickyReserve
.
value
=
null
sessionLimitEnabled
.
value
=
false
maxSessions
.
value
=
null
sessionIdleTimeout
.
value
=
null
// Only applies to Anthropic OAuth/SetupToken accounts
if
(
account
.
platform
!==
'
anthropic
'
||
(
account
.
type
!==
'
oauth
'
&&
account
.
type
!==
'
setup-token
'
))
{
return
}
// Load from extra field (via backend DTO fields)
if
(
account
.
window_cost_limit
!=
null
&&
account
.
window_cost_limit
>
0
)
{
windowCostEnabled
.
value
=
true
windowCostLimit
.
value
=
account
.
window_cost_limit
windowCostStickyReserve
.
value
=
account
.
window_cost_sticky_reserve
??
10
}
if
(
account
.
max_sessions
!=
null
&&
account
.
max_sessions
>
0
)
{
sessionLimitEnabled
.
value
=
true
maxSessions
.
value
=
account
.
max_sessions
sessionIdleTimeout
.
value
=
account
.
session_idle_timeout_minutes
??
5
}
}
function
formatTempUnschedKeywords
(
value
:
unknown
)
{
if
(
Array
.
isArray
(
value
))
{
return
value
...
...
@@ -1207,6 +1384,32 @@ const handleSubmit = async () => {
updatePayload
.
extra
=
newExtra
}
// 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
newExtra
:
Record
<
string
,
unknown
>
=
{
...
currentExtra
}
// Window cost limit settings
if
(
windowCostEnabled
.
value
&&
windowCostLimit
.
value
!=
null
&&
windowCostLimit
.
value
>
0
)
{
newExtra
.
window_cost_limit
=
windowCostLimit
.
value
newExtra
.
window_cost_sticky_reserve
=
windowCostStickyReserve
.
value
??
10
}
else
{
delete
newExtra
.
window_cost_limit
delete
newExtra
.
window_cost_sticky_reserve
}
// Session limit settings
if
(
sessionLimitEnabled
.
value
&&
maxSessions
.
value
!=
null
&&
maxSessions
.
value
>
0
)
{
newExtra
.
max_sessions
=
maxSessions
.
value
newExtra
.
session_idle_timeout_minutes
=
sessionIdleTimeout
.
value
??
5
}
else
{
delete
newExtra
.
max_sessions
delete
newExtra
.
session_idle_timeout_minutes
}
updatePayload
.
extra
=
newExtra
}
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
updatePayload
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountUpdated
'
))
emit
(
'
updated
'
)
...
...
frontend/src/components/account/UsageProgressBar.vue
View file @
6901b64f
...
...
@@ -15,7 +15,13 @@
<span
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
{{
formatTokens
}}
</span>
<span
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
$
{{
formatCost
}}
</span>
<span
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
A $
{{
formatAccountCost
}}
</span>
<span
v-if=
"windowStats?.user_cost != null"
class=
"rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
U $
{{
formatUserCost
}}
</span>
</div>
</div>
...
...
@@ -149,8 +155,13 @@ const formatTokens = computed(() => {
return
t
.
toString
()
})
const
formatCost
=
computed
(()
=>
{
const
format
Account
Cost
=
computed
(()
=>
{
if
(
!
props
.
windowStats
)
return
'
0.00
'
return
props
.
windowStats
.
cost
.
toFixed
(
2
)
})
const
formatUserCost
=
computed
(()
=>
{
if
(
!
props
.
windowStats
||
props
.
windowStats
.
user_cost
==
null
)
return
'
0.00
'
return
props
.
windowStats
.
user_cost
.
toFixed
(
2
)
})
</
script
>
frontend/src/components/admin/account/AccountStatsModal.vue
View file @
6901b64f
...
...
@@ -61,11 +61,12 @@
</p>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.accumulatedCost
'
)
}}
<span
class=
"text-gray-400 dark:text-gray-500"
>
(
{{
t
(
'
admin.accounts.stats.standardCost
'
)
}}
: $
{{
<span
class=
"text-gray-400 dark:text-gray-500"
>
(
{{
t
(
'
usage.userBilled
'
)
}}
: $
{{
formatCost
(
stats
.
summary
.
total_user_cost
)
}}
·
{{
t
(
'
admin.accounts.stats.standardCost
'
)
}}
: $
{{
formatCost
(
stats
.
summary
.
total_standard_cost
)
}}
)
</span
>
}}
)
</span
>
</p>
</div>
...
...
@@ -114,6 +115,9 @@
days
:
stats
.
summary
.
actual_days_used
}
)
}}
<
span
class
=
"
text-gray-400 dark:text-gray-500
"
>
({{
t
(
'
usage.userBilled
'
)
}}
:
$
{{
formatCost
(
stats
.
summary
.
avg_daily_user_cost
)
}}
)
<
/span
>
<
/p
>
<
/div
>
...
...
@@ -164,13 +168,17 @@
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
<
/span
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.accountBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.userBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
user_cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
...
...
@@ -210,13 +218,17 @@
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
<
/span
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.accountBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-orange-600 dark:text-orange-400
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_cost_day
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.userBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_cost_day
?.
user_cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
...
...
@@ -260,13 +272,17 @@
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
<
/span
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.accountBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_request_day
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.userBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_request_day
?.
user_cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
...
...
@@ -485,14 +501,24 @@ const trendChartData = computed(() => {
labels
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
label
),
datasets
:
[
{
label
:
t
(
'
admin
.account
s.stats.cost
'
)
+
'
(USD)
'
,
data
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
cost
),
label
:
t
(
'
usage
.account
Billed
'
)
+
'
(USD)
'
,
data
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
actual_
cost
),
borderColor
:
'
#3b82f6
'
,
backgroundColor
:
'
rgba(59, 130, 246, 0.1)
'
,
fill
:
true
,
tension
:
0.3
,
yAxisID
:
'
y
'
}
,
{
label
:
t
(
'
usage.userBilled
'
)
+
'
(USD)
'
,
data
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
user_cost
),
borderColor
:
'
#10b981
'
,
backgroundColor
:
'
rgba(16, 185, 129, 0.08)
'
,
fill
:
false
,
tension
:
0.3
,
borderDash
:
[
5
,
5
],
yAxisID
:
'
y
'
}
,
{
label
:
t
(
'
admin.accounts.stats.requests
'
),
data
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
requests
),
...
...
@@ -570,7 +596,7 @@ const lineChartOptions = computed(() => ({
}
,
title
:
{
display
:
true
,
text
:
t
(
'
admin
.account
s.stats.cost
'
)
+
'
(USD)
'
,
text
:
t
(
'
usage
.account
Billed
'
)
+
'
(USD)
'
,
color
:
'
#3b82f6
'
,
font
:
{
size
:
11
...
...
frontend/src/components/admin/usage/UsageStatsCards.vue
View file @
6901b64f
...
...
@@ -27,9 +27,18 @@
</div>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-xs font-medium text-gray-500"
>
{{
t
(
'
usage.totalCost
'
)
}}
</p>
<p
class=
"text-xl font-bold text-green-600"
>
$
{{
(
stats
?.
total_actual_cost
||
0
).
toFixed
(
4
)
}}
</p>
<p
class=
"text-xs text-gray-400"
>
{{
t
(
'
usage.standardCost
'
)
}}
:
<span
class=
"line-through"
>
$
{{
(
stats
?.
total_cost
||
0
).
toFixed
(
4
)
}}
</span>
<p
class=
"text-xl font-bold text-green-600"
>
$
{{
((
stats
?.
total_account_cost
??
stats
?.
total_actual_cost
)
||
0
).
toFixed
(
4
)
}}
</p>
<p
class=
"text-xs text-gray-400"
v-if=
"stats?.total_account_cost != null"
>
{{
t
(
'
usage.userBilled
'
)
}}
:
<span
class=
"text-gray-300"
>
$
{{
(
stats
?.
total_actual_cost
||
0
).
toFixed
(
4
)
}}
</span>
·
{{
t
(
'
usage.standardCost
'
)
}}
:
<span
class=
"text-gray-300"
>
$
{{
(
stats
?.
total_cost
||
0
).
toFixed
(
4
)
}}
</span>
</p>
<p
class=
"text-xs text-gray-400"
v-else
>
{{
t
(
'
usage.standardCost
'
)
}}
:
<span
class=
"line-through"
>
$
{{
(
stats
?.
total_cost
||
0
).
toFixed
(
4
)
}}
</span>
</p>
</div>
</div>
...
...
frontend/src/components/admin/usage/UsageTable.vue
View file @
6901b64f
...
...
@@ -81,7 +81,8 @@
</
template
>
<
template
#cell-cost=
"{ row }"
>
<div
class=
"flex items-center gap-1.5 text-sm"
>
<div
class=
"text-sm"
>
<div
class=
"flex items-center gap-1.5"
>
<span
class=
"font-medium text-green-600 dark:text-green-400"
>
$
{{
row
.
actual_cost
?.
toFixed
(
6
)
||
'
0.000000
'
}}
</span>
<!-- Cost Detail Tooltip -->
<div
...
...
@@ -94,6 +95,10 @@
</div>
</div>
</div>
<div
v-if=
"row.account_rate_multiplier != null"
class=
"mt-0.5 text-[11px] text-gray-400"
>
A $
{{
(
row
.
total_cost
*
row
.
account_rate_multiplier
).
toFixed
(
6
)
}}
</div>
</div>
</
template
>
<
template
#cell-first_token=
"{ row }"
>
...
...
@@ -202,14 +207,24 @@
<span
class=
"text-gray-400"
>
{{ t('usage.rate') }}
</span>
<span
class=
"font-semibold text-blue-400"
>
{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x
</span>
</div>
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{ t('usage.accountMultiplier') }}
</span>
<span
class=
"font-semibold text-blue-400"
>
{{ (tooltipData?.account_rate_multiplier ?? 1).toFixed(2) }}x
</span>
</div>
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{ t('usage.original') }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}
</span>
</div>
<div
class=
"flex items-center justify-between gap-6
border-t border-gray-700 pt-1.5
"
>
<span
class=
"text-gray-400"
>
{{ t('usage.
b
illed') }}
</span>
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{ t('usage.
userB
illed') }}
</span>
<span
class=
"font-semibold text-green-400"
>
${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}
</span>
</div>
<div
class=
"flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"
>
<span
class=
"text-gray-400"
>
{{ t('usage.accountBilled') }}
</span>
<span
class=
"font-semibold text-green-400"
>
${{ (((tooltipData?.total_cost || 0) * (tooltipData?.account_rate_multiplier ?? 1)) || 0).toFixed(6) }}
</span>
</div>
</div>
<div
class=
"absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
></div>
</div>
...
...
frontend/src/components/admin/user/UserCreateModal.vue
View file @
6901b64f
...
...
@@ -25,7 +25,7 @@
<label
class=
"input-label"
>
{{
t
(
'
admin.users.username
'
)
}}
</label>
<input
v-model=
"form.username"
type=
"text"
class=
"input"
:placeholder=
"t('admin.users.enterUsername')"
/>
</div>
<div
class=
"grid grid-cols-2 gap-4"
>
<div
class=
"grid
grid-cols-1 sm:
grid-cols-2 gap-4"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.users.columns.balance
'
)
}}
</label>
<input
v-model.number=
"form.balance"
type=
"number"
step=
"any"
class=
"input"
/>
...
...
frontend/src/components/common/DataTable.vue
View file @
6901b64f
<
template
>
<div
class=
"md:hidden space-y-3"
>
<template
v-if=
"loading"
>
<div
v-for=
"i in 5"
:key=
"i"
class=
"rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
>
<div
class=
"space-y-3"
>
<div
v-for=
"column in columns.filter(c => c.key !== 'actions')"
:key=
"column.key"
class=
"flex justify-between"
>
<div
class=
"h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"
></div>
<div
class=
"h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"
></div>
</div>
<div
v-if=
"hasActionsColumn"
class=
"border-t border-gray-200 pt-3 dark:border-dark-700"
>
<div
class=
"h-8 w-full animate-pulse rounded bg-gray-200 dark:bg-dark-700"
></div>
</div>
</div>
</div>
</
template
>
<
template
v-else-if=
"!data || data.length === 0"
>
<div
class=
"rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-dark-700 dark:bg-dark-900"
>
<slot
name=
"empty"
>
<div
class=
"flex flex-col items-center"
>
<Icon
name=
"inbox"
size=
"xl"
class=
"mb-4 h-12 w-12 text-gray-400 dark:text-dark-500"
/>
<p
class=
"text-lg font-medium text-gray-900 dark:text-gray-100"
>
{{
t
(
'
empty.noData
'
)
}}
</p>
</div>
</slot>
</div>
</
template
>
<
template
v-else
>
<div
v-for=
"(row, index) in sortedData"
:key=
"resolveRowKey(row, index)"
class=
"rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
>
<div
class=
"space-y-3"
>
<div
v-for=
"column in columns.filter(c => c.key !== 'actions')"
:key=
"column.key"
class=
"flex items-start justify-between gap-4"
>
<span
class=
"text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
{{
column
.
label
}}
</span>
<div
class=
"text-right text-sm text-gray-900 dark:text-gray-100"
>
<slot
:name=
"`cell-$
{column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
{{
column
.
formatter
?
column
.
formatter
(
row
[
column
.
key
],
row
)
:
row
[
column
.
key
]
}}
</slot>
</div>
</div>
<div
v-if=
"hasActionsColumn"
class=
"border-t border-gray-200 pt-3 dark:border-dark-700"
>
<slot
name=
"cell-actions"
:row=
"row"
:value=
"row['actions']"
:expanded=
"actionsExpanded"
></slot>
</div>
</div>
</div>
</
template
>
</div>
<div
ref=
"tableWrapperRef"
class=
"table-wrapper"
class=
"table-wrapper
hidden md:block
"
:class=
"{
'actions-expanded': actionsExpanded,
'is-scrollable': isScrollable
...
...
@@ -21,6 +82,12 @@
getStickyColumnClass(column, index)
]"
@
click=
"column.sortable && handleSort(column.key)"
>
<slot
:name=
"`header-${column.key}`"
:column=
"column"
:sort-key=
"sortKey"
:sort-order=
"sortOrder"
>
<div
class=
"flex items-center space-x-1"
>
<span>
{{ column.label }}
</span>
...
...
@@ -45,6 +112,7 @@
</svg>
</span>
</div>
</slot>
</th>
</tr>
</thead>
...
...
@@ -277,7 +345,10 @@ const sortedData = computed(() => {
})
})
// 检查第一列是否为勾选列
const
hasActionsColumn
=
computed
(()
=>
{
return
props
.
columns
.
some
(
column
=>
column
.
key
===
'
actions
'
)
})
const
hasSelectColumn
=
computed
(()
=>
{
return
props
.
columns
.
length
>
0
&&
props
.
columns
[
0
].
key
===
'
select
'
})
...
...
frontend/src/components/common/NavigationProgress.vue
0 → 100644
View file @
6901b64f
<
script
setup
lang=
"ts"
>
/**
* 导航进度条组件
* 在页面顶部显示加载进度,提供导航反馈
*/
import
{
computed
}
from
'
vue
'
import
{
useNavigationLoadingState
}
from
'
@/composables/useNavigationLoading
'
const
{
isLoading
}
=
useNavigationLoadingState
()
// 进度条可见性
const
isVisible
=
computed
(()
=>
isLoading
.
value
)
</
script
>
<
template
>
<Transition
name=
"progress-fade"
>
<div
v-show=
"isVisible"
class=
"navigation-progress"
role=
"progressbar"
aria-label=
"Loading"
aria-valuenow=
"0"
aria-valuemin=
"0"
aria-valuemax=
"100"
>
<div
class=
"navigation-progress-bar"
/>
</div>
</Transition>
</
template
>
<
style
scoped
>
.navigation-progress
{
position
:
fixed
;
top
:
0
;
left
:
0
;
right
:
0
;
height
:
3px
;
z-index
:
9999
;
overflow
:
hidden
;
background
:
transparent
;
}
.navigation-progress-bar
{
height
:
100%
;
width
:
100%
;
background
:
linear-gradient
(
90deg
,
transparent
0%
,
theme
(
'colors.primary.400'
)
20%
,
theme
(
'colors.primary.500'
)
50%
,
theme
(
'colors.primary.400'
)
80%
,
transparent
100%
);
animation
:
progress-slide
1.5s
ease-in-out
infinite
;
}
/* 暗色模式下的进度条颜色 */
:root
.dark
.navigation-progress-bar
{
background
:
linear-gradient
(
90deg
,
transparent
0%
,
theme
(
'colors.primary.500'
)
20%
,
theme
(
'colors.primary.400'
)
50%
,
theme
(
'colors.primary.500'
)
80%
,
transparent
100%
);
}
/* 进度条滑动动画 */
@keyframes
progress-slide
{
0
%
{
transform
:
translateX
(
-100%
);
}
100
%
{
transform
:
translateX
(
100%
);
}
}
/* 淡入淡出过渡 */
.progress-fade-enter-active
{
transition
:
opacity
0.15s
ease-out
;
}
.progress-fade-leave-active
{
transition
:
opacity
0.3s
ease-out
;
}
.progress-fade-enter-from
,
.progress-fade-leave-to
{
opacity
:
0
;
}
/* 减少动画模式 */
@media
(
prefers-reduced-motion
:
reduce
)
{
.navigation-progress-bar
{
animation
:
progress-pulse
2s
ease-in-out
infinite
;
}
@keyframes
progress-pulse
{
0
%,
100
%
{
opacity
:
0.4
;
}
50
%
{
opacity
:
1
;
}
}
}
</
style
>
frontend/src/components/common/__tests__/NavigationProgress.spec.ts
0 → 100644
View file @
6901b64f
/**
* NavigationProgress 组件单元测试
*/
import
{
describe
,
it
,
expect
,
vi
,
beforeEach
}
from
'
vitest
'
import
{
mount
}
from
'
@vue/test-utils
'
import
{
ref
}
from
'
vue
'
import
NavigationProgress
from
'
../../common/NavigationProgress.vue
'
// Mock useNavigationLoadingState
const
mockIsLoading
=
ref
(
false
)
vi
.
mock
(
'
@/composables/useNavigationLoading
'
,
()
=>
({
useNavigationLoadingState
:
()
=>
({
isLoading
:
mockIsLoading
})
}))
describe
(
'
NavigationProgress
'
,
()
=>
{
beforeEach
(()
=>
{
mockIsLoading
.
value
=
false
})
it
(
'
isLoading=false 时进度条应该隐藏
'
,
()
=>
{
mockIsLoading
.
value
=
false
const
wrapper
=
mount
(
NavigationProgress
)
const
progressBar
=
wrapper
.
find
(
'
.navigation-progress
'
)
// v-show 会设置 display: none
expect
(
progressBar
.
isVisible
()).
toBe
(
false
)
})
it
(
'
isLoading=true 时进度条应该可见
'
,
async
()
=>
{
mockIsLoading
.
value
=
true
const
wrapper
=
mount
(
NavigationProgress
)
await
wrapper
.
vm
.
$nextTick
()
const
progressBar
=
wrapper
.
find
(
'
.navigation-progress
'
)
expect
(
progressBar
.
exists
()).
toBe
(
true
)
expect
(
progressBar
.
isVisible
()).
toBe
(
true
)
})
it
(
'
应该有正确的 ARIA 属性
'
,
()
=>
{
mockIsLoading
.
value
=
true
const
wrapper
=
mount
(
NavigationProgress
)
const
progressBar
=
wrapper
.
find
(
'
.navigation-progress
'
)
expect
(
progressBar
.
attributes
(
'
role
'
)).
toBe
(
'
progressbar
'
)
expect
(
progressBar
.
attributes
(
'
aria-label
'
)).
toBe
(
'
Loading
'
)
expect
(
progressBar
.
attributes
(
'
aria-valuemin
'
)).
toBe
(
'
0
'
)
expect
(
progressBar
.
attributes
(
'
aria-valuemax
'
)).
toBe
(
'
100
'
)
})
it
(
'
进度条应该有动画 class
'
,
()
=>
{
mockIsLoading
.
value
=
true
const
wrapper
=
mount
(
NavigationProgress
)
const
bar
=
wrapper
.
find
(
'
.navigation-progress-bar
'
)
expect
(
bar
.
exists
()).
toBe
(
true
)
})
it
(
'
应该正确响应 isLoading 状态变化
'
,
async
()
=>
{
// 测试初始状态为 false
mockIsLoading
.
value
=
false
const
wrapper
=
mount
(
NavigationProgress
)
await
wrapper
.
vm
.
$nextTick
()
// 初始状态隐藏
expect
(
wrapper
.
find
(
'
.navigation-progress
'
).
isVisible
()).
toBe
(
false
)
// 卸载后重新挂载以测试 true 状态
wrapper
.
unmount
()
// 改变为 true 后重新挂载
mockIsLoading
.
value
=
true
const
wrapper2
=
mount
(
NavigationProgress
)
await
wrapper2
.
vm
.
$nextTick
()
expect
(
wrapper2
.
find
(
'
.navigation-progress
'
).
isVisible
()).
toBe
(
true
)
// 清理
wrapper2
.
unmount
()
})
})
frontend/src/composables/__tests__/useNavigationLoading.spec.ts
0 → 100644
View file @
6901b64f
/**
* useNavigationLoading 组合式函数单元测试
*/
import
{
describe
,
it
,
expect
,
vi
,
beforeEach
,
afterEach
}
from
'
vitest
'
import
{
useNavigationLoading
,
_resetNavigationLoadingInstance
}
from
'
../useNavigationLoading
'
describe
(
'
useNavigationLoading
'
,
()
=>
{
beforeEach
(()
=>
{
vi
.
useFakeTimers
()
_resetNavigationLoadingInstance
()
})
afterEach
(()
=>
{
vi
.
useRealTimers
()
})
describe
(
'
startNavigation
'
,
()
=>
{
it
(
'
导航开始时 isNavigating 应变为 true
'
,
()
=>
{
const
{
isNavigating
,
startNavigation
}
=
useNavigationLoading
()
expect
(
isNavigating
.
value
).
toBe
(
false
)
startNavigation
()
expect
(
isNavigating
.
value
).
toBe
(
true
)
})
it
(
'
导航开始后延迟显示加载指示器(防闪烁)
'
,
()
=>
{
const
{
isLoading
,
startNavigation
,
ANTI_FLICKER_DELAY
}
=
useNavigationLoading
()
startNavigation
()
// 立即检查,不应该显示
expect
(
isLoading
.
value
).
toBe
(
false
)
// 经过防闪烁延迟后应该显示
vi
.
advanceTimersByTime
(
ANTI_FLICKER_DELAY
)
expect
(
isLoading
.
value
).
toBe
(
true
)
})
})
describe
(
'
endNavigation
'
,
()
=>
{
it
(
'
导航结束时 isLoading 应变为 false
'
,
()
=>
{
const
{
isLoading
,
startNavigation
,
endNavigation
,
ANTI_FLICKER_DELAY
}
=
useNavigationLoading
()
startNavigation
()
vi
.
advanceTimersByTime
(
ANTI_FLICKER_DELAY
)
expect
(
isLoading
.
value
).
toBe
(
true
)
endNavigation
()
expect
(
isLoading
.
value
).
toBe
(
false
)
})
it
(
'
导航结束时 isNavigating 应变为 false
'
,
()
=>
{
const
{
isNavigating
,
startNavigation
,
endNavigation
}
=
useNavigationLoading
()
startNavigation
()
expect
(
isNavigating
.
value
).
toBe
(
true
)
endNavigation
()
expect
(
isNavigating
.
value
).
toBe
(
false
)
})
})
describe
(
'
快速导航(< 100ms)防闪烁
'
,
()
=>
{
it
(
'
快速导航不应触发显示加载指示器
'
,
()
=>
{
const
{
isLoading
,
startNavigation
,
endNavigation
,
ANTI_FLICKER_DELAY
}
=
useNavigationLoading
()
startNavigation
()
// 在防闪烁延迟之前结束导航
vi
.
advanceTimersByTime
(
ANTI_FLICKER_DELAY
-
50
)
endNavigation
()
// 不应该显示加载指示器
expect
(
isLoading
.
value
).
toBe
(
false
)
// 即使继续等待也不应该显示
vi
.
advanceTimersByTime
(
100
)
expect
(
isLoading
.
value
).
toBe
(
false
)
})
})
describe
(
'
cancelNavigation
'
,
()
=>
{
it
(
'
导航取消时应正确重置状态
'
,
()
=>
{
const
{
isLoading
,
startNavigation
,
cancelNavigation
,
ANTI_FLICKER_DELAY
}
=
useNavigationLoading
()
startNavigation
()
vi
.
advanceTimersByTime
(
ANTI_FLICKER_DELAY
/
2
)
cancelNavigation
()
// 取消后不应该触发显示
vi
.
advanceTimersByTime
(
ANTI_FLICKER_DELAY
)
expect
(
isLoading
.
value
).
toBe
(
false
)
})
})
describe
(
'
getNavigationDuration
'
,
()
=>
{
it
(
'
应该返回正确的导航持续时间
'
,
()
=>
{
const
{
startNavigation
,
getNavigationDuration
}
=
useNavigationLoading
()
expect
(
getNavigationDuration
()).
toBeNull
()
startNavigation
()
vi
.
advanceTimersByTime
(
500
)
const
duration
=
getNavigationDuration
()
expect
(
duration
).
toBe
(
500
)
})
it
(
'
导航结束后应返回 null
'
,
()
=>
{
const
{
startNavigation
,
endNavigation
,
getNavigationDuration
}
=
useNavigationLoading
()
startNavigation
()
vi
.
advanceTimersByTime
(
500
)
endNavigation
()
expect
(
getNavigationDuration
()).
toBeNull
()
})
})
describe
(
'
resetState
'
,
()
=>
{
it
(
'
应该重置所有状态
'
,
()
=>
{
const
{
isLoading
,
isNavigating
,
startNavigation
,
resetState
,
ANTI_FLICKER_DELAY
}
=
useNavigationLoading
()
startNavigation
()
vi
.
advanceTimersByTime
(
ANTI_FLICKER_DELAY
)
expect
(
isLoading
.
value
).
toBe
(
true
)
expect
(
isNavigating
.
value
).
toBe
(
true
)
resetState
()
expect
(
isLoading
.
value
).
toBe
(
false
)
expect
(
isNavigating
.
value
).
toBe
(
false
)
})
})
describe
(
'
连续导航场景
'
,
()
=>
{
it
(
'
连续快速导航应正确处理状态
'
,
()
=>
{
const
{
isLoading
,
startNavigation
,
cancelNavigation
,
endNavigation
,
ANTI_FLICKER_DELAY
}
=
useNavigationLoading
()
// 第一次导航
startNavigation
()
vi
.
advanceTimersByTime
(
30
)
// 第二次导航(取消第一次)
cancelNavigation
()
startNavigation
()
vi
.
advanceTimersByTime
(
30
)
// 第三次导航(取消第二次)
cancelNavigation
()
startNavigation
()
// 这次等待足够长时间
vi
.
advanceTimersByTime
(
ANTI_FLICKER_DELAY
)
expect
(
isLoading
.
value
).
toBe
(
true
)
// 结束导航
endNavigation
()
expect
(
isLoading
.
value
).
toBe
(
false
)
})
})
describe
(
'
ANTI_FLICKER_DELAY 常量
'
,
()
=>
{
it
(
'
应该为 100ms
'
,
()
=>
{
const
{
ANTI_FLICKER_DELAY
}
=
useNavigationLoading
()
expect
(
ANTI_FLICKER_DELAY
).
toBe
(
100
)
})
})
})
Prev
1
…
4
5
6
7
8
9
10
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