feat(usage): 用户用量按平台拆分 + UsersView 列设置可配置 + 用量列排序
后端 - BatchUserUsageStats / UserDashboardStats 新增 ByPlatform 字段 复用 ops 路径 COALESCE(g.platform, a.platform) 语义,不冗余 DB 字段 - 抽出 usageLogEffectivePlatformExpr 常量供管理员与用户两路径共用 - GetBatchUsersUsage cacheKey 加 v=2 + 当日日期,修复跨午夜旧缓存兼容新字段 前端 - 新建 PlatformUsageBreakdown:管理员用量列 hover tooltip 展示各平台 today/total - 新建 PlatformCostCell:单平台 today/total 紧凑单元格 - UsersView 列设置新增 Claude/OpenAI/Gemini/Antigravity 四个平台子列,默认隐藏可手动启用 - 普通用户 Dashboard 新增 Row 3 平台拆分卡片,受 isSimple 控制 - 平台之和 < 总值时显式展示"其他"行,避免数字对不齐 - last_active_at 从 FORCED_VISIBLE_COLUMNS 移除,允许用户隐藏并持久化 - 列设置加 schema 版本号 + 迁移机制,老用户升级时新增默认隐藏列自动应用 - UsersView 用量列(汇总 + 4 平台子列)加入前端单页排序:列头单按钮 + 弹出菜单 切换"今日 / 近30天",三态循环 desc → asc → off;菜单底部备注"仅对本页数据排序" - sortedUsers computed 在 server-side-sort 结果之上叠加本地排序,缺失值按 0 处理; usageSort 状态独立 localStorage 持久化,互不干扰后端 sort_by - i18n 新增 admin.users.sortBy / sortCurrentPageOnly Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b006e36af9
commit
664e9fdcd4
@ -546,9 +546,14 @@ func (h *DashboardHandler) GetBatchUsersUsage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cacheKey 必须包含当日日期,否则跨午夜后 30s 内会复用昨天的 "today_*" 结果。
|
||||||
keyRaw, _ := json.Marshal(struct {
|
keyRaw, _ := json.Marshal(struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Day string `json:"day"`
|
||||||
UserIDs []int64 `json:"user_ids"`
|
UserIDs []int64 `json:"user_ids"`
|
||||||
}{
|
}{
|
||||||
|
V: 2, // bump 当响应结构变化(如加入 by_platform 时)
|
||||||
|
Day: timezone.Today().Format("2006-01-02"),
|
||||||
UserIDs: userIDs,
|
UserIDs: userIDs,
|
||||||
})
|
})
|
||||||
cacheKey := string(keyRaw)
|
cacheKey := string(keyRaw)
|
||||||
|
|||||||
@ -230,6 +230,20 @@ type UserDashboardStats struct {
|
|||||||
// 性能指标
|
// 性能指标
|
||||||
Rpm int64 `json:"rpm"` // 近5分钟平均每分钟请求数
|
Rpm int64 `json:"rpm"` // 近5分钟平均每分钟请求数
|
||||||
Tpm int64 `json:"tpm"` // 近5分钟平均每分钟Token数
|
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
|
// UsageLogFilters represents filters for usage log queries
|
||||||
@ -265,13 +279,22 @@ type UsageStats struct {
|
|||||||
EndpointPaths []EndpointStat `json:"endpoint_paths,omitempty"`
|
EndpointPaths []EndpointStat `json:"endpoint_paths,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchUserUsageStats represents usage stats for a single user
|
// PlatformUsage 表示某用户/某 API key 在单个"有效平台"维度的用量明细。
|
||||||
type BatchUserUsageStats struct {
|
// Platform 取值与 ops 路径口径一致:优先 groups.platform,否则 accounts.platform。
|
||||||
UserID int64 `json:"user_id"`
|
type PlatformUsage struct {
|
||||||
|
Platform string `json:"platform"`
|
||||||
TodayActualCost float64 `json:"today_actual_cost"`
|
TodayActualCost float64 `json:"today_actual_cost"`
|
||||||
TotalActualCost float64 `json:"total_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
|
// BatchAPIKeyUsageStats represents usage stats for a single API key
|
||||||
type BatchAPIKeyUsageStats struct {
|
type BatchAPIKeyUsageStats struct {
|
||||||
APIKeyID int64 `json:"api_key_id"`
|
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.
|
// Historical rows may contain upstream/billing model values, while newer rows store requested_model.
|
||||||
// Requested/upstream/mapping analytics must use resolveModelDimensionExpression instead.
|
// 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
|
// dateFormatWhitelist 将 granularity 参数映射为 PostgreSQL TO_CHAR 格式字符串,防止外部输入直接拼入 SQL
|
||||||
var dateFormatWhitelist = map[string]string{
|
var dateFormatWhitelist = map[string]string{
|
||||||
"hour": "YYYY-MM-DD HH24:00",
|
"hour": "YYYY-MM-DD HH24:00",
|
||||||
@ -2414,6 +2430,9 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
|
|||||||
// UserDashboardStats 用户仪表盘统计
|
// UserDashboardStats 用户仪表盘统计
|
||||||
type UserDashboardStats = usagestats.UserDashboardStats
|
type UserDashboardStats = usagestats.UserDashboardStats
|
||||||
|
|
||||||
|
// PlatformDashboardStats 单平台用量明细
|
||||||
|
type PlatformDashboardStats = usagestats.PlatformDashboardStats
|
||||||
|
|
||||||
// GetUserDashboardStats 获取用户专属的仪表盘统计
|
// GetUserDashboardStats 获取用户专属的仪表盘统计
|
||||||
func (r *usageLogRepository) GetUserDashboardStats(ctx context.Context, userID int64) (*UserDashboardStats, error) {
|
func (r *usageLogRepository) GetUserDashboardStats(ctx context.Context, userID int64) (*UserDashboardStats, error) {
|
||||||
stats := &UserDashboardStats{}
|
stats := &UserDashboardStats{}
|
||||||
@ -2509,6 +2528,57 @@ func (r *usageLogRepository) GetUserDashboardStats(ctx context.Context, userID i
|
|||||||
stats.Rpm = rpm
|
stats.Rpm = rpm
|
||||||
stats.Tpm = tpm
|
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
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2769,6 +2839,9 @@ type UsageStats = usagestats.UsageStats
|
|||||||
// BatchUserUsageStats represents usage stats for a single user
|
// BatchUserUsageStats represents usage stats for a single user
|
||||||
type BatchUserUsageStats = usagestats.BatchUserUsageStats
|
type BatchUserUsageStats = usagestats.BatchUserUsageStats
|
||||||
|
|
||||||
|
// PlatformUsage represents per-platform usage breakdown
|
||||||
|
type PlatformUsage = usagestats.PlatformUsage
|
||||||
|
|
||||||
func normalizePositiveInt64IDs(ids []int64) []int64 {
|
func normalizePositiveInt64IDs(ids []int64) []int64 {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@ -2809,15 +2882,21 @@ func (r *usageLogRepository) GetBatchUserUsageStats(ctx context.Context, userIDs
|
|||||||
result[id] = &BatchUserUsageStats{UserID: id}
|
result[id] = &BatchUserUsageStats{UserID: id}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GROUP BY (user_id, effective_platform) 一次查询同时得到总值与按平台拆分。
|
||||||
|
// 应用层把同一 user_id 的多行累加为总值,并把非空 platform 行收集到 ByPlatform。
|
||||||
query := `
|
query := `
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
ul.user_id,
|
||||||
COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $2 AND created_at < $3), 0) as total_cost,
|
` + usageLogEffectivePlatformExpr + ` as platform,
|
||||||
COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $4), 0) as today_cost
|
COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $2 AND ul.created_at < $3), 0) as total_cost,
|
||||||
FROM usage_logs
|
COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $4), 0) as today_cost
|
||||||
WHERE user_id = ANY($1)
|
FROM usage_logs ul
|
||||||
AND created_at >= LEAST($2, $4)
|
LEFT JOIN groups g ON g.id = ul.group_id
|
||||||
GROUP BY user_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()
|
today := timezone.Today()
|
||||||
rows, err := r.sql.QueryContext(ctx, query, pq.Array(normalizedUserIDs), startTime, endTime, 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() {
|
for rows.Next() {
|
||||||
var userID int64
|
var userID int64
|
||||||
|
var platform sql.NullString
|
||||||
var total float64
|
var total float64
|
||||||
var todayTotal 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()
|
_ = rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if stats, ok := result[userID]; ok {
|
stats, ok := result[userID]
|
||||||
stats.TotalActualCost = total
|
if !ok {
|
||||||
stats.TodayActualCost = todayTotal
|
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 {
|
if err := rows.Close(); err != nil {
|
||||||
|
|||||||
@ -266,10 +266,17 @@ export async function getUserSpendingRanking(
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlatformUsage {
|
||||||
|
platform: string
|
||||||
|
today_actual_cost: number
|
||||||
|
total_actual_cost: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface BatchUserUsageStats {
|
export interface BatchUserUsageStats {
|
||||||
user_id: number
|
user_id: number
|
||||||
today_actual_cost: number
|
today_actual_cost: number
|
||||||
total_actual_cost: number
|
total_actual_cost: number
|
||||||
|
by_platform?: PlatformUsage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchUsersUsageResponse {
|
export interface BatchUsersUsageResponse {
|
||||||
|
|||||||
@ -15,6 +15,16 @@ import type {
|
|||||||
|
|
||||||
// ==================== Dashboard Types ====================
|
// ==================== 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 {
|
export interface UserDashboardStats {
|
||||||
total_api_keys: number
|
total_api_keys: number
|
||||||
active_api_keys: number
|
active_api_keys: number
|
||||||
@ -37,6 +47,7 @@ export interface UserDashboardStats {
|
|||||||
average_duration_ms: number
|
average_duration_ms: number
|
||||||
rpm: number // 近5分钟平均每分钟请求数
|
rpm: number // 近5分钟平均每分钟请求数
|
||||||
tpm: number // 近5分钟平均每分钟Token数
|
tpm: number // 近5分钟平均每分钟Token数
|
||||||
|
by_platform?: PlatformDashboardStats[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrendParams {
|
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>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import type { UserDashboardStats as UserStatsType } from '@/api/usage'
|
import type { UserDashboardStats as UserStatsType } from '@/api/usage'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
stats: UserStatsType
|
stats: UserStatsType
|
||||||
balance: number
|
balance: number
|
||||||
isSimple: boolean
|
isSimple: boolean
|
||||||
}>()
|
}>()
|
||||||
const { t } = useI18n()
|
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) =>
|
const formatBalance = (b: number) =>
|
||||||
new Intl.NumberFormat('en-US', {
|
new Intl.NumberFormat('en-US', {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
|
|||||||
@ -595,6 +595,10 @@ export default {
|
|||||||
hour: 'Hour',
|
hour: 'Hour',
|
||||||
modelDistribution: 'Model Distribution',
|
modelDistribution: 'Model Distribution',
|
||||||
groupDistribution: 'Group Usage Distribution',
|
groupDistribution: 'Group Usage Distribution',
|
||||||
|
platformBreakdown: 'Per-platform Breakdown',
|
||||||
|
platformBreakdownEmpty: 'No platform usage yet',
|
||||||
|
platformCount: '{count} platforms',
|
||||||
|
platformOther: 'Other',
|
||||||
tokenUsageTrend: 'Token Usage Trend',
|
tokenUsageTrend: 'Token Usage Trend',
|
||||||
noDataAvailable: 'No data available',
|
noDataAvailable: 'No data available',
|
||||||
model: 'Model',
|
model: 'Model',
|
||||||
@ -1754,6 +1758,10 @@ export default {
|
|||||||
subscriptions: 'Subscriptions',
|
subscriptions: 'Subscriptions',
|
||||||
balance: 'Balance',
|
balance: 'Balance',
|
||||||
usage: 'Usage',
|
usage: 'Usage',
|
||||||
|
usageAnthropic: 'Usage (Claude)',
|
||||||
|
usageOpenAI: 'Usage (OpenAI)',
|
||||||
|
usageGemini: 'Usage (Gemini)',
|
||||||
|
usageAntigravity: 'Usage (Antigravity)',
|
||||||
concurrency: 'Concurrency',
|
concurrency: 'Concurrency',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
lastActive: 'Last Active',
|
lastActive: 'Last Active',
|
||||||
@ -1763,6 +1771,8 @@ export default {
|
|||||||
},
|
},
|
||||||
today: 'Today',
|
today: 'Today',
|
||||||
total: 'Last 30d',
|
total: 'Last 30d',
|
||||||
|
sortBy: 'Sort By',
|
||||||
|
sortCurrentPageOnly: 'Sorts current page only',
|
||||||
noSubscription: 'No subscription',
|
noSubscription: 'No subscription',
|
||||||
publicGroupCount: '+{count} public',
|
publicGroupCount: '+{count} public',
|
||||||
exclusiveLabel: 'exclusive',
|
exclusiveLabel: 'exclusive',
|
||||||
@ -1855,6 +1865,12 @@ export default {
|
|||||||
// Balance History
|
// Balance History
|
||||||
balanceHistory: 'Recharge History',
|
balanceHistory: 'Recharge History',
|
||||||
balanceHistoryTip: 'Click to open 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',
|
balanceHistoryTitle: 'User Recharge & Concurrency History',
|
||||||
noBalanceHistory: 'No records found for this user',
|
noBalanceHistory: 'No records found for this user',
|
||||||
allTypes: 'All Types',
|
allTypes: 'All Types',
|
||||||
|
|||||||
@ -594,6 +594,10 @@ export default {
|
|||||||
hour: '按小时',
|
hour: '按小时',
|
||||||
modelDistribution: '模型分布',
|
modelDistribution: '模型分布',
|
||||||
groupDistribution: '分组使用分布',
|
groupDistribution: '分组使用分布',
|
||||||
|
platformBreakdown: '按平台拆分',
|
||||||
|
platformBreakdownEmpty: '暂无平台用量',
|
||||||
|
platformCount: '{count} 个平台',
|
||||||
|
platformOther: '其他',
|
||||||
tokenUsageTrend: 'Token 使用趋势',
|
tokenUsageTrend: 'Token 使用趋势',
|
||||||
noDataAvailable: '暂无数据',
|
noDataAvailable: '暂无数据',
|
||||||
model: '模型',
|
model: '模型',
|
||||||
@ -1775,6 +1779,10 @@ export default {
|
|||||||
subscriptions: '订阅分组',
|
subscriptions: '订阅分组',
|
||||||
balance: '余额',
|
balance: '余额',
|
||||||
usage: '用量',
|
usage: '用量',
|
||||||
|
usageAnthropic: '用量 (Claude)',
|
||||||
|
usageOpenAI: '用量 (OpenAI)',
|
||||||
|
usageGemini: '用量 (Gemini)',
|
||||||
|
usageAntigravity: '用量 (Antigravity)',
|
||||||
concurrency: '并发数',
|
concurrency: '并发数',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
lastActive: '最后活跃时间',
|
lastActive: '最后活跃时间',
|
||||||
@ -1784,6 +1792,8 @@ export default {
|
|||||||
},
|
},
|
||||||
today: '今日',
|
today: '今日',
|
||||||
total: '近30天',
|
total: '近30天',
|
||||||
|
sortBy: '排序方式',
|
||||||
|
sortCurrentPageOnly: '仅对本页数据排序',
|
||||||
noSubscription: '暂无订阅',
|
noSubscription: '暂无订阅',
|
||||||
publicGroupCount: '+{count} 公开',
|
publicGroupCount: '+{count} 公开',
|
||||||
exclusiveLabel: '专属',
|
exclusiveLabel: '专属',
|
||||||
@ -1912,6 +1922,12 @@ export default {
|
|||||||
// 余额变动记录
|
// 余额变动记录
|
||||||
balanceHistory: '充值记录',
|
balanceHistory: '充值记录',
|
||||||
balanceHistoryTip: '点击查看充值记录',
|
balanceHistoryTip: '点击查看充值记录',
|
||||||
|
columnAlwaysVisible: '该列固定显示,不可隐藏',
|
||||||
|
// 平台用量明细(悬浮显示)
|
||||||
|
platformBreakdown: '按平台拆分',
|
||||||
|
platformBreakdownEmpty: '暂无平台明细',
|
||||||
|
platformBreakdownHint: '悬浮查看各平台用量',
|
||||||
|
platformOther: '其他',
|
||||||
balanceHistoryTitle: '用户充值和并发变动记录',
|
balanceHistoryTitle: '用户充值和并发变动记录',
|
||||||
noBalanceHistory: '暂无变动记录',
|
noBalanceHistory: '暂无变动记录',
|
||||||
allTypes: '全部类型',
|
allTypes: '全部类型',
|
||||||
|
|||||||
@ -199,15 +199,22 @@
|
|||||||
<button
|
<button
|
||||||
v-for="col in toggleableColumns"
|
v-for="col in toggleableColumns"
|
||||||
:key="col.key"
|
:key="col.key"
|
||||||
|
:disabled="isForcedVisibleColumn(col.key)"
|
||||||
@click="toggleColumn(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>
|
<span>{{ col.label }}</span>
|
||||||
<Icon
|
<Icon
|
||||||
v-if="isColumnVisible(col.key)"
|
v-if="isColumnVisible(col.key)"
|
||||||
name="check"
|
name="check"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="text-primary-500"
|
:class="isForcedVisibleColumn(col.key) ? 'text-gray-400 dark:text-gray-500' : 'text-primary-500'"
|
||||||
:stroke-width="2"
|
:stroke-width="2"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@ -237,7 +244,7 @@
|
|||||||
<template #table>
|
<template #table>
|
||||||
<DataTable
|
<DataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="users"
|
:data="sortedUsers"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:actions-count="7"
|
:actions-count="7"
|
||||||
:server-side-sort="true"
|
:server-side-sort="true"
|
||||||
@ -413,23 +420,109 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-usage="{ row }">
|
<!-- 用量列自定义表头:列名 + 单个排序图标按钮,点击展开"今日/近30天"菜单。
|
||||||
<div class="text-sm">
|
column.sortable=false,DataTable 内置点击逻辑不会触发;
|
||||||
<div class="flex items-center gap-1.5">
|
菜单项三态循环:desc → asc → off。 -->
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.users.today') }}:</span>
|
<template
|
||||||
<span class="font-medium text-gray-900 dark:text-white">
|
v-for="usageKey in USAGE_COLUMN_KEYS"
|
||||||
${{ (usageStats[row.id]?.today_actual_cost ?? 0).toFixed(4) }}
|
:key="usageKey"
|
||||||
</span>
|
#[`header-${usageKey}`]="{ column }"
|
||||||
</div>
|
>
|
||||||
<div class="mt-0.5 flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.users.total') }}:</span>
|
<span>{{ column.label }}</span>
|
||||||
<span class="font-medium text-gray-900 dark:text-white">
|
<div class="usage-sort-trigger relative">
|
||||||
${{ (usageStats[row.id]?.total_actual_cost ?? 0).toFixed(4) }}
|
<button
|
||||||
</span>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 }">
|
<template #cell-concurrency="{ row }">
|
||||||
<UserConcurrencyCell
|
<UserConcurrencyCell
|
||||||
:current="row.current_concurrency ?? 0"
|
:current="row.current_concurrency ?? 0"
|
||||||
@ -641,6 +734,8 @@ import GroupBadge from '@/components/common/GroupBadge.vue'
|
|||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
import UserAttributesConfigModal from '@/components/user/UserAttributesConfigModal.vue'
|
import UserAttributesConfigModal from '@/components/user/UserAttributesConfigModal.vue'
|
||||||
import UserConcurrencyCell from '@/components/user/UserConcurrencyCell.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 UserCreateModal from '@/components/admin/user/UserCreateModal.vue'
|
||||||
import UserEditModal from '@/components/admin/user/UserEditModal.vue'
|
import UserEditModal from '@/components/admin/user/UserEditModal.vue'
|
||||||
import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.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: 'subscriptions', label: t('admin.users.columns.subscriptions'), sortable: false },
|
||||||
{ key: 'balance', label: t('admin.users.columns.balance'), sortable: true },
|
{ key: 'balance', label: t('admin.users.columns.balance'), sortable: true },
|
||||||
{ key: 'usage', label: t('admin.users.columns.usage'), sortable: false },
|
{ 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: 'concurrency', label: t('admin.users.columns.concurrency'), sortable: true },
|
||||||
{ key: 'status', label: t('admin.users.columns.status'), sortable: true },
|
{ key: 'status', label: t('admin.users.columns.status'), sortable: true },
|
||||||
{ key: 'last_active_at', label: t('admin.users.columns.lastActive'), 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())
|
const hiddenColumns = reactive<Set<string>>(new Set())
|
||||||
|
|
||||||
// Default hidden columns (columns hidden by default on first load)
|
// 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 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'
|
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
|
// Load saved column settings
|
||||||
const loadSavedColumns = () => {
|
const loadSavedColumns = () => {
|
||||||
@ -744,9 +856,27 @@ const loadSavedColumns = () => {
|
|||||||
parsed
|
parsed
|
||||||
.filter(key => !REMOVED_COLUMNS.has(key) && !FORCED_VISIBLE_COLUMNS.has(key))
|
.filter(key => !REMOVED_COLUMNS.has(key) && !FORCED_VISIBLE_COLUMNS.has(key))
|
||||||
.forEach(key => hiddenColumns.add(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 {
|
} else {
|
||||||
// Use default hidden columns on first load
|
// Use default hidden columns on first load
|
||||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||||
|
localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load saved columns:', e)
|
console.error('Failed to load saved columns:', e)
|
||||||
@ -758,13 +888,18 @@ const loadSavedColumns = () => {
|
|||||||
const saveColumnsToStorage = () => {
|
const saveColumnsToStorage = () => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
|
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
|
||||||
|
localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save columns:', e)
|
console.error('Failed to save columns:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle column visibility
|
// Toggle column visibility
|
||||||
|
const isForcedVisibleColumn = (key: string) => FORCED_VISIBLE_COLUMNS.has(key)
|
||||||
const toggleColumn = (key: string) => {
|
const toggleColumn = (key: string) => {
|
||||||
|
// 强制可见列(如 last_active_at)在加载时会被恢复成可见,
|
||||||
|
// 这里阻止用户在当前会话隐藏它,避免"取消勾选 → 刷新又恢复"的反直觉行为。
|
||||||
|
if (FORCED_VISIBLE_COLUMNS.has(key)) return
|
||||||
const wasHidden = hiddenColumns.has(key)
|
const wasHidden = hiddenColumns.has(key)
|
||||||
if (hiddenColumns.has(key)) {
|
if (hiddenColumns.has(key)) {
|
||||||
hiddenColumns.delete(key)
|
hiddenColumns.delete(key)
|
||||||
@ -772,7 +907,7 @@ const toggleColumn = (key: string) => {
|
|||||||
hiddenColumns.add(key)
|
hiddenColumns.add(key)
|
||||||
}
|
}
|
||||||
saveColumnsToStorage()
|
saveColumnsToStorage()
|
||||||
if (wasHidden && (key === 'usage' || key.startsWith('attr_'))) {
|
if (wasHidden && (key === 'usage' || key.startsWith('usage_') || key.startsWith('attr_'))) {
|
||||||
refreshCurrentPageSecondaryData()
|
refreshCurrentPageSecondaryData()
|
||||||
}
|
}
|
||||||
if (key === 'subscriptions') {
|
if (key === 'subscriptions') {
|
||||||
@ -785,7 +920,22 @@ const toggleColumn = (key: string) => {
|
|||||||
|
|
||||||
// Check if column is visible (not in hidden set)
|
// Check if column is visible (not in hidden set)
|
||||||
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
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 hasVisibleSubscriptionsColumn = computed(() => !hiddenColumns.has('subscriptions'))
|
||||||
const hasVisibleGroupsColumn = computed(() => !hiddenColumns.has('groups'))
|
const hasVisibleGroupsColumn = computed(() => !hiddenColumns.has('groups'))
|
||||||
const hasVisibleAttributeColumns = computed(() =>
|
const hasVisibleAttributeColumns = computed(() =>
|
||||||
@ -945,6 +1095,97 @@ const getAttributeDefinition = (attrId: number): UserAttributeDefinition | undef
|
|||||||
return attributeDefinitions.value.find(d => d.id === attrId)
|
return attributeDefinitions.value.find(d => d.id === attrId)
|
||||||
}
|
}
|
||||||
const usageStats = ref<Record<string, BatchUserUsageStats>>({})
|
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
|
// User attribute definitions and values
|
||||||
const attributeDefinitions = ref<UserAttributeDefinition[]>([])
|
const attributeDefinitions = ref<UserAttributeDefinition[]>([])
|
||||||
const userAttributeValues = ref<Record<number, Record<number, string>>>({})
|
const userAttributeValues = ref<Record<number, Record<number, string>>>({})
|
||||||
@ -1095,6 +1336,10 @@ const handleClickOutside = (event: MouseEvent) => {
|
|||||||
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
||||||
showColumnDropdown.value = false
|
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
|
// Close expanded group dropdown when clicking outside
|
||||||
if (expandedGroupUserId.value !== null) {
|
if (expandedGroupUserId.value !== null) {
|
||||||
expandedGroupUserId.value = null
|
expandedGroupUserId.value = null
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user