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
429f38d0
Commit
429f38d0
authored
Dec 26, 2025
by
shaw
Browse files
Merge PR #37: Add Gemini OAuth and Messages Compat Support
parents
2d89f366
2714be99
Changes
165
Hide whitespace changes
Inline
Side-by-side
frontend/src/api/admin/redeem.ts
View file @
429f38d0
...
...
@@ -3,13 +3,13 @@
* Handles redeem code generation and management for administrators
*/
import
{
apiClient
}
from
'
../client
'
;
import
{
apiClient
}
from
'
../client
'
import
type
{
RedeemCode
,
GenerateRedeemCodesRequest
,
RedeemCodeType
,
PaginatedResponse
,
}
from
'
@/types
'
;
PaginatedResponse
}
from
'
@/types
'
/**
* List all redeem codes with pagination
...
...
@@ -22,19 +22,19 @@ export async function list(
page
:
number
=
1
,
pageSize
:
number
=
20
,
filters
?:
{
type
?:
RedeemCodeType
;
status
?:
'
active
'
|
'
used
'
|
'
expired
'
|
'
unused
'
;
search
?:
string
;
type
?:
RedeemCodeType
status
?:
'
active
'
|
'
used
'
|
'
expired
'
|
'
unused
'
search
?:
string
}
):
Promise
<
PaginatedResponse
<
RedeemCode
>>
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
RedeemCode
>>
(
'
/admin/redeem-codes
'
,
{
params
:
{
page
,
page_size
:
pageSize
,
...
filters
,
}
,
})
;
return
data
;
...
filters
}
})
return
data
}
/**
...
...
@@ -43,8 +43,8 @@ export async function list(
* @returns Redeem code details
*/
export
async
function
getById
(
id
:
number
):
Promise
<
RedeemCode
>
{
const
{
data
}
=
await
apiClient
.
get
<
RedeemCode
>
(
`/admin/redeem-codes/
${
id
}
`
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
RedeemCode
>
(
`/admin/redeem-codes/
${
id
}
`
)
return
data
}
/**
...
...
@@ -66,19 +66,19 @@ export async function generate(
const
payload
:
GenerateRedeemCodesRequest
=
{
count
,
type
,
value
,
}
;
value
}
// 订阅类型专用字段
if
(
type
===
'
subscription
'
)
{
payload
.
group_id
=
groupId
;
payload
.
group_id
=
groupId
if
(
validityDays
&&
validityDays
>
0
)
{
payload
.
validity_days
=
validityDays
;
payload
.
validity_days
=
validityDays
}
}
const
{
data
}
=
await
apiClient
.
post
<
RedeemCode
[]
>
(
'
/admin/redeem-codes/generate
'
,
payload
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
post
<
RedeemCode
[]
>
(
'
/admin/redeem-codes/generate
'
,
payload
)
return
data
}
/**
...
...
@@ -87,8 +87,8 @@ export async function generate(
* @returns Success confirmation
*/
export
async
function
deleteCode
(
id
:
number
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
delete
<
{
message
:
string
}
>
(
`/admin/redeem-codes/
${
id
}
`
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
delete
<
{
message
:
string
}
>
(
`/admin/redeem-codes/
${
id
}
`
)
return
data
}
/**
...
...
@@ -97,14 +97,14 @@ export async function deleteCode(id: number): Promise<{ message: string }> {
* @returns Success confirmation
*/
export
async
function
batchDelete
(
ids
:
number
[]):
Promise
<
{
deleted
:
number
;
message
:
string
;
deleted
:
number
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
deleted
:
number
;
message
:
string
;
}
>
(
'
/admin/redeem-codes/batch-delete
'
,
{
ids
})
;
return
data
;
deleted
:
number
message
:
string
}
>
(
'
/admin/redeem-codes/batch-delete
'
,
{
ids
})
return
data
}
/**
...
...
@@ -113,8 +113,8 @@ export async function batchDelete(ids: number[]): Promise<{
* @returns Updated redeem code
*/
export
async
function
expire
(
id
:
number
):
Promise
<
RedeemCode
>
{
const
{
data
}
=
await
apiClient
.
post
<
RedeemCode
>
(
`/admin/redeem-codes/
${
id
}
/expire`
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
post
<
RedeemCode
>
(
`/admin/redeem-codes/
${
id
}
/expire`
)
return
data
}
/**
...
...
@@ -122,22 +122,22 @@ export async function expire(id: number): Promise<RedeemCode> {
* @returns Statistics about redeem codes
*/
export
async
function
getStats
():
Promise
<
{
total_codes
:
number
;
active_codes
:
number
;
used_codes
:
number
;
expired_codes
:
number
;
total_value_distributed
:
number
;
by_type
:
Record
<
RedeemCodeType
,
number
>
;
total_codes
:
number
active_codes
:
number
used_codes
:
number
expired_codes
:
number
total_value_distributed
:
number
by_type
:
Record
<
RedeemCodeType
,
number
>
}
>
{
const
{
data
}
=
await
apiClient
.
get
<
{
total_codes
:
number
;
active_codes
:
number
;
used_codes
:
number
;
expired_codes
:
number
;
total_value_distributed
:
number
;
by_type
:
Record
<
RedeemCodeType
,
number
>
;
}
>
(
'
/admin/redeem-codes/stats
'
)
;
return
data
;
total_codes
:
number
active_codes
:
number
used_codes
:
number
expired_codes
:
number
total_value_distributed
:
number
by_type
:
Record
<
RedeemCodeType
,
number
>
}
>
(
'
/admin/redeem-codes/stats
'
)
return
data
}
/**
...
...
@@ -146,14 +146,14 @@ export async function getStats(): Promise<{
* @returns CSV data as blob
*/
export
async
function
exportCodes
(
filters
?:
{
type
?:
RedeemCodeType
;
status
?:
'
active
'
|
'
used
'
|
'
expired
'
;
type
?:
RedeemCodeType
status
?:
'
active
'
|
'
used
'
|
'
expired
'
}):
Promise
<
Blob
>
{
const
response
=
await
apiClient
.
get
(
'
/admin/redeem-codes/export
'
,
{
params
:
filters
,
responseType
:
'
blob
'
,
})
;
return
response
.
data
;
responseType
:
'
blob
'
})
return
response
.
data
}
export
const
redeemAPI
=
{
...
...
@@ -164,7 +164,7 @@ export const redeemAPI = {
batchDelete
,
expire
,
getStats
,
exportCodes
,
}
;
exportCodes
}
export
default
redeemAPI
;
export
default
redeemAPI
frontend/src/api/admin/settings.ts
View file @
429f38d0
...
...
@@ -3,37 +3,37 @@
* Handles system settings management for administrators
*/
import
{
apiClient
}
from
'
../client
'
;
import
{
apiClient
}
from
'
../client
'
/**
* System settings interface
*/
export
interface
SystemSettings
{
// Registration settings
registration_enabled
:
boolean
;
email_verify_enabled
:
boolean
;
registration_enabled
:
boolean
email_verify_enabled
:
boolean
// Default settings
default_balance
:
number
;
default_concurrency
:
number
;
default_balance
:
number
default_concurrency
:
number
// OEM settings
site_name
:
string
;
site_logo
:
string
;
site_subtitle
:
string
;
api_base_url
:
string
;
contact_info
:
string
;
doc_url
:
string
;
site_name
:
string
site_logo
:
string
site_subtitle
:
string
api_base_url
:
string
contact_info
:
string
doc_url
:
string
// SMTP settings
smtp_host
:
string
;
smtp_port
:
number
;
smtp_username
:
string
;
smtp_password
:
string
;
smtp_from_email
:
string
;
smtp_from_name
:
string
;
smtp_use_tls
:
boolean
;
smtp_host
:
string
smtp_port
:
number
smtp_username
:
string
smtp_password
:
string
smtp_from_email
:
string
smtp_from_name
:
string
smtp_use_tls
:
boolean
// Cloudflare Turnstile settings
turnstile_enabled
:
boolean
;
turnstile_site_key
:
string
;
turnstile_secret_key
:
string
;
turnstile_enabled
:
boolean
turnstile_site_key
:
string
turnstile_secret_key
:
string
}
/**
...
...
@@ -41,8 +41,8 @@ export interface SystemSettings {
* @returns System settings
*/
export
async
function
getSettings
():
Promise
<
SystemSettings
>
{
const
{
data
}
=
await
apiClient
.
get
<
SystemSettings
>
(
'
/admin/settings
'
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
SystemSettings
>
(
'
/admin/settings
'
)
return
data
}
/**
...
...
@@ -51,19 +51,19 @@ export async function getSettings(): Promise<SystemSettings> {
* @returns Updated settings
*/
export
async
function
updateSettings
(
settings
:
Partial
<
SystemSettings
>
):
Promise
<
SystemSettings
>
{
const
{
data
}
=
await
apiClient
.
put
<
SystemSettings
>
(
'
/admin/settings
'
,
settings
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
put
<
SystemSettings
>
(
'
/admin/settings
'
,
settings
)
return
data
}
/**
* Test SMTP connection request
*/
export
interface
TestSmtpRequest
{
smtp_host
:
string
;
smtp_port
:
number
;
smtp_username
:
string
;
smtp_password
:
string
;
smtp_use_tls
:
boolean
;
smtp_host
:
string
smtp_port
:
number
smtp_username
:
string
smtp_password
:
string
smtp_use_tls
:
boolean
}
/**
...
...
@@ -72,22 +72,22 @@ export interface TestSmtpRequest {
* @returns Test result message
*/
export
async
function
testSmtpConnection
(
config
:
TestSmtpRequest
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
message
:
string
}
>
(
'
/admin/settings/test-smtp
'
,
config
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
post
<
{
message
:
string
}
>
(
'
/admin/settings/test-smtp
'
,
config
)
return
data
}
/**
* Send test email request
*/
export
interface
SendTestEmailRequest
{
email
:
string
;
smtp_host
:
string
;
smtp_port
:
number
;
smtp_username
:
string
;
smtp_password
:
string
;
smtp_from_email
:
string
;
smtp_from_name
:
string
;
smtp_use_tls
:
boolean
;
email
:
string
smtp_host
:
string
smtp_port
:
number
smtp_username
:
string
smtp_password
:
string
smtp_from_email
:
string
smtp_from_name
:
string
smtp_use_tls
:
boolean
}
/**
...
...
@@ -96,16 +96,19 @@ export interface SendTestEmailRequest {
* @returns Test result message
*/
export
async
function
sendTestEmail
(
request
:
SendTestEmailRequest
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
message
:
string
}
>
(
'
/admin/settings/send-test-email
'
,
request
);
return
data
;
const
{
data
}
=
await
apiClient
.
post
<
{
message
:
string
}
>
(
'
/admin/settings/send-test-email
'
,
request
)
return
data
}
/**
* Admin API Key status response
*/
export
interface
AdminApiKeyStatus
{
exists
:
boolean
;
masked_key
:
string
;
exists
:
boolean
masked_key
:
string
}
/**
...
...
@@ -113,8 +116,8 @@ export interface AdminApiKeyStatus {
* @returns Status indicating if key exists and masked version
*/
export
async
function
getAdminApiKey
():
Promise
<
AdminApiKeyStatus
>
{
const
{
data
}
=
await
apiClient
.
get
<
AdminApiKeyStatus
>
(
'
/admin/settings/admin-api-key
'
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
AdminApiKeyStatus
>
(
'
/admin/settings/admin-api-key
'
)
return
data
}
/**
...
...
@@ -122,8 +125,8 @@ export async function getAdminApiKey(): Promise<AdminApiKeyStatus> {
* @returns The new full API key (only shown once)
*/
export
async
function
regenerateAdminApiKey
():
Promise
<
{
key
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
key
:
string
}
>
(
'
/admin/settings/admin-api-key/regenerate
'
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
post
<
{
key
:
string
}
>
(
'
/admin/settings/admin-api-key/regenerate
'
)
return
data
}
/**
...
...
@@ -131,8 +134,8 @@ export async function regenerateAdminApiKey(): Promise<{ key: string }> {
* @returns Success message
*/
export
async
function
deleteAdminApiKey
():
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
delete
<
{
message
:
string
}
>
(
'
/admin/settings/admin-api-key
'
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
delete
<
{
message
:
string
}
>
(
'
/admin/settings/admin-api-key
'
)
return
data
}
export
const
settingsAPI
=
{
...
...
@@ -142,7 +145,7 @@ export const settingsAPI = {
sendTestEmail
,
getAdminApiKey
,
regenerateAdminApiKey
,
deleteAdminApiKey
,
}
;
deleteAdminApiKey
}
export
default
settingsAPI
;
export
default
settingsAPI
frontend/src/api/admin/subscriptions.ts
View file @
429f38d0
...
...
@@ -3,15 +3,15 @@
* Handles user subscription management for administrators
*/
import
{
apiClient
}
from
'
../client
'
;
import
{
apiClient
}
from
'
../client
'
import
type
{
UserSubscription
,
SubscriptionProgress
,
AssignSubscriptionRequest
,
BulkAssignSubscriptionRequest
,
ExtendSubscriptionRequest
,
PaginatedResponse
,
}
from
'
@/types
'
;
PaginatedResponse
}
from
'
@/types
'
/**
* List all subscriptions with pagination
...
...
@@ -24,19 +24,22 @@ export async function list(
page
:
number
=
1
,
pageSize
:
number
=
20
,
filters
?:
{
status
?:
'
active
'
|
'
expired
'
|
'
revoked
'
;
user_id
?:
number
;
group_id
?:
number
;
status
?:
'
active
'
|
'
expired
'
|
'
revoked
'
user_id
?:
number
group_id
?:
number
}
):
Promise
<
PaginatedResponse
<
UserSubscription
>>
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
UserSubscription
>>
(
'
/admin/subscriptions
'
,
{
params
:
{
page
,
page_size
:
pageSize
,
...
filters
,
},
});
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
UserSubscription
>>
(
'
/admin/subscriptions
'
,
{
params
:
{
page
,
page_size
:
pageSize
,
...
filters
}
}
)
return
data
}
/**
...
...
@@ -45,8 +48,8 @@ export async function list(
* @returns Subscription details
*/
export
async
function
getById
(
id
:
number
):
Promise
<
UserSubscription
>
{
const
{
data
}
=
await
apiClient
.
get
<
UserSubscription
>
(
`/admin/subscriptions/
${
id
}
`
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
UserSubscription
>
(
`/admin/subscriptions/
${
id
}
`
)
return
data
}
/**
...
...
@@ -55,8 +58,8 @@ export async function getById(id: number): Promise<UserSubscription> {
* @returns Subscription progress with usage stats
*/
export
async
function
getProgress
(
id
:
number
):
Promise
<
SubscriptionProgress
>
{
const
{
data
}
=
await
apiClient
.
get
<
SubscriptionProgress
>
(
`/admin/subscriptions/
${
id
}
/progress`
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
SubscriptionProgress
>
(
`/admin/subscriptions/
${
id
}
/progress`
)
return
data
}
/**
...
...
@@ -65,8 +68,8 @@ export async function getProgress(id: number): Promise<SubscriptionProgress> {
* @returns Created subscription
*/
export
async
function
assign
(
request
:
AssignSubscriptionRequest
):
Promise
<
UserSubscription
>
{
const
{
data
}
=
await
apiClient
.
post
<
UserSubscription
>
(
'
/admin/subscriptions/assign
'
,
request
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
post
<
UserSubscription
>
(
'
/admin/subscriptions/assign
'
,
request
)
return
data
}
/**
...
...
@@ -74,9 +77,14 @@ export async function assign(request: AssignSubscriptionRequest): Promise<UserSu
* @param request - Bulk assignment request
* @returns Created subscriptions
*/
export
async
function
bulkAssign
(
request
:
BulkAssignSubscriptionRequest
):
Promise
<
UserSubscription
[]
>
{
const
{
data
}
=
await
apiClient
.
post
<
UserSubscription
[]
>
(
'
/admin/subscriptions/bulk-assign
'
,
request
);
return
data
;
export
async
function
bulkAssign
(
request
:
BulkAssignSubscriptionRequest
):
Promise
<
UserSubscription
[]
>
{
const
{
data
}
=
await
apiClient
.
post
<
UserSubscription
[]
>
(
'
/admin/subscriptions/bulk-assign
'
,
request
)
return
data
}
/**
...
...
@@ -85,9 +93,15 @@ export async function bulkAssign(request: BulkAssignSubscriptionRequest): Promis
* @param request - Extension request with days
* @returns Updated subscription
*/
export
async
function
extend
(
id
:
number
,
request
:
ExtendSubscriptionRequest
):
Promise
<
UserSubscription
>
{
const
{
data
}
=
await
apiClient
.
post
<
UserSubscription
>
(
`/admin/subscriptions/
${
id
}
/extend`
,
request
);
return
data
;
export
async
function
extend
(
id
:
number
,
request
:
ExtendSubscriptionRequest
):
Promise
<
UserSubscription
>
{
const
{
data
}
=
await
apiClient
.
post
<
UserSubscription
>
(
`/admin/subscriptions/
${
id
}
/extend`
,
request
)
return
data
}
/**
...
...
@@ -96,8 +110,8 @@ export async function extend(id: number, request: ExtendSubscriptionRequest): Pr
* @returns Success confirmation
*/
export
async
function
revoke
(
id
:
number
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
delete
<
{
message
:
string
}
>
(
`/admin/subscriptions/
${
id
}
`
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
delete
<
{
message
:
string
}
>
(
`/admin/subscriptions/
${
id
}
`
)
return
data
}
/**
...
...
@@ -115,10 +129,10 @@ export async function listByGroup(
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
UserSubscription
>>
(
`/admin/groups/
${
groupId
}
/subscriptions`
,
{
params
:
{
page
,
page_size
:
pageSize
}
,
params
:
{
page
,
page_size
:
pageSize
}
}
)
;
return
data
;
)
return
data
}
/**
...
...
@@ -136,10 +150,10 @@ export async function listByUser(
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
UserSubscription
>>
(
`/admin/users/
${
userId
}
/subscriptions`
,
{
params
:
{
page
,
page_size
:
pageSize
}
,
params
:
{
page
,
page_size
:
pageSize
}
}
)
;
return
data
;
)
return
data
}
export
const
subscriptionsAPI
=
{
...
...
@@ -151,7 +165,7 @@ export const subscriptionsAPI = {
extend
,
revoke
,
listByGroup
,
listByUser
,
}
;
listByUser
}
export
default
subscriptionsAPI
;
export
default
subscriptionsAPI
frontend/src/api/admin/system.ts
View file @
429f38d0
...
...
@@ -2,31 +2,31 @@
* System API endpoints for admin operations
*/
import
{
apiClient
}
from
'
../client
'
;
import
{
apiClient
}
from
'
../client
'
export
interface
ReleaseInfo
{
name
:
string
;
body
:
string
;
published_at
:
string
;
html_url
:
string
;
name
:
string
body
:
string
published_at
:
string
html_url
:
string
}
export
interface
VersionInfo
{
current_version
:
string
;
latest_version
:
string
;
has_update
:
boolean
;
release_info
?:
ReleaseInfo
;
cached
:
boolean
;
warning
?:
string
;
build_type
:
string
;
// "source" for manual builds, "release" for CI builds
current_version
:
string
latest_version
:
string
has_update
:
boolean
release_info
?:
ReleaseInfo
cached
:
boolean
warning
?:
string
build_type
:
string
// "source" for manual builds, "release" for CI builds
}
/**
* Get current version
*/
export
async
function
getVersion
():
Promise
<
{
version
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
get
<
{
version
:
string
}
>
(
'
/admin/system/version
'
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
{
version
:
string
}
>
(
'
/admin/system/version
'
)
return
data
}
/**
...
...
@@ -35,14 +35,14 @@ export async function getVersion(): Promise<{ version: string }> {
*/
export
async
function
checkUpdates
(
force
=
false
):
Promise
<
VersionInfo
>
{
const
{
data
}
=
await
apiClient
.
get
<
VersionInfo
>
(
'
/admin/system/check-updates
'
,
{
params
:
force
?
{
force
:
'
true
'
}
:
undefined
,
})
;
return
data
;
params
:
force
?
{
force
:
'
true
'
}
:
undefined
})
return
data
}
export
interface
UpdateResult
{
message
:
string
;
need_restart
:
boolean
;
message
:
string
need_restart
:
boolean
}
/**
...
...
@@ -50,24 +50,24 @@ export interface UpdateResult {
* Downloads and applies the latest version
*/
export
async
function
performUpdate
():
Promise
<
UpdateResult
>
{
const
{
data
}
=
await
apiClient
.
post
<
UpdateResult
>
(
'
/admin/system/update
'
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
post
<
UpdateResult
>
(
'
/admin/system/update
'
)
return
data
}
/**
* Rollback to previous version
*/
export
async
function
rollback
():
Promise
<
UpdateResult
>
{
const
{
data
}
=
await
apiClient
.
post
<
UpdateResult
>
(
'
/admin/system/rollback
'
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
post
<
UpdateResult
>
(
'
/admin/system/rollback
'
)
return
data
}
/**
* Restart the service
*/
export
async
function
restartService
():
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
message
:
string
}
>
(
'
/admin/system/restart
'
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
post
<
{
message
:
string
}
>
(
'
/admin/system/restart
'
)
return
data
}
export
const
systemAPI
=
{
...
...
@@ -75,7 +75,7 @@ export const systemAPI = {
checkUpdates
,
performUpdate
,
rollback
,
restartService
,
}
;
restartService
}
export
default
systemAPI
;
export
default
systemAPI
frontend/src/api/admin/usage.ts
View file @
429f38d0
...
...
@@ -3,39 +3,35 @@
* Handles admin-level usage logs and statistics retrieval
*/
import
{
apiClient
}
from
'
../client
'
;
import
type
{
UsageLog
,
UsageQueryParams
,
PaginatedResponse
,
}
from
'
@/types
'
;
import
{
apiClient
}
from
'
../client
'
import
type
{
UsageLog
,
UsageQueryParams
,
PaginatedResponse
}
from
'
@/types
'
// ==================== Types ====================
export
interface
AdminUsageStatsResponse
{
total_requests
:
number
;
total_input_tokens
:
number
;
total_output_tokens
:
number
;
total_cache_tokens
:
number
;
total_tokens
:
number
;
total_cost
:
number
;
total_actual_cost
:
number
;
average_duration_ms
:
number
;
total_requests
:
number
total_input_tokens
:
number
total_output_tokens
:
number
total_cache_tokens
:
number
total_tokens
:
number
total_cost
:
number
total_actual_cost
:
number
average_duration_ms
:
number
}
export
interface
SimpleUser
{
id
:
number
;
email
:
string
;
id
:
number
email
:
string
}
export
interface
SimpleApiKey
{
id
:
number
;
name
:
string
;
user_id
:
number
;
id
:
number
name
:
string
user_id
:
number
}
export
interface
AdminUsageQueryParams
extends
UsageQueryParams
{
user_id
?:
number
;
user_id
?:
number
}
// ==================== API Functions ====================
...
...
@@ -47,9 +43,9 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
*/
export
async
function
list
(
params
:
AdminUsageQueryParams
):
Promise
<
PaginatedResponse
<
UsageLog
>>
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
UsageLog
>>
(
'
/admin/usage
'
,
{
params
,
})
;
return
data
;
params
})
return
data
}
/**
...
...
@@ -58,16 +54,16 @@ export async function list(params: AdminUsageQueryParams): Promise<PaginatedResp
* @returns Usage statistics
*/
export
async
function
getStats
(
params
:
{
user_id
?:
number
;
api_key_id
?:
number
;
period
?:
string
;
start_date
?:
string
;
end_date
?:
string
;
user_id
?:
number
api_key_id
?:
number
period
?:
string
start_date
?:
string
end_date
?:
string
}):
Promise
<
AdminUsageStatsResponse
>
{
const
{
data
}
=
await
apiClient
.
get
<
AdminUsageStatsResponse
>
(
'
/admin/usage/stats
'
,
{
params
,
})
;
return
data
;
params
})
return
data
}
/**
...
...
@@ -77,9 +73,9 @@ export async function getStats(params: {
*/
export
async
function
searchUsers
(
keyword
:
string
):
Promise
<
SimpleUser
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
SimpleUser
[]
>
(
'
/admin/usage/search-users
'
,
{
params
:
{
q
:
keyword
}
,
})
;
return
data
;
params
:
{
q
:
keyword
}
})
return
data
}
/**
...
...
@@ -89,24 +85,24 @@ export async function searchUsers(keyword: string): Promise<SimpleUser[]> {
* @returns List of matching API keys (max 30)
*/
export
async
function
searchApiKeys
(
userId
?:
number
,
keyword
?:
string
):
Promise
<
SimpleApiKey
[]
>
{
const
params
:
Record
<
string
,
unknown
>
=
{}
;
const
params
:
Record
<
string
,
unknown
>
=
{}
if
(
userId
!==
undefined
)
{
params
.
user_id
=
userId
;
params
.
user_id
=
userId
}
if
(
keyword
)
{
params
.
q
=
keyword
;
params
.
q
=
keyword
}
const
{
data
}
=
await
apiClient
.
get
<
SimpleApiKey
[]
>
(
'
/admin/usage/search-api-keys
'
,
{
params
,
})
;
return
data
;
params
})
return
data
}
export
const
adminUsageAPI
=
{
list
,
getStats
,
searchUsers
,
searchApiKeys
,
}
;
searchApiKeys
}
export
default
adminUsageAPI
;
export
default
adminUsageAPI
frontend/src/api/admin/users.ts
View file @
429f38d0
...
...
@@ -3,8 +3,8 @@
* Handles user management for administrators
*/
import
{
apiClient
}
from
'
../client
'
;
import
type
{
User
,
UpdateUserRequest
,
PaginatedResponse
}
from
'
@/types
'
;
import
{
apiClient
}
from
'
../client
'
import
type
{
User
,
UpdateUserRequest
,
PaginatedResponse
}
from
'
@/types
'
/**
* List all users with pagination
...
...
@@ -17,19 +17,19 @@ export async function list(
page
:
number
=
1
,
pageSize
:
number
=
20
,
filters
?:
{
status
?:
'
active
'
|
'
disabled
'
;
role
?:
'
admin
'
|
'
user
'
;
search
?:
string
;
status
?:
'
active
'
|
'
disabled
'
role
?:
'
admin
'
|
'
user
'
search
?:
string
}
):
Promise
<
PaginatedResponse
<
User
>>
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
User
>>
(
'
/admin/users
'
,
{
params
:
{
page
,
page_size
:
pageSize
,
...
filters
,
}
,
})
;
return
data
;
...
filters
}
})
return
data
}
/**
...
...
@@ -38,8 +38,8 @@ export async function list(
* @returns User details
*/
export
async
function
getById
(
id
:
number
):
Promise
<
User
>
{
const
{
data
}
=
await
apiClient
.
get
<
User
>
(
`/admin/users/
${
id
}
`
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
User
>
(
`/admin/users/
${
id
}
`
)
return
data
}
/**
...
...
@@ -48,14 +48,14 @@ export async function getById(id: number): Promise<User> {
* @returns Created user
*/
export
async
function
create
(
userData
:
{
email
:
string
;
password
:
string
;
balance
?:
number
;
concurrency
?:
number
;
allowed_groups
?:
number
[]
|
null
;
email
:
string
password
:
string
balance
?:
number
concurrency
?:
number
allowed_groups
?:
number
[]
|
null
}):
Promise
<
User
>
{
const
{
data
}
=
await
apiClient
.
post
<
User
>
(
'
/admin/users
'
,
userData
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
post
<
User
>
(
'
/admin/users
'
,
userData
)
return
data
}
/**
...
...
@@ -65,8 +65,8 @@ export async function create(userData: {
* @returns Updated user
*/
export
async
function
update
(
id
:
number
,
updates
:
UpdateUserRequest
):
Promise
<
User
>
{
const
{
data
}
=
await
apiClient
.
put
<
User
>
(
`/admin/users/
${
id
}
`
,
updates
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
put
<
User
>
(
`/admin/users/
${
id
}
`
,
updates
)
return
data
}
/**
...
...
@@ -75,8 +75,8 @@ export async function update(id: number, updates: UpdateUserRequest): Promise<Us
* @returns Success confirmation
*/
export
async
function
deleteUser
(
id
:
number
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
delete
<
{
message
:
string
}
>
(
`/admin/users/
${
id
}
`
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
delete
<
{
message
:
string
}
>
(
`/admin/users/
${
id
}
`
)
return
data
}
/**
...
...
@@ -96,9 +96,9 @@ export async function updateBalance(
const
{
data
}
=
await
apiClient
.
post
<
User
>
(
`/admin/users/
${
id
}
/balance`
,
{
balance
,
operation
,
notes
:
notes
||
''
,
})
;
return
data
;
notes
:
notes
||
''
})
return
data
}
/**
...
...
@@ -108,7 +108,7 @@ export async function updateBalance(
* @returns Updated user
*/
export
async
function
updateConcurrency
(
id
:
number
,
concurrency
:
number
):
Promise
<
User
>
{
return
update
(
id
,
{
concurrency
})
;
return
update
(
id
,
{
concurrency
})
}
/**
...
...
@@ -118,7 +118,7 @@ export async function updateConcurrency(id: number, concurrency: number): Promis
* @returns Updated user
*/
export
async
function
toggleStatus
(
id
:
number
,
status
:
'
active
'
|
'
disabled
'
):
Promise
<
User
>
{
return
update
(
id
,
{
status
})
;
return
update
(
id
,
{
status
})
}
/**
...
...
@@ -127,8 +127,8 @@ export async function toggleStatus(id: number, status: 'active' | 'disabled'): P
* @returns List of user's API keys
*/
export
async
function
getUserApiKeys
(
id
:
number
):
Promise
<
PaginatedResponse
<
any
>>
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
any
>>
(
`/admin/users/
${
id
}
/api-keys`
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
any
>>
(
`/admin/users/
${
id
}
/api-keys`
)
return
data
}
/**
...
...
@@ -141,18 +141,18 @@ export async function getUserUsageStats(
id
:
number
,
period
:
string
=
'
month
'
):
Promise
<
{
total_requests
:
number
;
total_cost
:
number
;
total_tokens
:
number
;
total_requests
:
number
total_cost
:
number
total_tokens
:
number
}
>
{
const
{
data
}
=
await
apiClient
.
get
<
{
total_requests
:
number
;
total_cost
:
number
;
total_tokens
:
number
;
total_requests
:
number
total_cost
:
number
total_tokens
:
number
}
>
(
`/admin/users/
${
id
}
/usage`
,
{
params
:
{
period
}
,
})
;
return
data
;
params
:
{
period
}
})
return
data
}
export
const
usersAPI
=
{
...
...
@@ -165,7 +165,7 @@ export const usersAPI = {
updateConcurrency
,
toggleStatus
,
getUserApiKeys
,
getUserUsageStats
,
}
;
getUserUsageStats
}
export
default
usersAPI
;
export
default
usersAPI
frontend/src/api/auth.ts
View file @
429f38d0
...
...
@@ -3,29 +3,37 @@
* Handles user login, registration, and logout operations
*/
import
{
apiClient
}
from
'
./client
'
;
import
type
{
LoginRequest
,
RegisterRequest
,
AuthResponse
,
User
,
SendVerifyCodeRequest
,
SendVerifyCodeResponse
,
PublicSettings
}
from
'
@/types
'
;
import
{
apiClient
}
from
'
./client
'
import
type
{
LoginRequest
,
RegisterRequest
,
AuthResponse
,
User
,
SendVerifyCodeRequest
,
SendVerifyCodeResponse
,
PublicSettings
}
from
'
@/types
'
/**
* Store authentication token in localStorage
*/
export
function
setAuthToken
(
token
:
string
):
void
{
localStorage
.
setItem
(
'
auth_token
'
,
token
)
;
localStorage
.
setItem
(
'
auth_token
'
,
token
)
}
/**
* Get authentication token from localStorage
*/
export
function
getAuthToken
():
string
|
null
{
return
localStorage
.
getItem
(
'
auth_token
'
)
;
return
localStorage
.
getItem
(
'
auth_token
'
)
}
/**
* Clear authentication token from localStorage
*/
export
function
clearAuthToken
():
void
{
localStorage
.
removeItem
(
'
auth_token
'
)
;
localStorage
.
removeItem
(
'
auth_user
'
)
;
localStorage
.
removeItem
(
'
auth_token
'
)
localStorage
.
removeItem
(
'
auth_user
'
)
}
/**
...
...
@@ -34,13 +42,13 @@ export function clearAuthToken(): void {
* @returns Authentication response with token and user data
*/
export
async
function
login
(
credentials
:
LoginRequest
):
Promise
<
AuthResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
AuthResponse
>
(
'
/auth/login
'
,
credentials
)
;
const
{
data
}
=
await
apiClient
.
post
<
AuthResponse
>
(
'
/auth/login
'
,
credentials
)
// Store token and user data
setAuthToken
(
data
.
access_token
)
;
localStorage
.
setItem
(
'
auth_user
'
,
JSON
.
stringify
(
data
.
user
))
;
setAuthToken
(
data
.
access_token
)
localStorage
.
setItem
(
'
auth_user
'
,
JSON
.
stringify
(
data
.
user
))
return
data
;
return
data
}
/**
...
...
@@ -49,13 +57,13 @@ export async function login(credentials: LoginRequest): Promise<AuthResponse> {
* @returns Authentication response with token and user data
*/
export
async
function
register
(
userData
:
RegisterRequest
):
Promise
<
AuthResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
AuthResponse
>
(
'
/auth/register
'
,
userData
)
;
const
{
data
}
=
await
apiClient
.
post
<
AuthResponse
>
(
'
/auth/register
'
,
userData
)
// Store token and user data
setAuthToken
(
data
.
access_token
)
;
localStorage
.
setItem
(
'
auth_user
'
,
JSON
.
stringify
(
data
.
user
))
;
setAuthToken
(
data
.
access_token
)
localStorage
.
setItem
(
'
auth_user
'
,
JSON
.
stringify
(
data
.
user
))
return
data
;
return
data
}
/**
...
...
@@ -63,8 +71,8 @@ export async function register(userData: RegisterRequest): Promise<AuthResponse>
* @returns User profile data
*/
export
async
function
getCurrentUser
():
Promise
<
User
>
{
const
{
data
}
=
await
apiClient
.
get
<
User
>
(
'
/auth/me
'
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
User
>
(
'
/auth/me
'
)
return
data
}
/**
...
...
@@ -72,7 +80,7 @@ export async function getCurrentUser(): Promise<User> {
* Clears authentication token and user data from localStorage
*/
export
function
logout
():
void
{
clearAuthToken
()
;
clearAuthToken
()
// Optionally redirect to login page
// window.location.href = '/login';
}
...
...
@@ -82,7 +90,7 @@ export function logout(): void {
* @returns True if user has valid token
*/
export
function
isAuthenticated
():
boolean
{
return
getAuthToken
()
!==
null
;
return
getAuthToken
()
!==
null
}
/**
...
...
@@ -90,8 +98,8 @@ export function isAuthenticated(): boolean {
* @returns Public settings including registration and Turnstile config
*/
export
async
function
getPublicSettings
():
Promise
<
PublicSettings
>
{
const
{
data
}
=
await
apiClient
.
get
<
PublicSettings
>
(
'
/settings/public
'
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
PublicSettings
>
(
'
/settings/public
'
)
return
data
}
/**
...
...
@@ -99,9 +107,11 @@ export async function getPublicSettings(): Promise<PublicSettings> {
* @param request - Email and optional Turnstile token
* @returns Response with countdown seconds
*/
export
async
function
sendVerifyCode
(
request
:
SendVerifyCodeRequest
):
Promise
<
SendVerifyCodeResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
SendVerifyCodeResponse
>
(
'
/auth/send-verify-code
'
,
request
);
return
data
;
export
async
function
sendVerifyCode
(
request
:
SendVerifyCodeRequest
):
Promise
<
SendVerifyCodeResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
SendVerifyCodeResponse
>
(
'
/auth/send-verify-code
'
,
request
)
return
data
}
export
const
authAPI
=
{
...
...
@@ -114,7 +124,7 @@ export const authAPI = {
getAuthToken
,
clearAuthToken
,
getPublicSettings
,
sendVerifyCode
,
}
;
sendVerifyCode
}
export
default
authAPI
;
export
default
authAPI
frontend/src/api/client.ts
View file @
429f38d0
...
...
@@ -3,70 +3,70 @@
* Base client with interceptors for authentication and error handling
*/
import
axios
,
{
AxiosInstance
,
AxiosError
,
InternalAxiosRequestConfig
}
from
'
axios
'
;
import
type
{
ApiResponse
}
from
'
@/types
'
;
import
axios
,
{
AxiosInstance
,
AxiosError
,
InternalAxiosRequestConfig
}
from
'
axios
'
import
type
{
ApiResponse
}
from
'
@/types
'
// ==================== Axios Instance Configuration ====================
const
API_BASE_URL
=
import
.
meta
.
env
.
VITE_API_BASE_URL
||
'
/api/v1
'
;
const
API_BASE_URL
=
import
.
meta
.
env
.
VITE_API_BASE_URL
||
'
/api/v1
'
export
const
apiClient
:
AxiosInstance
=
axios
.
create
({
baseURL
:
API_BASE_URL
,
timeout
:
30000
,
headers
:
{
'
Content-Type
'
:
'
application/json
'
,
}
,
})
;
'
Content-Type
'
:
'
application/json
'
}
})
// ==================== Request Interceptor ====================
apiClient
.
interceptors
.
request
.
use
(
(
config
:
InternalAxiosRequestConfig
)
=>
{
// Attach token from localStorage
const
token
=
localStorage
.
getItem
(
'
auth_token
'
)
;
const
token
=
localStorage
.
getItem
(
'
auth_token
'
)
if
(
token
&&
config
.
headers
)
{
config
.
headers
.
Authorization
=
`Bearer
${
token
}
`
;
config
.
headers
.
Authorization
=
`Bearer
${
token
}
`
}
return
config
;
return
config
},
(
error
)
=>
{
return
Promise
.
reject
(
error
)
;
return
Promise
.
reject
(
error
)
}
)
;
)
// ==================== Response Interceptor ====================
apiClient
.
interceptors
.
response
.
use
(
(
response
)
=>
{
// Unwrap standard API response format { code, message, data }
const
apiResponse
=
response
.
data
as
ApiResponse
<
unknown
>
;
const
apiResponse
=
response
.
data
as
ApiResponse
<
unknown
>
if
(
apiResponse
&&
typeof
apiResponse
===
'
object
'
&&
'
code
'
in
apiResponse
)
{
if
(
apiResponse
.
code
===
0
)
{
// Success - return the data portion
response
.
data
=
apiResponse
.
data
;
response
.
data
=
apiResponse
.
data
}
else
{
// API error
return
Promise
.
reject
({
status
:
response
.
status
,
code
:
apiResponse
.
code
,
message
:
apiResponse
.
message
||
'
Unknown error
'
,
})
;
message
:
apiResponse
.
message
||
'
Unknown error
'
})
}
}
return
response
;
return
response
},
(
error
:
AxiosError
<
ApiResponse
<
unknown
>>
)
=>
{
// Handle common errors
if
(
error
.
response
)
{
const
{
status
,
data
}
=
error
.
response
;
const
{
status
,
data
}
=
error
.
response
// 401: Unauthorized - clear token and redirect to login
if
(
status
===
401
)
{
localStorage
.
removeItem
(
'
auth_token
'
)
;
localStorage
.
removeItem
(
'
auth_user
'
)
;
localStorage
.
removeItem
(
'
auth_token
'
)
localStorage
.
removeItem
(
'
auth_user
'
)
// Only redirect if not already on login page
if
(
!
window
.
location
.
pathname
.
includes
(
'
/login
'
))
{
window
.
location
.
href
=
'
/login
'
;
window
.
location
.
href
=
'
/login
'
}
}
...
...
@@ -74,16 +74,16 @@ apiClient.interceptors.response.use(
return
Promise
.
reject
({
status
,
code
:
data
?.
code
,
message
:
data
?.
message
||
error
.
message
,
})
;
message
:
data
?.
message
||
error
.
message
})
}
// Network error
return
Promise
.
reject
({
status
:
0
,
message
:
'
Network error. Please check your connection.
'
,
})
;
message
:
'
Network error. Please check your connection.
'
})
}
)
;
)
export
default
apiClient
;
export
default
apiClient
frontend/src/api/groups.ts
View file @
429f38d0
...
...
@@ -3,8 +3,8 @@
* Handles group-related operations for regular users
*/
import
{
apiClient
}
from
'
./client
'
;
import
type
{
Group
}
from
'
@/types
'
;
import
{
apiClient
}
from
'
./client
'
import
type
{
Group
}
from
'
@/types
'
/**
* Get available groups that the current user can bind to API keys
...
...
@@ -14,12 +14,12 @@ import type { Group } from '@/types';
* @returns List of available groups
*/
export
async
function
getAvailable
():
Promise
<
Group
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
Group
[]
>
(
'
/groups/available
'
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
Group
[]
>
(
'
/groups/available
'
)
return
data
}
export
const
userGroupsAPI
=
{
getAvailable
,
}
;
getAvailable
}
export
default
userGroupsAPI
;
export
default
userGroupsAPI
frontend/src/api/index.ts
View file @
429f38d0
...
...
@@ -4,20 +4,20 @@
*/
// Re-export the HTTP client
export
{
apiClient
}
from
'
./client
'
;
export
{
apiClient
}
from
'
./client
'
// Auth API
export
{
authAPI
}
from
'
./auth
'
;
export
{
authAPI
}
from
'
./auth
'
// User APIs
export
{
keysAPI
}
from
'
./keys
'
;
export
{
usageAPI
}
from
'
./usage
'
;
export
{
userAPI
}
from
'
./user
'
;
export
{
redeemAPI
,
type
RedeemHistoryItem
}
from
'
./redeem
'
;
export
{
userGroupsAPI
}
from
'
./groups
'
;
export
{
keysAPI
}
from
'
./keys
'
export
{
usageAPI
}
from
'
./usage
'
export
{
userAPI
}
from
'
./user
'
export
{
redeemAPI
,
type
RedeemHistoryItem
}
from
'
./redeem
'
export
{
userGroupsAPI
}
from
'
./groups
'
// Admin APIs
export
{
adminAPI
}
from
'
./admin
'
;
export
{
adminAPI
}
from
'
./admin
'
// Default export
export
{
default
}
from
'
./client
'
;
export
{
default
}
from
'
./client
'
frontend/src/api/keys.ts
View file @
429f38d0
...
...
@@ -3,13 +3,8 @@
* Handles CRUD operations for user API keys
*/
import
{
apiClient
}
from
'
./client
'
;
import
type
{
ApiKey
,
CreateApiKeyRequest
,
UpdateApiKeyRequest
,
PaginatedResponse
,
}
from
'
@/types
'
;
import
{
apiClient
}
from
'
./client
'
import
type
{
ApiKey
,
CreateApiKeyRequest
,
UpdateApiKeyRequest
,
PaginatedResponse
}
from
'
@/types
'
/**
* List all API keys for current user
...
...
@@ -17,11 +12,14 @@ import type {
* @param pageSize - Items per page (default: 10)
* @returns Paginated list of API keys
*/
export
async
function
list
(
page
:
number
=
1
,
pageSize
:
number
=
10
):
Promise
<
PaginatedResponse
<
ApiKey
>>
{
export
async
function
list
(
page
:
number
=
1
,
pageSize
:
number
=
10
):
Promise
<
PaginatedResponse
<
ApiKey
>>
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
ApiKey
>>
(
'
/keys
'
,
{
params
:
{
page
,
page_size
:
pageSize
}
,
})
;
return
data
;
params
:
{
page
,
page_size
:
pageSize
}
})
return
data
}
/**
...
...
@@ -30,8 +28,8 @@ export async function list(page: number = 1, pageSize: number = 10): Promise<Pag
* @returns API key details
*/
export
async
function
getById
(
id
:
number
):
Promise
<
ApiKey
>
{
const
{
data
}
=
await
apiClient
.
get
<
ApiKey
>
(
`/keys/
${
id
}
`
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
ApiKey
>
(
`/keys/
${
id
}
`
)
return
data
}
/**
...
...
@@ -41,17 +39,21 @@ export async function getById(id: number): Promise<ApiKey> {
* @param customKey - Optional custom key value
* @returns Created API key
*/
export
async
function
create
(
name
:
string
,
groupId
?:
number
|
null
,
customKey
?:
string
):
Promise
<
ApiKey
>
{
const
payload
:
CreateApiKeyRequest
=
{
name
};
export
async
function
create
(
name
:
string
,
groupId
?:
number
|
null
,
customKey
?:
string
):
Promise
<
ApiKey
>
{
const
payload
:
CreateApiKeyRequest
=
{
name
}
if
(
groupId
!==
undefined
)
{
payload
.
group_id
=
groupId
;
payload
.
group_id
=
groupId
}
if
(
customKey
)
{
payload
.
custom_key
=
customKey
;
payload
.
custom_key
=
customKey
}
const
{
data
}
=
await
apiClient
.
post
<
ApiKey
>
(
'
/keys
'
,
payload
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
post
<
ApiKey
>
(
'
/keys
'
,
payload
)
return
data
}
/**
...
...
@@ -61,8 +63,8 @@ export async function create(name: string, groupId?: number | null, customKey?:
* @returns Updated API key
*/
export
async
function
update
(
id
:
number
,
updates
:
UpdateApiKeyRequest
):
Promise
<
ApiKey
>
{
const
{
data
}
=
await
apiClient
.
put
<
ApiKey
>
(
`/keys/
${
id
}
`
,
updates
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
put
<
ApiKey
>
(
`/keys/
${
id
}
`
,
updates
)
return
data
}
/**
...
...
@@ -71,8 +73,8 @@ export async function update(id: number, updates: UpdateApiKeyRequest): Promise<
* @returns Success confirmation
*/
export
async
function
deleteKey
(
id
:
number
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
delete
<
{
message
:
string
}
>
(
`/keys/
${
id
}
`
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
delete
<
{
message
:
string
}
>
(
`/keys/
${
id
}
`
)
return
data
}
/**
...
...
@@ -81,11 +83,8 @@ export async function deleteKey(id: number): Promise<{ message: string }> {
* @param status - New status
* @returns Updated API key
*/
export
async
function
toggleStatus
(
id
:
number
,
status
:
'
active
'
|
'
inactive
'
):
Promise
<
ApiKey
>
{
return
update
(
id
,
{
status
});
export
async
function
toggleStatus
(
id
:
number
,
status
:
'
active
'
|
'
inactive
'
):
Promise
<
ApiKey
>
{
return
update
(
id
,
{
status
})
}
export
const
keysAPI
=
{
...
...
@@ -94,7 +93,7 @@ export const keysAPI = {
create
,
update
,
delete
:
deleteKey
,
toggleStatus
,
}
;
toggleStatus
}
export
default
keysAPI
;
export
default
keysAPI
frontend/src/api/redeem.ts
View file @
429f38d0
...
...
@@ -3,24 +3,24 @@
* Handles redeem code redemption for users
*/
import
{
apiClient
}
from
'
./client
'
;
import
type
{
RedeemCodeRequest
}
from
'
@/types
'
;
import
{
apiClient
}
from
'
./client
'
import
type
{
RedeemCodeRequest
}
from
'
@/types
'
export
interface
RedeemHistoryItem
{
id
:
number
;
code
:
string
;
type
:
string
;
value
:
number
;
status
:
string
;
used_at
:
string
;
created_at
:
string
;
id
:
number
code
:
string
type
:
string
value
:
number
status
:
string
used_at
:
string
created_at
:
string
// 订阅类型专用字段
group_id
?:
number
;
validity_days
?:
number
;
group_id
?:
number
validity_days
?:
number
group
?:
{
id
:
number
;
name
:
string
;
}
;
id
:
number
name
:
string
}
}
/**
...
...
@@ -29,23 +29,23 @@ export interface RedeemHistoryItem {
* @returns Redemption result with updated balance or concurrency
*/
export
async
function
redeem
(
code
:
string
):
Promise
<
{
message
:
string
;
type
:
string
;
value
:
number
;
new_balance
?:
number
;
new_concurrency
?:
number
;
message
:
string
type
:
string
value
:
number
new_balance
?:
number
new_concurrency
?:
number
}
>
{
const
payload
:
RedeemCodeRequest
=
{
code
}
;
const
payload
:
RedeemCodeRequest
=
{
code
}
const
{
data
}
=
await
apiClient
.
post
<
{
message
:
string
;
type
:
string
;
value
:
number
;
new_balance
?:
number
;
new_concurrency
?:
number
;
}
>
(
'
/redeem
'
,
payload
)
;
message
:
string
type
:
string
value
:
number
new_balance
?:
number
new_concurrency
?:
number
}
>
(
'
/redeem
'
,
payload
)
return
data
;
return
data
}
/**
...
...
@@ -53,13 +53,13 @@ export async function redeem(code: string): Promise<{
* @returns List of redeemed codes
*/
export
async
function
getHistory
():
Promise
<
RedeemHistoryItem
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
RedeemHistoryItem
[]
>
(
'
/redeem/history
'
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
RedeemHistoryItem
[]
>
(
'
/redeem/history
'
)
return
data
}
export
const
redeemAPI
=
{
redeem
,
getHistory
,
}
;
getHistory
}
export
default
redeemAPI
;
export
default
redeemAPI
frontend/src/api/setup.ts
View file @
429f38d0
/**
* Setup API endpoints
*/
import
axios
from
'
axios
'
;
import
axios
from
'
axios
'
// Create a separate client for setup endpoints (not under /api/v1)
const
setupClient
=
axios
.
create
({
baseURL
:
''
,
timeout
:
30000
,
headers
:
{
'
Content-Type
'
:
'
application/json
'
,
}
,
})
;
'
Content-Type
'
:
'
application/json
'
}
})
export
interface
SetupStatus
{
needs_setup
:
boolean
;
step
:
string
;
needs_setup
:
boolean
step
:
string
}
export
interface
DatabaseConfig
{
host
:
string
;
port
:
number
;
user
:
string
;
password
:
string
;
dbname
:
string
;
sslmode
:
string
;
host
:
string
port
:
number
user
:
string
password
:
string
dbname
:
string
sslmode
:
string
}
export
interface
RedisConfig
{
host
:
string
;
port
:
number
;
password
:
string
;
db
:
number
;
host
:
string
port
:
number
password
:
string
db
:
number
}
export
interface
AdminConfig
{
email
:
string
;
password
:
string
;
email
:
string
password
:
string
}
export
interface
ServerConfig
{
host
:
string
;
port
:
number
;
mode
:
string
;
host
:
string
port
:
number
mode
:
string
}
export
interface
InstallRequest
{
database
:
DatabaseConfig
;
redis
:
RedisConfig
;
admin
:
AdminConfig
;
server
:
ServerConfig
;
database
:
DatabaseConfig
redis
:
RedisConfig
admin
:
AdminConfig
server
:
ServerConfig
}
export
interface
InstallResponse
{
message
:
string
;
restart
:
boolean
;
message
:
string
restart
:
boolean
}
/**
* Get setup status
*/
export
async
function
getSetupStatus
():
Promise
<
SetupStatus
>
{
const
response
=
await
setupClient
.
get
(
'
/setup/status
'
)
;
return
response
.
data
.
data
;
const
response
=
await
setupClient
.
get
(
'
/setup/status
'
)
return
response
.
data
.
data
}
/**
* Test database connection
*/
export
async
function
testDatabase
(
config
:
DatabaseConfig
):
Promise
<
void
>
{
await
setupClient
.
post
(
'
/setup/test-db
'
,
config
)
;
await
setupClient
.
post
(
'
/setup/test-db
'
,
config
)
}
/**
* Test Redis connection
*/
export
async
function
testRedis
(
config
:
RedisConfig
):
Promise
<
void
>
{
await
setupClient
.
post
(
'
/setup/test-redis
'
,
config
)
;
await
setupClient
.
post
(
'
/setup/test-redis
'
,
config
)
}
/**
* Perform installation
*/
export
async
function
install
(
config
:
InstallRequest
):
Promise
<
InstallResponse
>
{
const
response
=
await
setupClient
.
post
(
'
/setup/install
'
,
config
)
;
return
response
.
data
.
data
;
const
response
=
await
setupClient
.
post
(
'
/setup/install
'
,
config
)
return
response
.
data
.
data
}
frontend/src/api/subscriptions.ts
View file @
429f38d0
...
...
@@ -3,64 +3,68 @@
* API for regular users to view their own subscriptions and progress
*/
import
{
apiClient
}
from
'
./client
'
;
import
type
{
UserSubscription
,
SubscriptionProgress
}
from
'
@/types
'
;
import
{
apiClient
}
from
'
./client
'
import
type
{
UserSubscription
,
SubscriptionProgress
}
from
'
@/types
'
/**
* Subscription summary for user dashboard
*/
export
interface
SubscriptionSummary
{
active_count
:
number
;
active_count
:
number
subscriptions
:
Array
<
{
id
:
number
;
group_name
:
string
;
status
:
string
;
daily_progress
:
number
|
null
;
weekly_progress
:
number
|
null
;
monthly_progress
:
number
|
null
;
expires_at
:
string
|
null
;
days_remaining
:
number
|
null
;
}
>
;
id
:
number
group_name
:
string
status
:
string
daily_progress
:
number
|
null
weekly_progress
:
number
|
null
monthly_progress
:
number
|
null
expires_at
:
string
|
null
days_remaining
:
number
|
null
}
>
}
/**
* Get list of current user's subscriptions
*/
export
async
function
getMySubscriptions
():
Promise
<
UserSubscription
[]
>
{
const
response
=
await
apiClient
.
get
<
UserSubscription
[]
>
(
'
/subscriptions
'
)
;
return
response
.
data
;
const
response
=
await
apiClient
.
get
<
UserSubscription
[]
>
(
'
/subscriptions
'
)
return
response
.
data
}
/**
* Get current user's active subscriptions
*/
export
async
function
getActiveSubscriptions
():
Promise
<
UserSubscription
[]
>
{
const
response
=
await
apiClient
.
get
<
UserSubscription
[]
>
(
'
/subscriptions/active
'
)
;
return
response
.
data
;
const
response
=
await
apiClient
.
get
<
UserSubscription
[]
>
(
'
/subscriptions/active
'
)
return
response
.
data
}
/**
* Get progress for all user's active subscriptions
*/
export
async
function
getSubscriptionsProgress
():
Promise
<
SubscriptionProgress
[]
>
{
const
response
=
await
apiClient
.
get
<
SubscriptionProgress
[]
>
(
'
/subscriptions/progress
'
)
;
return
response
.
data
;
const
response
=
await
apiClient
.
get
<
SubscriptionProgress
[]
>
(
'
/subscriptions/progress
'
)
return
response
.
data
}
/**
* Get subscription summary for dashboard display
*/
export
async
function
getSubscriptionSummary
():
Promise
<
SubscriptionSummary
>
{
const
response
=
await
apiClient
.
get
<
SubscriptionSummary
>
(
'
/subscriptions/summary
'
)
;
return
response
.
data
;
const
response
=
await
apiClient
.
get
<
SubscriptionSummary
>
(
'
/subscriptions/summary
'
)
return
response
.
data
}
/**
* Get progress for a specific subscription
*/
export
async
function
getSubscriptionProgress
(
subscriptionId
:
number
):
Promise
<
SubscriptionProgress
>
{
const
response
=
await
apiClient
.
get
<
SubscriptionProgress
>
(
`/subscriptions/
${
subscriptionId
}
/progress`
);
return
response
.
data
;
export
async
function
getSubscriptionProgress
(
subscriptionId
:
number
):
Promise
<
SubscriptionProgress
>
{
const
response
=
await
apiClient
.
get
<
SubscriptionProgress
>
(
`/subscriptions/
${
subscriptionId
}
/progress`
)
return
response
.
data
}
export
default
{
...
...
@@ -68,5 +72,5 @@ export default {
getActiveSubscriptions
,
getSubscriptionsProgress
,
getSubscriptionSummary
,
getSubscriptionProgress
,
}
;
getSubscriptionProgress
}
frontend/src/api/usage.ts
View file @
429f38d0
...
...
@@ -3,59 +3,59 @@
* Handles usage logs and statistics retrieval
*/
import
{
apiClient
}
from
'
./client
'
;
import
{
apiClient
}
from
'
./client
'
import
type
{
UsageLog
,
UsageQueryParams
,
UsageStatsResponse
,
PaginatedResponse
,
TrendDataPoint
,
ModelStat
,
}
from
'
@/types
'
;
ModelStat
}
from
'
@/types
'
// ==================== Dashboard Types ====================
export
interface
UserDashboardStats
{
total_api_keys
:
number
;
active_api_keys
:
number
;
total_requests
:
number
;
total_input_tokens
:
number
;
total_output_tokens
:
number
;
total_cache_creation_tokens
:
number
;
total_cache_read_tokens
:
number
;
total_tokens
:
number
;
total_cost
:
number
;
// 标准计费
total_actual_cost
:
number
;
// 实际扣除
today_requests
:
number
;
today_input_tokens
:
number
;
today_output_tokens
:
number
;
today_cache_creation_tokens
:
number
;
today_cache_read_tokens
:
number
;
today_tokens
:
number
;
today_cost
:
number
;
// 今日标准计费
today_actual_cost
:
number
;
// 今日实际扣除
average_duration_ms
:
number
;
rpm
:
number
;
// 近5分钟平均每分钟请求数
tpm
:
number
;
// 近5分钟平均每分钟Token数
total_api_keys
:
number
active_api_keys
:
number
total_requests
:
number
total_input_tokens
:
number
total_output_tokens
:
number
total_cache_creation_tokens
:
number
total_cache_read_tokens
:
number
total_tokens
:
number
total_cost
:
number
// 标准计费
total_actual_cost
:
number
// 实际扣除
today_requests
:
number
today_input_tokens
:
number
today_output_tokens
:
number
today_cache_creation_tokens
:
number
today_cache_read_tokens
:
number
today_tokens
:
number
today_cost
:
number
// 今日标准计费
today_actual_cost
:
number
// 今日实际扣除
average_duration_ms
:
number
rpm
:
number
// 近5分钟平均每分钟请求数
tpm
:
number
// 近5分钟平均每分钟Token数
}
export
interface
TrendParams
{
start_date
?:
string
;
end_date
?:
string
;
granularity
?:
'
day
'
|
'
hour
'
;
start_date
?:
string
end_date
?:
string
granularity
?:
'
day
'
|
'
hour
'
}
export
interface
TrendResponse
{
trend
:
TrendDataPoint
[]
;
start_date
:
string
;
end_date
:
string
;
granularity
:
string
;
trend
:
TrendDataPoint
[]
start_date
:
string
end_date
:
string
granularity
:
string
}
export
interface
ModelStatsResponse
{
models
:
ModelStat
[]
;
start_date
:
string
;
end_date
:
string
;
models
:
ModelStat
[]
start_date
:
string
end_date
:
string
}
/**
...
...
@@ -72,17 +72,17 @@ export async function list(
):
Promise
<
PaginatedResponse
<
UsageLog
>>
{
const
params
:
UsageQueryParams
=
{
page
,
page_size
:
pageSize
,
}
;
page_size
:
pageSize
}
if
(
apiKeyId
!==
undefined
)
{
params
.
api_key_id
=
apiKeyId
;
params
.
api_key_id
=
apiKeyId
}
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
UsageLog
>>
(
'
/usage
'
,
{
params
,
})
;
return
data
;
params
})
return
data
}
/**
...
...
@@ -92,9 +92,9 @@ export async function list(
*/
export
async
function
query
(
params
:
UsageQueryParams
):
Promise
<
PaginatedResponse
<
UsageLog
>>
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
UsageLog
>>
(
'
/usage
'
,
{
params
,
})
;
return
data
;
params
})
return
data
}
/**
...
...
@@ -107,16 +107,16 @@ export async function getStats(
period
:
string
=
'
today
'
,
apiKeyId
?:
number
):
Promise
<
UsageStatsResponse
>
{
const
params
:
Record
<
string
,
unknown
>
=
{
period
}
;
const
params
:
Record
<
string
,
unknown
>
=
{
period
}
if
(
apiKeyId
!==
undefined
)
{
params
.
api_key_id
=
apiKeyId
;
params
.
api_key_id
=
apiKeyId
}
const
{
data
}
=
await
apiClient
.
get
<
UsageStatsResponse
>
(
'
/usage/stats
'
,
{
params
,
})
;
return
data
;
params
})
return
data
}
/**
...
...
@@ -133,17 +133,17 @@ export async function getStatsByDateRange(
):
Promise
<
UsageStatsResponse
>
{
const
params
:
Record
<
string
,
unknown
>
=
{
start_date
:
startDate
,
end_date
:
endDate
,
}
;
end_date
:
endDate
}
if
(
apiKeyId
!==
undefined
)
{
params
.
api_key_id
=
apiKeyId
;
params
.
api_key_id
=
apiKeyId
}
const
{
data
}
=
await
apiClient
.
get
<
UsageStatsResponse
>
(
'
/usage/stats
'
,
{
params
,
})
;
return
data
;
params
})
return
data
}
/**
...
...
@@ -162,17 +162,17 @@ export async function getByDateRange(
start_date
:
startDate
,
end_date
:
endDate
,
page
:
1
,
page_size
:
100
,
}
;
page_size
:
100
}
if
(
apiKeyId
!==
undefined
)
{
params
.
api_key_id
=
apiKeyId
;
params
.
api_key_id
=
apiKeyId
}
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
UsageLog
>>
(
'
/usage
'
,
{
params
,
})
;
return
data
;
params
})
return
data
}
/**
...
...
@@ -181,8 +181,8 @@ export async function getByDateRange(
* @returns Usage log details
*/
export
async
function
getById
(
id
:
number
):
Promise
<
UsageLog
>
{
const
{
data
}
=
await
apiClient
.
get
<
UsageLog
>
(
`/usage/
${
id
}
`
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
UsageLog
>
(
`/usage/
${
id
}
`
)
return
data
}
// ==================== Dashboard API ====================
...
...
@@ -192,8 +192,8 @@ export async function getById(id: number): Promise<UsageLog> {
* @returns Dashboard statistics for current user
*/
export
async
function
getDashboardStats
():
Promise
<
UserDashboardStats
>
{
const
{
data
}
=
await
apiClient
.
get
<
UserDashboardStats
>
(
'
/usage/dashboard/stats
'
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
UserDashboardStats
>
(
'
/usage/dashboard/stats
'
)
return
data
}
/**
...
...
@@ -202,8 +202,8 @@ export async function getDashboardStats(): Promise<UserDashboardStats> {
* @returns Usage trend data for current user
*/
export
async
function
getDashboardTrend
(
params
?:
TrendParams
):
Promise
<
TrendResponse
>
{
const
{
data
}
=
await
apiClient
.
get
<
TrendResponse
>
(
'
/usage/dashboard/trend
'
,
{
params
})
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
TrendResponse
>
(
'
/usage/dashboard/trend
'
,
{
params
})
return
data
}
/**
...
...
@@ -211,19 +211,22 @@ export async function getDashboardTrend(params?: TrendParams): Promise<TrendResp
* @param params - Query parameters for filtering
* @returns Model usage statistics for current user
*/
export
async
function
getDashboardModels
(
params
?:
{
start_date
?:
string
;
end_date
?:
string
}):
Promise
<
ModelStatsResponse
>
{
const
{
data
}
=
await
apiClient
.
get
<
ModelStatsResponse
>
(
'
/usage/dashboard/models
'
,
{
params
});
return
data
;
export
async
function
getDashboardModels
(
params
?:
{
start_date
?:
string
end_date
?:
string
}):
Promise
<
ModelStatsResponse
>
{
const
{
data
}
=
await
apiClient
.
get
<
ModelStatsResponse
>
(
'
/usage/dashboard/models
'
,
{
params
})
return
data
}
export
interface
BatchApiKeyUsageStats
{
api_key_id
:
number
;
today_actual_cost
:
number
;
total_actual_cost
:
number
;
api_key_id
:
number
today_actual_cost
:
number
total_actual_cost
:
number
}
export
interface
BatchApiKeysUsageResponse
{
stats
:
Record
<
string
,
BatchApiKeyUsageStats
>
;
stats
:
Record
<
string
,
BatchApiKeyUsageStats
>
}
/**
...
...
@@ -231,11 +234,16 @@ export interface BatchApiKeysUsageResponse {
* @param apiKeyIds - Array of API key IDs
* @returns Usage stats map keyed by API key ID
*/
export
async
function
getDashboardApiKeysUsage
(
apiKeyIds
:
number
[]):
Promise
<
BatchApiKeysUsageResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
BatchApiKeysUsageResponse
>
(
'
/usage/dashboard/api-keys-usage
'
,
{
api_key_ids
:
apiKeyIds
,
});
return
data
;
export
async
function
getDashboardApiKeysUsage
(
apiKeyIds
:
number
[]
):
Promise
<
BatchApiKeysUsageResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
BatchApiKeysUsageResponse
>
(
'
/usage/dashboard/api-keys-usage
'
,
{
api_key_ids
:
apiKeyIds
}
)
return
data
}
export
const
usageAPI
=
{
...
...
@@ -249,7 +257,7 @@ export const usageAPI = {
getDashboardStats
,
getDashboardTrend
,
getDashboardModels
,
getDashboardApiKeysUsage
,
}
;
getDashboardApiKeysUsage
}
export
default
usageAPI
;
export
default
usageAPI
frontend/src/api/user.ts
View file @
429f38d0
...
...
@@ -3,16 +3,16 @@
* Handles user profile management and password changes
*/
import
{
apiClient
}
from
'
./client
'
;
import
type
{
User
,
ChangePasswordRequest
}
from
'
@/types
'
;
import
{
apiClient
}
from
'
./client
'
import
type
{
User
,
ChangePasswordRequest
}
from
'
@/types
'
/**
* Get current user profile
* @returns User profile data
*/
export
async
function
getProfile
():
Promise
<
User
>
{
const
{
data
}
=
await
apiClient
.
get
<
User
>
(
'
/user/profile
'
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
get
<
User
>
(
'
/user/profile
'
)
return
data
}
/**
...
...
@@ -21,11 +21,11 @@ export async function getProfile(): Promise<User> {
* @returns Updated user profile data
*/
export
async
function
updateProfile
(
profile
:
{
username
?:
string
;
wechat
?:
string
;
username
?:
string
wechat
?:
string
}):
Promise
<
User
>
{
const
{
data
}
=
await
apiClient
.
put
<
User
>
(
'
/user
'
,
profile
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
put
<
User
>
(
'
/user
'
,
profile
)
return
data
}
/**
...
...
@@ -39,17 +39,17 @@ export async function changePassword(
):
Promise
<
{
message
:
string
}
>
{
const
payload
:
ChangePasswordRequest
=
{
old_password
:
oldPassword
,
new_password
:
newPassword
,
}
;
new_password
:
newPassword
}
const
{
data
}
=
await
apiClient
.
put
<
{
message
:
string
}
>
(
'
/user/password
'
,
payload
)
;
return
data
;
const
{
data
}
=
await
apiClient
.
put
<
{
message
:
string
}
>
(
'
/user/password
'
,
payload
)
return
data
}
export
const
userAPI
=
{
getProfile
,
updateProfile
,
changePassword
,
}
;
changePassword
}
export
default
userAPI
;
export
default
userAPI
frontend/src/components/TurnstileWidget.vue
View file @
429f38d0
...
...
@@ -5,158 +5,164 @@
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
onMounted
,
onUnmounted
,
watch
}
from
'
vue
'
;
import
{
ref
,
onMounted
,
onUnmounted
,
watch
}
from
'
vue
'
interface
TurnstileRenderOptions
{
sitekey
:
string
;
callback
:
(
token
:
string
)
=>
void
;
'
expired-callback
'
?:
()
=>
void
;
'
error-callback
'
?:
()
=>
void
;
theme
?:
'
light
'
|
'
dark
'
|
'
auto
'
;
size
?:
'
normal
'
|
'
compact
'
|
'
flexible
'
;
sitekey
:
string
callback
:
(
token
:
string
)
=>
void
'
expired-callback
'
?:
()
=>
void
'
error-callback
'
?:
()
=>
void
theme
?:
'
light
'
|
'
dark
'
|
'
auto
'
size
?:
'
normal
'
|
'
compact
'
|
'
flexible
'
}
interface
TurnstileAPI
{
render
:
(
container
:
HTMLElement
,
options
:
TurnstileRenderOptions
)
=>
string
;
reset
:
(
widgetId
?:
string
)
=>
void
;
remove
:
(
widgetId
?:
string
)
=>
void
;
render
:
(
container
:
HTMLElement
,
options
:
TurnstileRenderOptions
)
=>
string
reset
:
(
widgetId
?:
string
)
=>
void
remove
:
(
widgetId
?:
string
)
=>
void
}
declare
global
{
interface
Window
{
turnstile
?:
TurnstileAPI
;
onTurnstileLoad
?:
()
=>
void
;
turnstile
?:
TurnstileAPI
onTurnstileLoad
?:
()
=>
void
}
}
const
props
=
withDefaults
(
defineProps
<
{
siteKey
:
string
;
theme
?:
'
light
'
|
'
dark
'
|
'
auto
'
;
size
?:
'
normal
'
|
'
compact
'
|
'
flexible
'
;
}
>
(),
{
theme
:
'
auto
'
,
size
:
'
flexible
'
,
});
const
props
=
withDefaults
(
defineProps
<
{
siteKey
:
string
theme
?:
'
light
'
|
'
dark
'
|
'
auto
'
size
?:
'
normal
'
|
'
compact
'
|
'
flexible
'
}
>
(),
{
theme
:
'
auto
'
,
size
:
'
flexible
'
}
)
const
emit
=
defineEmits
<
{
(
e
:
'
verify
'
,
token
:
string
):
void
;
(
e
:
'
expire
'
):
void
;
(
e
:
'
error
'
):
void
;
}
>
()
;
(
e
:
'
verify
'
,
token
:
string
):
void
(
e
:
'
expire
'
):
void
(
e
:
'
error
'
):
void
}
>
()
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
;
const
widgetId
=
ref
<
string
|
null
>
(
null
)
;
const
scriptLoaded
=
ref
(
false
)
;
const
containerRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
widgetId
=
ref
<
string
|
null
>
(
null
)
const
scriptLoaded
=
ref
(
false
)
const
loadScript
=
():
Promise
<
void
>
=>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
if
(
window
.
turnstile
)
{
scriptLoaded
.
value
=
true
;
resolve
()
;
return
;
scriptLoaded
.
value
=
true
resolve
()
return
}
// Check if script is already loading
const
existingScript
=
document
.
querySelector
(
'
script[src*="turnstile"]
'
)
;
const
existingScript
=
document
.
querySelector
(
'
script[src*="turnstile"]
'
)
if
(
existingScript
)
{
window
.
onTurnstileLoad
=
()
=>
{
scriptLoaded
.
value
=
true
;
resolve
()
;
}
;
return
;
scriptLoaded
.
value
=
true
resolve
()
}
return
}
const
script
=
document
.
createElement
(
'
script
'
)
;
script
.
src
=
'
https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad
'
;
script
.
async
=
true
;
script
.
defer
=
true
;
const
script
=
document
.
createElement
(
'
script
'
)
script
.
src
=
'
https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad
'
script
.
async
=
true
script
.
defer
=
true
window
.
onTurnstileLoad
=
()
=>
{
scriptLoaded
.
value
=
true
;
resolve
()
;
}
;
scriptLoaded
.
value
=
true
resolve
()
}
script
.
onerror
=
()
=>
{
reject
(
new
Error
(
'
Failed to load Turnstile script
'
))
;
}
;
reject
(
new
Error
(
'
Failed to load Turnstile script
'
))
}
document
.
head
.
appendChild
(
script
)
;
})
;
}
;
document
.
head
.
appendChild
(
script
)
})
}
const
renderWidget
=
()
=>
{
if
(
!
window
.
turnstile
||
!
containerRef
.
value
||
!
props
.
siteKey
)
{
return
;
return
}
// Remove existing widget if any
if
(
widgetId
.
value
)
{
try
{
window
.
turnstile
.
remove
(
widgetId
.
value
)
;
window
.
turnstile
.
remove
(
widgetId
.
value
)
}
catch
{
// Ignore errors when removing
}
widgetId
.
value
=
null
;
widgetId
.
value
=
null
}
// Clear container
containerRef
.
value
.
innerHTML
=
''
;
containerRef
.
value
.
innerHTML
=
''
widgetId
.
value
=
window
.
turnstile
.
render
(
containerRef
.
value
,
{
sitekey
:
props
.
siteKey
,
callback
:
(
token
:
string
)
=>
{
emit
(
'
verify
'
,
token
)
;
emit
(
'
verify
'
,
token
)
},
'
expired-callback
'
:
()
=>
{
emit
(
'
expire
'
)
;
emit
(
'
expire
'
)
},
'
error-callback
'
:
()
=>
{
emit
(
'
error
'
)
;
emit
(
'
error
'
)
},
theme
:
props
.
theme
,
size
:
props
.
size
,
})
;
}
;
size
:
props
.
size
})
}
const
reset
=
()
=>
{
if
(
window
.
turnstile
&&
widgetId
.
value
)
{
window
.
turnstile
.
reset
(
widgetId
.
value
)
;
window
.
turnstile
.
reset
(
widgetId
.
value
)
}
}
;
}
// Expose reset method to parent
defineExpose
({
reset
})
;
defineExpose
({
reset
})
onMounted
(
async
()
=>
{
if
(
!
props
.
siteKey
)
{
return
;
return
}
try
{
await
loadScript
()
;
renderWidget
()
;
await
loadScript
()
renderWidget
()
}
catch
(
error
)
{
console
.
error
(
'
Failed to initialize Turnstile:
'
,
error
)
;
emit
(
'
error
'
)
;
console
.
error
(
'
Failed to initialize Turnstile:
'
,
error
)
emit
(
'
error
'
)
}
})
;
})
onUnmounted
(()
=>
{
if
(
window
.
turnstile
&&
widgetId
.
value
)
{
try
{
window
.
turnstile
.
remove
(
widgetId
.
value
)
;
window
.
turnstile
.
remove
(
widgetId
.
value
)
}
catch
{
// Ignore errors when removing
}
}
})
;
})
// Re-render when siteKey changes
watch
(()
=>
props
.
siteKey
,
(
newKey
)
=>
{
if
(
newKey
&&
scriptLoaded
.
value
)
{
renderWidget
();
watch
(
()
=>
props
.
siteKey
,
(
newKey
)
=>
{
if
(
newKey
&&
scriptLoaded
.
value
)
{
renderWidget
()
}
}
});
)
</
script
>
<
style
scoped
>
...
...
frontend/src/components/account/AccountStatsModal.vue
View file @
429f38d0
<
template
>
<Modal
:show=
"show"
:title=
"t('admin.accounts.usageStatistics')"
size=
"2xl"
@
close=
"handleClose"
>
<Modal
:show=
"show"
:title=
"t('admin.accounts.usageStatistics')"
size=
"2xl"
@
close=
"handleClose"
>
<div
class=
"space-y-6"
>
<!-- Account Info Header -->
<div
v-if=
"account"
class=
"flex items-center justify-between p-3 bg-gradient-to-r from-primary-50 to-primary-100 dark:from-primary-900/20 dark:to-primary-800/20 rounded-xl border border-primary-200 dark:border-primary-700/50"
>
<div
v-if=
"account"
class=
"flex items-center justify-between rounded-xl border border-primary-200 bg-gradient-to-r from-primary-50 to-primary-100 p-3 dark:border-primary-700/50 dark:from-primary-900/20 dark:to-primary-800/20"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"w-10 h-10 rounded-lg bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center"
>
<svg
class=
"w-5 h-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<svg
class=
"h-5 w-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<div>
...
...
@@ -23,7 +28,7 @@
</div>
<span
:class=
"[
'px-2.5 py-1 text-xs font-semibold
rounded-full
',
'
rounded-full
px-2.5 py-1 text-xs font-semibold',
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
...
...
@@ -42,62 +47,140 @@
<!-- Row 1: Main Stats Cards -->
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<!-- 30-Day Total Cost -->
<div
class=
"card p-4 bg-gradient-to-br from-emerald-50 to-white dark:from-emerald-900/10 dark:to-dark-700 border-emerald-200 dark:border-emerald-800/30"
>
<div
class=
"flex items-center justify-between mb-2"
>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.totalCost
'
)
}}
</span>
<div
class=
"p-1.5 rounded-lg bg-emerald-100 dark:bg-emerald-900/30"
>
<svg
class=
"w-4 h-4 text-emerald-600 dark:text-emerald-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<div
class=
"card border-emerald-200 bg-gradient-to-br from-emerald-50 to-white p-4 dark:border-emerald-800/30 dark:from-emerald-900/10 dark:to-dark-700"
>
<div
class=
"mb-2 flex items-center justify-between"
>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.totalCost
'
)
}}
</span>
<div
class=
"rounded-lg bg-emerald-100 p-1.5 dark:bg-emerald-900/30"
>
<svg
class=
"h-4 w-4 text-emerald-600 dark:text-emerald-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
$
{{
formatCost
(
stats
.
summary
.
total_cost
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mt-1"
>
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
$
{{
formatCost
(
stats
.
summary
.
total_cost
)
}}
</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
'
)
}}
: $
{{
formatCost
(
stats
.
summary
.
total_standard_cost
)
}}
)
</span>
<span
class=
"text-gray-400 dark:text-gray-500"
>
(
{{
t
(
'
admin.accounts.stats.standardCost
'
)
}}
: $
{{
formatCost
(
stats
.
summary
.
total_standard_cost
)
}}
)
</span
>
</p>
</div>
<!-- 30-Day Total Requests -->
<div
class=
"card p-4 bg-gradient-to-br from-blue-50 to-white dark:from-blue-900/10 dark:to-dark-700 border-blue-200 dark:border-blue-800/30"
>
<div
class=
"flex items-center justify-between mb-2"
>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.totalRequests
'
)
}}
</span>
<div
class=
"p-1.5 rounded-lg bg-blue-100 dark:bg-blue-900/30"
>
<svg
class=
"w-4 h-4 text-blue-600 dark:text-blue-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 10V3L4 14h7v7l9-11h-7z"
/>
<div
class=
"card border-blue-200 bg-gradient-to-br from-blue-50 to-white p-4 dark:border-blue-800/30 dark:from-blue-900/10 dark:to-dark-700"
>
<div
class=
"mb-2 flex items-center justify-between"
>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.totalRequests
'
)
}}
</span>
<div
class=
"rounded-lg bg-blue-100 p-1.5 dark:bg-blue-900/30"
>
<svg
class=
"h-4 w-4 text-blue-600 dark:text-blue-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
</div>
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
{{
formatNumber
(
stats
.
summary
.
total_requests
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mt-1"
>
{{
t
(
'
admin.accounts.stats.totalCalls
'
)
}}
</p>
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
{{
formatNumber
(
stats
.
summary
.
total_requests
)
}}
</p>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.totalCalls
'
)
}}
</p>
</div>
<!-- Daily Average Cost -->
<div
class=
"card p-4 bg-gradient-to-br from-amber-50 to-white dark:from-amber-900/10 dark:to-dark-700 border-amber-200 dark:border-amber-800/30"
>
<div
class=
"flex items-center justify-between mb-2"
>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.avgDailyCost
'
)
}}
</span>
<div
class=
"p-1.5 rounded-lg bg-amber-100 dark:bg-amber-900/30"
>
<svg
class=
"w-4 h-4 text-amber-600 dark:text-amber-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
<div
class=
"card border-amber-200 bg-gradient-to-br from-amber-50 to-white p-4 dark:border-amber-800/30 dark:from-amber-900/10 dark:to-dark-700"
>
<div
class=
"mb-2 flex items-center justify-between"
>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.avgDailyCost
'
)
}}
</span>
<div
class=
"rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/30"
>
<svg
class=
"h-4 w-4 text-amber-600 dark:text-amber-400"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
</div>
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
$
{{
formatCost
(
stats
.
summary
.
avg_daily_cost
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400 mt-1"
>
{{
t
(
'
admin.accounts.stats.basedOnActualDays
'
,
{
days
:
stats
.
summary
.
actual_days_used
}
)
}}
<
/p
>
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
$
{{
formatCost
(
stats
.
summary
.
avg_daily_cost
)
}}
</p>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.basedOnActualDays
'
,
{
days
:
stats
.
summary
.
actual_days_used
}
)
}}
<
/p
>
<
/div
>
<!--
Daily
Average
Requests
-->
<
div
class
=
"
card p-4 bg-gradient-to-br from-purple-50 to-white dark:from-purple-900/10 dark:to-dark-700 border-purple-200 dark:border-purple-800/30
"
>
<
div
class
=
"
flex items-center justify-between mb-2
"
>
<
span
class
=
"
text-xs font-medium text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.avgDailyRequests
'
)
}}
<
/span
>
<
div
class
=
"
p-1.5 rounded-lg bg-purple-100 dark:bg-purple-900/30
"
>
<
svg
class
=
"
w-4 h-4 text-purple-600 dark:text-purple-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z
"
/>
<
div
class
=
"
card border-purple-200 bg-gradient-to-br from-purple-50 to-white p-4 dark:border-purple-800/30 dark:from-purple-900/10 dark:to-dark-700
"
>
<
div
class
=
"
mb-2 flex items-center justify-between
"
>
<
span
class
=
"
text-xs font-medium text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.avgDailyRequests
'
)
}}
<
/span
>
<
div
class
=
"
rounded-lg bg-purple-100 p-1.5 dark:bg-purple-900/30
"
>
<
svg
class
=
"
h-4 w-4 text-purple-600 dark:text-purple-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z
"
/>
<
/svg
>
<
/div
>
<
/div
>
<
p
class
=
"
text-2xl font-bold text-gray-900 dark:text-white
"
>
{{
formatNumber
(
Math
.
round
(
stats
.
summary
.
avg_daily_requests
))
}}
<
/p
>
<
p
class
=
"
text-xs text-gray-500 dark:text-gray-400 mt-1
"
>
{{
t
(
'
admin.accounts.stats.avgDailyUsage
'
)
}}
<
/p
>
<
p
class
=
"
text-2xl font-bold text-gray-900 dark:text-white
"
>
{{
formatNumber
(
Math
.
round
(
stats
.
summary
.
avg_daily_requests
))
}}
<
/p
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.avgDailyUsage
'
)
}}
<
/p
>
<
/div
>
<
/div
>
...
...
@@ -105,78 +188,148 @@
<
div
class
=
"
grid grid-cols-1 gap-4 lg:grid-cols-3
"
>
<!--
Today
Overview
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
flex items-center gap-2 mb-3
"
>
<
div
class
=
"
p-1.5 rounded-lg bg-cyan-100 dark:bg-cyan-900/30
"
>
<
svg
class
=
"
w-4 h-4 text-cyan-600 dark:text-cyan-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
rounded-lg bg-cyan-100 p-1.5 dark:bg-cyan-900/30
"
>
<
svg
class
=
"
h-4 w-4 text-cyan-600 dark:text-cyan-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
<
/svg
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.todayOverview
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.todayOverview
'
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex justify-between items-center
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
cost
||
0
)
}}
<
/span
>
<
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-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex justify-between items-center
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatNumber
(
stats
.
summary
.
today
?.
requests
||
0
)
}}
<
/span
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatNumber
(
stats
.
summary
.
today
?.
requests
||
0
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex justify-between
items-center
"
>
<
div
class
=
"
flex
items-center
justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
Tokens
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
stats
.
summary
.
today
?.
tokens
||
0
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
stats
.
summary
.
today
?.
tokens
||
0
)
}}
<
/span
>
<
/div
>
<
/div
>
<
/div
>
<!--
Highest
Cost
Day
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
flex items-center gap-2 mb-3
"
>
<
div
class
=
"
p-1.5 rounded-lg bg-orange-100 dark:bg-orange-900/30
"
>
<
svg
class
=
"
w-4 h-4 text-orange-600 dark:text-orange-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z
"
/>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
rounded-lg bg-orange-100 p-1.5 dark:bg-orange-900/30
"
>
<
svg
class
=
"
h-4 w-4 text-orange-600 dark:text-orange-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z
"
/>
<
/svg
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.highestCostDay
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.highestCostDay
'
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex justify-between items-center
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.date
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
stats
.
summary
.
highest_cost_day
?.
label
||
'
-
'
}}
<
/span
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.date
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
stats
.
summary
.
highest_cost_day
?.
label
||
'
-
'
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex justify-between items-center
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-orange-600 dark:text-orange-400
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_cost_day
?.
cost
||
0
)
}}
<
/span
>
<
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-sm font-semibold text-orange-600 dark:text-orange-400
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_cost_day
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex justify-between items-center
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatNumber
(
stats
.
summary
.
highest_cost_day
?.
requests
||
0
)
}}
<
/span
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatNumber
(
stats
.
summary
.
highest_cost_day
?.
requests
||
0
)
}}
<
/span
>
<
/div
>
<
/div
>
<
/div
>
<!--
Highest
Request
Day
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
flex items-center gap-2 mb-3
"
>
<
div
class
=
"
p-1.5 rounded-lg bg-indigo-100 dark:bg-indigo-900/30
"
>
<
svg
class
=
"
w-4 h-4 text-indigo-600 dark:text-indigo-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13 7h8m0 0v8m0-8l-8 8-4-4-6 6
"
/>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
rounded-lg bg-indigo-100 p-1.5 dark:bg-indigo-900/30
"
>
<
svg
class
=
"
h-4 w-4 text-indigo-600 dark:text-indigo-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13 7h8m0 0v8m0-8l-8 8-4-4-6 6
"
/>
<
/svg
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.highestRequestDay
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.highestRequestDay
'
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex justify-between items-center
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.date
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
stats
.
summary
.
highest_request_day
?.
label
||
'
-
'
}}
<
/span
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.date
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
stats
.
summary
.
highest_request_day
?.
label
||
'
-
'
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex justify-between items-center
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-indigo-600 dark:text-indigo-400
"
>
{{
formatNumber
(
stats
.
summary
.
highest_request_day
?.
requests
||
0
)
}}
<
/span
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-indigo-600 dark:text-indigo-400
"
>
{{
formatNumber
(
stats
.
summary
.
highest_request_day
?.
requests
||
0
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex justify-between items-center
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_request_day
?.
cost
||
0
)
}}
<
/span
>
<
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-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_request_day
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
/div
>
<
/div
>
...
...
@@ -186,70 +339,134 @@
<
div
class
=
"
grid grid-cols-1 gap-4 lg:grid-cols-3
"
>
<!--
Accumulated
Tokens
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
flex items-center gap-2 mb-3
"
>
<
div
class
=
"
p-1.5 rounded-lg bg-teal-100 dark:bg-teal-900/30
"
>
<
svg
class
=
"
w-4 h-4 text-teal-600 dark:text-teal-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4
"
/>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
rounded-lg bg-teal-100 p-1.5 dark:bg-teal-900/30
"
>
<
svg
class
=
"
h-4 w-4 text-teal-600 dark:text-teal-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4
"
/>
<
/svg
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.accumulatedTokens
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.accumulatedTokens
'
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex justify-between items-center
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.totalTokens
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
stats
.
summary
.
total_tokens
)
}}
<
/span
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.totalTokens
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
stats
.
summary
.
total_tokens
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex justify-between items-center
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.dailyAvgTokens
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
Math
.
round
(
stats
.
summary
.
avg_daily_tokens
))
}}
<
/span
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.dailyAvgTokens
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
Math
.
round
(
stats
.
summary
.
avg_daily_tokens
))
}}
<
/span
>
<
/div
>
<
/div
>
<
/div
>
<!--
Performance
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
flex items-center gap-2 mb-3
"
>
<
div
class
=
"
p-1.5 rounded-lg bg-rose-100 dark:bg-rose-900/30
"
>
<
svg
class
=
"
w-4 h-4 text-rose-600 dark:text-rose-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13 10V3L4 14h7v7l9-11h-7z
"
/>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
rounded-lg bg-rose-100 p-1.5 dark:bg-rose-900/30
"
>
<
svg
class
=
"
h-4 w-4 text-rose-600 dark:text-rose-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M13 10V3L4 14h7v7l9-11h-7z
"
/>
<
/svg
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.performance
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.performance
'
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex justify-between items-center
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.avgResponseTime
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatDuration
(
stats
.
summary
.
avg_duration_ms
)
}}
<
/span
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.avgResponseTime
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatDuration
(
stats
.
summary
.
avg_duration_ms
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex justify-between items-center
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.daysActive
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
stats
.
summary
.
actual_days_used
}}
/
{{
stats
.
summary
.
days
}}
<
/span
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.daysActive
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
stats
.
summary
.
actual_days_used
}}
/
{{
stats
.
summary
.
days
}}
<
/spa
n
>
<
/div
>
<
/div
>
<
/div
>
<!--
Recent
Activity
-->
<
div
class
=
"
card p-4
"
>
<
div
class
=
"
flex items-center gap-2 mb-3
"
>
<
div
class
=
"
p-1.5 rounded-lg bg-lime-100 dark:bg-lime-900/30
"
>
<
svg
class
=
"
w-4 h-4 text-lime-600 dark:text-lime-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2
"
/>
<
div
class
=
"
mb-3 flex items-center gap-2
"
>
<
div
class
=
"
rounded-lg bg-lime-100 p-1.5 dark:bg-lime-900/30
"
>
<
svg
class
=
"
h-4 w-4 text-lime-600 dark:text-lime-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
2
"
d
=
"
M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2
"
/>
<
/svg
>
<
/div
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.recentActivity
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.recentActivity
'
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex justify-between items-center
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.todayRequests
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatNumber
(
stats
.
summary
.
today
?.
requests
||
0
)
}}
<
/span
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.todayRequests
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatNumber
(
stats
.
summary
.
today
?.
requests
||
0
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex justify-between items-center
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.todayTokens
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
stats
.
summary
.
today
?.
tokens
||
0
)
}}
<
/span
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.todayTokens
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
formatTokens
(
stats
.
summary
.
today
?.
tokens
||
0
)
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex justify-between items-center
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.todayCost
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
cost
||
0
)
}}
<
/span
>
<
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-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
/div
>
<
/div
>
...
...
@@ -257,26 +474,36 @@
<!--
Usage
Trend
Chart
-->
<
div
class
=
"
card p-4
"
>
<
h3
class
=
"
text-sm font-semibold text-gray-900 dark:text-white mb-4
"
>
{{
t
(
'
admin.accounts.stats.usageTrend
'
)
}}
<
/h3
>
<
h3
class
=
"
mb-4 text-sm font-semibold text-gray-900 dark:text-white
"
>
{{
t
(
'
admin.accounts.stats.usageTrend
'
)
}}
<
/h3
>
<
div
class
=
"
h-64
"
>
<
Line
v
-
if
=
"
trendChartData
"
:
data
=
"
trendChartData
"
:
options
=
"
lineChartOptions
"
/>
<
div
v
-
else
class
=
"
flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm
"
>
<
div
v
-
else
class
=
"
flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.dashboard.noDataAvailable
'
)
}}
<
/div
>
<
/div
>
<
/div
>
<!--
Model
Distribution
-->
<
ModelDistributionChart
:
model
-
stats
=
"
stats.models
"
:
loading
=
"
false
"
/>
<
ModelDistributionChart
:
model
-
stats
=
"
stats.models
"
:
loading
=
"
false
"
/>
<
/template
>
<!--
No
Data
State
-->
<
div
v
-
else
-
if
=
"
!loading
"
class
=
"
flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400
"
>
<
svg
class
=
"
w-12 h-12 mb-4
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
1.5
"
d
=
"
M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z
"
/>
<
div
v
-
else
-
if
=
"
!loading
"
class
=
"
flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400
"
>
<
svg
class
=
"
mb-4 h-12 w-12
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
stroke
=
"
currentColor
"
>
<
path
stroke
-
linecap
=
"
round
"
stroke
-
linejoin
=
"
round
"
stroke
-
width
=
"
1.5
"
d
=
"
M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z
"
/>
<
/svg
>
<
p
class
=
"
text-sm
"
>
{{
t
(
'
admin.accounts.stats.noData
'
)
}}
<
/p
>
<
/div
>
...
...
@@ -286,7 +513,7 @@
<
div
class
=
"
flex justify-end
"
>
<
button
@
click
=
"
handleClose
"
class
=
"
px-4 py-2 text-sm font-medium text-gray-700
dark:text-gray-300
bg-gray-
1
00 dark:bg-dark-600
hover:bg
-gray-
2
00 dark:hover:bg-dark-500
rounded-lg transition-colors
"
class
=
"
rounded-lg bg-gray-100
px-4 py-2 text-sm font-medium text-gray-700
transition-colors hover:
bg-gray-
2
00 dark:bg-dark-600
dark:text
-gray-
3
00 dark:hover:bg-dark-500
"
>
{{
t
(
'
common.close
'
)
}}
<
/button
>
...
...
@@ -349,7 +576,7 @@ const isDarkMode = computed(() => {
// Chart colors
const
chartColors
=
computed
(()
=>
({
text
:
isDarkMode
.
value
?
'
#e5e7eb
'
:
'
#374151
'
,
grid
:
isDarkMode
.
value
?
'
#374151
'
:
'
#e5e7eb
'
,
grid
:
isDarkMode
.
value
?
'
#374151
'
:
'
#e5e7eb
'
}
))
// Line chart data
...
...
@@ -357,27 +584,27 @@ const trendChartData = computed(() => {
if
(
!
stats
.
value
?.
history
?.
length
)
return
null
return
{
labels
:
stats
.
value
.
history
.
map
(
h
=>
h
.
label
),
labels
:
stats
.
value
.
history
.
map
(
(
h
)
=>
h
.
label
),
datasets
:
[
{
label
:
t
(
'
admin.accounts.stats.cost
'
)
+
'
(USD)
'
,
data
:
stats
.
value
.
history
.
map
(
h
=>
h
.
cost
),
data
:
stats
.
value
.
history
.
map
(
(
h
)
=>
h
.
cost
),
borderColor
:
'
#3b82f6
'
,
backgroundColor
:
'
rgba(59, 130, 246, 0.1)
'
,
fill
:
true
,
tension
:
0.3
,
yAxisID
:
'
y
'
,
yAxisID
:
'
y
'
}
,
{
label
:
t
(
'
admin.accounts.stats.requests
'
),
data
:
stats
.
value
.
history
.
map
(
h
=>
h
.
requests
),
data
:
stats
.
value
.
history
.
map
(
(
h
)
=>
h
.
requests
),
borderColor
:
'
#f97316
'
,
backgroundColor
:
'
rgba(249, 115, 22, 0.1)
'
,
fill
:
false
,
tension
:
0.3
,
yAxisID
:
'
y1
'
,
}
,
]
,
yAxisID
:
'
y1
'
}
]
}
}
)
...
...
@@ -387,7 +614,7 @@ const lineChartOptions = computed(() => ({
maintainAspectRatio
:
false
,
interaction
:
{
intersect
:
false
,
mode
:
'
index
'
as
const
,
mode
:
'
index
'
as
const
}
,
plugins
:
{
legend
:
{
...
...
@@ -398,9 +625,9 @@ const lineChartOptions = computed(() => ({
pointStyle
:
'
circle
'
,
padding
:
15
,
font
:
{
size
:
11
,
}
,
}
,
size
:
11
}
}
}
,
tooltip
:
{
callbacks
:
{
...
...
@@ -411,81 +638,84 @@ const lineChartOptions = computed(() => ({
return
`${label
}
: $${formatCost(value)
}
`
}
return
`${label
}
: ${formatNumber(value)
}
`
}
,
}
,
}
,
}
}
}
}
,
scales
:
{
x
:
{
grid
:
{
color
:
chartColors
.
value
.
grid
,
color
:
chartColors
.
value
.
grid
}
,
ticks
:
{
color
:
chartColors
.
value
.
text
,
font
:
{
size
:
10
,
size
:
10
}
,
maxRotation
:
45
,
minRotation
:
0
,
}
,
minRotation
:
0
}
}
,
y
:
{
type
:
'
linear
'
as
const
,
display
:
true
,
position
:
'
left
'
as
const
,
grid
:
{
color
:
chartColors
.
value
.
grid
,
color
:
chartColors
.
value
.
grid
}
,
ticks
:
{
color
:
'
#3b82f6
'
,
font
:
{
size
:
10
,
size
:
10
}
,
callback
:
(
value
:
string
|
number
)
=>
'
$
'
+
formatCost
(
Number
(
value
))
,
callback
:
(
value
:
string
|
number
)
=>
'
$
'
+
formatCost
(
Number
(
value
))
}
,
title
:
{
display
:
true
,
text
:
t
(
'
admin.accounts.stats.cost
'
)
+
'
(USD)
'
,
color
:
'
#3b82f6
'
,
font
:
{
size
:
11
,
}
,
}
,
size
:
11
}
}
}
,
y1
:
{
type
:
'
linear
'
as
const
,
display
:
true
,
position
:
'
right
'
as
const
,
grid
:
{
drawOnChartArea
:
false
,
drawOnChartArea
:
false
}
,
ticks
:
{
color
:
'
#f97316
'
,
font
:
{
size
:
10
,
size
:
10
}
,
callback
:
(
value
:
string
|
number
)
=>
formatNumber
(
Number
(
value
))
,
callback
:
(
value
:
string
|
number
)
=>
formatNumber
(
Number
(
value
))
}
,
title
:
{
display
:
true
,
text
:
t
(
'
admin.accounts.stats.requests
'
),
color
:
'
#f97316
'
,
font
:
{
size
:
11
,
}
,
}
,
}
,
}
,
size
:
11
}
}
}
}
}
))
// Load stats when modal opens
watch
(()
=>
props
.
show
,
async
(
newVal
)
=>
{
if
(
newVal
&&
props
.
account
)
{
await
loadStats
()
}
else
{
stats
.
value
=
null
watch
(
()
=>
props
.
show
,
async
(
newVal
)
=>
{
if
(
newVal
&&
props
.
account
)
{
await
loadStats
()
}
else
{
stats
.
value
=
null
}
}
}
)
)
const
loadStats
=
async
()
=>
{
if
(
!
props
.
account
)
return
...
...
frontend/src/components/account/AccountStatusIndicator.vue
View file @
429f38d0
<
template
>
<div
class=
"flex items-center gap-2"
>
<!-- Main Status Badge -->
<span
:class=
"[
'badge text-xs',
statusClass
]"
>
<span
:class=
"['badge text-xs', statusClass]"
>
{{
statusText
}}
</span>
<!-- Error Info Indicator -->
<div
v-if=
"hasError && account.error_message"
class=
"relative group/error"
>
<svg
class=
"w-4 h-4 text-red-500 dark:text-red-400 cursor-help hover:text-red-600 dark:hover:text-red-300 transition-colors"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
<div
v-if=
"hasError && account.error_message"
class=
"group/error relative"
>
<svg
class=
"h-4 w-4 cursor-help text-red-500 transition-colors hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg>
<!-- Tooltip - 向下显示 -->
<div
class=
"absolute top-full left-0 mt-1.5 px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-xs rounded-lg shadow-xl opacity-0 invisible group-hover/error:opacity-100 group-hover/error:visible transition-all duration-200 z-[100] min-w-[200px] max-w-[300px]"
>
<div
class=
"text-gray-300 break-words whitespace-pre-wrap leading-relaxed"
>
{{
account
.
error_message
}}
</div>
<div
class=
"invisible absolute left-0 top-full z-[100] mt-1.5 min-w-[200px] max-w-[300px] rounded-lg bg-gray-800 px-3 py-2 text-xs text-white opacity-0 shadow-xl transition-all duration-200 group-hover/error:visible group-hover/error:opacity-100 dark:bg-gray-900"
>
<div
class=
"whitespace-pre-wrap break-words leading-relaxed text-gray-300"
>
{{
account
.
error_message
}}
</div>
<!-- 上方小三角 -->
<div
class=
"absolute bottom-full left-3 border-[6px] border-transparent border-b-gray-800 dark:border-b-gray-900"
></div>
<div
class=
"absolute bottom-full left-3 border-[6px] border-transparent border-b-gray-800 dark:border-b-gray-900"
></div>
</div>
</div>
<!-- Rate Limit Indicator (429) -->
<div
v-if=
"isRateLimited"
class=
"relative group"
>
<span
class=
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
<svg
class=
"w-3 h-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
<div
v-if=
"isRateLimited"
class=
"group relative"
>
<span
class=
"inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
<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=
"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
429
</span>
<!-- Tooltip -->
<div
class=
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50"
>
<div
class=
"pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
Rate limited until
{{
formatTime
(
account
.
rate_limit_reset_at
)
}}
<div
class=
"absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
<div
class=
"absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
</div>
</div>
<!-- Overload Indicator (529) -->
<div
v-if=
"isOverloaded"
class=
"relative group"
>
<span
class=
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
<svg
class=
"w-3 h-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
<div
v-if=
"isOverloaded"
class=
"group relative"
>
<span
class=
"inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
<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=
"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
529
</span>
<!-- Tooltip -->
<div
class=
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50"
>
<div
class=
"pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
Overloaded until
{{
formatTime
(
account
.
overload_until
)
}}
<div
class=
"absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
<div
class=
"absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
</div>
</div>
</div>
...
...
frontend/src/components/account/AccountTestModal.vue
View file @
429f38d0
...
...
@@ -7,17 +7,29 @@
>
<div
class=
"space-y-4"
>
<!-- Account Info Card -->
<div
v-if=
"account"
class=
"flex items-center justify-between p-3 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-dark-700 dark:to-dark-600 rounded-xl border border-gray-200 dark:border-dark-500"
>
<div
v-if=
"account"
class=
"flex items-center justify-between rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-3 dark:border-dark-500 dark:from-dark-700 dark:to-dark-600"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"w-10 h-10 rounded-lg bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center"
>
<svg
class=
"w-5 h-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<svg
class=
"h-5 w-5 text-white"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<div
class=
"font-semibold text-gray-900 dark:text-gray-100"
>
{{
account
.
name
}}
</div>
<div
class=
"text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1.5"
>
<span
class=
"px-1.5 py-0.5 bg-gray-200 dark:bg-dark-500 rounded text-[10px] font-medium uppercase"
>
<div
class=
"flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"
>
<span
class=
"rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium uppercase dark:bg-dark-500"
>
{{
account
.
type
}}
</span>
<span>
{{
t
(
'
admin.accounts.account
'
)
}}
</span>
...
...
@@ -26,7 +38,7 @@
</div>
<span
:class=
"[
'px-2.5 py-1 text-xs font-semibold
rounded-full
',
'
rounded-full
px-2.5 py-1 text-xs font-semibold',
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
...
...
@@ -44,7 +56,7 @@
<select
v-model=
"selectedModelId"
:disabled=
"loadingModels || status === 'connecting'"
class=
"w-full
px-3 py-2 text-sm
rounded-lg border border-gray-300
dark:border-dark-500 bg-white dark:bg-dark-700 text-gray-900 dark:text-gra
y-
1
00 focus:ring-2 focus:ring-primary-500
focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed
"
class=
"w-full rounded-lg border border-gray-300
bg-white px-3 py-2 text-sm text-gray-900 focus:border-primar
y-
5
00 focus:ring-2 focus:ring-primary-500
disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-100
"
>
<option
v-if=
"loadingModels"
value=
""
>
{{
t
(
'
common.loading
'
)
}}
...
</option>
<option
v-for=
"model in availableModels"
:key=
"model.id"
:value=
"model.id"
>
...
...
@@ -54,22 +66,38 @@
</div>
<!-- Terminal Output -->
<div
class=
"relative
group
"
>
<div
class=
"
group
relative"
>
<div
ref=
"terminalRef"
class=
"
bg-gray-900 dark:bg-black rounded-xl p-4 min
-h-[
1
20px] m
ax
-h-[2
4
0px] overflow-y-auto
font-mono text-sm
border border-gray-700 dark:border-gray-800"
class=
"
max
-h-[2
4
0px] m
in
-h-[
1
20px] overflow-y-auto
rounded-xl
border border-gray-700
bg-gray-900 p-4 font-mono text-sm
dark:border-gray-800
dark:bg-black
"
>
<!-- Status Line -->
<div
v-if=
"status === 'idle'"
class=
"text-gray-500 flex items-center gap-2"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 10V3L4 14h7v7l9-11h-7z"
/>
<div
v-if=
"status === 'idle'"
class=
"flex items-center gap-2 text-gray-500"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span>
{{
t
(
'
admin.accounts.readyToTest
'
)
}}
</span>
</div>
<div
v-else-if=
"status === 'connecting'"
class=
"text-yellow-400 flex items-center gap-2"
>
<svg
class=
"animate-spin w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
<div
v-else-if=
"status === 'connecting'"
class=
"flex items-center gap-2 text-yellow-400"
>
<svg
class=
"h-4 w-4 animate-spin"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>
{{
t
(
'
admin.accounts.connectingToApi
'
)
}}
</span>
</div>
...
...
@@ -85,15 +113,31 @@
</div>
<!-- Result Status -->
<div
v-if=
"status === 'success'"
class=
"text-green-400 mt-3 pt-3 border-t border-gray-700 flex items-center gap-2"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<div
v-if=
"status === 'success'"
class=
"mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{{
t
(
'
admin.accounts.testCompleted
'
)
}}
</span>
</div>
<div
v-else-if=
"status === 'error'"
class=
"text-red-400 mt-3 pt-3 border-t border-gray-700 flex items-center gap-2"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<div
v-else-if=
"status === 'error'"
class=
"mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{{
errorMessage
}}
</span>
</div>
...
...
@@ -103,28 +147,43 @@
<button
v-if=
"outputLines.length > 0"
@
click=
"copyOutput"
class=
"absolute
top-2
right-2
p-1.5 text-gray-400 hover:text-white
bg-gray-800/80
hover:bg
-gray-
7
00
rounded-lg
transition-all
opacity-0
group-hover:opacity-100"
class=
"absolute right-2
top-2 rounded-lg
bg-gray-800/80
p-1.5 text
-gray-
4
00
opacity-0
transition-all
hover:bg-gray-700 hover:text-white
group-hover:opacity-100"
:title=
"t('admin.accounts.copyOutput')"
>
<svg
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
</div>
<!-- Test Info -->
<div
class=
"flex items-center justify-between text-xs text-gray-500 dark:text-gray-400
px-1
"
>
<div
class=
"flex items-center justify-between
px-1
text-xs text-gray-500 dark:text-gray-400"
>
<div
class=
"flex items-center gap-3"
>
<span
class=
"flex items-center gap-1"
>
<svg
class=
"w-3.5 h-3.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
<svg
class=
"h-3.5 w-3.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
{{
t
(
'
admin.accounts.testModel
'
)
}}
</span>
</div>
<span
class=
"flex items-center gap-1"
>
<svg
class=
"w-3.5 h-3.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
<svg
class=
"h-3.5 w-3.5"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
{{
t
(
'
admin.accounts.testPrompt
'
)
}}
</span>
...
...
@@ -135,7 +194,7 @@
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"handleClose"
class=
"px-4 py-2 text-sm font-medium text-gray-700
dark:text-gray-300
bg-gray-
1
00 dark:bg-dark-600
hover:bg
-gray-
2
00 dark:hover:bg-dark-500
rounded-lg transition-colors
"
class=
"
rounded-lg bg-gray-100
px-4 py-2 text-sm font-medium text-gray-700
transition-colors hover:
bg-gray-
2
00 dark:bg-dark-600
dark:text
-gray-
3
00 dark:hover:bg-dark-500"
:disabled=
"status === 'connecting'"
>
{{
t
(
'
common.close
'
)
}}
...
...
@@ -144,29 +203,72 @@
@
click=
"startTest"
:disabled=
"status === 'connecting' || !selectedModelId"
:class=
"[
'px-4 py-2 text-sm font-medium
rounded-lg
transition-all
flex items-center gap-2
',
'
flex items-center gap-2 rounded-lg
px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' || !selectedModelId
? 'bg-primary-400 text-white
cursor-not-allowed
'
? '
cursor-not-allowed
bg-primary-400 text-white'
: status === 'success'
? 'bg-green-500 hover:bg-green-600
text-white
'
? 'bg-green-500
text-white
hover:bg-green-600'
: status === 'error'
? 'bg-orange-500 hover:bg-orange-600
text-white
'
: 'bg-primary-500 hover:bg-primary-600
text-white
'
? 'bg-orange-500
text-white
hover:bg-orange-600'
: 'bg-primary-500
text-white
hover:bg-primary-600'
]"
>
<svg
v-if=
"status === 'connecting'"
class=
"animate-spin h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
<svg
v-if=
"status === 'connecting'"
class=
"h-4 w-4 animate-spin"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else-if=
"status === 'idle'"
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<svg
v-else-if=
"status === 'idle'"
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<svg
v-else
class=
"w-4 h-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>
{{
status
===
'
connecting
'
?
t
(
'
admin.accounts.testing
'
)
:
status
===
'
idle
'
?
t
(
'
admin.accounts.startTest
'
)
:
t
(
'
admin.accounts.retry
'
)
}}
{{
status
===
'
connecting
'
?
t
(
'
admin.accounts.testing
'
)
:
status
===
'
idle
'
?
t
(
'
admin.accounts.startTest
'
)
:
t
(
'
admin.accounts.retry
'
)
}}
</span>
</button>
</div>
...
...
@@ -208,14 +310,17 @@ const loadingModels = ref(false)
let
eventSource
:
EventSource
|
null
=
null
// Load available models when modal opens
watch
(()
=>
props
.
show
,
async
(
newVal
)
=>
{
if
(
newVal
&&
props
.
account
)
{
resetState
()
await
loadAvailableModels
()
}
else
{
closeEventSource
()
watch
(
()
=>
props
.
show
,
async
(
newVal
)
=>
{
if
(
newVal
&&
props
.
account
)
{
resetState
()
await
loadAvailableModels
()
}
else
{
closeEventSource
()
}
}
}
)
)
const
loadAvailableModels
=
async
()
=>
{
if
(
!
props
.
account
)
return
...
...
@@ -224,11 +329,18 @@ const loadAvailableModels = async () => {
selectedModelId
.
value
=
''
// Reset selection before loading
try
{
availableModels
.
value
=
await
adminAPI
.
accounts
.
getAvailableModels
(
props
.
account
.
id
)
// Default
to first model (usually Sonnet)
// Default
selection by platform
if
(
availableModels
.
value
.
length
>
0
)
{
// Try to select Sonnet as default, otherwise use first model
const
sonnetModel
=
availableModels
.
value
.
find
(
m
=>
m
.
id
.
includes
(
'
sonnet
'
))
selectedModelId
.
value
=
sonnetModel
?.
id
||
availableModels
.
value
[
0
].
id
if
(
props
.
account
.
platform
===
'
gemini
'
)
{
const
preferred
=
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-pro
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro
'
)
selectedModelId
.
value
=
preferred
?.
id
||
availableModels
.
value
[
0
].
id
}
else
{
// Try to select Sonnet as default, otherwise use first model
const
sonnetModel
=
availableModels
.
value
.
find
((
m
)
=>
m
.
id
.
includes
(
'
sonnet
'
))
selectedModelId
.
value
=
sonnetModel
?.
id
||
availableModels
.
value
[
0
].
id
}
}
}
catch
(
error
)
{
console
.
error
(
'
Failed to load available models:
'
,
error
)
...
...
@@ -290,7 +402,7 @@ const startTest = async () => {
const
response
=
await
fetch
(
url
,
{
method
:
'
POST
'
,
headers
:
{
'
Authorization
'
:
`Bearer
${
localStorage
.
getItem
(
'
auth_token
'
)}
`
,
Authorization
:
`Bearer
${
localStorage
.
getItem
(
'
auth_token
'
)}
`
,
'
Content-Type
'
:
'
application/json
'
},
body
:
JSON
.
stringify
({
model_id
:
selectedModelId
.
value
})
...
...
@@ -337,7 +449,13 @@ const startTest = async () => {
}
}
const
handleEvent
=
(
event
:
{
type
:
string
;
text
?:
string
;
model
?:
string
;
success
?:
boolean
;
error
?:
string
})
=>
{
const
handleEvent
=
(
event
:
{
type
:
string
text
?:
string
model
?:
string
success
?:
boolean
error
?:
string
})
=>
{
switch
(
event
.
type
)
{
case
'
test_start
'
:
addLine
(
t
(
'
admin.accounts.connectedToApi
'
),
'
text-green-400
'
)
...
...
@@ -382,7 +500,7 @@ const handleEvent = (event: { type: string; text?: string; model?: string; succe
}
const
copyOutput
=
()
=>
{
const
text
=
outputLines
.
value
.
map
(
l
=>
l
.
text
).
join
(
'
\n
'
)
const
text
=
outputLines
.
value
.
map
(
(
l
)
=>
l
.
text
).
join
(
'
\n
'
)
navigator
.
clipboard
.
writeText
(
text
)
}
</
script
>
Prev
1
2
3
4
5
6
7
8
9
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