mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-02 22:42:14 +08:00
feat(dashboard): add per-user drill-down for group, model, and endpoint distributions
Click on a group name, model name, or endpoint name in the distribution tables to expand and show per-user usage breakdown (requests, tokens, actual cost, standard cost). Backend: new GET /admin/dashboard/user-breakdown API with group_id, model, endpoint, endpoint_type filters. Frontend: clickable rows with expand/collapse sub-table in all three distribution charts.
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -604,3 +605,41 @@ func (h *DashboardHandler) GetBatchAPIKeysUsage(c *gin.Context) {
|
||||
c.Header("X-Snapshot-Cache", "miss")
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
// GetUserBreakdown handles getting per-user usage breakdown within a dimension.
|
||||
// GET /api/v1/admin/dashboard/user-breakdown
|
||||
// Query params: start_date, end_date, group_id, model, endpoint, endpoint_type, limit
|
||||
func (h *DashboardHandler) GetUserBreakdown(c *gin.Context) {
|
||||
startTime, endTime := parseTimeRange(c)
|
||||
|
||||
dim := usagestats.UserBreakdownDimension{}
|
||||
if v := c.Query("group_id"); v != "" {
|
||||
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
dim.GroupID = id
|
||||
}
|
||||
}
|
||||
dim.Model = c.Query("model")
|
||||
dim.Endpoint = c.Query("endpoint")
|
||||
dim.EndpointType = c.DefaultQuery("endpoint_type", "inbound")
|
||||
|
||||
limit := 50
|
||||
if v := c.Query("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := h.dashboardService.GetUserBreakdownStats(
|
||||
c.Request.Context(), startTime, endTime, dim, limit,
|
||||
)
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get user breakdown stats")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"users": stats,
|
||||
"start_date": startTime.Format("2006-01-02"),
|
||||
"end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -345,6 +345,9 @@ func (s *stubUsageLogRepo) GetUpstreamEndpointStatsWithFilters(ctx context.Conte
|
||||
func (s *stubUsageLogRepo) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubUsageLogRepo) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -129,6 +129,24 @@ type UserSpendingRankingResponse struct {
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// UserBreakdownItem represents per-user usage breakdown within a dimension (group, model, endpoint).
|
||||
type UserBreakdownItem struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Requests int64 `json:"requests"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
Cost float64 `json:"cost"` // 标准计费
|
||||
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
||||
}
|
||||
|
||||
// UserBreakdownDimension specifies the dimension to filter for user breakdown.
|
||||
type UserBreakdownDimension struct {
|
||||
GroupID int64 // filter by group_id (>0 to enable)
|
||||
Model string // filter by model name (non-empty to enable)
|
||||
Endpoint string // filter by endpoint value (non-empty to enable)
|
||||
EndpointType string // "inbound", "upstream", or "path"
|
||||
}
|
||||
|
||||
// APIKeyUsageTrendPoint represents API key usage trend data point
|
||||
type APIKeyUsageTrendPoint struct {
|
||||
Date string `json:"date"`
|
||||
|
||||
@@ -3000,6 +3000,85 @@ func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, start
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetUserBreakdownStats returns per-user usage breakdown within a specific dimension.
|
||||
func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) (results []usagestats.UserBreakdownItem, err error) {
|
||||
query := `
|
||||
SELECT
|
||||
COALESCE(ul.user_id, 0) as user_id,
|
||||
COALESCE(u.email, '') as email,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(ul.total_cost), 0) as cost,
|
||||
COALESCE(SUM(ul.actual_cost), 0) as actual_cost
|
||||
FROM usage_logs ul
|
||||
LEFT JOIN users u ON u.id = ul.user_id
|
||||
WHERE ul.created_at >= $1 AND ul.created_at < $2
|
||||
`
|
||||
args := []any{startTime, endTime}
|
||||
|
||||
if dim.GroupID > 0 {
|
||||
query += fmt.Sprintf(" AND ul.group_id = $%d", len(args)+1)
|
||||
args = append(args, dim.GroupID)
|
||||
}
|
||||
if dim.Model != "" {
|
||||
query += fmt.Sprintf(" AND ul.model = $%d", len(args)+1)
|
||||
args = append(args, dim.Model)
|
||||
}
|
||||
if dim.Endpoint != "" {
|
||||
col := resolveEndpointColumn(dim.EndpointType)
|
||||
query += fmt.Sprintf(" AND %s = $%d", col, len(args)+1)
|
||||
args = append(args, dim.Endpoint)
|
||||
}
|
||||
|
||||
query += " GROUP BY ul.user_id, u.email ORDER BY actual_cost DESC"
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
}
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
results = nil
|
||||
}
|
||||
}()
|
||||
|
||||
results = make([]usagestats.UserBreakdownItem, 0)
|
||||
for rows.Next() {
|
||||
var row usagestats.UserBreakdownItem
|
||||
if err := rows.Scan(
|
||||
&row.UserID,
|
||||
&row.Email,
|
||||
&row.Requests,
|
||||
&row.TotalTokens,
|
||||
&row.Cost,
|
||||
&row.ActualCost,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// resolveEndpointColumn maps endpoint type to the corresponding DB column name.
|
||||
func resolveEndpointColumn(endpointType string) string {
|
||||
switch endpointType {
|
||||
case "upstream":
|
||||
return "ul.upstream_endpoint"
|
||||
case "path":
|
||||
return "ul.inbound_endpoint || ' -> ' || ul.upstream_endpoint"
|
||||
default:
|
||||
return "ul.inbound_endpoint"
|
||||
}
|
||||
}
|
||||
|
||||
// GetGlobalStats gets usage statistics for all users within a time range
|
||||
func (r *usageLogRepository) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*UsageStats, error) {
|
||||
query := `
|
||||
|
||||
@@ -1637,6 +1637,10 @@ func (r *stubUsageLogRepo) GetGroupStatsWithFilters(ctx context.Context, startTi
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubUsageLogRepo) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
@@ -198,6 +198,7 @@ func registerDashboardRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
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.GET("/user-breakdown", h.Admin.Dashboard.GetUserBreakdown)
|
||||
dashboard.POST("/aggregation/backfill", h.Admin.Dashboard.BackfillAggregation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ type UsageLogRepository interface {
|
||||
GetEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.EndpointStat, error)
|
||||
GetUpstreamEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.EndpointStat, error)
|
||||
GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error)
|
||||
GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, 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)
|
||||
|
||||
@@ -335,6 +335,14 @@ func (s *DashboardService) GetUserSpendingRanking(ctx context.Context, startTime
|
||||
return ranking, nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) {
|
||||
stats, err := s.usageRepo.GetUserBreakdownStats(ctx, startTime, endTime, dim, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user breakdown stats: %w", err)
|
||||
}
|
||||
return stats, 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 {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
ApiKeyUsageTrendPoint,
|
||||
UserUsageTrendPoint,
|
||||
UserSpendingRankingResponse,
|
||||
UserBreakdownItem,
|
||||
UsageRequestType
|
||||
} from '@/types'
|
||||
|
||||
@@ -156,6 +157,29 @@ export async function getGroupStats(params?: GroupStatsParams): Promise<GroupSta
|
||||
return data
|
||||
}
|
||||
|
||||
export interface UserBreakdownParams {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
group_id?: number
|
||||
model?: string
|
||||
endpoint?: string
|
||||
endpoint_type?: 'inbound' | 'upstream' | 'path'
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface UserBreakdownResponse {
|
||||
users: UserBreakdownItem[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
|
||||
export async function getUserBreakdown(params: UserBreakdownParams): Promise<UserBreakdownResponse> {
|
||||
const { data } = await apiClient.get<UserBreakdownResponse>('/admin/dashboard/user-breakdown', {
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard snapshot v2 (aggregated response for heavy admin pages).
|
||||
*/
|
||||
|
||||
@@ -87,27 +87,40 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in displayEndpointStats"
|
||||
:key="item.endpoint"
|
||||
class="border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<td class="max-w-[180px] truncate py-1.5 font-medium text-gray-900 dark:text-white" :title="item.endpoint">
|
||||
{{ item.endpoint }}
|
||||
</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.total_tokens) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
|
||||
${{ formatCost(item.actual_cost) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
|
||||
${{ formatCost(item.cost) }}
|
||||
</td>
|
||||
</tr>
|
||||
<template v-for="item in displayEndpointStats" :key="item.endpoint">
|
||||
<tr
|
||||
class="border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
|
||||
@click="toggleBreakdown(item.endpoint)"
|
||||
>
|
||||
<td class="max-w-[180px] truncate py-1.5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" :title="item.endpoint">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg v-if="expandedKey === item.endpoint" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
<svg v-else class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
{{ item.endpoint }}
|
||||
</span>
|
||||
</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.total_tokens) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
|
||||
${{ formatCost(item.actual_cost) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
|
||||
${{ formatCost(item.cost) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedKey === item.endpoint">
|
||||
<td colspan="5" class="p-0">
|
||||
<UserBreakdownSubTable
|
||||
:items="breakdownItems"
|
||||
:loading="breakdownLoading"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -119,12 +132,14 @@
|
||||
</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 { EndpointStat } from '@/types'
|
||||
import UserBreakdownSubTable from './UserBreakdownSubTable.vue'
|
||||
import type { EndpointStat, UserBreakdownItem } from '@/types'
|
||||
import { getUserBreakdown } from '@/api/admin/dashboard'
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
|
||||
@@ -144,6 +159,8 @@ const props = withDefaults(
|
||||
source?: EndpointSource
|
||||
showMetricToggle?: boolean
|
||||
showSourceToggle?: boolean
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}>(),
|
||||
{
|
||||
upstreamEndpointStats: () => [],
|
||||
@@ -162,6 +179,33 @@ const emit = defineEmits<{
|
||||
'update:source': [value: EndpointSource]
|
||||
}>()
|
||||
|
||||
const expandedKey = ref<string | null>(null)
|
||||
const breakdownItems = ref<UserBreakdownItem[]>([])
|
||||
const breakdownLoading = ref(false)
|
||||
|
||||
const toggleBreakdown = async (endpoint: string) => {
|
||||
if (expandedKey.value === endpoint) {
|
||||
expandedKey.value = null
|
||||
return
|
||||
}
|
||||
expandedKey.value = endpoint
|
||||
breakdownLoading.value = true
|
||||
breakdownItems.value = []
|
||||
try {
|
||||
const res = await getUserBreakdown({
|
||||
start_date: props.startDate,
|
||||
end_date: props.endDate,
|
||||
endpoint,
|
||||
endpoint_type: props.source,
|
||||
})
|
||||
breakdownItems.value = res.users || []
|
||||
} catch {
|
||||
breakdownItems.value = []
|
||||
} finally {
|
||||
breakdownLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const chartColors = [
|
||||
'#3b82f6',
|
||||
'#10b981',
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<div class="h-48 w-48">
|
||||
<Doughnut :data="chartData" :options="doughnutOptions" />
|
||||
</div>
|
||||
<div class="max-h-48 flex-1 overflow-y-auto">
|
||||
<div class="max-h-64 flex-1 overflow-y-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-gray-500 dark:text-gray-400">
|
||||
@@ -49,30 +49,46 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="group in displayGroupStats"
|
||||
:key="group.group_id"
|
||||
class="border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<td
|
||||
class="max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
|
||||
:title="group.group_name || String(group.group_id)"
|
||||
<template v-for="group in displayGroupStats" :key="group.group_id">
|
||||
<tr
|
||||
class="border-t border-gray-100 transition-colors dark:border-gray-700"
|
||||
:class="group.group_id > 0 ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40' : ''"
|
||||
@click="group.group_id > 0 && toggleBreakdown('group', group.group_id)"
|
||||
>
|
||||
{{ group.group_name || t('admin.dashboard.noGroup') }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{{ formatNumber(group.requests) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{{ formatTokens(group.total_tokens) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
|
||||
${{ formatCost(group.actual_cost) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
|
||||
${{ formatCost(group.cost) }}
|
||||
</td>
|
||||
</tr>
|
||||
<td
|
||||
class="max-w-[100px] truncate py-1.5 font-medium"
|
||||
:class="group.group_id > 0 ? 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300' : 'text-gray-900 dark:text-white'"
|
||||
:title="group.group_name || String(group.group_id)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg v-if="group.group_id > 0 && expandedKey === `group-${group.group_id}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
<svg v-else-if="group.group_id > 0" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
{{ group.group_name || t('admin.dashboard.noGroup') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{{ formatNumber(group.requests) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{{ formatTokens(group.total_tokens) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
|
||||
${{ formatCost(group.actual_cost) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
|
||||
${{ formatCost(group.cost) }}
|
||||
</td>
|
||||
</tr>
|
||||
<!-- User breakdown sub-rows -->
|
||||
<tr v-if="expandedKey === `group-${group.group_id}`">
|
||||
<td colspan="5" class="p-0">
|
||||
<UserBreakdownSubTable
|
||||
:items="breakdownItems"
|
||||
:loading="breakdownLoading"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -87,12 +103,14 @@
|
||||
</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 { GroupStat } from '@/types'
|
||||
import UserBreakdownSubTable from './UserBreakdownSubTable.vue'
|
||||
import type { GroupStat, UserBreakdownItem } from '@/types'
|
||||
import { getUserBreakdown } from '@/api/admin/dashboard'
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
|
||||
@@ -105,6 +123,8 @@ const props = withDefaults(defineProps<{
|
||||
loading?: boolean
|
||||
metric?: DistributionMetric
|
||||
showMetricToggle?: boolean
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}>(), {
|
||||
loading: false,
|
||||
metric: 'tokens',
|
||||
@@ -115,6 +135,33 @@ const emit = defineEmits<{
|
||||
'update:metric': [value: DistributionMetric]
|
||||
}>()
|
||||
|
||||
const expandedKey = ref<string | null>(null)
|
||||
const breakdownItems = ref<UserBreakdownItem[]>([])
|
||||
const breakdownLoading = ref(false)
|
||||
|
||||
const toggleBreakdown = async (type: string, id: number | string) => {
|
||||
const key = `${type}-${id}`
|
||||
if (expandedKey.value === key) {
|
||||
expandedKey.value = null
|
||||
return
|
||||
}
|
||||
expandedKey.value = key
|
||||
breakdownLoading.value = true
|
||||
breakdownItems.value = []
|
||||
try {
|
||||
const res = await getUserBreakdown({
|
||||
start_date: props.startDate,
|
||||
end_date: props.endDate,
|
||||
group_id: Number(id),
|
||||
})
|
||||
breakdownItems.value = res.users || []
|
||||
} catch {
|
||||
breakdownItems.value = []
|
||||
} finally {
|
||||
breakdownLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const chartColors = [
|
||||
'#3b82f6',
|
||||
'#10b981',
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<div class="h-48 w-48">
|
||||
<Doughnut :data="chartData" :options="doughnutOptions" />
|
||||
</div>
|
||||
<div class="max-h-48 flex-1 overflow-y-auto">
|
||||
<div class="max-h-64 flex-1 overflow-y-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-gray-500 dark:text-gray-400">
|
||||
@@ -83,30 +83,43 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="model in displayModelStats"
|
||||
:key="model.model"
|
||||
class="border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<td
|
||||
class="max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
|
||||
:title="model.model"
|
||||
<template v-for="model in displayModelStats" :key="model.model">
|
||||
<tr
|
||||
class="border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
|
||||
@click="toggleBreakdown('model', model.model)"
|
||||
>
|
||||
{{ model.model }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{{ formatNumber(model.requests) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{{ formatTokens(model.total_tokens) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
|
||||
${{ formatCost(model.actual_cost) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
|
||||
${{ formatCost(model.cost) }}
|
||||
</td>
|
||||
</tr>
|
||||
<td
|
||||
class="max-w-[100px] truncate py-1.5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
:title="model.model"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg v-if="expandedKey === `model-${model.model}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
<svg v-else class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
{{ model.model }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{{ formatNumber(model.requests) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{{ formatTokens(model.total_tokens) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
|
||||
${{ formatCost(model.actual_cost) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
|
||||
${{ formatCost(model.cost) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedKey === `model-${model.model}`">
|
||||
<td colspan="5" class="p-0">
|
||||
<UserBreakdownSubTable
|
||||
:items="breakdownItems"
|
||||
:loading="breakdownLoading"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -193,7 +206,9 @@ 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, UserSpendingRankingItem } from '@/types'
|
||||
import UserBreakdownSubTable from './UserBreakdownSubTable.vue'
|
||||
import type { ModelStat, UserSpendingRankingItem, UserBreakdownItem } from '@/types'
|
||||
import { getUserBreakdown } from '@/api/admin/dashboard'
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
|
||||
@@ -213,6 +228,8 @@ const props = withDefaults(defineProps<{
|
||||
showMetricToggle?: boolean
|
||||
rankingLoading?: boolean
|
||||
rankingError?: boolean
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}>(), {
|
||||
enableRankingView: false,
|
||||
rankingItems: () => [],
|
||||
@@ -226,6 +243,33 @@ const props = withDefaults(defineProps<{
|
||||
rankingError: false
|
||||
})
|
||||
|
||||
const expandedKey = ref<string | null>(null)
|
||||
const breakdownItems = ref<UserBreakdownItem[]>([])
|
||||
const breakdownLoading = ref(false)
|
||||
|
||||
const toggleBreakdown = async (type: string, id: string) => {
|
||||
const key = `${type}-${id}`
|
||||
if (expandedKey.value === key) {
|
||||
expandedKey.value = null
|
||||
return
|
||||
}
|
||||
expandedKey.value = key
|
||||
breakdownLoading.value = true
|
||||
breakdownItems.value = []
|
||||
try {
|
||||
const res = await getUserBreakdown({
|
||||
start_date: props.startDate,
|
||||
end_date: props.endDate,
|
||||
model: id,
|
||||
})
|
||||
breakdownItems.value = res.users || []
|
||||
} catch {
|
||||
breakdownItems.value = []
|
||||
} finally {
|
||||
breakdownLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:metric': [value: DistributionMetric]
|
||||
'ranking-click': [item: UserSpendingRankingItem]
|
||||
|
||||
71
frontend/src/components/charts/UserBreakdownSubTable.vue
Normal file
71
frontend/src/components/charts/UserBreakdownSubTable.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="bg-gray-50/50 dark:bg-dark-700/30">
|
||||
<div v-if="loading" class="flex items-center justify-center py-3">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<div v-else-if="items.length === 0" class="py-2 text-center text-xs text-gray-400">
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
<table v-else class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-gray-400 dark:text-gray-500">
|
||||
<th class="py-1 pl-6 text-left">{{ t('admin.dashboard.spendingRankingUser') }}</th>
|
||||
<th class="py-1 text-right">{{ t('admin.dashboard.requests') }}</th>
|
||||
<th class="py-1 text-right">{{ t('admin.dashboard.tokens') }}</th>
|
||||
<th class="py-1 text-right">{{ t('admin.dashboard.actual') }}</th>
|
||||
<th class="py-1 pr-1 text-right">{{ t('admin.dashboard.standard') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="user in items"
|
||||
:key="user.user_id"
|
||||
class="border-t border-gray-100/50 dark:border-gray-700/50"
|
||||
>
|
||||
<td class="max-w-[120px] truncate py-1 pl-6 text-gray-600 dark:text-gray-300" :title="user.email">
|
||||
{{ user.email || `User #${user.user_id}` }}
|
||||
</td>
|
||||
<td class="py-1 text-right text-gray-500 dark:text-gray-400">
|
||||
{{ user.requests.toLocaleString() }}
|
||||
</td>
|
||||
<td class="py-1 text-right text-gray-500 dark:text-gray-400">
|
||||
{{ formatTokens(user.total_tokens) }}
|
||||
</td>
|
||||
<td class="py-1 text-right text-green-600 dark:text-green-400">
|
||||
${{ formatCost(user.actual_cost) }}
|
||||
</td>
|
||||
<td class="py-1 pr-1 text-right text-gray-400 dark:text-gray-500">
|
||||
${{ formatCost(user.cost) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import type { UserBreakdownItem } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
items: UserBreakdownItem[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(2)}K`
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatCost = (value: number): string => {
|
||||
if (value >= 1000) return (value / 1000).toFixed(2) + 'K'
|
||||
if (value >= 1) return value.toFixed(2)
|
||||
if (value >= 0.01) return value.toFixed(3)
|
||||
return value.toFixed(4)
|
||||
}
|
||||
</script>
|
||||
@@ -1202,6 +1202,15 @@ export interface GroupStat {
|
||||
actual_cost: number // 实际扣除
|
||||
}
|
||||
|
||||
export interface UserBreakdownItem {
|
||||
user_id: number
|
||||
email: string
|
||||
requests: number
|
||||
total_tokens: number
|
||||
cost: number
|
||||
actual_cost: number
|
||||
}
|
||||
|
||||
export interface UserUsageTrendPoint {
|
||||
date: string
|
||||
user_id: number
|
||||
|
||||
@@ -246,6 +246,8 @@
|
||||
:loading="chartsLoading"
|
||||
:ranking-loading="rankingLoading"
|
||||
:ranking-error="rankingError"
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
@ranking-click="goToUserUsage"
|
||||
/>
|
||||
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
||||
|
||||
@@ -18,12 +18,16 @@
|
||||
:model-stats="modelStats"
|
||||
:loading="chartsLoading"
|
||||
:show-metric-toggle="true"
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
/>
|
||||
<GroupDistributionChart
|
||||
v-model:metric="groupDistributionMetric"
|
||||
:group-stats="groupStats"
|
||||
:loading="chartsLoading"
|
||||
:show-metric-toggle="true"
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
@@ -37,6 +41,8 @@
|
||||
:show-source-toggle="true"
|
||||
:show-metric-toggle="true"
|
||||
:title="t('usage.endpointDistribution')"
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
/>
|
||||
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user