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
0743652d
Commit
0743652d
authored
Dec 27, 2025
by
shaw
Browse files
Merge branch 'feature/ui-and-backend-improvements'
parents
f133b051
96bec5c9
Changes
24
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/usage_handler.go
View file @
0743652d
...
@@ -371,24 +371,16 @@ func (h *UsageHandler) DashboardApiKeysUsage(c *gin.Context) {
...
@@ -371,24 +371,16 @@ func (h *UsageHandler) DashboardApiKeysUsage(c *gin.Context) {
return
return
}
}
// Verify ownership of all requested API keys
// Limit the number of API key IDs to prevent SQL parameter overflow
userApiKeys
,
_
,
err
:=
h
.
apiKeyService
.
List
(
c
.
Request
.
Context
(),
subject
.
UserID
,
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
1000
})
if
len
(
req
.
ApiKeyIDs
)
>
100
{
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Too many API key IDs (maximum 100 allowed)"
)
response
.
ErrorFrom
(
c
,
err
)
return
return
}
}
userApiKeyIDs
:=
make
(
map
[
int64
]
bool
)
validApiKeyIDs
,
err
:=
h
.
apiKeyService
.
VerifyOwnership
(
c
.
Request
.
Context
(),
subject
.
UserID
,
req
.
ApiKeyIDs
)
for
_
,
key
:=
range
userApiKeys
{
if
err
!=
nil
{
userApiKeyIDs
[
key
.
ID
]
=
true
response
.
ErrorFrom
(
c
,
err
)
}
return
// Filter to only include user's own API keys
validApiKeyIDs
:=
make
([]
int64
,
0
)
for
_
,
id
:=
range
req
.
ApiKeyIDs
{
if
userApiKeyIDs
[
id
]
{
validApiKeyIDs
=
append
(
validApiKeyIDs
,
id
)
}
}
}
if
len
(
validApiKeyIDs
)
==
0
{
if
len
(
validApiKeyIDs
)
==
0
{
...
...
backend/internal/repository/api_key_repo.go
View file @
0743652d
...
@@ -81,6 +81,22 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param
...
@@ -81,6 +81,22 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param
return
outKeys
,
paginationResultFromTotal
(
total
,
params
),
nil
return
outKeys
,
paginationResultFromTotal
(
total
,
params
),
nil
}
}
func
(
r
*
apiKeyRepository
)
VerifyOwnership
(
ctx
context
.
Context
,
userID
int64
,
apiKeyIDs
[]
int64
)
([]
int64
,
error
)
{
if
len
(
apiKeyIDs
)
==
0
{
return
[]
int64
{},
nil
}
ids
:=
make
([]
int64
,
0
,
len
(
apiKeyIDs
))
err
:=
r
.
db
.
WithContext
(
ctx
)
.
Model
(
&
apiKeyModel
{})
.
Where
(
"user_id = ? AND id IN ?"
,
userID
,
apiKeyIDs
)
.
Pluck
(
"id"
,
&
ids
)
.
Error
if
err
!=
nil
{
return
nil
,
err
}
return
ids
,
nil
}
func
(
r
*
apiKeyRepository
)
CountByUserID
(
ctx
context
.
Context
,
userID
int64
)
(
int64
,
error
)
{
func
(
r
*
apiKeyRepository
)
CountByUserID
(
ctx
context
.
Context
,
userID
int64
)
(
int64
,
error
)
{
var
count
int64
var
count
int64
err
:=
r
.
db
.
WithContext
(
ctx
)
.
Model
(
&
apiKeyModel
{})
.
Where
(
"user_id = ?"
,
userID
)
.
Count
(
&
count
)
.
Error
err
:=
r
.
db
.
WithContext
(
ctx
)
.
Model
(
&
apiKeyModel
{})
.
Where
(
"user_id = ?"
,
userID
)
.
Count
(
&
count
)
.
Error
...
...
backend/internal/repository/usage_log_repo.go
View file @
0743652d
...
@@ -129,51 +129,67 @@ type DashboardStats = usagestats.DashboardStats
...
@@ -129,51 +129,67 @@ type DashboardStats = usagestats.DashboardStats
func
(
r
*
usageLogRepository
)
GetDashboardStats
(
ctx
context
.
Context
)
(
*
DashboardStats
,
error
)
{
func
(
r
*
usageLogRepository
)
GetDashboardStats
(
ctx
context
.
Context
)
(
*
DashboardStats
,
error
)
{
var
stats
DashboardStats
var
stats
DashboardStats
today
:=
timezone
.
Today
()
today
:=
timezone
.
Today
()
now
:=
time
.
Now
()
// 总用户数
// 合并用户统计查询
r
.
db
.
WithContext
(
ctx
)
.
Model
(
&
userModel
{})
.
Count
(
&
stats
.
TotalUsers
)
var
userStats
struct
{
TotalUsers
int64
`gorm:"column:total_users"`
// 今日新增用户数
TodayNewUsers
int64
`gorm:"column:today_new_users"`
r
.
db
.
WithContext
(
ctx
)
.
Model
(
&
userModel
{})
.
ActiveUsers
int64
`gorm:"column:active_users"`
Where
(
"created_at >= ?"
,
today
)
.
}
Count
(
&
stats
.
TodayNewUsers
)
if
err
:=
r
.
db
.
WithContext
(
ctx
)
.
Raw
(
`
SELECT
// 今日活跃用户数 (今日有请求的用户)
COUNT(*) as total_users,
r
.
db
.
WithContext
(
ctx
)
.
Model
(
&
usageLogModel
{})
.
COUNT(CASE WHEN created_at >= ? THEN 1 END) as today_new_users,
Distinct
(
"user_id"
)
.
(SELECT COUNT(DISTINCT user_id) FROM usage_logs WHERE created_at >= ?) as active_users
Where
(
"created_at >= ?"
,
today
)
.
FROM users
Count
(
&
stats
.
ActiveUsers
)
`
,
today
,
today
)
.
Scan
(
&
userStats
)
.
Error
;
err
!=
nil
{
return
nil
,
err
// 总 API Key 数
}
r
.
db
.
WithContext
(
ctx
)
.
Model
(
&
apiKeyModel
{})
.
Count
(
&
stats
.
TotalApiKeys
)
stats
.
TotalUsers
=
userStats
.
TotalUsers
stats
.
TodayNewUsers
=
userStats
.
TodayNewUsers
// 活跃 API Key 数
stats
.
ActiveUsers
=
userStats
.
ActiveUsers
r
.
db
.
WithContext
(
ctx
)
.
Model
(
&
apiKeyModel
{})
.
Where
(
"status = ?"
,
service
.
StatusActive
)
.
Count
(
&
stats
.
ActiveApiKeys
)
// 总账户数
r
.
db
.
WithContext
(
ctx
)
.
Model
(
&
accountModel
{})
.
Count
(
&
stats
.
TotalAccounts
)
// 正常账户数 (schedulable=true, status=active)
r
.
db
.
WithContext
(
ctx
)
.
Model
(
&
accountModel
{})
.
Where
(
"status = ? AND schedulable = ?"
,
service
.
StatusActive
,
true
)
.
Count
(
&
stats
.
NormalAccounts
)
// 异常账户数 (status=error)
r
.
db
.
WithContext
(
ctx
)
.
Model
(
&
accountModel
{})
.
Where
(
"status = ?"
,
service
.
StatusError
)
.
Count
(
&
stats
.
ErrorAccounts
)
// 限流账户数
// 合并API Key统计查询
r
.
db
.
WithContext
(
ctx
)
.
Model
(
&
accountModel
{})
.
var
apiKeyStats
struct
{
Where
(
"rate_limited_at IS NOT NULL AND rate_limit_reset_at > ?"
,
time
.
Now
())
.
TotalApiKeys
int64
`gorm:"column:total_api_keys"`
Count
(
&
stats
.
RateLimitAccounts
)
ActiveApiKeys
int64
`gorm:"column:active_api_keys"`
}
if
err
:=
r
.
db
.
WithContext
(
ctx
)
.
Raw
(
`
SELECT
COUNT(*) as total_api_keys,
COUNT(CASE WHEN status = ? THEN 1 END) as active_api_keys
FROM api_keys
`
,
service
.
StatusActive
)
.
Scan
(
&
apiKeyStats
)
.
Error
;
err
!=
nil
{
return
nil
,
err
}
stats
.
TotalApiKeys
=
apiKeyStats
.
TotalApiKeys
stats
.
ActiveApiKeys
=
apiKeyStats
.
ActiveApiKeys
// 过载账户数
// 合并账户统计查询
r
.
db
.
WithContext
(
ctx
)
.
Model
(
&
accountModel
{})
.
var
accountStats
struct
{
Where
(
"overload_until IS NOT NULL AND overload_until > ?"
,
time
.
Now
())
.
TotalAccounts
int64
`gorm:"column:total_accounts"`
Count
(
&
stats
.
OverloadAccounts
)
NormalAccounts
int64
`gorm:"column:normal_accounts"`
ErrorAccounts
int64
`gorm:"column:error_accounts"`
RateLimitAccounts
int64
`gorm:"column:ratelimit_accounts"`
OverloadAccounts
int64
`gorm:"column:overload_accounts"`
}
if
err
:=
r
.
db
.
WithContext
(
ctx
)
.
Raw
(
`
SELECT
COUNT(*) as total_accounts,
COUNT(CASE WHEN status = ? AND schedulable = true THEN 1 END) as normal_accounts,
COUNT(CASE WHEN status = ? THEN 1 END) as error_accounts,
COUNT(CASE WHEN rate_limited_at IS NOT NULL AND rate_limit_reset_at > ? THEN 1 END) as ratelimit_accounts,
COUNT(CASE WHEN overload_until IS NOT NULL AND overload_until > ? THEN 1 END) as overload_accounts
FROM accounts
`
,
service
.
StatusActive
,
service
.
StatusError
,
now
,
now
)
.
Scan
(
&
accountStats
)
.
Error
;
err
!=
nil
{
return
nil
,
err
}
stats
.
TotalAccounts
=
accountStats
.
TotalAccounts
stats
.
NormalAccounts
=
accountStats
.
NormalAccounts
stats
.
ErrorAccounts
=
accountStats
.
ErrorAccounts
stats
.
RateLimitAccounts
=
accountStats
.
RateLimitAccounts
stats
.
OverloadAccounts
=
accountStats
.
OverloadAccounts
// 累计 Token 统计
// 累计 Token 统计
var
totalStats
struct
{
var
totalStats
struct
{
...
@@ -273,6 +289,88 @@ func (r *usageLogRepository) ListByUserAndTimeRange(ctx context.Context, userID
...
@@ -273,6 +289,88 @@ func (r *usageLogRepository) ListByUserAndTimeRange(ctx context.Context, userID
return
usageLogModelsToService
(
logs
),
nil
,
err
return
usageLogModelsToService
(
logs
),
nil
,
err
}
}
// GetUserStatsAggregated returns aggregated usage statistics for a user using database-level aggregation
func
(
r
*
usageLogRepository
)
GetUserStatsAggregated
(
ctx
context
.
Context
,
userID
int64
,
startTime
,
endTime
time
.
Time
)
(
*
usagestats
.
UsageStats
,
error
)
{
var
stats
struct
{
TotalRequests
int64
`gorm:"column:total_requests"`
TotalInputTokens
int64
`gorm:"column:total_input_tokens"`
TotalOutputTokens
int64
`gorm:"column:total_output_tokens"`
TotalCacheTokens
int64
`gorm:"column:total_cache_tokens"`
TotalCost
float64
`gorm:"column:total_cost"`
TotalActualCost
float64
`gorm:"column:total_actual_cost"`
AverageDurationMs
float64
`gorm:"column:avg_duration_ms"`
}
err
:=
r
.
db
.
WithContext
(
ctx
)
.
Model
(
&
usageLogModel
{})
.
Select
(
`
COUNT(*) as total_requests,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens,
COALESCE(SUM(total_cost), 0) as total_cost,
COALESCE(SUM(actual_cost), 0) as total_actual_cost,
COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms
`
)
.
Where
(
"user_id = ? AND created_at >= ? AND created_at < ?"
,
userID
,
startTime
,
endTime
)
.
Scan
(
&
stats
)
.
Error
if
err
!=
nil
{
return
nil
,
err
}
return
&
usagestats
.
UsageStats
{
TotalRequests
:
stats
.
TotalRequests
,
TotalInputTokens
:
stats
.
TotalInputTokens
,
TotalOutputTokens
:
stats
.
TotalOutputTokens
,
TotalCacheTokens
:
stats
.
TotalCacheTokens
,
TotalTokens
:
stats
.
TotalInputTokens
+
stats
.
TotalOutputTokens
+
stats
.
TotalCacheTokens
,
TotalCost
:
stats
.
TotalCost
,
TotalActualCost
:
stats
.
TotalActualCost
,
AverageDurationMs
:
stats
.
AverageDurationMs
,
},
nil
}
// GetApiKeyStatsAggregated returns aggregated usage statistics for an API key using database-level aggregation
func
(
r
*
usageLogRepository
)
GetApiKeyStatsAggregated
(
ctx
context
.
Context
,
apiKeyID
int64
,
startTime
,
endTime
time
.
Time
)
(
*
usagestats
.
UsageStats
,
error
)
{
var
stats
struct
{
TotalRequests
int64
`gorm:"column:total_requests"`
TotalInputTokens
int64
`gorm:"column:total_input_tokens"`
TotalOutputTokens
int64
`gorm:"column:total_output_tokens"`
TotalCacheTokens
int64
`gorm:"column:total_cache_tokens"`
TotalCost
float64
`gorm:"column:total_cost"`
TotalActualCost
float64
`gorm:"column:total_actual_cost"`
AverageDurationMs
float64
`gorm:"column:avg_duration_ms"`
}
err
:=
r
.
db
.
WithContext
(
ctx
)
.
Model
(
&
usageLogModel
{})
.
Select
(
`
COUNT(*) as total_requests,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens,
COALESCE(SUM(total_cost), 0) as total_cost,
COALESCE(SUM(actual_cost), 0) as total_actual_cost,
COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms
`
)
.
Where
(
"api_key_id = ? AND created_at >= ? AND created_at < ?"
,
apiKeyID
,
startTime
,
endTime
)
.
Scan
(
&
stats
)
.
Error
if
err
!=
nil
{
return
nil
,
err
}
return
&
usagestats
.
UsageStats
{
TotalRequests
:
stats
.
TotalRequests
,
TotalInputTokens
:
stats
.
TotalInputTokens
,
TotalOutputTokens
:
stats
.
TotalOutputTokens
,
TotalCacheTokens
:
stats
.
TotalCacheTokens
,
TotalTokens
:
stats
.
TotalInputTokens
+
stats
.
TotalOutputTokens
+
stats
.
TotalCacheTokens
,
TotalCost
:
stats
.
TotalCost
,
TotalActualCost
:
stats
.
TotalActualCost
,
AverageDurationMs
:
stats
.
AverageDurationMs
,
},
nil
}
func
(
r
*
usageLogRepository
)
ListByApiKeyAndTimeRange
(
ctx
context
.
Context
,
apiKeyID
int64
,
startTime
,
endTime
time
.
Time
)
([]
service
.
UsageLog
,
*
pagination
.
PaginationResult
,
error
)
{
func
(
r
*
usageLogRepository
)
ListByApiKeyAndTimeRange
(
ctx
context
.
Context
,
apiKeyID
int64
,
startTime
,
endTime
time
.
Time
)
([]
service
.
UsageLog
,
*
pagination
.
PaginationResult
,
error
)
{
var
logs
[]
usageLogModel
var
logs
[]
usageLogModel
err
:=
r
.
db
.
WithContext
(
ctx
)
.
err
:=
r
.
db
.
WithContext
(
ctx
)
.
...
...
backend/internal/server/api_contract_test.go
View file @
0743652d
...
@@ -788,6 +788,25 @@ func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params
...
@@ -788,6 +788,25 @@ func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params
},
nil
},
nil
}
}
func
(
r
*
stubApiKeyRepo
)
VerifyOwnership
(
ctx
context
.
Context
,
userID
int64
,
apiKeyIDs
[]
int64
)
([]
int64
,
error
)
{
if
len
(
apiKeyIDs
)
==
0
{
return
[]
int64
{},
nil
}
seen
:=
make
(
map
[
int64
]
struct
{},
len
(
apiKeyIDs
))
out
:=
make
([]
int64
,
0
,
len
(
apiKeyIDs
))
for
_
,
id
:=
range
apiKeyIDs
{
if
_
,
ok
:=
seen
[
id
];
ok
{
continue
}
seen
[
id
]
=
struct
{}{}
key
,
ok
:=
r
.
byID
[
id
]
if
ok
&&
key
.
UserID
==
userID
{
out
=
append
(
out
,
id
)
}
}
return
out
,
nil
}
func
(
r
*
stubApiKeyRepo
)
CountByUserID
(
ctx
context
.
Context
,
userID
int64
)
(
int64
,
error
)
{
func
(
r
*
stubApiKeyRepo
)
CountByUserID
(
ctx
context
.
Context
,
userID
int64
)
(
int64
,
error
)
{
var
count
int64
var
count
int64
for
_
,
key
:=
range
r
.
byID
{
for
_
,
key
:=
range
r
.
byID
{
...
@@ -903,6 +922,55 @@ func (r *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, end
...
@@ -903,6 +922,55 @@ func (r *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, end
return
nil
,
errors
.
New
(
"not implemented"
)
return
nil
,
errors
.
New
(
"not implemented"
)
}
}
func
(
r
*
stubUsageLogRepo
)
GetUserStatsAggregated
(
ctx
context
.
Context
,
userID
int64
,
startTime
,
endTime
time
.
Time
)
(
*
usagestats
.
UsageStats
,
error
)
{
logs
:=
r
.
userLogs
[
userID
]
if
len
(
logs
)
==
0
{
return
&
usagestats
.
UsageStats
{},
nil
}
var
totalRequests
int64
var
totalInputTokens
int64
var
totalOutputTokens
int64
var
totalCacheTokens
int64
var
totalCost
float64
var
totalActualCost
float64
var
totalDuration
int64
var
durationCount
int64
for
_
,
log
:=
range
logs
{
totalRequests
++
totalInputTokens
+=
int64
(
log
.
InputTokens
)
totalOutputTokens
+=
int64
(
log
.
OutputTokens
)
totalCacheTokens
+=
int64
(
log
.
CacheCreationTokens
+
log
.
CacheReadTokens
)
totalCost
+=
log
.
TotalCost
totalActualCost
+=
log
.
ActualCost
if
log
.
DurationMs
!=
nil
{
totalDuration
+=
int64
(
*
log
.
DurationMs
)
durationCount
++
}
}
var
avgDuration
float64
if
durationCount
>
0
{
avgDuration
=
float64
(
totalDuration
)
/
float64
(
durationCount
)
}
return
&
usagestats
.
UsageStats
{
TotalRequests
:
totalRequests
,
TotalInputTokens
:
totalInputTokens
,
TotalOutputTokens
:
totalOutputTokens
,
TotalCacheTokens
:
totalCacheTokens
,
TotalTokens
:
totalInputTokens
+
totalOutputTokens
+
totalCacheTokens
,
TotalCost
:
totalCost
,
TotalActualCost
:
totalActualCost
,
AverageDurationMs
:
avgDuration
,
},
nil
}
func
(
r
*
stubUsageLogRepo
)
GetApiKeyStatsAggregated
(
ctx
context
.
Context
,
apiKeyID
int64
,
startTime
,
endTime
time
.
Time
)
(
*
usagestats
.
UsageStats
,
error
)
{
return
nil
,
errors
.
New
(
"not implemented"
)
}
func
(
r
*
stubUsageLogRepo
)
GetBatchUserUsageStats
(
ctx
context
.
Context
,
userIDs
[]
int64
)
(
map
[
int64
]
*
usagestats
.
BatchUserUsageStats
,
error
)
{
func
(
r
*
stubUsageLogRepo
)
GetBatchUserUsageStats
(
ctx
context
.
Context
,
userIDs
[]
int64
)
(
map
[
int64
]
*
usagestats
.
BatchUserUsageStats
,
error
)
{
return
nil
,
errors
.
New
(
"not implemented"
)
return
nil
,
errors
.
New
(
"not implemented"
)
}
}
...
...
backend/internal/service/account_usage_service.go
View file @
0743652d
...
@@ -48,6 +48,10 @@ type UsageLogRepository interface {
...
@@ -48,6 +48,10 @@ type UsageLogRepository interface {
// Account stats
// Account stats
GetAccountUsageStats
(
ctx
context
.
Context
,
accountID
int64
,
startTime
,
endTime
time
.
Time
)
(
*
usagestats
.
AccountUsageStatsResponse
,
error
)
GetAccountUsageStats
(
ctx
context
.
Context
,
accountID
int64
,
startTime
,
endTime
time
.
Time
)
(
*
usagestats
.
AccountUsageStatsResponse
,
error
)
// Aggregated stats (optimized)
GetUserStatsAggregated
(
ctx
context
.
Context
,
userID
int64
,
startTime
,
endTime
time
.
Time
)
(
*
usagestats
.
UsageStats
,
error
)
GetApiKeyStatsAggregated
(
ctx
context
.
Context
,
apiKeyID
int64
,
startTime
,
endTime
time
.
Time
)
(
*
usagestats
.
UsageStats
,
error
)
}
}
// usageCache 用于缓存usage数据
// usageCache 用于缓存usage数据
...
...
backend/internal/service/api_key_service.go
View file @
0743652d
...
@@ -34,6 +34,7 @@ type ApiKeyRepository interface {
...
@@ -34,6 +34,7 @@ type ApiKeyRepository interface {
Delete
(
ctx
context
.
Context
,
id
int64
)
error
Delete
(
ctx
context
.
Context
,
id
int64
)
error
ListByUserID
(
ctx
context
.
Context
,
userID
int64
,
params
pagination
.
PaginationParams
)
([]
ApiKey
,
*
pagination
.
PaginationResult
,
error
)
ListByUserID
(
ctx
context
.
Context
,
userID
int64
,
params
pagination
.
PaginationParams
)
([]
ApiKey
,
*
pagination
.
PaginationResult
,
error
)
VerifyOwnership
(
ctx
context
.
Context
,
userID
int64
,
apiKeyIDs
[]
int64
)
([]
int64
,
error
)
CountByUserID
(
ctx
context
.
Context
,
userID
int64
)
(
int64
,
error
)
CountByUserID
(
ctx
context
.
Context
,
userID
int64
)
(
int64
,
error
)
ExistsByKey
(
ctx
context
.
Context
,
key
string
)
(
bool
,
error
)
ExistsByKey
(
ctx
context
.
Context
,
key
string
)
(
bool
,
error
)
ListByGroupID
(
ctx
context
.
Context
,
groupID
int64
,
params
pagination
.
PaginationParams
)
([]
ApiKey
,
*
pagination
.
PaginationResult
,
error
)
ListByGroupID
(
ctx
context
.
Context
,
groupID
int64
,
params
pagination
.
PaginationParams
)
([]
ApiKey
,
*
pagination
.
PaginationResult
,
error
)
...
@@ -256,6 +257,18 @@ func (s *ApiKeyService) List(ctx context.Context, userID int64, params paginatio
...
@@ -256,6 +257,18 @@ func (s *ApiKeyService) List(ctx context.Context, userID int64, params paginatio
return
keys
,
pagination
,
nil
return
keys
,
pagination
,
nil
}
}
func
(
s
*
ApiKeyService
)
VerifyOwnership
(
ctx
context
.
Context
,
userID
int64
,
apiKeyIDs
[]
int64
)
([]
int64
,
error
)
{
if
len
(
apiKeyIDs
)
==
0
{
return
[]
int64
{},
nil
}
validIDs
,
err
:=
s
.
apiKeyRepo
.
VerifyOwnership
(
ctx
,
userID
,
apiKeyIDs
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"verify api key ownership: %w"
,
err
)
}
return
validIDs
,
nil
}
// GetByID 根据ID获取API Key
// GetByID 根据ID获取API Key
func
(
s
*
ApiKeyService
)
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
ApiKey
,
error
)
{
func
(
s
*
ApiKeyService
)
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
ApiKey
,
error
)
{
apiKey
,
err
:=
s
.
apiKeyRepo
.
GetByID
(
ctx
,
id
)
apiKey
,
err
:=
s
.
apiKeyRepo
.
GetByID
(
ctx
,
id
)
...
...
backend/internal/service/usage_service.go
View file @
0743652d
...
@@ -148,22 +148,40 @@ func (s *UsageService) ListByAccount(ctx context.Context, accountID int64, param
...
@@ -148,22 +148,40 @@ func (s *UsageService) ListByAccount(ctx context.Context, accountID int64, param
// GetStatsByUser 获取用户的使用统计
// GetStatsByUser 获取用户的使用统计
func
(
s
*
UsageService
)
GetStatsByUser
(
ctx
context
.
Context
,
userID
int64
,
startTime
,
endTime
time
.
Time
)
(
*
UsageStats
,
error
)
{
func
(
s
*
UsageService
)
GetStatsByUser
(
ctx
context
.
Context
,
userID
int64
,
startTime
,
endTime
time
.
Time
)
(
*
UsageStats
,
error
)
{
logs
,
_
,
err
:=
s
.
usageRepo
.
ListByUserAndTimeRange
(
ctx
,
userID
,
startTime
,
endTime
)
stats
,
err
:=
s
.
usageRepo
.
GetUserStatsAggregated
(
ctx
,
userID
,
startTime
,
endTime
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"
lis
t us
age log
s: %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"
ge
t us
er stat
s: %w"
,
err
)
}
}
return
s
.
calculateStats
(
logs
),
nil
return
&
UsageStats
{
TotalRequests
:
stats
.
TotalRequests
,
TotalInputTokens
:
stats
.
TotalInputTokens
,
TotalOutputTokens
:
stats
.
TotalOutputTokens
,
TotalCacheTokens
:
stats
.
TotalCacheTokens
,
TotalTokens
:
stats
.
TotalTokens
,
TotalCost
:
stats
.
TotalCost
,
TotalActualCost
:
stats
.
TotalActualCost
,
AverageDurationMs
:
stats
.
AverageDurationMs
,
},
nil
}
}
// GetStatsByApiKey 获取API Key的使用统计
// GetStatsByApiKey 获取API Key的使用统计
func
(
s
*
UsageService
)
GetStatsByApiKey
(
ctx
context
.
Context
,
apiKeyID
int64
,
startTime
,
endTime
time
.
Time
)
(
*
UsageStats
,
error
)
{
func
(
s
*
UsageService
)
GetStatsByApiKey
(
ctx
context
.
Context
,
apiKeyID
int64
,
startTime
,
endTime
time
.
Time
)
(
*
UsageStats
,
error
)
{
logs
,
_
,
err
:=
s
.
usageRepo
.
ListByApiKeyAndTimeRange
(
ctx
,
apiKeyID
,
startTime
,
endTime
)
stats
,
err
:=
s
.
usageRepo
.
GetApiKeyStatsAggregated
(
ctx
,
apiKeyID
,
startTime
,
endTime
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"
list usage log
s: %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"
get api key stat
s: %w"
,
err
)
}
}
return
s
.
calculateStats
(
logs
),
nil
return
&
UsageStats
{
TotalRequests
:
stats
.
TotalRequests
,
TotalInputTokens
:
stats
.
TotalInputTokens
,
TotalOutputTokens
:
stats
.
TotalOutputTokens
,
TotalCacheTokens
:
stats
.
TotalCacheTokens
,
TotalTokens
:
stats
.
TotalTokens
,
TotalCost
:
stats
.
TotalCost
,
TotalActualCost
:
stats
.
TotalActualCost
,
AverageDurationMs
:
stats
.
AverageDurationMs
,
},
nil
}
}
// GetStatsByAccount 获取账号的使用统计
// GetStatsByAccount 获取账号的使用统计
...
...
frontend/src/components/common/ConfirmDialog.vue
View file @
0743652d
...
@@ -31,8 +31,12 @@
...
@@ -31,8 +31,12 @@
</template>
</template>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Modal
from
'
./Modal.vue
'
import
Modal
from
'
./Modal.vue
'
const
{
t
}
=
useI18n
()
interface
Props
{
interface
Props
{
show
:
boolean
show
:
boolean
title
:
string
title
:
string
...
@@ -47,12 +51,13 @@ interface Emits {
...
@@ -47,12 +51,13 @@ interface Emits {
(
e
:
'
cancel
'
):
void
(
e
:
'
cancel
'
):
void
}
}
withDefaults
(
defineProps
<
Props
>
(),
{
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
confirmText
:
'
Confirm
'
,
cancelText
:
'
Cancel
'
,
danger
:
false
danger
:
false
})
})
const
confirmText
=
computed
(()
=>
props
.
confirmText
||
t
(
'
common.confirm
'
))
const
cancelText
=
computed
(()
=>
props
.
cancelText
||
t
(
'
common.cancel
'
))
const
emit
=
defineEmits
<
Emits
>
()
const
emit
=
defineEmits
<
Emits
>
()
const
handleConfirm
=
()
=>
{
const
handleConfirm
=
()
=>
{
...
...
frontend/src/components/common/DataTable.vue
View file @
0743652d
...
@@ -152,6 +152,7 @@ const { t } = useI18n()
...
@@ -152,6 +152,7 @@ const { t } = useI18n()
// 表格容器引用
// 表格容器引用
const
tableWrapperRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
tableWrapperRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
isScrollable
=
ref
(
false
)
const
isScrollable
=
ref
(
false
)
const
actionsColumnNeedsExpanding
=
ref
(
false
)
// 检查是否可滚动
// 检查是否可滚动
const
checkScrollable
=
()
=>
{
const
checkScrollable
=
()
=>
{
...
@@ -160,17 +161,49 @@ const checkScrollable = () => {
...
@@ -160,17 +161,49 @@ const checkScrollable = () => {
}
}
}
}
// 检查操作列是否需要展开
const
checkActionsColumnWidth
=
()
=>
{
if
(
!
tableWrapperRef
.
value
)
return
// 查找操作列的表头单元格
const
actionsHeader
=
tableWrapperRef
.
value
.
querySelector
(
'
th:has(button[title*="Expand"], button[title*="展开"])
'
)
if
(
!
actionsHeader
)
return
// 查找第一行的操作列单元格
const
firstActionCell
=
tableWrapperRef
.
value
.
querySelector
(
'
tbody tr:first-child td:last-child
'
)
if
(
!
firstActionCell
)
return
// 获取操作列内容的实际宽度
const
actionsContent
=
firstActionCell
.
querySelector
(
'
div
'
)
if
(
!
actionsContent
)
return
// 比较内容宽度和单元格宽度
const
contentWidth
=
actionsContent
.
scrollWidth
const
cellWidth
=
(
firstActionCell
as
HTMLElement
).
clientWidth
// 如果内容宽度超过单元格宽度,说明需要展开
actionsColumnNeedsExpanding
.
value
=
contentWidth
>
cellWidth
}
// 监听尺寸变化
// 监听尺寸变化
let
resizeObserver
:
ResizeObserver
|
null
=
null
let
resizeObserver
:
ResizeObserver
|
null
=
null
onMounted
(()
=>
{
onMounted
(()
=>
{
checkScrollable
()
checkScrollable
()
checkActionsColumnWidth
()
if
(
tableWrapperRef
.
value
&&
typeof
ResizeObserver
!==
'
undefined
'
)
{
if
(
tableWrapperRef
.
value
&&
typeof
ResizeObserver
!==
'
undefined
'
)
{
resizeObserver
=
new
ResizeObserver
(
checkScrollable
)
resizeObserver
=
new
ResizeObserver
(()
=>
{
checkScrollable
()
checkActionsColumnWidth
()
})
resizeObserver
.
observe
(
tableWrapperRef
.
value
)
resizeObserver
.
observe
(
tableWrapperRef
.
value
)
}
else
{
}
else
{
// 降级方案:不支持 ResizeObserver 时使用 window resize
// 降级方案:不支持 ResizeObserver 时使用 window resize
window
.
addEventListener
(
'
resize
'
,
checkScrollable
)
const
handleResize
=
()
=>
{
checkScrollable
()
checkActionsColumnWidth
()
}
window
.
addEventListener
(
'
resize
'
,
handleResize
)
}
}
})
})
...
@@ -205,6 +238,7 @@ watch(
...
@@ -205,6 +238,7 @@ watch(
async
()
=>
{
async
()
=>
{
await
nextTick
()
await
nextTick
()
checkScrollable
()
checkScrollable
()
checkActionsColumnWidth
()
},
},
{
flush
:
'
post
'
}
{
flush
:
'
post
'
}
)
)
...
@@ -234,7 +268,11 @@ const sortedData = computed(() => {
...
@@ -234,7 +268,11 @@ const sortedData = computed(() => {
// 检查是否有可展开的操作列
// 检查是否有可展开的操作列
const
hasExpandableActions
=
computed
(()
=>
{
const
hasExpandableActions
=
computed
(()
=>
{
return
props
.
expandableActions
&&
props
.
columns
.
some
((
col
)
=>
col
.
key
===
'
actions
'
)
return
(
props
.
expandableActions
&&
props
.
columns
.
some
((
col
)
=>
col
.
key
===
'
actions
'
)
&&
actionsColumnNeedsExpanding
.
value
)
})
})
// 切换操作列展开/折叠状态
// 切换操作列展开/折叠状态
...
...
frontend/src/components/common/EmptyState.vue
View file @
0743652d
...
@@ -25,7 +25,7 @@
...
@@ -25,7 +25,7 @@
<!-- Title -->
<!-- Title -->
<h3
class=
"empty-state-title"
>
<h3
class=
"empty-state-title"
>
{{
t
itle
}}
{{
displayT
itle
}}
</h3>
</h3>
<!-- Description -->
<!-- Description -->
...
@@ -61,8 +61,12 @@
...
@@ -61,8 +61,12 @@
</
template
>
</
template
>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
Component
}
from
'
vue
'
import
type
{
Component
}
from
'
vue
'
const
{
t
}
=
useI18n
()
interface
Props
{
interface
Props
{
icon
?:
Component
|
string
icon
?:
Component
|
string
title
?:
string
title
?:
string
...
@@ -73,11 +77,12 @@ interface Props {
...
@@ -73,11 +77,12 @@ interface Props {
message
?:
string
message
?:
string
}
}
withDefaults
(
defineProps
<
Props
>
(),
{
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
title
:
'
No data found
'
,
description
:
''
,
description
:
''
,
actionIcon
:
true
actionIcon
:
true
})
})
const
displayTitle
=
computed
(()
=>
props
.
title
||
t
(
'
common.noData
'
))
defineEmits
([
'
action
'
])
defineEmits
([
'
action
'
])
</
script
>
</
script
>
frontend/src/components/common/SubscriptionProgressMini.vue
View file @
0743652d
...
@@ -246,7 +246,7 @@ function formatDaysRemaining(expiresAt: string): string {
...
@@ -246,7 +246,7 @@ function formatDaysRemaining(expiresAt: string): string {
const
diff
=
expires
.
getTime
()
-
now
.
getTime
()
const
diff
=
expires
.
getTime
()
-
now
.
getTime
()
if
(
diff
<
0
)
return
t
(
'
subscriptionProgress.expired
'
)
if
(
diff
<
0
)
return
t
(
'
subscriptionProgress.expired
'
)
const
days
=
Math
.
ceil
(
diff
/
(
1000
*
60
*
60
*
24
))
const
days
=
Math
.
ceil
(
diff
/
(
1000
*
60
*
60
*
24
))
if
(
days
===
0
)
return
t
(
'
subscriptionProgress.expires
t
oday
'
)
if
(
days
===
0
)
return
t
(
'
subscriptionProgress.expires
T
oday
'
)
if
(
days
===
1
)
return
t
(
'
subscriptionProgress.expiresTomorrow
'
)
if
(
days
===
1
)
return
t
(
'
subscriptionProgress.expiresTomorrow
'
)
return
t
(
'
subscriptionProgress.daysRemaining
'
,
{
days
}
)
return
t
(
'
subscriptionProgress.daysRemaining
'
,
{
days
}
)
}
}
...
...
frontend/src/i18n/locales/en.ts
View file @
0743652d
...
@@ -52,6 +52,7 @@ export default {
...
@@ -52,6 +52,7 @@ export default {
password
:
'
Password
'
,
password
:
'
Password
'
,
databaseName
:
'
Database Name
'
,
databaseName
:
'
Database Name
'
,
sslMode
:
'
SSL Mode
'
,
sslMode
:
'
SSL Mode
'
,
passwordPlaceholder
:
'
Password
'
,
ssl
:
{
ssl
:
{
disable
:
'
Disable
'
,
disable
:
'
Disable
'
,
require
:
'
Require
'
,
require
:
'
Require
'
,
...
@@ -64,13 +65,17 @@ export default {
...
@@ -64,13 +65,17 @@ export default {
host
:
'
Host
'
,
host
:
'
Host
'
,
port
:
'
Port
'
,
port
:
'
Port
'
,
password
:
'
Password (optional)
'
,
password
:
'
Password (optional)
'
,
database
:
'
Database
'
database
:
'
Database
'
,
passwordPlaceholder
:
'
Password
'
},
},
admin
:
{
admin
:
{
title
:
'
Admin Account
'
,
title
:
'
Admin Account
'
,
email
:
'
Email
'
,
email
:
'
Email
'
,
password
:
'
Password
'
,
password
:
'
Password
'
,
confirmPassword
:
'
Confirm Password
'
confirmPassword
:
'
Confirm Password
'
,
passwordPlaceholder
:
'
Min 6 characters
'
,
confirmPasswordPlaceholder
:
'
Confirm password
'
,
passwordMismatch
:
'
Passwords do not match
'
},
},
ready
:
{
ready
:
{
title
:
'
Ready to Install
'
,
title
:
'
Ready to Install
'
,
...
@@ -127,7 +132,14 @@ export default {
...
@@ -127,7 +132,14 @@ export default {
searchPlaceholder
:
'
Search...
'
,
searchPlaceholder
:
'
Search...
'
,
noOptionsFound
:
'
No options found
'
,
noOptionsFound
:
'
No options found
'
,
saving
:
'
Saving...
'
,
saving
:
'
Saving...
'
,
refresh
:
'
Refresh
'
refresh
:
'
Refresh
'
,
time
:
{
never
:
'
Never
'
,
justNow
:
'
Just now
'
,
minutesAgo
:
'
{n}m ago
'
,
hoursAgo
:
'
{n}h ago
'
,
daysAgo
:
'
{n}d ago
'
}
},
},
// Navigation
// Navigation
...
@@ -263,7 +275,7 @@ export default {
...
@@ -263,7 +275,7 @@ export default {
created
:
'
Created
'
,
created
:
'
Created
'
,
copyToClipboard
:
'
Copy to clipboard
'
,
copyToClipboard
:
'
Copy to clipboard
'
,
copied
:
'
Copied!
'
,
copied
:
'
Copied!
'
,
importToCcSwitch
:
'
Import to CC
Switch
'
,
importToCcSwitch
:
'
Import to CC
S
'
,
enable
:
'
Enable
'
,
enable
:
'
Enable
'
,
disable
:
'
Disable
'
,
disable
:
'
Disable
'
,
nameLabel
:
'
Name
'
,
nameLabel
:
'
Name
'
,
...
@@ -517,6 +529,7 @@ export default {
...
@@ -517,6 +529,7 @@ export default {
actual
:
'
Actual
'
,
actual
:
'
Actual
'
,
standard
:
'
Standard
'
,
standard
:
'
Standard
'
,
noDataAvailable
:
'
No data available
'
,
noDataAvailable
:
'
No data available
'
,
recentUsage
:
'
Recent Usage
'
,
failedToLoad
:
'
Failed to load dashboard statistics
'
failedToLoad
:
'
Failed to load dashboard statistics
'
},
},
...
@@ -569,9 +582,13 @@ export default {
...
@@ -569,9 +582,13 @@ export default {
noSubscription
:
'
No subscription
'
,
noSubscription
:
'
No subscription
'
,
daysRemaining
:
'
{days}d
'
,
daysRemaining
:
'
{days}d
'
,
expired
:
'
Expired
'
,
expired
:
'
Expired
'
,
disable
:
'
Disable
'
,
enable
:
'
Enable
'
,
disableUser
:
'
Disable User
'
,
disableUser
:
'
Disable User
'
,
enableUser
:
'
Enable User
'
,
enableUser
:
'
Enable User
'
,
viewApiKeys
:
'
View API Keys
'
,
viewApiKeys
:
'
View API Keys
'
,
groups
:
'
Groups
'
,
apiKeys
:
'
API Keys
'
,
userApiKeys
:
'
User API Keys
'
,
userApiKeys
:
'
User API Keys
'
,
noApiKeys
:
'
This user has no API keys
'
,
noApiKeys
:
'
This user has no API keys
'
,
group
:
'
Group
'
,
group
:
'
Group
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
0743652d
...
@@ -49,6 +49,7 @@ export default {
...
@@ -49,6 +49,7 @@ export default {
password
:
'
密码
'
,
password
:
'
密码
'
,
databaseName
:
'
数据库名称
'
,
databaseName
:
'
数据库名称
'
,
sslMode
:
'
SSL 模式
'
,
sslMode
:
'
SSL 模式
'
,
passwordPlaceholder
:
'
密码
'
,
ssl
:
{
ssl
:
{
disable
:
'
禁用
'
,
disable
:
'
禁用
'
,
require
:
'
要求
'
,
require
:
'
要求
'
,
...
@@ -61,13 +62,17 @@ export default {
...
@@ -61,13 +62,17 @@ export default {
host
:
'
主机
'
,
host
:
'
主机
'
,
port
:
'
端口
'
,
port
:
'
端口
'
,
password
:
'
密码(可选)
'
,
password
:
'
密码(可选)
'
,
database
:
'
数据库
'
database
:
'
数据库
'
,
passwordPlaceholder
:
'
密码
'
},
},
admin
:
{
admin
:
{
title
:
'
管理员账户
'
,
title
:
'
管理员账户
'
,
email
:
'
邮箱
'
,
email
:
'
邮箱
'
,
password
:
'
密码
'
,
password
:
'
密码
'
,
confirmPassword
:
'
确认密码
'
confirmPassword
:
'
确认密码
'
,
passwordPlaceholder
:
'
至少 6 个字符
'
,
confirmPasswordPlaceholder
:
'
确认密码
'
,
passwordMismatch
:
'
密码不匹配
'
},
},
ready
:
{
ready
:
{
title
:
'
准备安装
'
,
title
:
'
准备安装
'
,
...
@@ -124,7 +129,14 @@ export default {
...
@@ -124,7 +129,14 @@ export default {
searchPlaceholder
:
'
搜索...
'
,
searchPlaceholder
:
'
搜索...
'
,
noOptionsFound
:
'
无匹配选项
'
,
noOptionsFound
:
'
无匹配选项
'
,
saving
:
'
保存中...
'
,
saving
:
'
保存中...
'
,
refresh
:
'
刷新
'
refresh
:
'
刷新
'
,
time
:
{
never
:
'
从未
'
,
justNow
:
'
刚刚
'
,
minutesAgo
:
'
{n}分钟前
'
,
hoursAgo
:
'
{n}小时前
'
,
daysAgo
:
'
{n}天前
'
}
},
},
// Navigation
// Navigation
...
@@ -260,7 +272,7 @@ export default {
...
@@ -260,7 +272,7 @@ export default {
created
:
'
创建时间
'
,
created
:
'
创建时间
'
,
copyToClipboard
:
'
复制到剪贴板
'
,
copyToClipboard
:
'
复制到剪贴板
'
,
copied
:
'
已复制!
'
,
copied
:
'
已复制!
'
,
importToCcSwitch
:
'
导入到 CC
Switch
'
,
importToCcSwitch
:
'
导入到 CC
S
'
,
enable
:
'
启用
'
,
enable
:
'
启用
'
,
disable
:
'
禁用
'
,
disable
:
'
禁用
'
,
nameLabel
:
'
名称
'
,
nameLabel
:
'
名称
'
,
...
@@ -589,9 +601,13 @@ export default {
...
@@ -589,9 +601,13 @@ export default {
noSubscription
:
'
暂无订阅
'
,
noSubscription
:
'
暂无订阅
'
,
daysRemaining
:
'
{days}天
'
,
daysRemaining
:
'
{days}天
'
,
expired
:
'
已过期
'
,
expired
:
'
已过期
'
,
disable
:
'
禁用
'
,
enable
:
'
启用
'
,
disableUser
:
'
禁用用户
'
,
disableUser
:
'
禁用用户
'
,
enableUser
:
'
启用用户
'
,
enableUser
:
'
启用用户
'
,
viewApiKeys
:
'
查看 API 密钥
'
,
viewApiKeys
:
'
查看 API 密钥
'
,
groups
:
'
分组
'
,
apiKeys
:
'
API密钥
'
,
userApiKeys
:
'
用户 API 密钥
'
,
userApiKeys
:
'
用户 API 密钥
'
,
noApiKeys
:
'
此用户暂无 API 密钥
'
,
noApiKeys
:
'
此用户暂无 API 密钥
'
,
group
:
'
分组
'
,
group
:
'
分组
'
,
...
@@ -727,10 +743,13 @@ export default {
...
@@ -727,10 +743,13 @@ export default {
priorityHint
:
'
数值越高优先级越高,用于账号调度
'
,
priorityHint
:
'
数值越高优先级越高,用于账号调度
'
,
statusLabel
:
'
状态
'
statusLabel
:
'
状态
'
},
},
exclusive
:
{
exclusive
Obj
:
{
yes
:
'
是
'
,
yes
:
'
是
'
,
no
:
'
否
'
no
:
'
否
'
},
},
exclusive
:
'
独占
'
,
exclusiveHint
:
'
启用后,此分组的用户将独占使用分配的账号
'
,
rateMultiplierHint
:
'
1.0 = 标准费率,0.5 = 半价,2.0 = 双倍
'
,
platforms
:
{
platforms
:
{
all
:
'
全部平台
'
,
all
:
'
全部平台
'
,
claude
:
'
Claude
'
,
claude
:
'
Claude
'
,
...
@@ -876,6 +895,7 @@ export default {
...
@@ -876,6 +895,7 @@ export default {
deleteConfirmMessage
:
"
确定要删除账号 '{name}' 吗?
"
,
deleteConfirmMessage
:
"
确定要删除账号 '{name}' 吗?
"
,
refreshCookie
:
'
刷新 Cookie
'
,
refreshCookie
:
'
刷新 Cookie
'
,
testAccount
:
'
测试账号
'
,
testAccount
:
'
测试账号
'
,
searchAccounts
:
'
搜索账号...
'
,
// Filter options
// Filter options
allPlatforms
:
'
全部平台
'
,
allPlatforms
:
'
全部平台
'
,
allTypes
:
'
全部类型
'
,
allTypes
:
'
全部类型
'
,
...
@@ -903,6 +923,19 @@ export default {
...
@@ -903,6 +923,19 @@ export default {
lastUsed
:
'
最近使用
'
,
lastUsed
:
'
最近使用
'
,
actions
:
'
操作
'
actions
:
'
操作
'
},
},
clearRateLimit
:
'
清除速率限制
'
,
testConnection
:
'
测试连接
'
,
reAuthorize
:
'
重新授权
'
,
refreshToken
:
'
刷新令牌
'
,
noAccountsYet
:
'
暂无账号
'
,
createFirstAccount
:
'
添加 AI 平台账号以开始使用 API 网关。
'
,
tokenRefreshed
:
'
Token 刷新成功
'
,
accountDeleted
:
'
账号删除成功
'
,
rateLimitCleared
:
'
速率限制已清除
'
,
setupToken
:
'
Setup Token
'
,
apiKey
:
'
API Key
'
,
deleteConfirm
:
"
确定要删除账号 '{name}' 吗?此操作无法撤销。
"
,
failedToClearRateLimit
:
'
清除速率限制失败
'
,
platforms
:
{
platforms
:
{
claude
:
'
Claude
'
,
claude
:
'
Claude
'
,
openai
:
'
OpenAI
'
,
openai
:
'
OpenAI
'
,
...
...
frontend/src/utils/format.ts
View file @
0743652d
...
@@ -3,30 +3,32 @@
...
@@ -3,30 +3,32 @@
* 参考 CRS 项目的 format.js 实现
* 参考 CRS 项目的 format.js 实现
*/
*/
import
{
i18n
}
from
'
@/i18n
'
/**
/**
* 格式化相对时间
* 格式化相对时间
* @param date 日期字符串或 Date 对象
* @param date 日期字符串或 Date 对象
* @returns 相对时间字符串,如 "5m ago", "2h ago", "3d ago"
* @returns 相对时间字符串,如 "5m ago", "2h ago", "3d ago"
*/
*/
export
function
formatRelativeTime
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
export
function
formatRelativeTime
(
date
:
string
|
Date
|
null
|
undefined
):
string
{
if
(
!
date
)
return
'
N
ever
'
if
(
!
date
)
return
i18n
.
global
.
t
(
'
common.time.n
ever
'
)
const
now
=
new
Date
()
const
now
=
new
Date
()
const
past
=
new
Date
(
date
)
const
past
=
new
Date
(
date
)
const
diffMs
=
now
.
getTime
()
-
past
.
getTime
()
const
diffMs
=
now
.
getTime
()
-
past
.
getTime
()
// 处理未来时间或无效日期
// 处理未来时间或无效日期
if
(
diffMs
<
0
||
isNaN
(
diffMs
))
return
'
N
ever
'
if
(
diffMs
<
0
||
isNaN
(
diffMs
))
return
i18n
.
global
.
t
(
'
common.time.n
ever
'
)
const
diffSecs
=
Math
.
floor
(
diffMs
/
1000
)
const
diffSecs
=
Math
.
floor
(
diffMs
/
1000
)
const
diffMins
=
Math
.
floor
(
diffSecs
/
60
)
const
diffMins
=
Math
.
floor
(
diffSecs
/
60
)
const
diffHours
=
Math
.
floor
(
diffMins
/
60
)
const
diffHours
=
Math
.
floor
(
diffMins
/
60
)
const
diffDays
=
Math
.
floor
(
diffHours
/
24
)
const
diffDays
=
Math
.
floor
(
diffHours
/
24
)
if
(
diffDays
>
0
)
return
`
${
diffDays
}
d ago`
if
(
diffDays
>
0
)
return
i18n
.
global
.
t
(
'
common.time.daysAgo
'
,
{
n
:
diffDays
})
if
(
diffHours
>
0
)
return
`
${
diffHours
}
h ago`
if
(
diffHours
>
0
)
return
i18n
.
global
.
t
(
'
common.time.hoursAgo
'
,
{
n
:
diffHours
})
if
(
diffMins
>
0
)
return
`
${
diffMins
}
m ago`
if
(
diffMins
>
0
)
return
i18n
.
global
.
t
(
'
common.time.minutesAgo
'
,
{
n
:
diffMins
})
return
'
J
ust
n
ow
'
return
i18n
.
global
.
t
(
'
common.time.j
ust
N
ow
'
)
}
}
/**
/**
...
...
frontend/src/views/admin/AccountsView.vue
View file @
0743652d
...
@@ -280,8 +280,7 @@
...
@@ -280,8 +280,7 @@
<!--
主要操作
:
编辑和删除
(
始终显示
)
-->
<!--
主要操作
:
编辑和删除
(
始终显示
)
-->
<
button
<
button
@
click
=
"
handleEdit(row)
"
@
click
=
"
handleEdit(row)
"
class
=
"
rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400
"
:
title
=
"
t('common.edit')
"
>
>
<
svg
<
svg
class
=
"
h-4 w-4
"
class
=
"
h-4 w-4
"
...
@@ -296,11 +295,11 @@
...
@@ -296,11 +295,11 @@
d
=
"
M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10
"
d
=
"
M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10
"
/>
/>
<
/svg
>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
common.edit
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<
button
<
button
@
click
=
"
handleDelete(row)
"
@
click
=
"
handleDelete(row)
"
class
=
"
rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
:
title
=
"
t('common.delete')
"
>
>
<
svg
<
svg
class
=
"
h-4 w-4
"
class
=
"
h-4 w-4
"
...
@@ -315,6 +314,7 @@
...
@@ -315,6 +314,7 @@
d
=
"
M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0
"
d
=
"
M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0
"
/>
/>
<
/svg
>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
common.delete
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<!--
次要操作
:
展开时显示
-->
<!--
次要操作
:
展开时显示
-->
...
@@ -323,8 +323,7 @@
...
@@ -323,8 +323,7 @@
<
button
<
button
v
-
if
=
"
row.status === 'error'
"
v
-
if
=
"
row.status === 'error'
"
@
click
=
"
handleResetStatus(row)
"
@
click
=
"
handleResetStatus(row)
"
class
=
"
rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
:
title
=
"
t('admin.accounts.resetStatus')
"
>
>
<
svg
<
svg
class
=
"
h-4 w-4
"
class
=
"
h-4 w-4
"
...
@@ -339,13 +338,13 @@
...
@@ -339,13 +338,13 @@
d
=
"
M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3
"
d
=
"
M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3
"
/>
/>
<
/svg
>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.resetStatus
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<!--
Clear
Rate
Limit
button
-->
<!--
Clear
Rate
Limit
button
-->
<
button
<
button
v
-
if
=
"
isRateLimited(row) || isOverloaded(row)
"
v
-
if
=
"
isRateLimited(row) || isOverloaded(row)
"
@
click
=
"
handleClearRateLimit(row)
"
@
click
=
"
handleClearRateLimit(row)
"
class
=
"
rounded-lg p-2 text-amber-500 transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-400
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-amber-500 transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-400
"
:
title
=
"
t('admin.accounts.clearRateLimit')
"
>
>
<
svg
<
svg
class
=
"
h-4 w-4
"
class
=
"
h-4 w-4
"
...
@@ -360,12 +359,12 @@
...
@@ -360,12 +359,12 @@
d
=
"
M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z
"
d
=
"
M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
/>
<
/svg
>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.clearRateLimit
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<!--
Test
Connection
button
-->
<!--
Test
Connection
button
-->
<
button
<
button
@
click
=
"
handleTest(row)
"
@
click
=
"
handleTest(row)
"
class
=
"
rounded-lg p-2 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400
"
:
title
=
"
t('admin.accounts.testConnection')
"
>
>
<
svg
<
svg
class
=
"
h-4 w-4
"
class
=
"
h-4 w-4
"
...
@@ -380,12 +379,12 @@
...
@@ -380,12 +379,12 @@
d
=
"
M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z
"
d
=
"
M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z
"
/>
/>
<
/svg
>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.testConnection
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<!--
View
Stats
button
-->
<!--
View
Stats
button
-->
<
button
<
button
@
click
=
"
handleViewStats(row)
"
@
click
=
"
handleViewStats(row)
"
class
=
"
rounded-lg p-2 text-gray-500 transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/20 dark:hover:text-indigo-400
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/20 dark:hover:text-indigo-400
"
:
title
=
"
t('admin.accounts.viewStats')
"
>
>
<
svg
<
svg
class
=
"
h-4 w-4
"
class
=
"
h-4 w-4
"
...
@@ -400,12 +399,12 @@
...
@@ -400,12 +399,12 @@
d
=
"
M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z
"
d
=
"
M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z
"
/>
/>
<
/svg
>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.viewStats
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<
button
<
button
v
-
if
=
"
row.type === 'oauth' || row.type === 'setup-token'
"
v
-
if
=
"
row.type === 'oauth' || row.type === 'setup-token'
"
@
click
=
"
handleReAuth(row)
"
@
click
=
"
handleReAuth(row)
"
class
=
"
rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400
"
:
title
=
"
t('admin.accounts.reAuthorize')
"
>
>
<
svg
<
svg
class
=
"
h-4 w-4
"
class
=
"
h-4 w-4
"
...
@@ -420,12 +419,12 @@
...
@@ -420,12 +419,12 @@
d
=
"
M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244
"
d
=
"
M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244
"
/>
/>
<
/svg
>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.reAuthorize
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<
button
<
button
v
-
if
=
"
row.type === 'oauth' || row.type === 'setup-token'
"
v
-
if
=
"
row.type === 'oauth' || row.type === 'setup-token'
"
@
click
=
"
handleRefreshToken(row)
"
@
click
=
"
handleRefreshToken(row)
"
class
=
"
rounded-lg p-2 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400
"
:
title
=
"
t('admin.accounts.refreshToken')
"
>
>
<
svg
<
svg
class
=
"
h-4 w-4
"
class
=
"
h-4 w-4
"
...
@@ -440,6 +439,7 @@
...
@@ -440,6 +439,7 @@
d
=
"
M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99
"
d
=
"
M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99
"
/>
/>
<
/svg
>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.accounts.refreshToken
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<
/template
>
<
/template
>
<
/div
>
<
/div
>
...
...
frontend/src/views/admin/GroupsView.vue
View file @
0743652d
...
@@ -166,8 +166,7 @@
...
@@ -166,8 +166,7 @@
<
div
class
=
"
flex items-center gap-1
"
>
<
div
class
=
"
flex items-center gap-1
"
>
<
button
<
button
@
click
=
"
handleEdit(row)
"
@
click
=
"
handleEdit(row)
"
class
=
"
rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400
"
:
title
=
"
t('common.edit')
"
>
>
<
svg
<
svg
class
=
"
h-4 w-4
"
class
=
"
h-4 w-4
"
...
@@ -182,11 +181,11 @@
...
@@ -182,11 +181,11 @@
d
=
"
M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10
"
d
=
"
M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10
"
/>
/>
<
/svg
>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
common.edit
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<
button
<
button
@
click
=
"
handleDelete(row)
"
@
click
=
"
handleDelete(row)
"
class
=
"
rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
:
title
=
"
t('common.delete')
"
>
>
<
svg
<
svg
class
=
"
h-4 w-4
"
class
=
"
h-4 w-4
"
...
@@ -201,6 +200,7 @@
...
@@ -201,6 +200,7 @@
d
=
"
M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0
"
d
=
"
M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0
"
/>
/>
<
/svg
>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
common.delete
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<
/div
>
<
/div
>
<
/template
>
<
/template
>
...
...
frontend/src/views/admin/ProxiesView.vue
View file @
0743652d
...
@@ -112,8 +112,7 @@
...
@@ -112,8 +112,7 @@
<button
<button
@
click=
"handleTestConnection(row)"
@
click=
"handleTestConnection(row)"
:disabled=
"testingProxyIds.has(row.id)"
:disabled=
"testingProxyIds.has(row.id)"
class=
"rounded-lg p-2 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
:title=
"t('admin.proxies.testConnection')"
>
>
<svg
<svg
v-if=
"testingProxyIds.has(row.id)"
v-if=
"testingProxyIds.has(row.id)"
...
@@ -149,11 +148,11 @@
...
@@ -149,11 +148,11 @@
d=
"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
d=
"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
/>
</svg>
</svg>
<span
class=
"text-xs"
>
{{
t
(
'
admin.proxies.testConnection
'
)
}}
</span>
</button>
</button>
<button
<button
@
click=
"handleEdit(row)"
@
click=
"handleEdit(row)"
class=
"rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
:title=
"t('common.edit')"
>
>
<svg
<svg
class=
"h-4 w-4"
class=
"h-4 w-4"
...
@@ -168,11 +167,11 @@
...
@@ -168,11 +167,11 @@
d=
"M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
d=
"M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
/>
</svg>
</svg>
<span
class=
"text-xs"
>
{{
t
(
'
common.edit
'
)
}}
</span>
</button>
</button>
<button
<button
@
click=
"handleDelete(row)"
@
click=
"handleDelete(row)"
class=
"rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title=
"t('common.delete')"
>
>
<svg
<svg
class=
"h-4 w-4"
class=
"h-4 w-4"
...
@@ -187,6 +186,7 @@
...
@@ -187,6 +186,7 @@
d=
"M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
d=
"M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
/>
</svg>
</svg>
<span
class=
"text-xs"
>
{{
t
(
'
common.delete
'
)
}}
</span>
</button>
</button>
</div>
</div>
</
template
>
</
template
>
...
...
frontend/src/views/admin/RedeemView.vue
View file @
0743652d
...
@@ -161,8 +161,7 @@
...
@@ -161,8 +161,7 @@
<
button
<
button
v
-
if
=
"
row.status === 'unused'
"
v
-
if
=
"
row.status === 'unused'
"
@
click
=
"
handleDelete(row)
"
@
click
=
"
handleDelete(row)
"
class
=
"
rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
:
title
=
"
t('common.delete')
"
>
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
>
<
svg
class
=
"
h-4 w-4
"
fill
=
"
none
"
stroke
=
"
currentColor
"
viewBox
=
"
0 0 24 24
"
>
<
path
<
path
...
@@ -172,6 +171,7 @@
...
@@ -172,6 +171,7 @@
d
=
"
M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16
"
d
=
"
M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16
"
/>
/>
<
/svg
>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
common.delete
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<
span
v
-
else
class
=
"
text-gray-400 dark:text-dark-500
"
>-<
/span
>
<
span
v
-
else
class
=
"
text-gray-400 dark:text-dark-500
"
>-<
/span
>
<
/div
>
<
/div
>
...
...
frontend/src/views/admin/SubscriptionsView.vue
View file @
0743652d
...
@@ -257,8 +257,7 @@
...
@@ -257,8 +257,7 @@
<
button
<
button
v
-
if
=
"
row.status === 'active'
"
v
-
if
=
"
row.status === 'active'
"
@
click
=
"
handleExtend(row)
"
@
click
=
"
handleExtend(row)
"
class
=
"
rounded-lg p-2 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400
"
:
title
=
"
t('admin.subscriptions.extend')
"
>
>
<
svg
<
svg
class
=
"
h-4 w-4
"
class
=
"
h-4 w-4
"
...
@@ -273,12 +272,12 @@
...
@@ -273,12 +272,12 @@
d
=
"
M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z
"
d
=
"
M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z
"
/>
/>
<
/svg
>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.subscriptions.extend
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<
button
<
button
v
-
if
=
"
row.status === 'active'
"
v
-
if
=
"
row.status === 'active'
"
@
click
=
"
handleRevoke(row)
"
@
click
=
"
handleRevoke(row)
"
class
=
"
rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
:
title
=
"
t('admin.subscriptions.revoke')
"
>
>
<
svg
<
svg
class
=
"
h-4 w-4
"
class
=
"
h-4 w-4
"
...
@@ -293,6 +292,7 @@
...
@@ -293,6 +292,7 @@
d
=
"
M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636
"
d
=
"
M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636
"
/>
/>
<
/svg
>
<
/svg
>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.subscriptions.revoke
'
)
}}
<
/span
>
<
/button
>
<
/button
>
<
/div
>
<
/div
>
<
/template
>
<
/template
>
...
...
frontend/src/views/admin/UsageView.vue
View file @
0743652d
...
@@ -455,7 +455,11 @@
...
@@ -455,7 +455,11 @@
$
{{
row
.
actual_cost
.
toFixed
(
6
)
}}
$
{{
row
.
actual_cost
.
toFixed
(
6
)
}}
</span>
</span>
<!-- Cost Detail Tooltip -->
<!-- Cost Detail Tooltip -->
<div
class=
"group relative"
>
<div
class=
"group relative"
@
mouseenter=
"showTooltip($event, row)"
@
mouseleave=
"hideTooltip"
>
<div
<div
class=
"flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
class=
"flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
>
>
...
@@ -471,60 +475,6 @@
...
@@ -471,60 +475,6 @@
/>
/>
</svg>
</svg>
</div>
</div>
<!-- Tooltip Content (right side) -->
<div
class=
"invisible absolute left-full top-1/2 z-[100] ml-2 -translate-y-1/2 opacity-0 transition-all duration-200 group-hover:visible group-hover:opacity-100"
>
<div
class=
"whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
>
<div
class=
"space-y-1.5"
>
<!-- Cost Breakdown -->
<div
class=
"mb-2 border-b border-gray-700 pb-1.5"
>
<div
class=
"text-xs font-semibold text-gray-300 mb-1"
>
成本明细
</div>
<div
v-if=
"row.input_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
admin.usage.inputCost
'
)
}}
</span>
<span
class=
"font-medium text-white"
>
$
{{
row
.
input_cost
.
toFixed
(
6
)
}}
</span>
</div>
<div
v-if=
"row.output_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
admin.usage.outputCost
'
)
}}
</span>
<span
class=
"font-medium text-white"
>
$
{{
row
.
output_cost
.
toFixed
(
6
)
}}
</span>
</div>
<div
v-if=
"row.cache_creation_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
admin.usage.cacheCreationCost
'
)
}}
</span>
<span
class=
"font-medium text-white"
>
$
{{
row
.
cache_creation_cost
.
toFixed
(
6
)
}}
</span>
</div>
<div
v-if=
"row.cache_read_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
admin.usage.cacheReadCost
'
)
}}
</span>
<span
class=
"font-medium text-white"
>
$
{{
row
.
cache_read_cost
.
toFixed
(
6
)
}}
</span>
</div>
</div>
<!-- Rate and Summary -->
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
usage.rate
'
)
}}
</span>
<span
class=
"font-semibold text-blue-400"
>
{{
(
row
.
rate_multiplier
||
1
).
toFixed
(
2
)
}}
x
</span
>
</div>
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
usage.original
'
)
}}
</span>
<span
class=
"font-medium text-white"
>
$
{{
row
.
total_cost
.
toFixed
(
6
)
}}
</span>
</div>
<div
class=
"flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
usage.billed
'
)
}}
</span>
<span
class=
"font-semibold text-green-400"
>
$
{{
row
.
actual_cost
.
toFixed
(
6
)
}}
</span
>
</div>
</div>
<!-- Tooltip Arrow (left side) -->
<div
class=
"absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
></div>
</div>
</div>
</div>
</div>
</div>
</div>
</
template
>
</
template
>
...
@@ -587,6 +537,66 @@
...
@@ -587,6 +537,66 @@
/>
/>
</div>
</div>
</AppLayout>
</AppLayout>
<!-- Tooltip Portal -->
<Teleport
to=
"body"
>
<div
v-if=
"tooltipVisible"
class=
"fixed z-[9999] pointer-events-none -translate-y-1/2"
:style=
"{
left: tooltipPosition.x + 'px',
top: tooltipPosition.y + 'px'
}"
>
<div
class=
"whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
>
<div
class=
"space-y-1.5"
>
<!-- Cost Breakdown -->
<div
class=
"mb-2 border-b border-gray-700 pb-1.5"
>
<div
class=
"text-xs font-semibold text-gray-300 mb-1"
>
成本明细
</div>
<div
v-if=
"tooltipData && tooltipData.input_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.inputCost') }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData.input_cost.toFixed(6) }}
</span>
</div>
<div
v-if=
"tooltipData && tooltipData.output_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.outputCost') }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData.output_cost.toFixed(6) }}
</span>
</div>
<div
v-if=
"tooltipData && tooltipData.cache_creation_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.cacheCreationCost') }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData.cache_creation_cost.toFixed(6) }}
</span>
</div>
<div
v-if=
"tooltipData && tooltipData.cache_read_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.cacheReadCost') }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData.cache_read_cost.toFixed(6) }}
</span>
</div>
</div>
<!-- Rate and Summary -->
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{ t('usage.rate') }}
</span>
<span
class=
"font-semibold text-blue-400"
>
{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x
</span
>
</div>
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{ t('usage.original') }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData?.total_cost.toFixed(6) }}
</span>
</div>
<div
class=
"flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"
>
<span
class=
"text-gray-400"
>
{{ t('usage.billed') }}
</span>
<span
class=
"font-semibold text-green-400"
>
${{ tooltipData?.actual_cost.toFixed(6) }}
</span
>
</div>
</div>
<!-- Tooltip Arrow (left side) -->
<div
class=
"absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
></div>
</div>
</div>
</Teleport>
</template>
</template>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
...
@@ -615,6 +625,11 @@ import type {
...
@@ -615,6 +625,11 @@ import type {
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
appStore
=
useAppStore
()
// Tooltip state
const
tooltipVisible
=
ref
(
false
)
const
tooltipPosition
=
ref
({
x
:
0
,
y
:
0
})
const
tooltipData
=
ref
<
UsageLog
|
null
>
(
null
)
// Usage stats from API
// Usage stats from API
const
usageStats
=
ref
<
AdminUsageStatsResponse
|
null
>
(
null
)
const
usageStats
=
ref
<
AdminUsageStatsResponse
|
null
>
(
null
)
...
@@ -1038,6 +1053,22 @@ const handleClickOutside = (event: MouseEvent) => {
...
@@ -1038,6 +1053,22 @@ const handleClickOutside = (event: MouseEvent) => {
}
}
}
}
// Tooltip functions
const
showTooltip
=
(
event
:
MouseEvent
,
row
:
UsageLog
)
=>
{
const
target
=
event
.
currentTarget
as
HTMLElement
const
rect
=
target
.
getBoundingClientRect
()
tooltipData
.
value
=
row
tooltipPosition
.
value
.
x
=
rect
.
right
+
8
tooltipPosition
.
value
.
y
=
rect
.
top
+
rect
.
height
/
2
tooltipVisible
.
value
=
true
}
const
hideTooltip
=
()
=>
{
tooltipVisible
.
value
=
false
tooltipData
.
value
=
null
}
onMounted
(()
=>
{
onMounted
(()
=>
{
initializeDateRange
()
initializeDateRange
()
loadFilterOptions
()
loadFilterOptions
()
...
...
Prev
1
2
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