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:
DaydreamCoding 2026-05-12 19:06:54 +08:00 committed by QTom
parent b006e36af9
commit 664e9fdcd4
11 changed files with 673 additions and 36 deletions

View File

@ -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)

View File

@ -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"`

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View 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>

View 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>

View File

@ -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,

View File

@ -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',

View File

@ -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: '全部类型',

View File

@ -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=falseDataTable 内置点击逻辑不会触发
菜单项三态循环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