- 输入/输出列补回 nowrap 并居中(去掉 .right 类后丢了 nowrap 被压折行),定宽 132px - Token / 请求 / 输入输出列全部定宽,剩余空间按比例摊到各列, 不再由单列 auto 独吞宽度造成数字右对齐后左侧大片空白
876 lines
36 KiB
Vue
876 lines
36 KiB
Vue
<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>
|