Merge pull request #2416 from DaydreamCoding/pr/user-usage-by-platform
feat(usage): 用户用量按平台拆分 + UsersView 列设置可配置 + 用量列排序
This commit is contained in:
commit
98c9798f1a
@ -546,9 +546,14 @@ func (h *DashboardHandler) GetBatchUsersUsage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// cacheKey 必须包含当日日期,否则跨午夜后 30s 内会复用昨天的 "today_*" 结果。
|
||||
keyRaw, _ := json.Marshal(struct {
|
||||
V int `json:"v"`
|
||||
Day string `json:"day"`
|
||||
UserIDs []int64 `json:"user_ids"`
|
||||
}{
|
||||
V: 2, // bump 当响应结构变化(如加入 by_platform 时)
|
||||
Day: timezone.Today().Format("2006-01-02"),
|
||||
UserIDs: userIDs,
|
||||
})
|
||||
cacheKey := string(keyRaw)
|
||||
|
||||
@ -230,6 +230,20 @@ type UserDashboardStats struct {
|
||||
// 性能指标
|
||||
Rpm int64 `json:"rpm"` // 近5分钟平均每分钟请求数
|
||||
Tpm int64 `json:"tpm"` // 近5分钟平均每分钟Token数
|
||||
|
||||
// 按"有效平台"维度拆分(与 ops 路径口径一致:group.platform 优先,否则 account.platform)
|
||||
ByPlatform []PlatformDashboardStats `json:"by_platform,omitempty"`
|
||||
}
|
||||
|
||||
// PlatformDashboardStats 单个平台的用量明细。
|
||||
type PlatformDashboardStats struct {
|
||||
Platform string `json:"platform"`
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
TotalActualCost float64 `json:"total_actual_cost"`
|
||||
TodayRequests int64 `json:"today_requests"`
|
||||
TodayTokens int64 `json:"today_tokens"`
|
||||
TodayActualCost float64 `json:"today_actual_cost"`
|
||||
}
|
||||
|
||||
// UsageLogFilters represents filters for usage log queries
|
||||
@ -265,13 +279,22 @@ type UsageStats struct {
|
||||
EndpointPaths []EndpointStat `json:"endpoint_paths,omitempty"`
|
||||
}
|
||||
|
||||
// BatchUserUsageStats represents usage stats for a single user
|
||||
type BatchUserUsageStats struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
// PlatformUsage 表示某用户/某 API key 在单个"有效平台"维度的用量明细。
|
||||
// Platform 取值与 ops 路径口径一致:优先 groups.platform,否则 accounts.platform。
|
||||
type PlatformUsage struct {
|
||||
Platform string `json:"platform"`
|
||||
TodayActualCost float64 `json:"today_actual_cost"`
|
||||
TotalActualCost float64 `json:"total_actual_cost"`
|
||||
}
|
||||
|
||||
// BatchUserUsageStats represents usage stats for a single user
|
||||
type BatchUserUsageStats struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
TodayActualCost float64 `json:"today_actual_cost"`
|
||||
TotalActualCost float64 `json:"total_actual_cost"`
|
||||
ByPlatform []PlatformUsage `json:"by_platform,omitempty"`
|
||||
}
|
||||
|
||||
// BatchAPIKeyUsageStats represents usage stats for a single API key
|
||||
type BatchAPIKeyUsageStats struct {
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
|
||||
@ -96,6 +96,22 @@ const rawUsageLogModelColumn = "model"
|
||||
// Historical rows may contain upstream/billing model values, while newer rows store requested_model.
|
||||
// Requested/upstream/mapping analytics must use resolveModelDimensionExpression instead.
|
||||
|
||||
// usageLogSuccessFilterUL 用于把"失败请求 usage log"(tokens=0、cost=0、不计费的占位记录)
|
||||
// 从统计性聚合中排除,避免污染 Dashboard / 用量拆分等指标。
|
||||
//
|
||||
// schema 中没有 success bool 列;新增列要做迁移,风险大;这里用 actual_cost > 0 作为代理:
|
||||
// 任何成功落账的请求都会产生 actual_cost(包括 token 计费、纯图片 token 计费、按次/按图计费),
|
||||
// 反之 failed-request usage log 的 actual_cost 为 0。
|
||||
// 早期版本用 4 项 token 和 > 0 判定会把"按次/按图计费"与"image_output_tokens 独立计费"的纯图片
|
||||
// 请求误判为失败,导致这部分请求从用量统计里消失,故改用 actual_cost。
|
||||
// 配合 `FROM usage_logs ul` JOIN 查询使用。
|
||||
const usageLogSuccessFilterUL = "ul.actual_cost > 0"
|
||||
|
||||
// usageLogEffectivePlatformExpr 用于按"有效平台"维度聚合 usage_logs:
|
||||
// 优先取请求实际走的分组 platform,若分组未设置 platform 再 fallback 到 account.platform。
|
||||
// 配套要求查询里 LEFT JOIN groups g ON g.id = ul.group_id 与 LEFT JOIN accounts a ON a.id = ul.account_id。
|
||||
const usageLogEffectivePlatformExpr = "COALESCE(NULLIF(g.platform,''), a.platform)"
|
||||
|
||||
// dateFormatWhitelist 将 granularity 参数映射为 PostgreSQL TO_CHAR 格式字符串,防止外部输入直接拼入 SQL
|
||||
var dateFormatWhitelist = map[string]string{
|
||||
"hour": "YYYY-MM-DD HH24:00",
|
||||
@ -2414,6 +2430,9 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
|
||||
// UserDashboardStats 用户仪表盘统计
|
||||
type UserDashboardStats = usagestats.UserDashboardStats
|
||||
|
||||
// PlatformDashboardStats 单平台用量明细
|
||||
type PlatformDashboardStats = usagestats.PlatformDashboardStats
|
||||
|
||||
// GetUserDashboardStats 获取用户专属的仪表盘统计
|
||||
func (r *usageLogRepository) GetUserDashboardStats(ctx context.Context, userID int64) (*UserDashboardStats, error) {
|
||||
stats := &UserDashboardStats{}
|
||||
@ -2509,6 +2528,57 @@ func (r *usageLogRepository) GetUserDashboardStats(ctx context.Context, userID i
|
||||
stats.Rpm = rpm
|
||||
stats.Tpm = tpm
|
||||
|
||||
// 按"有效平台"维度拆分(group.platform 优先,否则 account.platform)。
|
||||
// 与 ops 路径口径一致;HAVING 过滤掉无法确定平台的行(避免出现空字符串平台)。
|
||||
// 与上面 totalStatsQuery/todayStatsQuery 的总值可能略微差异,原因有二:
|
||||
// 1) 无平台归属的极少数行(group/account 都没 platform)会被 HAVING 排除;
|
||||
// 2) usageLogSuccessFilterUL 会把 actual_cost = 0 的失败 placeholder 行排除,
|
||||
// 而 totalStatsQuery/todayStatsQuery 没有这层过滤、会把这些行的 request 计数算进去。
|
||||
platformQuery := `
|
||||
SELECT
|
||||
` + usageLogEffectivePlatformExpr + ` as platform,
|
||||
COUNT(*) as total_requests,
|
||||
COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(ul.actual_cost), 0) as total_actual_cost,
|
||||
COUNT(*) FILTER (WHERE ul.created_at >= $2) as today_requests,
|
||||
COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens) FILTER (WHERE ul.created_at >= $2), 0) as today_tokens,
|
||||
COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $2), 0) as today_actual_cost
|
||||
FROM usage_logs ul
|
||||
LEFT JOIN groups g ON g.id = ul.group_id
|
||||
LEFT JOIN accounts a ON a.id = ul.account_id
|
||||
WHERE ul.user_id = $1
|
||||
AND ` + usageLogSuccessFilterUL + `
|
||||
GROUP BY ` + usageLogEffectivePlatformExpr + `
|
||||
HAVING ` + usageLogEffectivePlatformExpr + ` IS NOT NULL AND ` + usageLogEffectivePlatformExpr + ` <> ''
|
||||
ORDER BY total_actual_cost DESC
|
||||
`
|
||||
rows, err := r.sql.QueryContext(ctx, platformQuery, userID, today)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var p PlatformDashboardStats
|
||||
if err := rows.Scan(
|
||||
&p.Platform,
|
||||
&p.TotalRequests,
|
||||
&p.TotalTokens,
|
||||
&p.TotalActualCost,
|
||||
&p.TodayRequests,
|
||||
&p.TodayTokens,
|
||||
&p.TodayActualCost,
|
||||
); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
stats.ByPlatform = append(stats.ByPlatform, p)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
@ -2769,6 +2839,9 @@ type UsageStats = usagestats.UsageStats
|
||||
// BatchUserUsageStats represents usage stats for a single user
|
||||
type BatchUserUsageStats = usagestats.BatchUserUsageStats
|
||||
|
||||
// PlatformUsage represents per-platform usage breakdown
|
||||
type PlatformUsage = usagestats.PlatformUsage
|
||||
|
||||
func normalizePositiveInt64IDs(ids []int64) []int64 {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
@ -2809,15 +2882,21 @@ func (r *usageLogRepository) GetBatchUserUsageStats(ctx context.Context, userIDs
|
||||
result[id] = &BatchUserUsageStats{UserID: id}
|
||||
}
|
||||
|
||||
// GROUP BY (user_id, effective_platform) 一次查询同时得到总值与按平台拆分。
|
||||
// 应用层把同一 user_id 的多行累加为总值,并把非空 platform 行收集到 ByPlatform。
|
||||
query := `
|
||||
SELECT
|
||||
user_id,
|
||||
COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $2 AND created_at < $3), 0) as total_cost,
|
||||
COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $4), 0) as today_cost
|
||||
FROM usage_logs
|
||||
WHERE user_id = ANY($1)
|
||||
AND created_at >= LEAST($2, $4)
|
||||
GROUP BY user_id
|
||||
ul.user_id,
|
||||
` + usageLogEffectivePlatformExpr + ` as platform,
|
||||
COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $2 AND ul.created_at < $3), 0) as total_cost,
|
||||
COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $4), 0) as today_cost
|
||||
FROM usage_logs ul
|
||||
LEFT JOIN groups g ON g.id = ul.group_id
|
||||
LEFT JOIN accounts a ON a.id = ul.account_id
|
||||
WHERE ul.user_id = ANY($1)
|
||||
AND ul.created_at >= LEAST($2, $4)
|
||||
AND ` + usageLogSuccessFilterUL + `
|
||||
GROUP BY ul.user_id, ` + usageLogEffectivePlatformExpr + `
|
||||
`
|
||||
today := timezone.Today()
|
||||
rows, err := r.sql.QueryContext(ctx, query, pq.Array(normalizedUserIDs), startTime, endTime, today)
|
||||
@ -2826,15 +2905,25 @@ func (r *usageLogRepository) GetBatchUserUsageStats(ctx context.Context, userIDs
|
||||
}
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
var platform sql.NullString
|
||||
var total float64
|
||||
var todayTotal float64
|
||||
if err := rows.Scan(&userID, &total, &todayTotal); err != nil {
|
||||
if err := rows.Scan(&userID, &platform, &total, &todayTotal); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if stats, ok := result[userID]; ok {
|
||||
stats.TotalActualCost = total
|
||||
stats.TodayActualCost = todayTotal
|
||||
stats, ok := result[userID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
stats.TotalActualCost += total
|
||||
stats.TodayActualCost += todayTotal
|
||||
if platform.Valid && platform.String != "" {
|
||||
stats.ByPlatform = append(stats.ByPlatform, PlatformUsage{
|
||||
Platform: platform.String,
|
||||
TotalActualCost: total,
|
||||
TodayActualCost: todayTotal,
|
||||
})
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
|
||||
@ -266,10 +266,17 @@ export async function getUserSpendingRanking(
|
||||
return data
|
||||
}
|
||||
|
||||
export interface PlatformUsage {
|
||||
platform: string
|
||||
today_actual_cost: number
|
||||
total_actual_cost: number
|
||||
}
|
||||
|
||||
export interface BatchUserUsageStats {
|
||||
user_id: number
|
||||
today_actual_cost: number
|
||||
total_actual_cost: number
|
||||
by_platform?: PlatformUsage[]
|
||||
}
|
||||
|
||||
export interface BatchUsersUsageResponse {
|
||||
|
||||
@ -15,6 +15,16 @@ import type {
|
||||
|
||||
// ==================== Dashboard Types ====================
|
||||
|
||||
export interface PlatformDashboardStats {
|
||||
platform: string
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_actual_cost: number
|
||||
today_requests: number
|
||||
today_tokens: number
|
||||
today_actual_cost: number
|
||||
}
|
||||
|
||||
export interface UserDashboardStats {
|
||||
total_api_keys: number
|
||||
active_api_keys: number
|
||||
@ -37,6 +47,7 @@ export interface UserDashboardStats {
|
||||
average_duration_ms: number
|
||||
rpm: number // 近5分钟平均每分钟请求数
|
||||
tpm: number // 近5分钟平均每分钟Token数
|
||||
by_platform?: PlatformDashboardStats[]
|
||||
}
|
||||
|
||||
export interface TrendParams {
|
||||
|
||||
24
frontend/src/components/user/PlatformCostCell.vue
Normal file
24
frontend/src/components/user/PlatformCostCell.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div v-if="usage" class="text-sm">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.users.today') }}:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ usage.today_actual_cost.toFixed(4) }}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center gap-1.5">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.users.total') }}:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ usage.total_actual_cost.toFixed(4) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">—</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PlatformUsage } from '@/api/admin/dashboard'
|
||||
|
||||
defineProps<{
|
||||
usage?: PlatformUsage
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
103
frontend/src/components/user/PlatformUsageBreakdown.vue
Normal file
103
frontend/src/components/user/PlatformUsageBreakdown.vue
Normal file
@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="group/usage relative text-sm">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.users.today') }}:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ today.toFixed(4) }}</span>
|
||||
<Icon
|
||||
v-if="hasBreakdown"
|
||||
name="infoCircle"
|
||||
size="xs"
|
||||
class="text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center gap-1.5">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.users.total') }}:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ total.toFixed(4) }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasBreakdown"
|
||||
class="pointer-events-none absolute left-full top-0 z-50 ml-2 min-w-[220px] whitespace-nowrap rounded-md bg-gray-900 px-3 py-2 text-xs text-white opacity-0 shadow-xl transition-opacity duration-100 group-hover/usage:opacity-100 dark:bg-dark-600"
|
||||
>
|
||||
<div class="mb-1.5 flex items-center justify-between gap-3 border-b border-white/10 pb-1 text-[11px] opacity-80">
|
||||
<span>{{ t('admin.users.platformBreakdown') }}</span>
|
||||
<span class="font-mono">{{ t('admin.users.today') }} / {{ t('admin.users.total') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in sortedBreakdown"
|
||||
:key="item.platform"
|
||||
class="flex items-center justify-between gap-3 py-0.5"
|
||||
:class="{ 'opacity-70 italic': item.isOther }"
|
||||
>
|
||||
<span class="capitalize">
|
||||
{{ item.isOther ? t('admin.users.platformOther') : platformLabel(item.platform) }}
|
||||
</span>
|
||||
<span class="font-mono">
|
||||
${{ item.today_actual_cost.toFixed(4) }}
|
||||
<span class="opacity-50">/</span>
|
||||
${{ item.total_actual_cost.toFixed(4) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { PlatformUsage } from '@/api/admin/dashboard'
|
||||
|
||||
const props = defineProps<{
|
||||
today: number
|
||||
total: number
|
||||
byPlatform?: PlatformUsage[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 与 UserDashboardStats 保持一致:把"总值 - 各平台之和"的差作为"其他"行展示,
|
||||
// 避免 tooltip 内各平台费用加总与列首总值对不上。
|
||||
const OTHER_THRESHOLD = 0.0001
|
||||
|
||||
interface BreakdownRow {
|
||||
platform: string
|
||||
today_actual_cost: number
|
||||
total_actual_cost: number
|
||||
isOther?: boolean
|
||||
}
|
||||
|
||||
const sortedBreakdown = computed<BreakdownRow[]>(() => {
|
||||
const list = props.byPlatform ?? []
|
||||
const rows: BreakdownRow[] = [...list]
|
||||
.sort((a, b) => b.total_actual_cost - a.total_actual_cost)
|
||||
.map((p) => ({ ...p }))
|
||||
|
||||
const sumTotal = rows.reduce((s, r) => s + r.total_actual_cost, 0)
|
||||
const sumToday = rows.reduce((s, r) => s + r.today_actual_cost, 0)
|
||||
const diffTotal = Math.max(0, props.total - sumTotal)
|
||||
const diffToday = Math.max(0, props.today - sumToday)
|
||||
if (diffTotal > OTHER_THRESHOLD || diffToday > OTHER_THRESHOLD) {
|
||||
rows.push({
|
||||
platform: '__other__',
|
||||
today_actual_cost: diffToday,
|
||||
total_actual_cost: diffTotal,
|
||||
isOther: true
|
||||
})
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
const hasBreakdown = computed(() => sortedBreakdown.value.length > 0)
|
||||
|
||||
const PLATFORM_LABELS: Record<string, string> = {
|
||||
anthropic: 'Claude',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity'
|
||||
}
|
||||
|
||||
function platformLabel(platform: string): string {
|
||||
return PLATFORM_LABELS[platform] ?? platform
|
||||
}
|
||||
</script>
|
||||
@ -131,20 +131,118 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Per-platform breakdown -->
|
||||
<div v-if="!isSimple && platformCards.length > 0" class="card p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('dashboard.platformBreakdown') }}</h3>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('dashboard.platformCount', { count: sortedPlatforms.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div
|
||||
v-for="item in platformCards"
|
||||
:key="item.platform"
|
||||
:class="[
|
||||
'rounded-lg border p-3',
|
||||
item.isOther
|
||||
? 'border-dashed border-gray-300 bg-gray-50 dark:border-dark-500 dark:bg-dark-700/30'
|
||||
: 'border-gray-200 dark:border-dark-600'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ item.isOther ? t('dashboard.platformOther') : platformLabel(item.platform) }}
|
||||
</span>
|
||||
<span class="font-mono text-sm text-purple-600 dark:text-purple-400" :title="t('dashboard.actual')">
|
||||
${{ formatCost(item.total_actual_cost) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 space-y-1 text-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('dashboard.todayCost') }}</span>
|
||||
<span class="font-mono text-gray-900 dark:text-white">${{ formatCost(item.today_actual_cost) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('dashboard.requests') }}</span>
|
||||
<span class="font-mono text-gray-700 dark:text-gray-300">
|
||||
{{ item.total_requests > 0 ? formatNumber(item.total_requests) : '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('dashboard.tokens') }}</span>
|
||||
<span class="font-mono text-gray-700 dark:text-gray-300">
|
||||
{{ item.total_tokens > 0 ? formatTokens(item.total_tokens) : '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { UserDashboardStats as UserStatsType } from '@/api/usage'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
stats: UserStatsType
|
||||
balance: number
|
||||
isSimple: boolean
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const PLATFORM_LABELS: Record<string, string> = {
|
||||
anthropic: 'Claude',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity'
|
||||
}
|
||||
|
||||
const platformLabel = (p: string) => PLATFORM_LABELS[p] ?? p
|
||||
|
||||
const sortedPlatforms = computed(() => {
|
||||
const list = props.stats?.by_platform ?? []
|
||||
return [...list].sort((a, b) => b.total_actual_cost - a.total_actual_cost)
|
||||
})
|
||||
|
||||
// 处理"各平台之和 < 总值"的差值:后端按平台聚合时过滤了无法归属平台的行
|
||||
// (group 与 account 都缺 platform)。这里把差值作为"其他"卡片显式展示,
|
||||
// 避免 Row 1 总值与 Row 3 平台拆分加总对不上、用户困惑。
|
||||
const OTHER_THRESHOLD = 0.0001
|
||||
const platformCards = computed(() => {
|
||||
const cards: Array<{
|
||||
platform: string
|
||||
total_actual_cost: number
|
||||
today_actual_cost: number
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
isOther?: boolean
|
||||
}> = sortedPlatforms.value.map((p) => ({ ...p }))
|
||||
|
||||
const total = props.stats?.total_actual_cost ?? 0
|
||||
const today = props.stats?.today_actual_cost ?? 0
|
||||
const sumTotal = cards.reduce((s, c) => s + c.total_actual_cost, 0)
|
||||
const sumToday = cards.reduce((s, c) => s + c.today_actual_cost, 0)
|
||||
const diffTotal = Math.max(0, total - sumTotal)
|
||||
const diffToday = Math.max(0, today - sumToday)
|
||||
|
||||
if (diffTotal > OTHER_THRESHOLD || diffToday > OTHER_THRESHOLD) {
|
||||
cards.push({
|
||||
platform: '__other__',
|
||||
total_actual_cost: diffTotal,
|
||||
today_actual_cost: diffToday,
|
||||
total_requests: 0,
|
||||
total_tokens: 0,
|
||||
isOther: true
|
||||
})
|
||||
}
|
||||
return cards
|
||||
})
|
||||
|
||||
const formatBalance = (b: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
|
||||
@ -595,6 +595,10 @@ export default {
|
||||
hour: 'Hour',
|
||||
modelDistribution: 'Model Distribution',
|
||||
groupDistribution: 'Group Usage Distribution',
|
||||
platformBreakdown: 'Per-platform Breakdown',
|
||||
platformBreakdownEmpty: 'No platform usage yet',
|
||||
platformCount: '{count} platforms',
|
||||
platformOther: 'Other',
|
||||
tokenUsageTrend: 'Token Usage Trend',
|
||||
noDataAvailable: 'No data available',
|
||||
model: 'Model',
|
||||
@ -1754,6 +1758,10 @@ export default {
|
||||
subscriptions: 'Subscriptions',
|
||||
balance: 'Balance',
|
||||
usage: 'Usage',
|
||||
usageAnthropic: 'Usage (Claude)',
|
||||
usageOpenAI: 'Usage (OpenAI)',
|
||||
usageGemini: 'Usage (Gemini)',
|
||||
usageAntigravity: 'Usage (Antigravity)',
|
||||
concurrency: 'Concurrency',
|
||||
status: 'Status',
|
||||
lastActive: 'Last Active',
|
||||
@ -1763,6 +1771,8 @@ export default {
|
||||
},
|
||||
today: 'Today',
|
||||
total: 'Last 30d',
|
||||
sortBy: 'Sort By',
|
||||
sortCurrentPageOnly: 'Sorts current page only',
|
||||
noSubscription: 'No subscription',
|
||||
publicGroupCount: '+{count} public',
|
||||
exclusiveLabel: 'exclusive',
|
||||
@ -1855,6 +1865,12 @@ export default {
|
||||
// Balance History
|
||||
balanceHistory: 'Recharge History',
|
||||
balanceHistoryTip: 'Click to open recharge history',
|
||||
columnAlwaysVisible: 'This column is always visible',
|
||||
// Per-platform usage breakdown (hover tooltip)
|
||||
platformBreakdown: 'Per-platform breakdown',
|
||||
platformBreakdownEmpty: 'No platform usage yet',
|
||||
platformBreakdownHint: 'Hover for per-platform usage',
|
||||
platformOther: 'Other',
|
||||
balanceHistoryTitle: 'User Recharge & Concurrency History',
|
||||
noBalanceHistory: 'No records found for this user',
|
||||
allTypes: 'All Types',
|
||||
|
||||
@ -594,6 +594,10 @@ export default {
|
||||
hour: '按小时',
|
||||
modelDistribution: '模型分布',
|
||||
groupDistribution: '分组使用分布',
|
||||
platformBreakdown: '按平台拆分',
|
||||
platformBreakdownEmpty: '暂无平台用量',
|
||||
platformCount: '{count} 个平台',
|
||||
platformOther: '其他',
|
||||
tokenUsageTrend: 'Token 使用趋势',
|
||||
noDataAvailable: '暂无数据',
|
||||
model: '模型',
|
||||
@ -1775,6 +1779,10 @@ export default {
|
||||
subscriptions: '订阅分组',
|
||||
balance: '余额',
|
||||
usage: '用量',
|
||||
usageAnthropic: '用量 (Claude)',
|
||||
usageOpenAI: '用量 (OpenAI)',
|
||||
usageGemini: '用量 (Gemini)',
|
||||
usageAntigravity: '用量 (Antigravity)',
|
||||
concurrency: '并发数',
|
||||
status: '状态',
|
||||
lastActive: '最后活跃时间',
|
||||
@ -1784,6 +1792,8 @@ export default {
|
||||
},
|
||||
today: '今日',
|
||||
total: '近30天',
|
||||
sortBy: '排序方式',
|
||||
sortCurrentPageOnly: '仅对本页数据排序',
|
||||
noSubscription: '暂无订阅',
|
||||
publicGroupCount: '+{count} 公开',
|
||||
exclusiveLabel: '专属',
|
||||
@ -1912,6 +1922,12 @@ export default {
|
||||
// 余额变动记录
|
||||
balanceHistory: '充值记录',
|
||||
balanceHistoryTip: '点击查看充值记录',
|
||||
columnAlwaysVisible: '该列固定显示,不可隐藏',
|
||||
// 平台用量明细(悬浮显示)
|
||||
platformBreakdown: '按平台拆分',
|
||||
platformBreakdownEmpty: '暂无平台明细',
|
||||
platformBreakdownHint: '悬浮查看各平台用量',
|
||||
platformOther: '其他',
|
||||
balanceHistoryTitle: '用户充值和并发变动记录',
|
||||
noBalanceHistory: '暂无变动记录',
|
||||
allTypes: '全部类型',
|
||||
|
||||
@ -199,15 +199,22 @@
|
||||
<button
|
||||
v-for="col in toggleableColumns"
|
||||
:key="col.key"
|
||||
:disabled="isForcedVisibleColumn(col.key)"
|
||||
@click="toggleColumn(col.key)"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
:class="[
|
||||
'flex w-full items-center justify-between px-4 py-2 text-left text-sm',
|
||||
isForcedVisibleColumn(col.key)
|
||||
? 'cursor-not-allowed text-gray-400 dark:text-gray-500'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700'
|
||||
]"
|
||||
:title="isForcedVisibleColumn(col.key) ? t('admin.users.columnAlwaysVisible') : ''"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<Icon
|
||||
v-if="isColumnVisible(col.key)"
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:class="isForcedVisibleColumn(col.key) ? 'text-gray-400 dark:text-gray-500' : 'text-primary-500'"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
@ -237,7 +244,7 @@
|
||||
<template #table>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="users"
|
||||
:data="sortedUsers"
|
||||
:loading="loading"
|
||||
:actions-count="7"
|
||||
:server-side-sort="true"
|
||||
@ -413,23 +420,109 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-usage="{ row }">
|
||||
<div class="text-sm">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.users.today') }}:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
${{ (usageStats[row.id]?.today_actual_cost ?? 0).toFixed(4) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center gap-1.5">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.users.total') }}:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
${{ (usageStats[row.id]?.total_actual_cost ?? 0).toFixed(4) }}
|
||||
</span>
|
||||
<!-- 用量列自定义表头:列名 + 单个排序图标按钮,点击展开"今日/近30天"菜单。
|
||||
column.sortable=false,DataTable 内置点击逻辑不会触发;
|
||||
菜单项三态循环:desc → asc → off。 -->
|
||||
<template
|
||||
v-for="usageKey in USAGE_COLUMN_KEYS"
|
||||
:key="usageKey"
|
||||
#[`header-${usageKey}`]="{ column }"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span>{{ column.label }}</span>
|
||||
<div class="usage-sort-trigger relative">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 rounded px-1 py-0.5 transition-colors hover:bg-gray-200 dark:hover:bg-dark-700"
|
||||
:class="usageSort && usageSort.key === usageKey
|
||||
? 'text-primary-600 dark:text-primary-400'
|
||||
: 'text-gray-400 dark:text-dark-500'"
|
||||
:title="t('admin.users.sortBy')"
|
||||
@click.stop="toggleUsageSortMenu(usageKey)"
|
||||
>
|
||||
<span
|
||||
v-if="usageSort && usageSort.key === usageKey"
|
||||
class="text-[10px] normal-case font-medium tracking-normal"
|
||||
>{{ usageSort.metric === 'today' ? t('admin.users.today') : t('admin.users.total') }}</span>
|
||||
<svg
|
||||
v-if="usageSort && usageSort.key === usageKey"
|
||||
class="h-3.5 w-3.5"
|
||||
:class="{ 'rotate-180': usageSort.order === 'desc' }"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 3l-4 5h8l-4-5zM10 17l4-5H6l4 5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 弹出菜单:今日 / 近30天,点击进行三态循环切换。 -->
|
||||
<div
|
||||
v-if="openUsageSortMenu === usageKey"
|
||||
class="absolute right-0 top-full z-50 mt-1 min-w-[120px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="metric in (['today', 'total'] as const)"
|
||||
:key="metric"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-xs normal-case tracking-normal hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||
:class="isUsageSortActive(usageKey, metric)
|
||||
? 'font-medium text-primary-600 dark:text-primary-400'
|
||||
: 'text-gray-700 dark:text-gray-300'"
|
||||
@click.stop="toggleUsageSort(usageKey, metric)"
|
||||
>
|
||||
<span>{{ metric === 'today' ? t('admin.users.today') : t('admin.users.total') }}</span>
|
||||
<svg
|
||||
v-if="getUsageSortOrder(usageKey, metric)"
|
||||
class="h-3 w-3"
|
||||
:class="{ 'rotate-180': getUsageSortOrder(usageKey, metric) === 'desc' }"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="mt-1 border-t border-gray-100 px-3 py-1 text-[10px] normal-case tracking-normal text-gray-400 dark:border-dark-700 dark:text-dark-500">
|
||||
{{ t('admin.users.sortCurrentPageOnly') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-usage="{ row }">
|
||||
<PlatformUsageBreakdown
|
||||
:today="usageStats[row.id]?.today_actual_cost ?? 0"
|
||||
:total="usageStats[row.id]?.total_actual_cost ?? 0"
|
||||
:by-platform="usageStats[row.id]?.by_platform"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell-usage_anthropic="{ row }">
|
||||
<PlatformCostCell :usage="getPlatformUsage(row.id, 'anthropic')" />
|
||||
</template>
|
||||
|
||||
<template #cell-usage_openai="{ row }">
|
||||
<PlatformCostCell :usage="getPlatformUsage(row.id, 'openai')" />
|
||||
</template>
|
||||
|
||||
<template #cell-usage_gemini="{ row }">
|
||||
<PlatformCostCell :usage="getPlatformUsage(row.id, 'gemini')" />
|
||||
</template>
|
||||
|
||||
<template #cell-usage_antigravity="{ row }">
|
||||
<PlatformCostCell :usage="getPlatformUsage(row.id, 'antigravity')" />
|
||||
</template>
|
||||
|
||||
<template #cell-concurrency="{ row }">
|
||||
<UserConcurrencyCell
|
||||
:current="row.current_concurrency ?? 0"
|
||||
@ -641,6 +734,8 @@ import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import UserAttributesConfigModal from '@/components/user/UserAttributesConfigModal.vue'
|
||||
import UserConcurrencyCell from '@/components/user/UserConcurrencyCell.vue'
|
||||
import PlatformUsageBreakdown from '@/components/user/PlatformUsageBreakdown.vue'
|
||||
import PlatformCostCell from '@/components/user/PlatformCostCell.vue'
|
||||
import UserCreateModal from '@/components/admin/user/UserCreateModal.vue'
|
||||
import UserEditModal from '@/components/admin/user/UserEditModal.vue'
|
||||
import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue'
|
||||
@ -710,6 +805,10 @@ const allColumns = computed<Column[]>(() => [
|
||||
{ key: 'subscriptions', label: t('admin.users.columns.subscriptions'), sortable: false },
|
||||
{ key: 'balance', label: t('admin.users.columns.balance'), sortable: true },
|
||||
{ key: 'usage', label: t('admin.users.columns.usage'), sortable: false },
|
||||
{ key: 'usage_anthropic', label: t('admin.users.columns.usageAnthropic'), sortable: false },
|
||||
{ key: 'usage_openai', label: t('admin.users.columns.usageOpenAI'), sortable: false },
|
||||
{ key: 'usage_gemini', label: t('admin.users.columns.usageGemini'), sortable: false },
|
||||
{ key: 'usage_antigravity', label: t('admin.users.columns.usageAntigravity'), sortable: false },
|
||||
{ key: 'concurrency', label: t('admin.users.columns.concurrency'), sortable: true },
|
||||
{ key: 'status', label: t('admin.users.columns.status'), sortable: true },
|
||||
{ key: 'last_active_at', label: t('admin.users.columns.lastActive'), sortable: true },
|
||||
@ -728,12 +827,25 @@ const toggleableColumns = computed(() =>
|
||||
const hiddenColumns = reactive<Set<string>>(new Set())
|
||||
|
||||
// Default hidden columns (columns hidden by default on first load)
|
||||
const DEFAULT_HIDDEN_COLUMNS = ['notes', 'groups', 'subscriptions', 'usage', 'concurrency']
|
||||
const DEFAULT_HIDDEN_COLUMNS = [
|
||||
'notes', 'groups', 'subscriptions', 'usage', 'concurrency',
|
||||
'usage_anthropic', 'usage_openai', 'usage_gemini', 'usage_antigravity'
|
||||
]
|
||||
const REMOVED_COLUMNS = new Set(['last_login_at'])
|
||||
const FORCED_VISIBLE_COLUMNS = new Set(['last_active_at'])
|
||||
// 强制可见列:加载时会被强制移出 hiddenColumns,并在列设置 UI 上 disabled。
|
||||
// 当前没有列需要强制可见 —— last_active_at 已改为可被用户隐藏。
|
||||
const FORCED_VISIBLE_COLUMNS = new Set<string>()
|
||||
|
||||
// localStorage key for column settings
|
||||
// localStorage keys for column settings
|
||||
const HIDDEN_COLUMNS_KEY = 'user-hidden-columns'
|
||||
// 列设置 schema 版本号。每次给 DEFAULT_HIDDEN_COLUMNS 新增列时 bump 一次,
|
||||
// 并在 VERSION_NEW_HIDDEN_COLUMNS 中登记该版本新增的 key。
|
||||
// 这样老用户升级后这些新列会被自动隐藏一次,而不会影响他们对其它老列的偏好。
|
||||
const COLUMN_SETTINGS_VERSION_KEY = 'user-column-settings-version'
|
||||
const COLUMN_SETTINGS_VERSION = 2
|
||||
const VERSION_NEW_HIDDEN_COLUMNS: Record<number, string[]> = {
|
||||
2: ['usage_anthropic', 'usage_openai', 'usage_gemini', 'usage_antigravity']
|
||||
}
|
||||
|
||||
// Load saved column settings
|
||||
const loadSavedColumns = () => {
|
||||
@ -744,9 +856,27 @@ const loadSavedColumns = () => {
|
||||
parsed
|
||||
.filter(key => !REMOVED_COLUMNS.has(key) && !FORCED_VISIBLE_COLUMNS.has(key))
|
||||
.forEach(key => hiddenColumns.add(key))
|
||||
|
||||
// 老用户升级:把每个未应用过的版本里新增的默认隐藏列自动追加到 hiddenColumns。
|
||||
const storedVersion = Number(localStorage.getItem(COLUMN_SETTINGS_VERSION_KEY) ?? '1')
|
||||
if (storedVersion < COLUMN_SETTINGS_VERSION) {
|
||||
let mutated = false
|
||||
for (let v = storedVersion + 1; v <= COLUMN_SETTINGS_VERSION; v++) {
|
||||
for (const key of VERSION_NEW_HIDDEN_COLUMNS[v] ?? []) {
|
||||
if (REMOVED_COLUMNS.has(key) || FORCED_VISIBLE_COLUMNS.has(key)) continue
|
||||
if (!hiddenColumns.has(key)) {
|
||||
hiddenColumns.add(key)
|
||||
mutated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mutated) saveColumnsToStorage()
|
||||
else localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
|
||||
}
|
||||
} else {
|
||||
// Use default hidden columns on first load
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||
localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load saved columns:', e)
|
||||
@ -758,13 +888,18 @@ const loadSavedColumns = () => {
|
||||
const saveColumnsToStorage = () => {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
|
||||
localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
|
||||
} catch (e) {
|
||||
console.error('Failed to save columns:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle column visibility
|
||||
const isForcedVisibleColumn = (key: string) => FORCED_VISIBLE_COLUMNS.has(key)
|
||||
const toggleColumn = (key: string) => {
|
||||
// 强制可见列(如 last_active_at)在加载时会被恢复成可见,
|
||||
// 这里阻止用户在当前会话隐藏它,避免"取消勾选 → 刷新又恢复"的反直觉行为。
|
||||
if (FORCED_VISIBLE_COLUMNS.has(key)) return
|
||||
const wasHidden = hiddenColumns.has(key)
|
||||
if (hiddenColumns.has(key)) {
|
||||
hiddenColumns.delete(key)
|
||||
@ -772,7 +907,7 @@ const toggleColumn = (key: string) => {
|
||||
hiddenColumns.add(key)
|
||||
}
|
||||
saveColumnsToStorage()
|
||||
if (wasHidden && (key === 'usage' || key.startsWith('attr_'))) {
|
||||
if (wasHidden && (key === 'usage' || key.startsWith('usage_') || key.startsWith('attr_'))) {
|
||||
refreshCurrentPageSecondaryData()
|
||||
}
|
||||
if (key === 'subscriptions') {
|
||||
@ -785,7 +920,22 @@ const toggleColumn = (key: string) => {
|
||||
|
||||
// Check if column is visible (not in hidden set)
|
||||
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
||||
const hasVisibleUsageColumn = computed(() => !hiddenColumns.has('usage'))
|
||||
// usage 主列或任意 usage_<platform> 子列可见时都需要批量拉取用量数据
|
||||
// 列 key → 平台名('usage' 主列汇总所有平台时为 null)
|
||||
// 显式数组取代 Object.keys():保证迭代顺序(决定列头排序按钮渲染顺序)
|
||||
// 不会因 JS 引擎差异或 USAGE_COLUMN_PLATFORMS 属性顺序调整而静默变化。
|
||||
const USAGE_COLUMN_KEYS: readonly string[] = ['usage', 'usage_anthropic', 'usage_openai', 'usage_gemini', 'usage_antigravity']
|
||||
const USAGE_COLUMN_PLATFORMS: Record<string, string | null> = {
|
||||
usage: null,
|
||||
usage_anthropic: 'anthropic',
|
||||
usage_openai: 'openai',
|
||||
usage_gemini: 'gemini',
|
||||
usage_antigravity: 'antigravity'
|
||||
}
|
||||
const PLATFORM_USAGE_COLUMNS = USAGE_COLUMN_KEYS.filter((k) => k !== 'usage')
|
||||
const hasVisibleUsageColumn = computed(
|
||||
() => !hiddenColumns.has('usage') || PLATFORM_USAGE_COLUMNS.some((k) => !hiddenColumns.has(k))
|
||||
)
|
||||
const hasVisibleSubscriptionsColumn = computed(() => !hiddenColumns.has('subscriptions'))
|
||||
const hasVisibleGroupsColumn = computed(() => !hiddenColumns.has('groups'))
|
||||
const hasVisibleAttributeColumns = computed(() =>
|
||||
@ -945,6 +1095,97 @@ const getAttributeDefinition = (attrId: number): UserAttributeDefinition | undef
|
||||
return attributeDefinitions.value.find(d => d.id === attrId)
|
||||
}
|
||||
const usageStats = ref<Record<string, BatchUserUsageStats>>({})
|
||||
|
||||
const getPlatformUsage = (userId: number, platform: string) =>
|
||||
usageStats.value[userId]?.by_platform?.find((p) => p.platform === platform)
|
||||
|
||||
// 用量列前端排序:DataTable 工作在 server-side-sort 模式,所有 sortable
|
||||
// 字段都会触发后端查询,而用量列数据是异步批量拉取后再合并到当前页,
|
||||
// 因此采用独立的前端排序状态对当前页 users 做本地排序。
|
||||
// 排序状态独立于后端 sortState 持久化;缺失数据按 0 处理(desc 沉底、asc 置顶)。
|
||||
type UsageMetric = 'today' | 'total'
|
||||
type UsageSortState = { key: string; metric: UsageMetric; order: 'asc' | 'desc' } | null
|
||||
const USAGE_SORT_STORAGE_KEY = 'admin-users-usage-sort'
|
||||
|
||||
const loadInitialUsageSort = (): UsageSortState => {
|
||||
try {
|
||||
const raw = localStorage.getItem(USAGE_SORT_STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as Partial<{ key: string; metric: string; order: string }>
|
||||
if (!parsed.key || !USAGE_COLUMN_KEYS.includes(parsed.key)) return null
|
||||
const metric: UsageMetric = parsed.metric === 'total' ? 'total' : 'today'
|
||||
const order: 'asc' | 'desc' = parsed.order === 'asc' ? 'asc' : 'desc'
|
||||
return { key: parsed.key, metric, order }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const usageSort = ref<UsageSortState>(loadInitialUsageSort())
|
||||
const persistUsageSort = () => {
|
||||
try {
|
||||
if (usageSort.value) {
|
||||
localStorage.setItem(USAGE_SORT_STORAGE_KEY, JSON.stringify(usageSort.value))
|
||||
} else {
|
||||
localStorage.removeItem(USAGE_SORT_STORAGE_KEY)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to persist usage sort:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const isUsageSortActive = (key: string, metric: UsageMetric) =>
|
||||
!!usageSort.value && usageSort.value.key === key && usageSort.value.metric === metric
|
||||
const getUsageSortOrder = (key: string, metric: UsageMetric): 'asc' | 'desc' | null =>
|
||||
isUsageSortActive(key, metric) ? usageSort.value!.order : null
|
||||
|
||||
// 三态循环:desc → asc → off。选完即关闭菜单(用户大多希望"选中即应用",
|
||||
// 想再切换 order 时重新打开菜单点同一项即可)。
|
||||
const toggleUsageSort = (key: string, metric: UsageMetric) => {
|
||||
const cur = usageSort.value
|
||||
if (cur && cur.key === key && cur.metric === metric) {
|
||||
usageSort.value = cur.order === 'desc' ? { key, metric, order: 'asc' } : null
|
||||
} else {
|
||||
usageSort.value = { key, metric, order: 'desc' }
|
||||
}
|
||||
persistUsageSort()
|
||||
openUsageSortMenu.value = null
|
||||
}
|
||||
|
||||
// 列头排序按钮点击后弹出的"今日/近30天"选择菜单,同时只允许一个列展开。
|
||||
// 点击图标本身不触发排序,仅开关菜单;首次排序由用户在菜单内选择 metric 触发(默认 desc,详见 toggleUsageSort)。
|
||||
const openUsageSortMenu = ref<string | null>(null)
|
||||
const toggleUsageSortMenu = (key: string) => {
|
||||
openUsageSortMenu.value = openUsageSortMenu.value === key ? null : key
|
||||
}
|
||||
|
||||
const getUsageValue = (userId: number, key: string, metric: UsageMetric): number => {
|
||||
const stats = usageStats.value[userId]
|
||||
if (!stats) return 0
|
||||
const platform = USAGE_COLUMN_PLATFORMS[key]
|
||||
if (platform === null) {
|
||||
return metric === 'today' ? stats.today_actual_cost ?? 0 : stats.total_actual_cost ?? 0
|
||||
}
|
||||
const p = stats.by_platform?.find((x) => x.platform === platform)
|
||||
if (!p) return 0
|
||||
return metric === 'today' ? p.today_actual_cost ?? 0 : p.total_actual_cost ?? 0
|
||||
}
|
||||
|
||||
// 在 server-side 排序结果之上叠加用量列的本地排序;无 usageSort 时直接透传原数组。
|
||||
// 稳定排序:等值按原 index 保序,避免拉取新用量数据时表行抖动。
|
||||
const sortedUsers = computed(() => {
|
||||
const s = usageSort.value
|
||||
if (!s) return users.value
|
||||
return [...users.value]
|
||||
.map((row, index) => ({ row, index }))
|
||||
.sort((a, b) => {
|
||||
const av = getUsageValue(a.row.id, s.key, s.metric)
|
||||
const bv = getUsageValue(b.row.id, s.key, s.metric)
|
||||
if (av !== bv) return s.order === 'asc' ? av - bv : bv - av
|
||||
return a.index - b.index
|
||||
})
|
||||
.map((x) => x.row)
|
||||
})
|
||||
|
||||
// User attribute definitions and values
|
||||
const attributeDefinitions = ref<UserAttributeDefinition[]>([])
|
||||
const userAttributeValues = ref<Record<number, Record<number, string>>>({})
|
||||
@ -1095,6 +1336,10 @@ const handleClickOutside = (event: MouseEvent) => {
|
||||
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
||||
showColumnDropdown.value = false
|
||||
}
|
||||
// Close usage sort dropdown when clicking outside any usage-sort-trigger
|
||||
if (openUsageSortMenu.value !== null && !target.closest('.usage-sort-trigger')) {
|
||||
openUsageSortMenu.value = null
|
||||
}
|
||||
// Close expanded group dropdown when clicking outside
|
||||
if (expandedGroupUserId.value !== null) {
|
||||
expandedGroupUserId.value = null
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user