Files
token-list-share/src/App.vue
Charles7c db75b7954a 修复排行榜输入/输出列折行与数值列间距失衡
- 输入/输出列补回 nowrap 并居中(去掉 .right 类后丢了 nowrap 被压折行),定宽 132px
- Token / 请求 / 输入输出列全部定宽,剩余空间按比例摊到各列,
  不再由单列 auto 独吞宽度造成数字右对齐后左侧大片空白
2026-09-15 16:21:02 +08:00

876 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useAutoRefresh } from './useAutoRefresh'
import type {
AvailabilityStatus,
CatalogModel,
ChartPoint,
ModelStat,
Period,
RankedUser,
SortKey,
TrendGranularity,
} from './types'
import UsageChart from './UsageChart.vue'
import TotalTrendChart from './TotalTrendChart.vue'
import { GROUP_IDS } from './groups.config'
import {
cacheTokensOf,
filterModelsByAllowlist,
getAccountAvailability,
getAccountModels,
getModels,
getUsageActiveBounds,
getUsageTrend,
getUserBreakdown,
listAccounts,
listGroups,
listUsers,
mergeAvailability,
projectAccountModels,
} from './api'
import { useModelPricing } from './useModelPricing'
import { markSort, nextSort, sortModelsBy } from './modelSort'
import type { ModelSortKey, ModelSortableItem } from './modelSort'
import {
fillTrend,
formatActiveAt,
formatActiveExact,
formatCount,
formatCost,
formatCostExact,
formatPercent,
formatTokens,
formatTokensExact,
normalizeRange,
rangeFor,
trendGranularity,
} from './format'
const period = ref<Period>('day')
const customStart = ref(rangeFor('day').start)
const customEnd = ref(rangeFor('day').end)
const sortBy = ref<SortKey>('actual_cost')
const sortDir = ref<'desc' | 'asc'>('desc')
const query = ref('')
const modelQuery = ref('')
const loading = ref(false)
const error = ref('')
const updatedAt = ref('')
const users = ref<RankedUser[]>([])
const usageByModel = ref<Map<string, ModelStat>>(new Map())
const groupCatalog = ref<CatalogModel[]>([])
const availabilityError = ref('')
const availabilityLoading = ref(false)
const modelDialogOpen = ref(false)
const expanded = ref<number | null>(null)
const loadingModels = reactive<Record<number, boolean>>({})
const loadingTrend = reactive<Record<number, boolean>>({})
const siteTrend = ref<ChartPoint[]>([])
const siteTrendGranularity = ref<TrendGranularity>('hour')
const siteTrendLoading = ref(false)
const siteTrendError = ref<string | null>(null)
let loadSeq = 0
const periodLabel: Record<Exclude<Period, 'custom'>, string> = {
day: '今日',
rolling7: '近 7 天',
week: '本周',
rolling30: '近 30 天',
month: '本月',
}
const range = computed(() => {
if (period.value === 'custom') {
const next = normalizeRange(customStart.value, customEnd.value)
return {
start: next.start,
end: next.end,
label: next.start === next.end ? next.start : `${next.start} ~ ${next.end}`,
}
}
return rangeFor(period.value)
})
const titleLabel = computed(() => (period.value === 'custom' ? '自定义' : periodLabel[period.value]))
/** 时间列排序取值精准日志时间ISO优先回退趋势点 key统一转毫秒时间戳解析失败返回 null排最后 */
function activeSortValue(user: RankedUser, which: 'first' | 'last'): number | null {
const iso = which === 'first' ? user.firstActiveExact : user.lastActiveExact
if (iso) {
const t = new Date(iso).getTime()
if (!Number.isNaN(t)) return t
return null
}
const key = which === 'first' ? user.firstActive || '' : user.lastActive || ''
if (!key) return null
// 趋势 key 形如「YYYY-MM-DD HH:00」小时粒度或「YYYY-MM-DD」天粒度按上海时区解析
const parsed = new Date(key.includes(' ') ? `${key.replace(' ', 'T')}:00+08:00` : `${key}T00:00:00+08:00`).getTime()
return Number.isNaN(parsed) ? null : parsed
}
/** 时间列排序:无效值(无活跃记录)恒排最后,与升降序无关 */
function compareActive(a: RankedUser, b: RankedUser, which: 'first' | 'last', dir: number): number {
const ta = activeSortValue(a, which)
const tb = activeSortValue(b, which)
if (ta === null && tb === null) return 0
if (ta === null) return 1
if (tb === null) return -1
return (ta - tb) * dir
}
const sortedUsers = computed(() => {
const q = query.value.trim().toLowerCase()
const list = users.value.filter((u) => {
if (!q) return true
return u.username.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
})
const key = sortBy.value
const dir = sortDir.value === 'asc' ? 1 : -1
return [...list].sort((a, b) => {
const diff =
key === 'first_active' || key === 'last_active'
? compareActive(a, b, key === 'first_active' ? 'first' : 'last', dir)
: (Number(a[key] || 0) - Number(b[key] || 0)) * dir
if (diff !== 0) return diff
return a.username.localeCompare(b.username, 'zh-CN')
})
})
/** 表头排序文案:当前列显示方向箭头 */
function sortMark(key: SortKey): string {
if (sortBy.value !== key) return ''
return sortDir.value === 'asc' ? '↑' : '↓'
}
/** 模型参考单价(分组配置优先,内置目录懒加载兜底),弹窗与明细共用 */
const {
loadConfigured: loadConfiguredPricing,
requestBuiltinPricing,
pricingTitle,
pricingInputLabel,
unitPriceOf: modelUnitPriceOf,
} = useModelPricing()
function modelMetricOf(item: ModelSortableItem, key: ModelSortKey): number | null {
switch (key) {
case 'unit_price':
return modelUnitPriceOf(item.model)
case 'cache':
return Number(item.cache_creation_tokens || 0) + Number(item.cache_read_tokens || 0)
case 'model':
return 0
default:
return Number(item[key] || 0)
}
}
/** 「可用模型」弹窗排序状态:默认区间成本降序 */
const catalogSort = ref<ModelSortKey>('actual_cost')
const catalogDir = ref<'asc' | 'desc'>('desc')
const setCatalogSort = (key: ModelSortKey) => nextSort(catalogSort, catalogDir, key)
const catalogMark = (key: ModelSortKey) => markSort(catalogSort, catalogDir, key)
/** 用户模型明细排序状态:默认单价降序 */
const detailSort = ref<ModelSortKey>('unit_price')
const detailDir = ref<'asc' | 'desc'>('desc')
const setDetailSort = (key: ModelSortKey) => nextSort(detailSort, detailDir, key)
const detailMark = (key: ModelSortKey) => markSort(detailSort, detailDir, key)
/** 展开行里的模型明细:按当前明细排序状态排 */
function sortedDetailModels(models: ModelStat[]): ModelStat[] {
return sortModelsBy(models, detailSort.value, detailDir.value, modelMetricOf)
}
const catalogModels = computed<CatalogModel[]>(() => {
const usage = usageByModel.value
return groupCatalog.value.map((item) => {
const stat = usage.get(item.model) || usage.get(item.model.toLowerCase())
return {
...item,
requests: stat?.requests || 0,
total_tokens: stat?.total_tokens || 0,
actual_cost: stat?.actual_cost || 0,
}
})
})
const visibleCatalog = computed(() => {
const q = modelQuery.value.trim().toLowerCase()
const list = catalogModels.value.filter((item) => !q || item.model.toLowerCase().includes(q))
return sortModelsBy(list, catalogSort.value, catalogDir.value, modelMetricOf)
})
const availableCount = computed(
() => catalogModels.value.filter((item) => item.status === 'available').length,
)
const totalTokens = computed(() => users.value.reduce((s, u) => s + (u.total_tokens || 0), 0))
const totalRequests = computed(() => users.value.reduce((s, u) => s + (u.requests || 0), 0))
const totalInput = computed(() => users.value.reduce((s, u) => s + (u.input_tokens || 0), 0))
const totalOutput = computed(() => users.value.reduce((s, u) => s + (u.output_tokens || 0), 0))
const totalCache = computed(() => users.value.reduce((s, u) => s + (u.cache_tokens || 0), 0))
/** 参考成本合计Sub2API 记账单位USD */
const totalCost = computed(() => users.value.reduce((s, u) => s + (u.actual_cost || 0), 0))
/** 每百万 Token 均价,成本效率参考 */
const costPerMillion = computed(() =>
totalTokens.value ? (totalCost.value / totalTokens.value) * 1e6 : 0,
)
/** 成本占比:该用户参考成本 / 全部用户参考成本 */
const totalAccountCost = computed(() =>
users.value.reduce((s, u) => s + (u.account_cost || 0), 0),
)
const visibleTotalCost = computed(() =>
sortedUsers.value.reduce((s, u) => s + (u.actual_cost || 0), 0),
)
function costShare(user: RankedUser): number {
if (!totalCost.value) return 0
return ((user.actual_cost || 0) / totalCost.value) * 100
}
/** 扫描逐点趋势,取本周期内首个 / 末个有用量(请求或 Token的时间点 key */
function computeActiveRange(trend: ChartPoint[]): { first: string | null; last: string | null } {
let first: string | null = null
let last: string | null = null
for (const p of trend) {
if ((p.tokens || 0) > 0 || (p.requests || 0) > 0) {
if (!first) first = p.key
last = p.key
}
}
return { first, last }
}
/** 单元格短文案:有日志精准值(到分)用精准值,否则回退趋势粒度近似值 */
function activeText(exact: string | null, approx: string | null): string {
return formatActiveExact(exact) || formatActiveAt(approx)
}
/** 首次 / 末次活跃时间的悬浮说明(上海时区):精准值到秒,未取到时回退趋势粒度近似值 */
function activeRangeTitle(user: RankedUser): string {
const first = formatActiveExact(user.firstActiveExact, { seconds: true }) || formatActiveAt(user.firstActive)
const last = formatActiveExact(user.lastActiveExact, { seconds: true }) || formatActiveAt(user.lastActive)
return `周期首次使用 ${first || '—'} · 最后活跃 ${last || '—'}(上海时区)`
}
/** 输入输出列的悬停说明:输入 / 输出 / 缓存的精确值 */
function ioTitle(user: RankedUser): string {
return `输入 ${formatTokensExact(user.input_tokens)} · 输出 ${formatTokensExact(user.output_tokens)} · 缓存 ${formatTokensExact(user.cache_tokens)}`
}
function applyPreset(next: Exclude<Period, 'custom'>) {
period.value = next
const preset = rangeFor(next)
customStart.value = preset.start
customEnd.value = preset.end
}
function onCustomDateChange() {
period.value = 'custom'
const next = normalizeRange(customStart.value, customEnd.value)
customStart.value = next.start
customEnd.value = next.end
}
function setSort(key: SortKey) {
if (sortBy.value === key) {
sortDir.value = sortDir.value === 'desc' ? 'asc' : 'desc'
return
}
sortBy.value = key
sortDir.value = 'desc'
}
function rankClass(i: number) {
if (i === 0) return 'gold'
if (i === 1) return 'silver'
if (i === 2) return 'bronze'
return ''
}
function statusLabel(status: AvailabilityStatus) {
return status === 'available' ? '可用' : '不可用'
}
/** 模型名复制:点击后短暂显示对勾(与分组页一致) */
const copiedModel = ref('')
async function copyModel(model: string) {
try {
await navigator.clipboard.writeText(model)
} catch {
// 非安全上下文http 访问)下 clipboard API 不可用,退回临时 textarea
const ta = document.createElement('textarea')
ta.value = model
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
copiedModel.value = model
window.setTimeout(() => {
if (copiedModel.value === model) copiedModel.value = ''
}, 1500)
}
function usageKeyMap(list: ModelStat[]) {
const map = new Map<string, ModelStat>()
for (const item of list) {
map.set(item.model, item)
map.set(item.model.toLowerCase(), item)
}
return map
}
async function loadAvailability() {
availabilityLoading.value = true
availabilityError.value = ''
try {
const [accountPage, availability, groupList] = await Promise.all([
listAccounts(),
getAccountAvailability(),
listGroups(),
])
// 只统计「已开放分组」groups.config.ts 中配置的 GROUP_IDS账号挂载的模型
// 每个分组按 Sub2API 分组级模型白名单model_allowlist过滤与网关「看到什么=能调什么」一致。
const wantedGroups = new Set<number>(GROUP_IDS as readonly number[])
const accounts = (accountPage.items || []).filter((a) =>
(a.group_ids || []).some((id) => wantedGroups.has(id)),
)
const allowByGroup = new Map<number, { enabled: boolean; models?: string[] }>()
for (const g of groupList) {
if (wantedGroups.has(g.id)) allowByGroup.set(g.id, g.model_allowlist ?? { enabled: false })
}
const isUp = (id: number) => availability[String(id)]?.is_available === true
// 逐个分组:每个账号的模型先按分组白名单过滤(与网关「能调什么」一致),
// 再按「该账号是否在线」标注状态。状态判定是账号粒度,不能用「分组任一账号
// 在线」把可用放大到全组模型,否则只挂在故障/限流账号上的模型也会被标成可用。
const statusMap = new Map<string, boolean>()
for (const gid of wantedGroups) {
const allow = allowByGroup.get(gid)
const groupAccounts = accounts.filter((a) => (a.group_ids || []).includes(gid))
if (!groupAccounts.length) continue
const perAccount = await Promise.all(
groupAccounts.map(async (a) => {
// 上游全量目录 → 账号 model_mapping 投影(上游名换成别名)→ 分组白名单
const upstream = await getAccountModels(a.id)
return {
up: isUp(a.id),
models: filterModelsByAllowlist(projectAccountModels(a, upstream), allow),
}
}),
)
for (const entry of perAccount) {
mergeAvailability(statusMap, entry.models, entry.up)
}
}
groupCatalog.value = [...statusMap.keys()]
.sort((a, b) => a.localeCompare(b))
.map((model) => ({
model,
status: (statusMap.get(model) ? 'available' : 'unavailable') as AvailabilityStatus,
requests: 0,
total_tokens: 0,
actual_cost: 0,
}))
// 弹窗要展示单价:为分组未配价的模型补查内置目录
requestBuiltinPricing(groupCatalog.value.map((item) => item.model))
} catch (err) {
availabilityError.value = err instanceof Error ? err.message : String(err)
groupCatalog.value = []
} finally {
availabilityLoading.value = false
}
}
function openModelDialog() {
modelDialogOpen.value = true
if (!availabilityLoading.value && groupCatalog.value.length === 0) {
void loadAvailability()
}
}
function closeModelDialog() {
modelDialogOpen.value = false
}
async function loadSiteTrend(start: string, end: string, seq: number) {
siteTrendLoading.value = true
siteTrendError.value = null
try {
const granularity = trendGranularity(start, end)
const res = await getUsageTrend(start, end, granularity)
if (seq !== loadSeq) return
siteTrendGranularity.value = granularity
siteTrend.value = fillTrend(start, end, granularity, res.trend || [])
} catch (err) {
if (seq !== loadSeq) return
siteTrendError.value = err instanceof Error ? err.message : String(err)
siteTrend.value = []
} finally {
if (seq === loadSeq) siteTrendLoading.value = false
}
}
async function load() {
const seq = ++loadSeq
loading.value = true
error.value = ''
expanded.value = null
siteTrend.value = []
siteTrendError.value = null
try {
const { start, end } = range.value
const [userPage, breakdown, modelStats] = await Promise.all([
listUsers(),
getUserBreakdown(start, end, sortBy.value),
getModels(start, end),
loadSiteTrend(start, end, seq),
])
if (seq !== loadSeq) return
// 后台模型单价配置(静态数据,失败不影响主榜)
void loadConfiguredPricing()
const directory = new Map((userPage.items || []).map((u) => [u.id, u]))
const ranked: RankedUser[] = []
for (const usage of breakdown.users || []) {
const profile = directory.get(usage.user_id)
if ((usage.requests || 0) <= 0) continue
ranked.push({
...usage,
email: profile?.email || usage.email,
username: profile?.username || profile?.email || usage.email || `用户 #${usage.user_id}`,
role: profile?.role || 'user',
share: 0,
models: [],
modelsLoaded: false,
modelsError: null,
trend: [],
trendGranularity: trendGranularity(start, end),
trendLoaded: false,
trendError: null,
firstActive: null,
lastActive: null,
firstActiveExact: null,
lastActiveExact: null,
})
}
const tokenSum = ranked.reduce((s, u) => s + (u.total_tokens || 0), 0)
for (const u of ranked) {
u.share = tokenSum ? (u.total_tokens / tokenSum) * 100 : 0
}
users.value = ranked
usageByModel.value = usageKeyMap(modelStats.models || [])
updatedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
// 预取每个用户的模型明细、逐点趋势、日志首末活跃时刻:
// 趋势给出近似兜底值,日志探针给出分钟级精准值(精准优先展示)
await Promise.all([
...users.value.filter((u) => !u.modelsLoaded).map((u) => ensureModels(u, false)),
...users.value.filter((u) => !u.trendLoaded).map((u) => ensureTrend(u, false)),
...users.value.map((u) => ensureActiveBounds(u, start, end, seq)),
])
if (seq !== loadSeq) return
for (const u of users.value) {
const { first, last } = computeActiveRange(u.trend)
u.firstActive = first
u.lastActive = last
}
} catch (err) {
if (seq !== loadSeq) return
error.value = err instanceof Error ? err.message : String(err)
users.value = []
usageByModel.value = new Map()
updatedAt.value = ''
siteTrend.value = []
} finally {
if (seq === loadSeq) loading.value = false
}
}
async function ensureActiveBounds(user: RankedUser, start: string, end: string, seq: number) {
try {
const { first, last } = await getUsageActiveBounds(user.user_id, start, end)
if (seq !== loadSeq) return
user.firstActiveExact = first
user.lastActiveExact = last
} catch {
// 日志接口不可用时静默回退趋势粒度近似值,不打扰主榜
}
}
async function ensureModels(user: RankedUser, force = false) {
if (user.modelsLoaded && !force) return
if (loadingModels[user.user_id]) return
loadingModels[user.user_id] = true
user.modelsError = null
try {
const { start, end } = range.value
const res = await getModels(start, end, user.user_id)
// 模型明细默认按参考成本降序,成本相同的再看 Token
user.models = [...(res.models || [])].sort(
(a, b) =>
(b.actual_cost || 0) - (a.actual_cost || 0) || (b.total_tokens || 0) - (a.total_tokens || 0),
)
user.modelsLoaded = true
requestBuiltinPricing(user.models.map((m) => m.model))
} catch (err) {
user.modelsError = err instanceof Error ? err.message : String(err)
} finally {
loadingModels[user.user_id] = false
}
}
async function ensureTrend(user: RankedUser, force = false) {
if (user.trendLoaded && !force) return
if (loadingTrend[user.user_id]) return
loadingTrend[user.user_id] = true
user.trendError = null
try {
const { start, end } = range.value
const granularity = trendGranularity(start, end)
const res = await getUsageTrend(start, end, granularity, user.user_id)
user.trendGranularity = granularity
user.trend = fillTrend(start, end, granularity, res.trend || [])
user.trendLoaded = true
} catch (err) {
user.trendError = err instanceof Error ? err.message : String(err)
user.trend = []
} finally {
loadingTrend[user.user_id] = false
}
}
async function toggle(user: RankedUser) {
if (expanded.value === user.user_id) {
expanded.value = null
return
}
expanded.value = user.user_id
await Promise.all([ensureModels(user), ensureTrend(user)])
}
function modelCostShare(user: RankedUser, model: ModelStat) {
if (!user.actual_cost) return 0
return ((model.actual_cost || 0) / user.actual_cost) * 100
}
watch(
() => [period.value, customStart.value, customEnd.value],
load,
)
// 排序键也传给服务端,保证 limit 截断时拿到的是该维度的前 200 名
watch(sortBy, load)
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeModelDialog()
}
onMounted(() => {
loadAvailability()
load()
window.addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
})
// 挂着页面时每 5 分钟静默刷新当前周期数据
useAutoRefresh(load, { isLoading: () => loading.value })
</script>
<template>
<div class="page">
<header class="hero">
<div>
<p class="eyebrow">行业应用 · 用量排行</p>
<h1>{{ titleLabel }}用量榜</h1>
<p class="range-label">
统计区间 {{ range.label }} · 默认按参考成本降序
<span v-if="updatedAt"> · 更新于 {{ updatedAt }}</span>
</p>
</div>
</header>
<div class="range-bar">
<div class="period-switch">
<button type="button" :class="{ active: period === 'day' }" @click="applyPreset('day')">今日</button>
<button type="button" :class="{ active: period === 'week' }" @click="applyPreset('week')">本周</button>
<button type="button" :class="{ active: period === 'month' }" @click="applyPreset('month')">本月</button>
<button type="button" :class="{ active: period === 'rolling7' }" @click="applyPreset('rolling7')"> 7 </button>
<button type="button" :class="{ active: period === 'rolling30' }" @click="applyPreset('rolling30')"> 30 </button>
</div>
<label class="date-field">
<span>开始</span>
<input v-model="customStart" type="date" @change="onCustomDateChange" />
</label>
<label class="date-field">
<span>结束</span>
<input v-model="customEnd" type="date" @change="onCustomDateChange" />
</label>
<div class="range-actions">
<button type="button" class="refresh" @click.stop.prevent="openModelDialog">
可用模型
<span v-if="groupCatalog.length" class="count-pill">{{ groupCatalog.length }}</span>
</button>
</div>
</div>
<section class="stats">
<article class="stat">
<div class="k">参考成本</div>
<div class="v num" :title="'账户成本合计 ' + formatCostExact(totalAccountCost)">
{{ formatCost(totalCost) }}
</div>
<div class="s">每百万 Token {{ formatCost(costPerMillion) }}</div>
</article>
<article class="stat">
<div class="k">Token 总量</div>
<div class="v num" :title="formatTokensExact(totalTokens)">{{ formatTokens(totalTokens) }}</div>
<div class="s">含缓存 {{ formatTokens(totalCache) }}</div>
</article>
<article class="stat">
<div class="k">输入 / 输出</div>
<div class="v num">
{{ formatTokens(totalInput) }}
<span style="color: var(--faint); font-size: 14px"> / </span>
{{ formatTokens(totalOutput) }}
</div>
<div class="s">不含缓存</div>
</article>
<article class="stat">
<div class="k">请求次数</div>
<div class="v num" :title="formatCount(totalRequests)">{{ formatCount(totalRequests) }}</div>
<div class="s">使用用户合计</div>
</article>
<article class="stat">
<div class="k">使用人数</div>
<div class="v num">{{ users.length }}</div>
<div class="s">本区间有请求</div>
</article>
</section>
<TotalTrendChart
:points="siteTrend"
:granularity="siteTrendGranularity"
:loading="siteTrendLoading"
:error="siteTrendError"
/>
<div class="toolbar">
<input v-model="query" class="search" placeholder="按姓名 / 邮箱筛选" />
<div class="hint">点击用户行展开模型明细 · 点表头改排序再点一次切换升 / · 筛选合计成本 {{ formatCost(visibleTotalCost) }}</div>
<button type="button" class="refresh" :disabled="loading" @click="load">刷新</button>
</div>
<div class="board">
<div v-if="loading" class="state"><span class="loading-dot" />正在拉取 {{ titleLabel }} 数据</div>
<div v-else-if="error" class="state error">{{ error }}</div>
<div v-else-if="sortedUsers.length === 0" class="state">这个区间没有请求</div>
<table v-else class="rank">
<colgroup>
<col class="col-rank" />
<col class="col-user" />
<col class="col-cost" />
<col class="col-share hide-sm" />
<col class="col-num" />
<col class="col-share hide-sm" />
<col class="col-io hide-sm" />
<col class="col-requests" />
<col class="col-time hide-sm" />
<col class="col-time hide-sm" />
</colgroup>
<thead>
<tr>
<th class="col-rank">#</th>
<th class="left">用户</th>
<th class="sortable right cost-col" :class="{ active: sortBy === 'actual_cost' }" @click="setSort('actual_cost')">
成本 {{ sortMark('actual_cost') }}
</th>
<th class="right hide-sm share-col">成本占比</th>
<th class="sortable right" :class="{ active: sortBy === 'total_tokens' }" @click="setSort('total_tokens')">
Token {{ sortMark('total_tokens') }}
</th>
<th class="right hide-sm share-col">Token 占比</th>
<th class="hide-sm io-col" title="输入 / 输出 Token悬停查看缓存">输入/输出</th>
<th class="sortable right" :class="{ active: sortBy === 'requests' }" @click="setSort('requests')">
请求 {{ sortMark('requests') }}
</th>
<th class="time-col hide-sm sortable" :class="{ active: sortBy === 'first_active' }" @click="setSort('first_active')" title="本周期内首次有请求 / Token 的时间">
开始使用 {{ sortMark('first_active') }}
</th>
<th class="time-col hide-sm sortable" :class="{ active: sortBy === 'last_active' }" @click="setSort('last_active')" title="本周期内末次有请求 / Token 的时间">
最后活跃 {{ sortMark('last_active') }}
</th>
</tr>
</thead>
<tbody>
<template v-for="(user, index) in sortedUsers" :key="user.user_id">
<tr
class="user-row"
:class="{ open: expanded === user.user_id, idle: (user.total_tokens || 0) === 0 }"
@click="toggle(user)"
>
<td class="col-rank">
<span class="rank-cell">
<span class="caret" :class="{ open: expanded === user.user_id }">
<svg viewBox="0 0 12 12" width="12" height="12" aria-hidden="true">
<path d="M4 2.5 8 6 4 9.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
<span class="rank-badge" :class="rankClass(index)">{{ index + 1 }}</span>
</span>
</td>
<td class="left">
<span class="user-name">{{ user.username }}</span>
</td>
<td class="right num cost-col" :title="formatCostExact(user.actual_cost)">
{{ formatCost(user.actual_cost) }}
</td>
<td class="right hide-sm share-col">
<div class="share-cell">
<span class="share-bar"><i class="cost" :style="{ width: `${Math.min(costShare(user), 100)}%` }" /></span>
<span class="num">{{ formatPercent(costShare(user)) }}</span>
</div>
</td>
<td class="right num" :title="formatTokensExact(user.total_tokens)">{{ formatTokens(user.total_tokens) }}</td>
<td class="right hide-sm share-col">
<div class="share-cell">
<span class="share-bar"><i :style="{ width: `${Math.min(user.share, 100)}%` }" /></span>
<span class="num">{{ formatPercent(user.share) }}</span>
</div>
</td>
<td class="num hide-sm io-col" :title="ioTitle(user)">
{{ formatTokens(user.input_tokens) }}<span class="io-sep"> / </span>{{ formatTokens(user.output_tokens) }}
</td>
<td class="right num">{{ formatCount(user.requests) }}</td>
<td class="time-col num hide-sm" :title="activeRangeTitle(user)">{{ activeText(user.firstActiveExact, user.firstActive) }}</td>
<td class="time-col num hide-sm" :title="activeRangeTitle(user)">{{ activeText(user.lastActiveExact, user.lastActive) }}</td>
</tr>
<tr v-if="expanded === user.user_id" class="expand">
<td colspan="10">
<div class="expand-inner" @click.stop>
<UsageChart
:points="user.trend"
:granularity="user.trendGranularity"
:loading="!!loadingTrend[user.user_id]"
:error="user.trendError"
/>
<p v-if="loadingModels[user.user_id]" class="expand-note">模型用量加载中</p>
<p v-else-if="user.modelsError" class="expand-note error">{{ user.modelsError }}</p>
<div v-if="user.models.length" class="model-table-wrap">
<table class="model-table">
<thead>
<tr>
<th class="sortable" :class="{ active: detailSort === 'model' }" @click="setDetailSort('model')">模型 {{ detailMark('model') }}</th>
<th class="sortable right" :class="{ active: detailSort === 'requests' }" @click="setDetailSort('requests')">请求 {{ detailMark('requests') }}</th>
<th class="sortable right" :class="{ active: detailSort === 'input_tokens' }" @click="setDetailSort('input_tokens')">输入 {{ detailMark('input_tokens') }}</th>
<th class="sortable right" :class="{ active: detailSort === 'output_tokens' }" @click="setDetailSort('output_tokens')">输出 {{ detailMark('output_tokens') }}</th>
<th class="sortable right" :class="{ active: detailSort === 'cache' }" @click="setDetailSort('cache')">缓存 {{ detailMark('cache') }}</th>
<th class="sortable right" :class="{ active: detailSort === 'total_tokens' }" @click="setDetailSort('total_tokens')">Token {{ detailMark('total_tokens') }}</th>
<th class="sortable right" :class="{ active: detailSort === 'unit_price' }" @click="setDetailSort('unit_price')" title="输入价(每百万 Token悬停看全套价格与来源">单价 {{ detailMark('unit_price') }}</th>
<th class="sortable right" :class="{ active: detailSort === 'actual_cost' }" @click="setDetailSort('actual_cost')">成本 {{ detailMark('actual_cost') }}</th>
<th style="width: 160px">成本占比</th>
</tr>
</thead>
<tbody>
<tr v-for="m in sortedDetailModels(user.models)" :key="m.model">
<td class="model-name">{{ m.model }}</td>
<td class="right num">{{ formatCount(m.requests) }}</td>
<td class="right num" :title="formatTokensExact(m.input_tokens)">{{ formatTokens(m.input_tokens) }}</td>
<td class="right num" :title="formatTokensExact(m.output_tokens)">{{ formatTokens(m.output_tokens) }}</td>
<td class="right num" :title="formatTokensExact(cacheTokensOf(m))">{{ formatTokens(cacheTokensOf(m)) }}</td>
<td class="right num" :title="formatTokensExact(m.total_tokens)">{{ formatTokens(m.total_tokens) }}</td>
<td class="right num pricing-cell" :title="pricingTitle(m.model)">{{ pricingInputLabel(m.model) }}</td>
<td class="right num" :title="formatCostExact(m.actual_cost)">{{ formatCost(m.actual_cost) }}</td>
<td>
<div class="share-cell">
<span class="share-bar"><i class="cost" :style="{ width: `${Math.min(modelCostShare(user, m), 100)}%` }" /></span>
<span class="num">{{ formatPercent(modelCostShare(user, m)) }}</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else-if="!loadingModels[user.user_id]" class="expand-note">本区间没有模型用量</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<Teleport to="body">
<div v-if="modelDialogOpen" class="modal-mask" @click.self="closeModelDialog">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="model-dialog-title" @click.stop>
<div class="modal-head">
<div>
<h2 id="model-dialog-title">可用模型</h2>
<p>
已开放分组groups.config.ts实际挂载的模型 · 可用 {{ availableCount }} / {{ catalogModels.length }}
<span v-if="availabilityError" class="error"> · {{ availabilityError }}</span>
</p>
</div>
<button type="button" class="refresh" @click="closeModelDialog">关闭</button>
</div>
<input v-model="modelQuery" class="search modal-search" placeholder="筛选模型名" />
<div v-if="availabilityLoading" class="state"><span class="loading-dot" />正在拉取模型列表</div>
<div v-else-if="visibleCatalog.length === 0" class="state">没有匹配的模型</div>
<div v-else class="modal-table-wrap">
<table class="model-table catalog-table">
<thead>
<tr>
<th class="sortable" :class="{ active: catalogSort === 'model' }" @click="setCatalogSort('model')">
模型 {{ catalogMark('model') }}
</th>
<th>状态</th>
<th class="sortable right" :class="{ active: catalogSort === 'unit_price' }" @click="setCatalogSort('unit_price')" title="输入价(每百万 Token悬停行内看全套价格">
单价 {{ catalogMark('unit_price') }}
</th>
<th class="sortable right" :class="{ active: catalogSort === 'total_tokens' }" @click="setCatalogSort('total_tokens')">
区间 Token {{ catalogMark('total_tokens') }}
</th>
<th class="sortable right" :class="{ active: catalogSort === 'actual_cost' }" @click="setCatalogSort('actual_cost')">
区间成本 {{ catalogMark('actual_cost') }}
</th>
<th class="sortable right" :class="{ active: catalogSort === 'requests' }" @click="setCatalogSort('requests')">
请求 {{ catalogMark('requests') }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in visibleCatalog" :key="item.model">
<td class="model-name">
<button
type="button"
class="copy-model"
:title="`复制 ${item.model}`"
@click="copyModel(item.model)"
>
<span class="model-name">{{ item.model }}</span>
<span class="copy-icon">{{ copiedModel === item.model ? '✓' : '⧉' }}</span>
</button>
</td>
<td>
<span class="avail" :class="{ on: item.status === 'available', off: item.status !== 'available' }">
{{ statusLabel(item.status) }}
</span>
</td>
<td class="right num pricing-cell" :title="pricingTitle(item.model)">{{ pricingInputLabel(item.model) }}</td>
<td class="right num" :title="formatTokensExact(item.total_tokens)">{{ formatTokens(item.total_tokens) }}</td>
<td class="right num" :title="formatCostExact(item.actual_cost)">{{ formatCost(item.actual_cost) }}</td>
<td class="right num">{{ formatCount(item.requests) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</Teleport>
</div>
</template>