mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-03 06:52:13 +08:00
fix(admin): polish spending ranking and usage defaults
This commit is contained in:
@@ -512,6 +512,8 @@ func (h *DashboardHandler) GetUserSpendingRanking(c *gin.Context) {
|
||||
payload := gin.H{
|
||||
"ranking": ranking.Ranking,
|
||||
"total_actual_cost": ranking.TotalActualCost,
|
||||
"total_requests": ranking.TotalRequests,
|
||||
"total_tokens": ranking.TotalTokens,
|
||||
"start_date": startTime.Format("2006-01-02"),
|
||||
"end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ func (s *dashboardUsageRepoCapture) GetUserSpendingRanking(
|
||||
return &usagestats.UserSpendingRankingResponse{
|
||||
Ranking: s.ranking,
|
||||
TotalActualCost: s.rankingTotal,
|
||||
TotalRequests: 44,
|
||||
TotalTokens: 1234,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -164,6 +166,8 @@ func TestDashboardUsersRankingLimitAndCache(t *testing.T) {
|
||||
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.Contains(t, rec.Body.String(), "\"total_requests\":44")
|
||||
require.Contains(t, rec.Body.String(), "\"total_tokens\":1234")
|
||||
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)
|
||||
|
||||
@@ -116,6 +116,8 @@ type UserSpendingRankingItem struct {
|
||||
type UserSpendingRankingResponse struct {
|
||||
Ranking []UserSpendingRankingItem `json:"ranking"`
|
||||
TotalActualCost float64 `json:"total_actual_cost"`
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// APIKeyUsageTrendPoint represents API key usage trend data point
|
||||
|
||||
@@ -2139,7 +2139,9 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
|
||||
actual_cost,
|
||||
requests,
|
||||
tokens,
|
||||
COALESCE(SUM(actual_cost) OVER (), 0) as total_actual_cost
|
||||
COALESCE(SUM(actual_cost) OVER (), 0) as total_actual_cost,
|
||||
COALESCE(SUM(requests) OVER (), 0) as total_requests,
|
||||
COALESCE(SUM(tokens) OVER (), 0) as total_tokens
|
||||
FROM user_spend
|
||||
ORDER BY actual_cost DESC, tokens DESC, user_id ASC
|
||||
LIMIT $3
|
||||
@@ -2150,7 +2152,9 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
|
||||
actual_cost,
|
||||
requests,
|
||||
tokens,
|
||||
total_actual_cost
|
||||
total_actual_cost,
|
||||
total_requests,
|
||||
total_tokens
|
||||
FROM ranked
|
||||
ORDER BY actual_cost DESC, tokens DESC, user_id ASC
|
||||
`
|
||||
@@ -2168,9 +2172,11 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
|
||||
|
||||
ranking := make([]UserSpendingRankingItem, 0)
|
||||
totalActualCost := 0.0
|
||||
totalRequests := int64(0)
|
||||
totalTokens := int64(0)
|
||||
for rows.Next() {
|
||||
var row UserSpendingRankingItem
|
||||
if err = rows.Scan(&row.UserID, &row.Email, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost); err != nil {
|
||||
if err = rows.Scan(&row.UserID, &row.Email, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost, &totalRequests, &totalTokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ranking = append(ranking, row)
|
||||
@@ -2182,6 +2188,8 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
|
||||
return &UserSpendingRankingResponse{
|
||||
Ranking: ranking,
|
||||
TotalActualCost: totalActualCost,
|
||||
TotalRequests: totalRequests,
|
||||
TotalTokens: totalTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -255,10 +255,10 @@ func TestUsageLogRepositoryGetUserSpendingRanking(t *testing.T) {
|
||||
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)
|
||||
rows := sqlmock.NewRows([]string{"user_id", "email", "actual_cost", "requests", "tokens", "total_actual_cost", "total_requests", "total_tokens"}).
|
||||
AddRow(int64(2), "beta@example.com", 12.5, int64(9), int64(900), 40.0, int64(30), int64(2600)).
|
||||
AddRow(int64(1), "alpha@example.com", 12.5, int64(8), int64(800), 40.0, int64(30), int64(2600)).
|
||||
AddRow(int64(3), "gamma@example.com", 4.25, int64(5), int64(300), 40.0, int64(30), int64(2600))
|
||||
|
||||
mock.ExpectQuery("WITH user_spend AS \\(").
|
||||
WithArgs(start, end, 12).
|
||||
@@ -273,6 +273,8 @@ func TestUsageLogRepositoryGetUserSpendingRanking(t *testing.T) {
|
||||
{UserID: 3, Email: "gamma@example.com", ActualCost: 4.25, Requests: 5, Tokens: 300},
|
||||
},
|
||||
TotalActualCost: 40.0,
|
||||
TotalRequests: 30,
|
||||
TotalTokens: 2600,
|
||||
}, got)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
>
|
||||
{{ t('admin.dashboard.failedToLoad') }}
|
||||
</div>
|
||||
<div v-else-if="rankingItems.length > 0 && rankingChartData" class="flex items-center gap-6">
|
||||
<div v-else-if="rankingDisplayItems.length > 0 && rankingChartData" class="flex items-center gap-6">
|
||||
<div class="h-48 w-48">
|
||||
<Doughnut :data="rankingChartData" :options="rankingDoughnutOptions" />
|
||||
</div>
|
||||
@@ -143,21 +143,24 @@
|
||||
</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)"
|
||||
v-for="(item, index) in rankingDisplayItems"
|
||||
:key="item.isOther ? 'others' : `${item.user_id}-${index}`"
|
||||
class="border-t border-gray-100 transition-colors dark:border-gray-700"
|
||||
:class="item.isOther
|
||||
? 'bg-gray-50/70 dark:bg-dark-700/20'
|
||||
: 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40'"
|
||||
@click="item.isOther ? undefined : 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 }}
|
||||
{{ item.isOther ? 'Σ' : `#${index + 1}` }}
|
||||
</span>
|
||||
<span
|
||||
class="block max-w-[140px] truncate font-medium text-gray-900 dark:text-white"
|
||||
:title="getRankingUserLabel(item)"
|
||||
:title="getRankingRowLabel(item)"
|
||||
>
|
||||
{{ getRankingUserLabel(item) }}
|
||||
{{ getRankingRowLabel(item) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
@@ -197,11 +200,14 @@ ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
const { t } = useI18n()
|
||||
|
||||
type DistributionMetric = 'tokens' | 'actual_cost'
|
||||
type RankingDisplayItem = UserSpendingRankingItem & { isOther?: boolean }
|
||||
const props = withDefaults(defineProps<{
|
||||
modelStats: ModelStat[]
|
||||
enableRankingView?: boolean
|
||||
rankingItems?: UserSpendingRankingItem[]
|
||||
rankingTotalActualCost?: number
|
||||
rankingTotalRequests?: number
|
||||
rankingTotalTokens?: number
|
||||
loading?: boolean
|
||||
metric?: DistributionMetric
|
||||
showMetricToggle?: boolean
|
||||
@@ -211,6 +217,8 @@ const props = withDefaults(defineProps<{
|
||||
enableRankingView: false,
|
||||
rankingItems: () => [],
|
||||
rankingTotalActualCost: 0,
|
||||
rankingTotalRequests: 0,
|
||||
rankingTotalTokens: 0,
|
||||
loading: false,
|
||||
metric: 'tokens',
|
||||
showMetricToggle: false,
|
||||
@@ -266,14 +274,14 @@ 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)
|
||||
const backgroundColor = chartColors.slice(0, props.rankingItems.length)
|
||||
|
||||
if (otherActualCost > 0.000001) {
|
||||
if (otherRankingItem.value) {
|
||||
labels.push(t('admin.dashboard.spendingRankingOther'))
|
||||
data.push(otherActualCost)
|
||||
data.push(otherRankingItem.value.actual_cost)
|
||||
backgroundColor.push('#94a3b8')
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -281,13 +289,43 @@ const rankingChartData = computed(() => {
|
||||
datasets: [
|
||||
{
|
||||
data,
|
||||
backgroundColor: chartColors.slice(0, data.length),
|
||||
backgroundColor,
|
||||
borderWidth: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const otherRankingItem = computed<RankingDisplayItem | null>(() => {
|
||||
if (!props.rankingItems?.length) return null
|
||||
|
||||
const rankedActualCost = props.rankingItems.reduce((sum, item) => sum + item.actual_cost, 0)
|
||||
const rankedRequests = props.rankingItems.reduce((sum, item) => sum + item.requests, 0)
|
||||
const rankedTokens = props.rankingItems.reduce((sum, item) => sum + item.tokens, 0)
|
||||
|
||||
const otherActualCost = Math.max((props.rankingTotalActualCost || 0) - rankedActualCost, 0)
|
||||
const otherRequests = Math.max((props.rankingTotalRequests || 0) - rankedRequests, 0)
|
||||
const otherTokens = Math.max((props.rankingTotalTokens || 0) - rankedTokens, 0)
|
||||
|
||||
if (otherActualCost <= 0.000001 && otherRequests <= 0 && otherTokens <= 0) return null
|
||||
|
||||
return {
|
||||
user_id: 0,
|
||||
email: '',
|
||||
actual_cost: otherActualCost,
|
||||
requests: otherRequests,
|
||||
tokens: otherTokens,
|
||||
isOther: true
|
||||
}
|
||||
})
|
||||
|
||||
const rankingDisplayItems = computed<RankingDisplayItem[]>(() => {
|
||||
if (!props.rankingItems?.length) return []
|
||||
return otherRankingItem.value
|
||||
? [...props.rankingItems, otherRankingItem.value]
|
||||
: [...props.rankingItems]
|
||||
})
|
||||
|
||||
const doughnutOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -351,6 +389,11 @@ const getRankingUserLabel = (item: UserSpendingRankingItem): string => {
|
||||
return t('admin.redeem.userPrefix', { id: item.user_id })
|
||||
}
|
||||
|
||||
const getRankingRowLabel = (item: RankingDisplayItem): string => {
|
||||
if (item.isOther) return t('admin.dashboard.spendingRankingOther')
|
||||
return getRankingUserLabel(item)
|
||||
}
|
||||
|
||||
const formatCost = (value: number): string => {
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(2) + 'K'
|
||||
|
||||
@@ -5,6 +5,14 @@ import ModelDistributionChart from '../ModelDistributionChart.vue'
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
'admin.dashboard.modelDistribution': 'Model Distribution',
|
||||
'admin.dashboard.spendingRankingTitle': 'User Spending Ranking',
|
||||
'admin.dashboard.viewModelDistribution': 'Model Distribution',
|
||||
'admin.dashboard.viewSpendingRanking': 'User Spending Ranking',
|
||||
'admin.dashboard.spendingRankingUser': 'User',
|
||||
'admin.dashboard.spendingRankingRequests': 'Requests',
|
||||
'admin.dashboard.spendingRankingTokens': 'Tokens',
|
||||
'admin.dashboard.spendingRankingSpend': 'Spend',
|
||||
'admin.dashboard.spendingRankingOther': 'Others',
|
||||
'admin.dashboard.model': 'Model',
|
||||
'admin.dashboard.requests': 'Requests',
|
||||
'admin.dashboard.tokens': 'Tokens',
|
||||
@@ -13,6 +21,7 @@ const messages: Record<string, string> = {
|
||||
'admin.dashboard.metricTokens': 'By Tokens',
|
||||
'admin.dashboard.metricActualCost': 'By Actual Cost',
|
||||
'admin.dashboard.noDataAvailable': 'No data available',
|
||||
'admin.redeem.userPrefix': 'User #{id}',
|
||||
}
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
@@ -116,4 +125,47 @@ describe('ModelDistributionChart', () => {
|
||||
})
|
||||
expect(label).toBe('model-b: $1.40 (87.5%)')
|
||||
})
|
||||
|
||||
it('renders Others in the spending ranking table and uses a dedicated chart color', async () => {
|
||||
const wrapper = mount(ModelDistributionChart, {
|
||||
props: {
|
||||
modelStats: [],
|
||||
enableRankingView: true,
|
||||
rankingItems: [
|
||||
{ user_id: 1, email: 'alpha@example.com', actual_cost: 12, requests: 10, tokens: 1000 },
|
||||
{ user_id: 2, email: 'beta@example.com', actual_cost: 8, requests: 6, tokens: 600 },
|
||||
],
|
||||
rankingTotalActualCost: 30,
|
||||
rankingTotalRequests: 20,
|
||||
rankingTotalTokens: 2000,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
LoadingSpinner: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const rankingButton = wrapper.findAll('button').find((button) => button.text() === 'User Spending Ranking')
|
||||
expect(rankingButton).toBeTruthy()
|
||||
await rankingButton!.trigger('click')
|
||||
|
||||
const chartData = JSON.parse(wrapper.find('.chart-data').text())
|
||||
expect(chartData.labels).toEqual([
|
||||
'#1 alpha@example.com',
|
||||
'#2 beta@example.com',
|
||||
'Others',
|
||||
])
|
||||
expect(chartData.datasets[0].data).toEqual([12, 8, 10])
|
||||
expect(chartData.datasets[0].backgroundColor[0]).toBe('#3b82f6')
|
||||
expect(chartData.datasets[0].backgroundColor[2]).toBe('#94a3b8')
|
||||
expect(chartData.datasets[0].backgroundColor[2]).not.toBe(chartData.datasets[0].backgroundColor[0])
|
||||
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(rows[2].text()).toContain('Others')
|
||||
expect(rows[2].text()).toContain('4')
|
||||
expect(rows[2].text()).toContain('400')
|
||||
expect(rows[2].text()).toContain('$10.00')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1199,6 +1199,8 @@ export interface UserSpendingRankingItem {
|
||||
export interface UserSpendingRankingResponse {
|
||||
ranking: UserSpendingRankingItem[]
|
||||
total_actual_cost: number
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
|
||||
@@ -241,6 +241,8 @@
|
||||
:enable-ranking-view="true"
|
||||
:ranking-items="rankingItems"
|
||||
:ranking-total-actual-cost="rankingTotalActualCost"
|
||||
:ranking-total-requests="rankingTotalRequests"
|
||||
:ranking-total-tokens="rankingTotalTokens"
|
||||
:loading="chartsLoading"
|
||||
:ranking-loading="rankingLoading"
|
||||
:ranking-error="rankingError"
|
||||
@@ -334,6 +336,8 @@ const modelStats = ref<ModelStat[]>([])
|
||||
const userTrend = ref<UserUsageTrendPoint[]>([])
|
||||
const rankingItems = ref<UserSpendingRankingItem[]>([])
|
||||
const rankingTotalActualCost = ref(0)
|
||||
const rankingTotalRequests = ref(0)
|
||||
const rankingTotalTokens = ref(0)
|
||||
let chartLoadSeq = 0
|
||||
let usersTrendLoadSeq = 0
|
||||
let rankingLoadSeq = 0
|
||||
@@ -347,7 +351,7 @@ const formatLocalDate = (date: Date): string => {
|
||||
const getTodayLocalDate = () => formatLocalDate(new Date())
|
||||
|
||||
// Date range
|
||||
const granularity = ref<'day' | 'hour'>('day')
|
||||
const granularity = ref<'day' | 'hour'>('hour')
|
||||
const startDate = ref(getTodayLocalDate())
|
||||
const endDate = ref(getTodayLocalDate())
|
||||
|
||||
@@ -630,11 +634,15 @@ const loadUserSpendingRanking = async () => {
|
||||
if (currentSeq !== rankingLoadSeq) return
|
||||
rankingItems.value = response.ranking || []
|
||||
rankingTotalActualCost.value = response.total_actual_cost || 0
|
||||
rankingTotalRequests.value = response.total_requests || 0
|
||||
rankingTotalTokens.value = response.total_tokens || 0
|
||||
} catch (error) {
|
||||
if (currentSeq !== rankingLoadSeq) return
|
||||
console.error('Error loading user spending ranking:', error)
|
||||
rankingItems.value = []
|
||||
rankingTotalActualCost.value = 0
|
||||
rankingTotalRequests.value = 0
|
||||
rankingTotalTokens.value = 0
|
||||
rankingError.value = true
|
||||
} finally {
|
||||
if (currentSeq === rankingLoadSeq) {
|
||||
|
||||
@@ -107,7 +107,7 @@ 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 trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const groupStats = ref<GroupStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('hour')
|
||||
const modelDistributionMetric = ref<DistributionMetric>('tokens')
|
||||
const groupDistributionMetric = ref<DistributionMetric>('tokens')
|
||||
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
|
||||
@@ -137,6 +137,7 @@ const formatLD = (d: Date) => {
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
const getTodayLocalDate = () => formatLD(new Date())
|
||||
const getGranularityForRange = (start: string, end: string): 'day' | 'hour' => start === end ? 'hour' : 'day'
|
||||
const startDate = ref(getTodayLocalDate()); const endDate = ref(getTodayLocalDate())
|
||||
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 })
|
||||
@@ -171,6 +172,7 @@ const applyRouteQueryFilters = () => {
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value
|
||||
}
|
||||
granularity.value = getGranularityForRange(startDate.value, endDate.value)
|
||||
}
|
||||
|
||||
const loadLogs = async () => {
|
||||
@@ -224,7 +226,7 @@ const loadChartData = async () => {
|
||||
}
|
||||
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() }
|
||||
const refreshData = () => { loadLogs(); loadStats(); loadChartData() }
|
||||
const resetFilters = () => { startDate.value = getTodayLocalDate(); endDate.value = getTodayLocalDate(); filters.value = { start_date: startDate.value, end_date: endDate.value, request_type: undefined, billing_type: null }; granularity.value = 'day'; applyFilters() }
|
||||
const resetFilters = () => { startDate.value = getTodayLocalDate(); endDate.value = getTodayLocalDate(); filters.value = { start_date: startDate.value, end_date: endDate.value, request_type: undefined, billing_type: null }; granularity.value = getGranularityForRange(startDate.value, endDate.value); applyFilters() }
|
||||
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
|
||||
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() }
|
||||
const cancelExport = () => exportAbortController?.abort()
|
||||
|
||||
Reference in New Issue
Block a user