Files
sub2api/frontend/src/components/user/PlatformUsageBreakdown.vue
T
DaydreamCodingandClaude Opus 4.7 664e9fdcd4 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>
2026-05-19 15:25:34 +08:00

104 lines
3.5 KiB
Vue

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