M0-M4: 推倒重来基线(基建+用户/密钥/核心代理+前端+管理后台+三协议互转)

- 后端 Go+Gin+GORM: 配置(OT_ env)/SQLite/Postgres 双驱动、用户体系(argon2id+JWT access/refresh)、
  API Key(sk- 48位, 仅存 SHA-256 哈希)
- 代理网关: /v1/chat/completions、/v1/responses、/v1/messages、/v1/models;错误按客户端协议返回
- 三协议互转(convert 包): Chat↔Messages↔Responses 请求/响应 + 流式 SSE 逐事件转换(直通优先)
- 用量计费: 异步批量记账、余额扣减、balance_logs、usage_daily 日聚合
- 管理 API: 用户/渠道 CRUD+测试+模型导入/模型定价+绑定/统计/系统配置
- 前端 Vue3+TS+Tailwind(taste-skill 设计 tokens): Landing/登录注册/控制台/管理后台,
  自建组件+Phosphor 图标+自建 SVG 趋势图, 已过 web-design-guidelines 复查
- mock 上游: OpenAI+Anthropic 双协议模拟(含流式)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-15 15:34:06 +08:00
co-authored by Claude
parent b25e9ec8a7
commit ec4de8d913
92 changed files with 6203 additions and 3064 deletions
+85 -143
View File
@@ -1,167 +1,109 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import client, { unwrap } from '../../api/client'
import Badge from '../../components/ui/Badge.vue'
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 TrendChart from '@/components/ui/TrendChart.vue'
import type { UsageLog } from '@/types'
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent])
const toast = useToastStore()
interface BalanceInfo { balance: number; spent_last_30d: number; today: { requests: number; tokens: number; cost: number }; models_available: number }
interface UsagePoint { date: string; requests: number; tokens: number; cost: number }
interface LogItem { id: number; model: string; protocol: string; input_tokens: number; output_tokens: number; cost: number; latency_ms: number; status: string; created_at: string }
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[]>([])
const balance = ref<BalanceInfo | null>(null)
const stats = ref<UsagePoint[]>([])
const recentLogs = ref<LogItem[]>([])
const error = ref('')
const loaded = ref(false)
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
const usdPrecise = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 6, maximumFractionDigits: 6 })
const balanceText = computed(() => (balance.value ? usd.format(balance.value.balance) : '—'))
const spent30d = computed(() => usd.format(balance.value?.spent_last_30d ?? 0))
const todayCostText = computed(() => usdPrecise.format(balance.value?.today.cost ?? 0))
const modelsCount = computed(() => balance.value?.models_available ?? '—')
const todayRequests = computed(() => balance.value?.today.requests ?? '—')
const todayTokens = computed(() => balance.value?.today.tokens ?? 0)
const chartOption = computed(() => ({
grid: { left: 8, right: 8, top: 24, bottom: 0, containLabel: true },
tooltip: {
trigger: 'axis',
backgroundColor: '#16191d',
borderColor: '#282d34',
textStyle: { color: '#eae8e3', fontSize: 12, fontFamily: 'JetBrains Mono' },
},
xAxis: {
type: 'category',
data: stats.value.map((s) => s.date.slice(5)),
axisLine: { lineStyle: { color: '#282d34' } },
axisLabel: { color: '#63686f', fontFamily: 'JetBrains Mono', fontSize: 11 },
axisTick: { show: false },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#1c2025' } },
axisLabel: { color: '#63686f', fontFamily: 'JetBrains Mono', fontSize: 11 },
},
series: [
{
name: '请求数',
type: 'bar',
data: stats.value.map((s) => s.requests),
itemStyle: { color: '#e5a13c', borderRadius: [3, 3, 0, 0] },
barMaxWidth: 22,
},
],
}))
onMounted(async () => {
async function load() {
try {
const [b, s, logs] = await Promise.all([
unwrap<BalanceInfo>(client.get('/user/balance')),
unwrap<{ items: UsagePoint[] }>(client.get('/usage/stats', { params: { group: 'day' } })),
unwrap<{ items: LogItem[] }>(client.get('/usage/logs', { params: { page_size: 8 } })),
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'),
])
balance.value = b
stats.value = s.items ?? []
recentLogs.value = logs.items ?? []
} catch (e: any) {
error.value = e.message || '加载失败'
} finally {
loaded.value = true
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))
}
})
}
const fmtCost = (n: number) => usd.format(n)
function daysAgo(n: number): string {
const d = new Date(Date.now() - n * 864e5)
return d.toISOString().slice(0, 10)
}
onMounted(load)
</script>
<template>
<div>
<div class="flex items-end justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">仪表盘</h1>
<p class="mt-1 text-[13px] text-paper-500">今日与近 30 日用量总览</p>
</div>
<router-link
to="/console/keys"
class="inline-flex h-10 touch-manipulation items-center rounded-md bg-signal-400 px-4 text-sm font-medium text-ink-950 transition-colors hover:bg-signal-300 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
>新建密钥</router-link>
<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>
<p v-if="error" class="mt-4 rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300" role="alert">{{ error }}</p>
<!-- 指标条 -->
<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>
<p 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>
<p 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>
<p 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>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtCost(monthCost) }}</p>
</div>
</div>
<!-- 指标行 -->
<section aria-label="用量指标" class="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
<template v-if="!loaded">
<div v-for="i in 4" :key="i" class="rounded-lg border border-ink-700 bg-ink-900 p-4" aria-hidden="true">
<div class="h-3 w-14 animate-pulse rounded bg-ink-700" />
<div class="mt-3 h-7 w-24 animate-pulse rounded bg-ink-700" />
<div class="mt-2 h-3 w-20 animate-pulse rounded bg-ink-700" />
<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>
</template>
<template v-else>
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<p class="text-xs text-paper-500">余额</p>
<p class="num mt-1.5 text-2xl font-semibold text-mint-400" translate="no">{{ balanceText }}</p>
<p class="num mt-1 text-[11px] text-paper-600">30 日消耗 <span translate="no">{{ spent30d }}</span></p>
</div>
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<p class="text-xs text-paper-500">今日请求</p>
<p class="num mt-1.5 text-2xl font-semibold">{{ todayRequests }}</p>
<p class="num mt-1 text-[11px] text-paper-600">{{ todayTokens }} tokens</p>
</div>
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<p class="text-xs text-paper-500">今日成本</p>
<p class="num mt-1.5 text-2xl font-semibold text-signal-300" translate="no">{{ todayCostText }}</p>
<p class="num mt-1 text-[11px] text-paper-600">按量计费 · USD</p>
</div>
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<p class="text-xs text-paper-500">可用模型</p>
<p class="num mt-1.5 text-2xl font-semibold">{{ modelsCount }}</p>
<p class="mt-1 text-[11px] text-paper-600">GET /v1/models 查看</p>
</div>
</template>
</section>
<!-- 图表 + 最近请求 -->
<div class="mt-6 grid grid-cols-1 gap-3 lg:grid-cols-[1.4fr_1fr]">
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<div class="mb-2 flex items-center justify-between">
<h2 class="text-sm font-medium text-paper-300">近 30 日请求</h2>
<span class="font-mono text-[11px] text-paper-600" translate="no">usage/stats?group=day</span>
</div>
<VChart v-if="stats.length" class="h-56" :option="chartOption" autoresize />
<div v-else-if="loaded" class="flex h-56 items-center justify-center text-[13px] text-paper-600">暂无数据,发起第一次请求后这里会出现图表</div>
<div v-else class="h-56 animate-pulse rounded bg-ink-800" aria-hidden="true" />
<TrendChart :points="trend" :format="(v) => '$' + v.toExponential(2)" />
</div>
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<div class="mb-2 flex items-center justify-between">
<h2 class="text-sm font-medium text-paper-300">最近请求</h2>
<router-link to="/console/usage" class="text-xs text-signal-300 transition-colors hover:text-signal-200">全部 →</router-link>
<!-- 最近请求 -->
<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>
<div v-if="recentLogs.length" class="divide-y divide-ink-800">
<div v-for="l in recentLogs" :key="l.id" class="flex items-center justify-between gap-3 py-2.5">
<ul 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-[12.5px] text-paper-100" translate="no">{{ l.model }}</p>
<p class="num mt-0.5 text-[11px] text-paper-600" translate="no">{{ l.protocol }} · {{ l.input_tokens }}/{{ l.output_tokens }} tok · {{ l.latency_ms }}ms</p>
<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 shrink-0 items-center gap-2">
<span class="num text-[12.5px] text-paper-300" translate="no">{{ fmtCost(l.cost) }}</span>
<Badge :tone="l.status === 'success' ? 'success' : 'error'" />
<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>
</div>
</div>
<div v-else-if="loaded" class="flex h-48 items-center justify-center text-[13px] text-paper-600">还没有请求记录</div>
<div v-else class="space-y-3 pt-2" aria-hidden="true">
<div v-for="i in 4" :key="i" class="h-8 animate-pulse rounded bg-ink-800" />
</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>