- ratelimit(内存计数): 密钥级每日请求数/Token 配额、用户级每秒速率 (OT_RATELIMIT_USER_RPS), 超限返回 429 - 网关 Auth 前置配额/限流检查, finishUsage 累计密钥 token 用量 - 前端 Skeleton 组件 + Dashboard/管理总览加载态 - Go 服务托管 web/dist 静态资源(SPA 回退), 单端口即可访问前后端 Co-Authored-By: Claude <noreply@anthropic.com>
126 lines
4.9 KiB
Vue
126 lines
4.9 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, ref } from 'vue'
|
||
import { http, errMsg } from '@/api/client'
|
||
import { useToastStore } from '@/stores/toast'
|
||
import { fmtMoney, fmtNum, fmtCost, fmtTime } from '@/lib/format'
|
||
import Badge from '@/components/ui/Badge.vue'
|
||
import Skeleton from '@/components/ui/Skeleton.vue'
|
||
import TrendChart from '@/components/ui/TrendChart.vue'
|
||
import type { UsageLog } from '@/types'
|
||
|
||
const toast = useToastStore()
|
||
|
||
const loading = ref(true)
|
||
const balance = ref(0)
|
||
const today = ref({ requests: 0, tokens: 0, cost: 0 })
|
||
const monthCost = ref(0)
|
||
const models = ref(0)
|
||
const trend = ref<{ label: string; value: number }[]>([])
|
||
const logs = ref<UsageLog[]>([])
|
||
|
||
async function load() {
|
||
try {
|
||
const [b, s, l] = await Promise.all([
|
||
http.get('/user/balance'),
|
||
http.get('/usage/stats?group=day&from=' + daysAgo(13)),
|
||
http.get('/usage/logs?page_size=8'),
|
||
])
|
||
const d = b.data.data
|
||
balance.value = d.balance
|
||
today.value = d.today
|
||
monthCost.value = d.spent_last_30d
|
||
models.value = d.models_available
|
||
trend.value = s.data.data.items.map((x: { date: string; cost: number }) => ({ label: x.date.slice(5), value: x.cost }))
|
||
logs.value = l.data.data.items
|
||
} catch (e) {
|
||
toast.err(errMsg(e))
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function daysAgo(n: number): string {
|
||
const d = new Date(Date.now() - n * 864e5)
|
||
return d.toISOString().slice(0, 10)
|
||
}
|
||
|
||
onMounted(load)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="mx-auto max-w-5xl space-y-6">
|
||
<div class="mb-2">
|
||
<h1 class="text-lg font-semibold">仪表盘</h1>
|
||
<p class="text-sm text-zinc-500">余额、用量与最近请求</p>
|
||
</div>
|
||
|
||
<!-- 指标条 -->
|
||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||
<div class="card p-4">
|
||
<p class="text-xs text-zinc-500">余额</p>
|
||
<Skeleton v-if="loading" class="mt-2" height="1.5rem" width="55%" />
|
||
<p v-else class="mono-num mt-1 text-xl text-emerald-300">{{ fmtMoney(balance) }}</p>
|
||
</div>
|
||
<div class="card p-4">
|
||
<p class="text-xs text-zinc-500">今日请求</p>
|
||
<Skeleton v-if="loading" class="mt-2" height="1.5rem" width="40%" />
|
||
<p v-else class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(today.requests) }}</p>
|
||
</div>
|
||
<div class="card p-4">
|
||
<p class="text-xs text-zinc-500">今日 Token</p>
|
||
<Skeleton v-if="loading" class="mt-2" height="1.5rem" width="40%" />
|
||
<p v-else class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(today.tokens) }}</p>
|
||
</div>
|
||
<div class="card p-4">
|
||
<p class="text-xs text-zinc-500">近 30 日消耗</p>
|
||
<Skeleton v-if="loading" class="mt-2" height="1.5rem" width="45%" />
|
||
<p v-else class="mono-num mt-1 text-xl text-zinc-100">{{ fmtCost(monthCost) }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid gap-6 lg:grid-cols-5">
|
||
<!-- 用量趋势 -->
|
||
<div class="card p-5 lg:col-span-3">
|
||
<div class="mb-4 flex items-baseline justify-between">
|
||
<h2 class="text-sm font-semibold">近 14 天成本</h2>
|
||
<span class="font-mono text-[11px] text-zinc-600">{{ models }} 个可用模型</span>
|
||
</div>
|
||
<Skeleton v-if="loading" height="160px" />
|
||
<TrendChart v-else :points="trend" :format="(v) => '$' + v.toExponential(2)" />
|
||
</div>
|
||
|
||
<!-- 最近请求 -->
|
||
<div class="card p-5 lg:col-span-2">
|
||
<div class="mb-3 flex items-baseline justify-between">
|
||
<h2 class="text-sm font-semibold">最近请求</h2>
|
||
<router-link to="/console/usage" class="text-xs text-accent hover:text-accent-strong">查看全部</router-link>
|
||
</div>
|
||
<ul v-if="loading" class="divide-y divide-zinc-800/70">
|
||
<li v-for="i in 4" :key="i" class="flex items-center justify-between py-2">
|
||
<Skeleton height="0.75rem" width="40%" />
|
||
<Skeleton height="0.75rem" width="30%" />
|
||
</li>
|
||
</ul>
|
||
<ul v-else class="divide-y divide-zinc-800/70">
|
||
<li v-for="l in logs" :key="l.id" class="flex items-center justify-between py-2">
|
||
<div class="min-w-0">
|
||
<p class="truncate font-mono text-xs text-zinc-300">{{ l.model }}</p>
|
||
<p class="text-[11px] text-zinc-600">{{ fmtTime(l.created_at) }}</p>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<span class="mono-num text-xs text-zinc-400">{{ l.input_tokens }}/{{ l.output_tokens }}</span>
|
||
<span class="mono-num w-16 text-right text-xs text-zinc-500">{{ fmtCost(l.cost) }}</span>
|
||
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge>
|
||
</div>
|
||
</li>
|
||
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-zinc-600">
|
||
还没有请求记录,去
|
||
<router-link to="/console/keys" class="text-accent">API Keys</router-link>
|
||
创建密钥开始调用
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|