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
4588258d
Unverified
Commit
4588258d
authored
Mar 13, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 13, 2026
Browse files
Merge pull request #960 from 0xObjc/codex/user-spending-ranking
feat(admin): add user spending ranking dashboard view
parents
c12e48f9
80d8d6c3
Changes
17
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/dashboard_handler.go
View file @
4588258d
...
...
@@ -466,9 +466,60 @@ type BatchUsersUsageRequest struct {
UserIDs
[]
int64
`json:"user_ids" binding:"required"`
}
var
dashboardUsersRankingCache
=
newSnapshotCache
(
5
*
time
.
Minute
)
var
dashboardBatchUsersUsageCache
=
newSnapshotCache
(
30
*
time
.
Second
)
var
dashboardBatchAPIKeysUsageCache
=
newSnapshotCache
(
30
*
time
.
Second
)
func
parseRankingLimit
(
raw
string
)
int
{
limit
,
err
:=
strconv
.
Atoi
(
strings
.
TrimSpace
(
raw
))
if
err
!=
nil
||
limit
<=
0
{
return
12
}
if
limit
>
50
{
return
50
}
return
limit
}
// GetUserSpendingRanking handles getting user spending ranking data.
// GET /api/v1/admin/dashboard/users-ranking
func
(
h
*
DashboardHandler
)
GetUserSpendingRanking
(
c
*
gin
.
Context
)
{
startTime
,
endTime
:=
parseTimeRange
(
c
)
limit
:=
parseRankingLimit
(
c
.
DefaultQuery
(
"limit"
,
"12"
))
keyRaw
,
_
:=
json
.
Marshal
(
struct
{
Start
string
`json:"start"`
End
string
`json:"end"`
Limit
int
`json:"limit"`
}{
Start
:
startTime
.
UTC
()
.
Format
(
time
.
RFC3339
),
End
:
endTime
.
UTC
()
.
Format
(
time
.
RFC3339
),
Limit
:
limit
,
})
cacheKey
:=
string
(
keyRaw
)
if
cached
,
ok
:=
dashboardUsersRankingCache
.
Get
(
cacheKey
);
ok
{
c
.
Header
(
"X-Snapshot-Cache"
,
"hit"
)
response
.
Success
(
c
,
cached
.
Payload
)
return
}
ranking
,
err
:=
h
.
dashboardService
.
GetUserSpendingRanking
(
c
.
Request
.
Context
(),
startTime
,
endTime
,
limit
)
if
err
!=
nil
{
response
.
Error
(
c
,
500
,
"Failed to get user spending ranking"
)
return
}
payload
:=
gin
.
H
{
"ranking"
:
ranking
.
Ranking
,
"total_actual_cost"
:
ranking
.
TotalActualCost
,
"start_date"
:
startTime
.
Format
(
"2006-01-02"
),
"end_date"
:
endTime
.
Add
(
-
24
*
time
.
Hour
)
.
Format
(
"2006-01-02"
),
}
dashboardUsersRankingCache
.
Set
(
cacheKey
,
payload
)
c
.
Header
(
"X-Snapshot-Cache"
,
"miss"
)
response
.
Success
(
c
,
payload
)
}
// GetBatchUsersUsage handles getting usage stats for multiple users
// POST /api/v1/admin/dashboard/users-usage
func
(
h
*
DashboardHandler
)
GetBatchUsersUsage
(
c
*
gin
.
Context
)
{
...
...
backend/internal/handler/admin/dashboard_handler_request_type_test.go
View file @
4588258d
...
...
@@ -19,6 +19,9 @@ type dashboardUsageRepoCapture struct {
trendStream
*
bool
modelRequestType
*
int16
modelStream
*
bool
rankingLimit
int
ranking
[]
usagestats
.
UserSpendingRankingItem
rankingTotal
float64
}
func
(
s
*
dashboardUsageRepoCapture
)
GetUsageTrendWithFilters
(
...
...
@@ -49,6 +52,18 @@ func (s *dashboardUsageRepoCapture) GetModelStatsWithFilters(
return
[]
usagestats
.
ModelStat
{},
nil
}
func
(
s
*
dashboardUsageRepoCapture
)
GetUserSpendingRanking
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
limit
int
,
)
(
*
usagestats
.
UserSpendingRankingResponse
,
error
)
{
s
.
rankingLimit
=
limit
return
&
usagestats
.
UserSpendingRankingResponse
{
Ranking
:
s
.
ranking
,
TotalActualCost
:
s
.
rankingTotal
,
},
nil
}
func
newDashboardRequestTypeTestRouter
(
repo
*
dashboardUsageRepoCapture
)
*
gin
.
Engine
{
gin
.
SetMode
(
gin
.
TestMode
)
dashboardSvc
:=
service
.
NewDashboardService
(
repo
,
nil
,
nil
,
nil
)
...
...
@@ -56,6 +71,7 @@ func newDashboardRequestTypeTestRouter(repo *dashboardUsageRepoCapture) *gin.Eng
router
:=
gin
.
New
()
router
.
GET
(
"/admin/dashboard/trend"
,
handler
.
GetUsageTrend
)
router
.
GET
(
"/admin/dashboard/models"
,
handler
.
GetModelStats
)
router
.
GET
(
"/admin/dashboard/users-ranking"
,
handler
.
GetUserSpendingRanking
)
return
router
}
...
...
@@ -130,3 +146,30 @@ func TestDashboardModelStatsInvalidStream(t *testing.T) {
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
rec
.
Code
)
}
func
TestDashboardUsersRankingLimitAndCache
(
t
*
testing
.
T
)
{
dashboardUsersRankingCache
=
newSnapshotCache
(
5
*
time
.
Minute
)
repo
:=
&
dashboardUsageRepoCapture
{
ranking
:
[]
usagestats
.
UserSpendingRankingItem
{
{
UserID
:
7
,
Email
:
"rank@example.com"
,
ActualCost
:
10.5
,
Requests
:
3
,
Tokens
:
300
},
},
rankingTotal
:
88.8
,
}
router
:=
newDashboardRequestTypeTestRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/dashboard/users-ranking?limit=100&start_date=2025-01-01&end_date=2025-01-02"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Equal
(
t
,
50
,
repo
.
rankingLimit
)
require
.
Contains
(
t
,
rec
.
Body
.
String
(),
"
\"
total_actual_cost
\"
:88.8"
)
require
.
Equal
(
t
,
"miss"
,
rec
.
Header
()
.
Get
(
"X-Snapshot-Cache"
))
req2
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/dashboard/users-ranking?limit=100&start_date=2025-01-01&end_date=2025-01-02"
,
nil
)
rec2
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec2
,
req2
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec2
.
Code
)
require
.
Equal
(
t
,
"hit"
,
rec2
.
Header
()
.
Get
(
"X-Snapshot-Cache"
))
}
backend/internal/handler/sora_gateway_handler_test.go
View file @
4588258d
...
...
@@ -343,6 +343,9 @@ func (s *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, e
func
(
s
*
stubUsageLogRepo
)
GetUserUsageTrend
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
granularity
string
,
limit
int
)
([]
usagestats
.
UserUsageTrendPoint
,
error
)
{
return
nil
,
nil
}
func
(
s
*
stubUsageLogRepo
)
GetUserSpendingRanking
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
limit
int
)
(
*
usagestats
.
UserSpendingRankingResponse
,
error
)
{
return
nil
,
nil
}
func
(
s
*
stubUsageLogRepo
)
GetBatchUserUsageStats
(
ctx
context
.
Context
,
userIDs
[]
int64
,
startTime
,
endTime
time
.
Time
)
(
map
[
int64
]
*
usagestats
.
BatchUserUsageStats
,
error
)
{
return
nil
,
nil
}
...
...
backend/internal/pkg/usagestats/usage_log_types.go
View file @
4588258d
...
...
@@ -103,6 +103,21 @@ type UserUsageTrendPoint struct {
ActualCost
float64
`json:"actual_cost"`
// 实际扣除
}
// UserSpendingRankingItem represents a user spending ranking row.
type
UserSpendingRankingItem
struct
{
UserID
int64
`json:"user_id"`
Email
string
`json:"email"`
ActualCost
float64
`json:"actual_cost"`
// 实际扣除
Requests
int64
`json:"requests"`
Tokens
int64
`json:"tokens"`
}
// UserSpendingRankingResponse represents ranking rows plus total spend for the time range.
type
UserSpendingRankingResponse
struct
{
Ranking
[]
UserSpendingRankingItem
`json:"ranking"`
TotalActualCost
float64
`json:"total_actual_cost"`
}
// APIKeyUsageTrendPoint represents API key usage trend data point
type
APIKeyUsageTrendPoint
struct
{
Date
string
`json:"date"`
...
...
backend/internal/repository/usage_log_repo.go
View file @
4588258d
...
...
@@ -1993,6 +1993,10 @@ type ModelStat = usagestats.ModelStat
// UserUsageTrendPoint represents user usage trend data point
type
UserUsageTrendPoint
=
usagestats
.
UserUsageTrendPoint
// UserSpendingRankingItem represents a user spending ranking row.
type
UserSpendingRankingItem
=
usagestats
.
UserSpendingRankingItem
type
UserSpendingRankingResponse
=
usagestats
.
UserSpendingRankingResponse
// APIKeyUsageTrendPoint represents API key usage trend data point
type
APIKeyUsageTrendPoint
=
usagestats
.
APIKeyUsageTrendPoint
...
...
@@ -2109,6 +2113,78 @@ func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, e
return
results
,
nil
}
// GetUserSpendingRanking returns user spending ranking aggregated within the time range.
func
(
r
*
usageLogRepository
)
GetUserSpendingRanking
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
limit
int
)
(
result
*
UserSpendingRankingResponse
,
err
error
)
{
if
limit
<=
0
{
limit
=
12
}
query
:=
`
WITH user_spend AS (
SELECT
u.user_id,
COALESCE(us.email, '') as email,
COALESCE(SUM(u.actual_cost), 0) as actual_cost,
COUNT(*) as requests,
COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens
FROM usage_logs u
LEFT JOIN users us ON u.user_id = us.id
WHERE u.created_at >= $1 AND u.created_at < $2
GROUP BY u.user_id, us.email
),
ranked AS (
SELECT
user_id,
email,
actual_cost,
requests,
tokens,
COALESCE(SUM(actual_cost) OVER (), 0) as total_actual_cost
FROM user_spend
ORDER BY actual_cost DESC, tokens DESC, user_id ASC
LIMIT $3
)
SELECT
user_id,
email,
actual_cost,
requests,
tokens,
total_actual_cost
FROM ranked
ORDER BY actual_cost DESC, tokens DESC, user_id ASC
`
rows
,
err
:=
r
.
sql
.
QueryContext
(
ctx
,
query
,
startTime
,
endTime
,
limit
)
if
err
!=
nil
{
return
nil
,
err
}
defer
func
()
{
if
closeErr
:=
rows
.
Close
();
closeErr
!=
nil
&&
err
==
nil
{
err
=
closeErr
result
=
nil
}
}()
ranking
:=
make
([]
UserSpendingRankingItem
,
0
)
totalActualCost
:=
0.0
for
rows
.
Next
()
{
var
row
UserSpendingRankingItem
if
err
=
rows
.
Scan
(
&
row
.
UserID
,
&
row
.
Email
,
&
row
.
ActualCost
,
&
row
.
Requests
,
&
row
.
Tokens
,
&
totalActualCost
);
err
!=
nil
{
return
nil
,
err
}
ranking
=
append
(
ranking
,
row
)
}
if
err
=
rows
.
Err
();
err
!=
nil
{
return
nil
,
err
}
return
&
UserSpendingRankingResponse
{
Ranking
:
ranking
,
TotalActualCost
:
totalActualCost
,
},
nil
}
// UserDashboardStats 用户仪表盘统计
type
UserDashboardStats
=
usagestats
.
UserDashboardStats
...
...
backend/internal/repository/usage_log_repo_request_type_test.go
View file @
4588258d
...
...
@@ -248,6 +248,35 @@ func TestUsageLogRepositoryGetStatsWithFiltersRequestTypePriority(t *testing.T)
require
.
NoError
(
t
,
mock
.
ExpectationsWereMet
())
}
func
TestUsageLogRepositoryGetUserSpendingRanking
(
t
*
testing
.
T
)
{
db
,
mock
:=
newSQLMock
(
t
)
repo
:=
&
usageLogRepository
{
sql
:
db
}
start
:=
time
.
Date
(
2025
,
1
,
1
,
0
,
0
,
0
,
0
,
time
.
UTC
)
end
:=
start
.
Add
(
24
*
time
.
Hour
)
rows
:=
sqlmock
.
NewRows
([]
string
{
"user_id"
,
"email"
,
"actual_cost"
,
"requests"
,
"tokens"
,
"total_actual_cost"
})
.
AddRow
(
int64
(
2
),
"beta@example.com"
,
12.5
,
int64
(
9
),
int64
(
900
),
40.0
)
.
AddRow
(
int64
(
1
),
"alpha@example.com"
,
12.5
,
int64
(
8
),
int64
(
800
),
40.0
)
.
AddRow
(
int64
(
3
),
"gamma@example.com"
,
4.25
,
int64
(
5
),
int64
(
300
),
40.0
)
mock
.
ExpectQuery
(
"WITH user_spend AS
\\
("
)
.
WithArgs
(
start
,
end
,
12
)
.
WillReturnRows
(
rows
)
got
,
err
:=
repo
.
GetUserSpendingRanking
(
context
.
Background
(),
start
,
end
,
12
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
&
usagestats
.
UserSpendingRankingResponse
{
Ranking
:
[]
usagestats
.
UserSpendingRankingItem
{
{
UserID
:
2
,
Email
:
"beta@example.com"
,
ActualCost
:
12.5
,
Requests
:
9
,
Tokens
:
900
},
{
UserID
:
1
,
Email
:
"alpha@example.com"
,
ActualCost
:
12.5
,
Requests
:
8
,
Tokens
:
800
},
{
UserID
:
3
,
Email
:
"gamma@example.com"
,
ActualCost
:
4.25
,
Requests
:
5
,
Tokens
:
300
},
},
TotalActualCost
:
40.0
,
},
got
)
require
.
NoError
(
t
,
mock
.
ExpectationsWereMet
())
}
func
TestBuildRequestTypeFilterConditionLegacyFallback
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
...
...
backend/internal/server/api_contract_test.go
View file @
4588258d
...
...
@@ -1635,6 +1635,10 @@ func (r *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, end
return
nil
,
errors
.
New
(
"not implemented"
)
}
func
(
r
*
stubUsageLogRepo
)
GetUserSpendingRanking
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
limit
int
)
(
*
usagestats
.
UserSpendingRankingResponse
,
error
)
{
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
{
...
...
backend/internal/server/routes/admin.go
View file @
4588258d
...
...
@@ -192,6 +192,7 @@ func registerDashboardRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
dashboard
.
GET
(
"/groups"
,
h
.
Admin
.
Dashboard
.
GetGroupStats
)
dashboard
.
GET
(
"/api-keys-trend"
,
h
.
Admin
.
Dashboard
.
GetAPIKeyUsageTrend
)
dashboard
.
GET
(
"/users-trend"
,
h
.
Admin
.
Dashboard
.
GetUserUsageTrend
)
dashboard
.
GET
(
"/users-ranking"
,
h
.
Admin
.
Dashboard
.
GetUserSpendingRanking
)
dashboard
.
POST
(
"/users-usage"
,
h
.
Admin
.
Dashboard
.
GetBatchUsersUsage
)
dashboard
.
POST
(
"/api-keys-usage"
,
h
.
Admin
.
Dashboard
.
GetBatchAPIKeysUsage
)
dashboard
.
POST
(
"/aggregation/backfill"
,
h
.
Admin
.
Dashboard
.
BackfillAggregation
)
...
...
backend/internal/service/account_usage_service.go
View file @
4588258d
...
...
@@ -47,6 +47,7 @@ type UsageLogRepository interface {
GetGroupStatsWithFilters
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
userID
,
apiKeyID
,
accountID
,
groupID
int64
,
requestType
*
int16
,
stream
*
bool
,
billingType
*
int8
)
([]
usagestats
.
GroupStat
,
error
)
GetAPIKeyUsageTrend
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
granularity
string
,
limit
int
)
([]
usagestats
.
APIKeyUsageTrendPoint
,
error
)
GetUserUsageTrend
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
granularity
string
,
limit
int
)
([]
usagestats
.
UserUsageTrendPoint
,
error
)
GetUserSpendingRanking
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
limit
int
)
(
*
usagestats
.
UserSpendingRankingResponse
,
error
)
GetBatchUserUsageStats
(
ctx
context
.
Context
,
userIDs
[]
int64
,
startTime
,
endTime
time
.
Time
)
(
map
[
int64
]
*
usagestats
.
BatchUserUsageStats
,
error
)
GetBatchAPIKeyUsageStats
(
ctx
context
.
Context
,
apiKeyIDs
[]
int64
,
startTime
,
endTime
time
.
Time
)
(
map
[
int64
]
*
usagestats
.
BatchAPIKeyUsageStats
,
error
)
...
...
backend/internal/service/dashboard_service.go
View file @
4588258d
...
...
@@ -327,6 +327,14 @@ func (s *DashboardService) GetUserUsageTrend(ctx context.Context, startTime, end
return
trend
,
nil
}
func
(
s
*
DashboardService
)
GetUserSpendingRanking
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
limit
int
)
(
*
usagestats
.
UserSpendingRankingResponse
,
error
)
{
ranking
,
err
:=
s
.
usageRepo
.
GetUserSpendingRanking
(
ctx
,
startTime
,
endTime
,
limit
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"get user spending ranking: %w"
,
err
)
}
return
ranking
,
nil
}
func
(
s
*
DashboardService
)
GetBatchUserUsageStats
(
ctx
context
.
Context
,
userIDs
[]
int64
,
startTime
,
endTime
time
.
Time
)
(
map
[
int64
]
*
usagestats
.
BatchUserUsageStats
,
error
)
{
stats
,
err
:=
s
.
usageRepo
.
GetBatchUserUsageStats
(
ctx
,
userIDs
,
startTime
,
endTime
)
if
err
!=
nil
{
...
...
frontend/src/api/admin/dashboard.ts
View file @
4588258d
...
...
@@ -11,6 +11,7 @@ import type {
GroupStat
,
ApiKeyUsageTrendPoint
,
UserUsageTrendPoint
,
UserSpendingRankingResponse
,
UsageRequestType
}
from
'
@/types
'
...
...
@@ -201,6 +202,11 @@ export interface UserTrendResponse {
granularity
:
string
}
export
interface
UserSpendingRankingParams
extends
Pick
<
TrendParams
,
'
start_date
'
|
'
end_date
'
>
{
limit
?:
number
}
/**
* Get user usage trend data
* @param params - Query parameters for filtering
...
...
@@ -213,6 +219,20 @@ export async function getUserUsageTrend(params?: UserTrendParams): Promise<UserT
return
data
}
/**
* Get user spending ranking data
* @param params - Query parameters for filtering
* @returns User spending ranking data
*/
export
async
function
getUserSpendingRanking
(
params
?:
UserSpendingRankingParams
):
Promise
<
UserSpendingRankingResponse
>
{
const
{
data
}
=
await
apiClient
.
get
<
UserSpendingRankingResponse
>
(
'
/admin/dashboard/users-ranking
'
,
{
params
})
return
data
}
export
interface
BatchUserUsageStats
{
user_id
:
number
today_actual_cost
:
number
...
...
@@ -271,6 +291,7 @@ export const dashboardAPI = {
getSnapshotV2
,
getApiKeyUsageTrend
,
getUserUsageTrend
,
getUserSpendingRanking
,
getBatchUsersUsage
,
getBatchApiKeysUsage
}
...
...
frontend/src/components/charts/ModelDistributionChart.vue
View file @
4588258d
...
...
@@ -2,38 +2,72 @@
<div
class=
"card p-4"
>
<div
class=
"mb-4 flex items-center justify-between gap-3"
>
<h3
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
admin.dashboard.modelDistribution
'
)
}}
{{
!
enableRankingView
||
activeView
===
'
model_distribution
'
?
t
(
'
admin.dashboard.modelDistribution
'
)
:
t
(
'
admin.dashboard.spendingRankingTitle
'
)
}}
</h3>
<div
v-if=
"showMetricToggle"
class=
"inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
>
<button
type=
"button"
class=
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class=
"metric === 'tokens'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@
click=
"emit('update:metric', 'tokens')"
<div
class=
"flex items-center gap-2"
>
<div
v-if=
"showMetricToggle"
class=
"inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
>
{{
t
(
'
admin.dashboard.metricTokens
'
)
}}
</button>
<button
type=
"button"
class=
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class=
"metric === 'actual_cost'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@
click=
"emit('update:metric', 'actual_cost')"
>
{{
t
(
'
admin.dashboard.metricActualCost
'
)
}}
</button>
<button
type=
"button"
class=
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class=
"metric === 'tokens'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@
click=
"emit('update:metric', 'tokens')"
>
{{
t
(
'
admin.dashboard.metricTokens
'
)
}}
</button>
<button
type=
"button"
class=
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class=
"metric === 'actual_cost'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@
click=
"emit('update:metric', 'actual_cost')"
>
{{
t
(
'
admin.dashboard.metricActualCost
'
)
}}
</button>
</div>
<div
v-if=
"enableRankingView"
class=
"inline-flex rounded-lg bg-gray-100 p-1 dark:bg-dark-800"
>
<button
type=
"button"
class=
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class=
"
activeView === 'model_distribution'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
"
@
click=
"activeView = 'model_distribution'"
>
{{
t
(
'
admin.dashboard.viewModelDistribution
'
)
}}
</button>
<button
type=
"button"
class=
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class=
"
activeView === 'spending_ranking'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
"
@
click=
"activeView = 'spending_ranking'"
>
{{
t
(
'
admin.dashboard.viewSpendingRanking
'
)
}}
</button>
</div>
</div>
</div>
<div
v-if=
"loading"
class=
"flex h-48 items-center justify-center"
>
<div
v-if=
"activeView === 'model_distribution' && loading"
class=
"flex h-48 items-center justify-center"
>
<LoadingSpinner
/>
</div>
<div
v-else-if=
"displayModelStats.length > 0 && chartData"
class=
"flex items-center gap-6"
>
<div
v-else-if=
"activeView === 'model_distribution' && displayModelStats.length > 0 && chartData"
class=
"flex items-center gap-6"
>
<div
class=
"h-48 w-48"
>
<Doughnut
:data=
"chartData"
:options=
"doughnutOptions"
/>
</div>
...
...
@@ -77,6 +111,70 @@
</table>
</div>
</div>
<div
v-else-if=
"activeView === 'model_distribution'"
class=
"flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.dashboard.noDataAvailable
'
)
}}
</div>
<div
v-else-if=
"rankingLoading"
class=
"flex h-48 items-center justify-center"
>
<LoadingSpinner
/>
</div>
<div
v-else-if=
"rankingError"
class=
"flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.dashboard.failedToLoad
'
)
}}
</div>
<div
v-else-if=
"rankingItems.length > 0 && rankingChartData"
class=
"flex items-center gap-6"
>
<div
class=
"h-48 w-48"
>
<Doughnut
:data=
"rankingChartData"
:options=
"rankingDoughnutOptions"
/>
</div>
<div
class=
"max-h-48 flex-1 overflow-y-auto"
>
<table
class=
"w-full text-xs"
>
<thead>
<tr
class=
"text-gray-500 dark:text-gray-400"
>
<th
class=
"pb-2 text-left"
>
{{
t
(
'
admin.dashboard.spendingRankingUser
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.spendingRankingRequests
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.spendingRankingTokens
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.spendingRankingSpend
'
)
}}
</th>
</tr>
</thead>
<tbody>
<tr
v-for=
"(item, index) in rankingItems"
:key=
"`$
{item.user_id}-${index}`"
class="cursor-pointer border-t border-gray-100 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
@click="emit('ranking-click', item)"
>
<td
class=
"py-1.5"
>
<div
class=
"flex min-w-0 items-center gap-2"
>
<span
class=
"shrink-0 text-[11px] font-semibold text-gray-500 dark:text-gray-400"
>
#
{{
index
+
1
}}
</span>
<span
class=
"block max-w-[140px] truncate font-medium text-gray-900 dark:text-white"
:title=
"getRankingUserLabel(item)"
>
{{
getRankingUserLabel
(
item
)
}}
</span>
</div>
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatNumber
(
item
.
requests
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatTokens
(
item
.
tokens
)
}}
</td>
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
item
.
actual_cost
)
}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
v-else
class=
"flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
...
...
@@ -87,34 +185,47 @@
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
computed
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
Chart
as
ChartJS
,
ArcElement
,
Tooltip
,
Legend
}
from
'
chart.js
'
import
{
Doughnut
}
from
'
vue-chartjs
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
type
{
ModelStat
}
from
'
@/types
'
import
type
{
ModelStat
,
UserSpendingRankingItem
}
from
'
@/types
'
ChartJS
.
register
(
ArcElement
,
Tooltip
,
Legend
)
const
{
t
}
=
useI18n
()
type
DistributionMetric
=
'
tokens
'
|
'
actual_cost
'
const
props
=
withDefaults
(
defineProps
<
{
modelStats
:
ModelStat
[]
enableRankingView
?:
boolean
rankingItems
?:
UserSpendingRankingItem
[]
rankingTotalActualCost
?:
number
loading
?:
boolean
metric
?:
DistributionMetric
showMetricToggle
?:
boolean
rankingLoading
?:
boolean
rankingError
?:
boolean
}
>
(),
{
enableRankingView
:
false
,
rankingItems
:
()
=>
[],
rankingTotalActualCost
:
0
,
loading
:
false
,
metric
:
'
tokens
'
,
showMetricToggle
:
false
,
rankingLoading
:
false
,
rankingError
:
false
})
const
emit
=
defineEmits
<
{
'
update:metric
'
:
[
value
:
DistributionMetric
]
'
ranking-click
'
:
[
item
:
UserSpendingRankingItem
]
}
>
()
const
enableRankingView
=
computed
(()
=>
props
.
enableRankingView
)
const
activeView
=
ref
<
'
model_distribution
'
|
'
spending_ranking
'
>
(
'
model_distribution
'
)
const
chartColors
=
[
'
#3b82f6
'
,
'
#10b981
'
,
...
...
@@ -125,7 +236,9 @@ const chartColors = [
'
#14b8a6
'
,
'
#f97316
'
,
'
#6366f1
'
,
'
#84cc16
'
'
#84cc16
'
,
'
#06b6d4
'
,
'
#a855f7
'
]
const
displayModelStats
=
computed
(()
=>
{
...
...
@@ -150,6 +263,31 @@ const chartData = computed(() => {
}
})
const
rankingChartData
=
computed
(()
=>
{
if
(
!
props
.
rankingItems
?.
length
)
return
null
const
rankedTotal
=
props
.
rankingItems
.
reduce
((
sum
,
item
)
=>
sum
+
item
.
actual_cost
,
0
)
const
otherActualCost
=
Math
.
max
((
props
.
rankingTotalActualCost
||
0
)
-
rankedTotal
,
0
)
const
labels
=
props
.
rankingItems
.
map
((
item
,
index
)
=>
`#
${
index
+
1
}
${
getRankingUserLabel
(
item
)}
`
)
const
data
=
props
.
rankingItems
.
map
((
item
)
=>
item
.
actual_cost
)
if
(
otherActualCost
>
0.000001
)
{
labels
.
push
(
t
(
'
admin.dashboard.spendingRankingOther
'
))
data
.
push
(
otherActualCost
)
}
return
{
labels
,
datasets
:
[
{
data
,
backgroundColor
:
chartColors
.
slice
(
0
,
data
.
length
),
borderWidth
:
0
}
]
}
})
const
doughnutOptions
=
computed
(()
=>
({
responsive
:
true
,
maintainAspectRatio
:
false
,
...
...
@@ -173,6 +311,26 @@ const doughnutOptions = computed(() => ({
}
}))
const
rankingDoughnutOptions
=
computed
(()
=>
({
responsive
:
true
,
maintainAspectRatio
:
false
,
plugins
:
{
legend
:
{
display
:
false
},
tooltip
:
{
callbacks
:
{
label
:
(
context
:
any
)
=>
{
const
value
=
context
.
raw
as
number
const
total
=
context
.
dataset
.
data
.
reduce
((
a
:
number
,
b
:
number
)
=>
a
+
b
,
0
)
const
percentage
=
total
>
0
?
((
value
/
total
)
*
100
).
toFixed
(
1
)
:
'
0.0
'
return
`
${
context
.
label
}
: $
${
formatCost
(
value
)}
(
${
percentage
}
%)`
}
}
}
}
}))
const
formatTokens
=
(
value
:
number
):
string
=>
{
if
(
value
>=
1
_000_000_000
)
{
return
`
${(
value
/
1
_000_000_000
).
toFixed
(
2
)}
B`
...
...
@@ -188,6 +346,11 @@ const formatNumber = (value: number): string => {
return
value
.
toLocaleString
()
}
const
getRankingUserLabel
=
(
item
:
UserSpendingRankingItem
):
string
=>
{
if
(
item
.
email
)
return
item
.
email
return
t
(
'
admin.redeem.userPrefix
'
,
{
id
:
item
.
user_id
})
}
const
formatCost
=
(
value
:
number
):
string
=>
{
if
(
value
>=
1000
)
{
return
(
value
/
1000
).
toFixed
(
2
)
+
'
K
'
...
...
frontend/src/i18n/locales/en.ts
View file @
4588258d
...
...
@@ -963,6 +963,18 @@ export default {
standard
:
'
Standard
'
,
noDataAvailable
:
'
No data available
'
,
recentUsage
:
'
Recent Usage
'
,
viewModelDistribution
:
'
Model Distribution
'
,
viewSpendingRanking
:
'
User Spending Ranking
'
,
spendingRankingTitle
:
'
User Spending Ranking
'
,
spendingRankingUser
:
'
User
'
,
spendingRankingRequests
:
'
Requests
'
,
spendingRankingTokens
:
'
Tokens
'
,
spendingRankingSpend
:
'
Spend
'
,
spendingRankingOther
:
'
Others
'
,
spendingRankingUsage
:
'
Usage
'
,
spendShort
:
'
Spend
'
,
requestsShort
:
'
Req
'
,
tokensShort
:
'
Tok
'
,
failedToLoad
:
'
Failed to load dashboard statistics
'
},
...
...
frontend/src/i18n/locales/zh.ts
View file @
4588258d
...
...
@@ -974,6 +974,18 @@ export default {
tokens
:
'
Token
'
,
cache
:
'
缓存
'
,
recentUsage
:
'
最近使用
'
,
viewModelDistribution
:
'
模型分布
'
,
viewSpendingRanking
:
'
用户消费榜
'
,
spendingRankingTitle
:
'
用户消费榜
'
,
spendingRankingUser
:
'
用户
'
,
spendingRankingRequests
:
'
请求
'
,
spendingRankingTokens
:
'
Token
'
,
spendingRankingSpend
:
'
消费
'
,
spendingRankingOther
:
'
其他
'
,
spendingRankingUsage
:
'
用量
'
,
spendShort
:
'
消费
'
,
requestsShort
:
'
请求
'
,
tokensShort
:
'
Token
'
,
last7Days
:
'
近 7 天
'
,
noUsageRecords
:
'
暂无使用记录
'
,
startUsingApi
:
'
开始使用 API 后,使用历史将显示在这里。
'
,
...
...
frontend/src/types/index.ts
View file @
4588258d
...
...
@@ -1162,6 +1162,21 @@ export interface UserUsageTrendPoint {
actual_cost
:
number
// 实际扣除
}
export
interface
UserSpendingRankingItem
{
user_id
:
number
email
:
string
actual_cost
:
number
requests
:
number
tokens
:
number
}
export
interface
UserSpendingRankingResponse
{
ranking
:
UserSpendingRankingItem
[]
total_actual_cost
:
number
start_date
:
string
end_date
:
string
}
export
interface
ApiKeyUsageTrendPoint
{
date
:
string
api_key_id
:
number
...
...
frontend/src/views/admin/DashboardView.vue
View file @
4588258d
...
...
@@ -236,7 +236,16 @@
<!-- Charts Grid -->
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<ModelDistributionChart
:model-stats=
"modelStats"
:loading=
"chartsLoading"
/>
<ModelDistributionChart
:model-stats=
"modelStats"
:enable-ranking-view=
"true"
:ranking-items=
"rankingItems"
:ranking-total-actual-cost=
"rankingTotalActualCost"
:loading=
"chartsLoading"
:ranking-loading=
"rankingLoading"
:ranking-error=
"rankingError"
@
ranking-click=
"goToUserUsage"
/>
<TokenUsageTrend
:trend-data=
"trendData"
:loading=
"chartsLoading"
/>
</div>
...
...
@@ -267,11 +276,18 @@
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useRouter
}
from
'
vue-router
'
import
{
useAppStore
}
from
'
@/stores/app
'
const
{
t
}
=
useI18n
()
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
DashboardStats
,
TrendDataPoint
,
ModelStat
,
UserUsageTrendPoint
}
from
'
@/types
'
import
type
{
DashboardStats
,
TrendDataPoint
,
ModelStat
,
UserUsageTrendPoint
,
UserSpendingRankingItem
}
from
'
@/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
...
...
@@ -286,7 +302,6 @@ import {
LinearScale
,
PointElement
,
LineElement
,
Title
,
Tooltip
,
Legend
,
Filler
...
...
@@ -299,24 +314,30 @@ ChartJS.register(
LinearScale
,
PointElement
,
LineElement
,
Title
,
Tooltip
,
Legend
,
Filler
)
const
appStore
=
useAppStore
()
const
router
=
useRouter
()
const
stats
=
ref
<
DashboardStats
|
null
>
(
null
)
const
loading
=
ref
(
false
)
const
chartsLoading
=
ref
(
false
)
const
userTrendLoading
=
ref
(
false
)
const
rankingLoading
=
ref
(
false
)
const
rankingError
=
ref
(
false
)
// Chart data
const
trendData
=
ref
<
TrendDataPoint
[]
>
([])
const
modelStats
=
ref
<
ModelStat
[]
>
([])
const
userTrend
=
ref
<
UserUsageTrendPoint
[]
>
([])
const
rankingItems
=
ref
<
UserSpendingRankingItem
[]
>
([])
const
rankingTotalActualCost
=
ref
(
0
)
let
chartLoadSeq
=
0
let
usersTrendLoadSeq
=
0
let
rankingLoadSeq
=
0
const
rankingLimit
=
12
// Helper function to format date in local timezone
const
formatLocalDate
=
(
date
:
Date
):
string
=>
{
...
...
@@ -505,6 +526,17 @@ const formatDuration = (ms: number): string => {
return
`
${
Math
.
round
(
ms
)}
ms`
}
const
goToUserUsage
=
(
item
:
UserSpendingRankingItem
)
=>
{
void
router
.
push
({
path
:
'
/admin/usage
'
,
query
:
{
user_id
:
String
(
item
.
user_id
),
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
}
})
}
// Date range change handler
const
onDateRangeChange
=
(
range
:
{
startDate
:
string
...
...
@@ -585,14 +617,46 @@ const loadUsersTrend = async () => {
}
}
const
loadUserSpendingRanking
=
async
()
=>
{
const
currentSeq
=
++
rankingLoadSeq
rankingLoading
.
value
=
true
rankingError
.
value
=
false
try
{
const
response
=
await
adminAPI
.
dashboard
.
getUserSpendingRanking
({
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
,
limit
:
rankingLimit
})
if
(
currentSeq
!==
rankingLoadSeq
)
return
rankingItems
.
value
=
response
.
ranking
||
[]
rankingTotalActualCost
.
value
=
response
.
total_actual_cost
||
0
}
catch
(
error
)
{
if
(
currentSeq
!==
rankingLoadSeq
)
return
console
.
error
(
'
Error loading user spending ranking:
'
,
error
)
rankingItems
.
value
=
[]
rankingTotalActualCost
.
value
=
0
rankingError
.
value
=
true
}
finally
{
if
(
currentSeq
===
rankingLoadSeq
)
{
rankingLoading
.
value
=
false
}
}
}
const
loadDashboardStats
=
async
()
=>
{
await
loadDashboardSnapshot
(
true
)
void
loadUsersTrend
()
await
Promise
.
all
([
loadDashboardSnapshot
(
true
),
loadUsersTrend
(),
loadUserSpendingRanking
()
])
}
const
loadChartData
=
async
()
=>
{
await
loadDashboardSnapshot
(
false
)
void
loadUsersTrend
()
await
Promise
.
all
([
loadDashboardSnapshot
(
false
),
loadUsersTrend
(),
loadUserSpendingRanking
()
])
}
onMounted
(()
=>
{
...
...
frontend/src/views/admin/UsageView.vue
View file @
4588258d
...
...
@@ -89,6 +89,7 @@
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
saveAs
}
from
'
file-saver
'
import
{
useRoute
}
from
'
vue-router
'
import
{
useAppStore
}
from
'
@/stores/app
'
;
import
{
adminAPI
}
from
'
@/api/admin
'
;
import
{
adminUsageAPI
}
from
'
@/api/admin/usage
'
import
{
formatReasoningEffort
}
from
'
@/utils/format
'
import
{
resolveUsageRequestType
,
requestTypeToLegacyStream
}
from
'
@/utils/usageRequestType
'
...
...
@@ -104,7 +105,7 @@ import type { AdminUsageLog, TrendDataPoint, ModelStat, GroupStat, AdminUser } f
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
type
DistributionMetric
=
'
tokens
'
|
'
actual_cost
'
const
route
=
useRoute
()
const
usageStats
=
ref
<
AdminUsageStatsResponse
|
null
>
(
null
);
const
usageLogs
=
ref
<
AdminUsageLog
[]
>
([]);
const
loading
=
ref
(
false
);
const
exporting
=
ref
(
false
)
const
trendData
=
ref
<
TrendDataPoint
[]
>
([]);
const
modelStats
=
ref
<
ModelStat
[]
>
([]);
const
groupStats
=
ref
<
GroupStat
[]
>
([]);
const
chartsLoading
=
ref
(
false
);
const
granularity
=
ref
<
'
day
'
|
'
hour
'
>
(
'
day
'
)
const
modelDistributionMetric
=
ref
<
DistributionMetric
>
(
'
tokens
'
)
...
...
@@ -140,6 +141,38 @@ const startDate = ref(getTodayLocalDate()); const endDate = ref(getTodayLocalDat
const
filters
=
ref
<
AdminUsageQueryParams
>
({
user_id
:
undefined
,
model
:
undefined
,
group_id
:
undefined
,
request_type
:
undefined
,
billing_type
:
null
,
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
})
const
pagination
=
reactive
({
page
:
1
,
page_size
:
20
,
total
:
0
})
const
getSingleQueryValue
=
(
value
:
string
|
null
|
Array
<
string
|
null
>
|
undefined
):
string
|
undefined
=>
{
if
(
Array
.
isArray
(
value
))
return
value
.
find
((
item
):
item
is
string
=>
typeof
item
===
'
string
'
&&
item
.
length
>
0
)
return
typeof
value
===
'
string
'
&&
value
.
length
>
0
?
value
:
undefined
}
const
getNumericQueryValue
=
(
value
:
string
|
null
|
Array
<
string
|
null
>
|
undefined
):
number
|
undefined
=>
{
const
raw
=
getSingleQueryValue
(
value
)
if
(
!
raw
)
return
undefined
const
parsed
=
Number
(
raw
)
return
Number
.
isFinite
(
parsed
)
?
parsed
:
undefined
}
const
applyRouteQueryFilters
=
()
=>
{
const
queryStartDate
=
getSingleQueryValue
(
route
.
query
.
start_date
)
const
queryEndDate
=
getSingleQueryValue
(
route
.
query
.
end_date
)
const
queryUserId
=
getNumericQueryValue
(
route
.
query
.
user_id
)
if
(
queryStartDate
)
{
startDate
.
value
=
queryStartDate
}
if
(
queryEndDate
)
{
endDate
.
value
=
queryEndDate
}
filters
.
value
=
{
...
filters
.
value
,
user_id
:
queryUserId
,
start_date
:
startDate
.
value
,
end_date
:
endDate
.
value
}
}
const
loadLogs
=
async
()
=>
{
abortController
?.
abort
();
const
c
=
new
AbortController
();
abortController
=
c
;
loading
.
value
=
true
try
{
...
...
@@ -329,6 +362,7 @@ const handleColumnClickOutside = (event: MouseEvent) => {
}
onMounted
(()
=>
{
applyRouteQueryFilters
()
loadLogs
()
loadStats
()
window
.
setTimeout
(()
=>
{
...
...
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