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
+29 -76
View File
@@ -1,85 +1,38 @@
<script setup lang="ts">
import { useAuthStore } from '../../stores/auth'
import { useRouter } from 'vue-router'
import { computed } from 'vue'
import Toast from '../../components/ui/Toast.vue'
import { useAuthStore } from '@/stores/auth'
import ShellLayout from '@/components/layout/ShellLayout.vue'
const auth = useAuthStore()
const router = useRouter()
const nav = [
{ to: '/console/dashboard', label: '仪表盘', icon: 'M3 12l9-9 9 9M5 10v10h5v-6h4v6h5V10' },
{ to: '/console/keys', label: 'API 密钥', icon: 'M15 7a4 4 0 11-8 0 4 4 0 018 0zM3 21v-1a6 6 0 0112 0v1' },
{ to: '/console/usage', label: '用量明细', icon: 'M4 20V10M10 20V4M16 20v-7M22 20H2' },
]
const balanceFmt = computed(() => {
if (!auth.user) return '—'
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 4 }).format(auth.user.balance)
const sections = computed(() => {
const s: { title: string; items: { to: string; label: string }[] }[] = [
{
title: '控制台',
items: [
{ to: '/console/dashboard', label: '仪表盘' },
{ to: '/console/keys', label: 'API Keys' },
{ to: '/console/usage', label: '用量' },
],
},
]
if (auth.isAdmin) {
s.push({
title: '管理',
items: [
{ to: '/admin/overview', label: '运营总览' },
{ to: '/admin/channels', label: '渠道' },
{ to: '/admin/models', label: '模型与定价' },
{ to: '/admin/users', label: '用户' },
{ to: '/admin/config', label: '系统配置' },
],
})
}
return s
})
async function logout() {
await auth.logout()
router.push('/')
}
</script>
<template>
<div class="min-h-[100dvh] bg-ink-950 text-paper-100">
<!-- 无障碍:跳过导航 -->
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[70] focus:rounded-md focus:bg-signal-400 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-ink-950"
>跳到主内容</a>
<!-- 侧边栏 -->
<aside aria-label="主导航" class="fixed inset-y-0 left-0 z-30 flex w-56 flex-col border-r border-ink-800 bg-ink-900/60">
<div class="flex h-16 items-center gap-2.5 border-b border-ink-800 px-5">
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-signal-400" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8h3l2-5 3 10 2-5h2" stroke="#0c0d0f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="text-[15px] font-semibold tracking-tight" translate="no">openteam</span>
</div>
<nav class="flex-1 space-y-0.5 px-3 py-4" aria-label="控制台">
<router-link
v-for="item in nav"
:key="item.to"
:to="item.to"
class="flex items-center gap-3 rounded-md px-3 py-2 text-[13.5px] text-paper-500 transition-colors hover:bg-ink-800 hover:text-paper-100 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
active-class="bg-ink-800 text-signal-300! font-medium"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path :d="item.icon" /></svg>
{{ item.label }}
</router-link>
</nav>
<div class="border-t border-ink-800 px-3 py-4">
<div class="flex items-center justify-between rounded-md bg-ink-850 px-3 py-2.5">
<div class="min-w-0">
<p class="truncate text-[13px] font-medium text-paper-100">{{ auth.user?.username }}</p>
<p class="text-xs text-paper-600">{{ auth.isAdmin ? 'admin' : 'user' }}</p>
</div>
<span class="num text-[13px] font-medium text-mint-400" translate="no">{{ balanceFmt }}</span>
</div>
<button
class="mt-2 flex w-full touch-manipulation items-center justify-center gap-2 rounded-md px-3 py-2 text-[13px] text-paper-500 transition-colors hover:bg-ink-800 hover:text-ember-300 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none cursor-pointer"
@click="logout"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" /></svg>
退出登录
</button>
</div>
</aside>
<!-- 主区域 -->
<div class="ml-56 flex-1">
<main id="main-content" class="mx-auto max-w-6xl scroll-mt-4 px-8 py-8" tabindex="-1">
<router-view />
</main>
</div>
<!-- 全局通知 -->
<Toast />
</div>
<ShellLayout :sections="sections">
<router-view />
</ShellLayout>
</template>
+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>
+107 -158
View File
@@ -1,205 +1,154 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import client, { unwrap } from '../../api/client'
import Badge from '../../components/ui/Badge.vue'
import Button from '../../components/ui/Button.vue'
import Modal from '../../components/ui/Modal.vue'
import Input from '../../components/ui/Input.vue'
import { useToastStore } from '../../stores/toast'
interface APIKey {
id: number
name: string
key_prefix: string
quota_tokens_per_day: number | null
quota_requests_per_day: number | null
status: string
last_used_at: string | null
created_at: string
}
import { onMounted, ref } from 'vue'
import { PhCopy, PhCheck } from '@phosphor-icons/vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { fmtTime } from '@/lib/format'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import Badge from '@/components/ui/Badge.vue'
import type { ApiKey } from '@/types'
const toast = useToastStore()
const keys = ref<ApiKey[]>([])
const keys = ref<APIKey[]>([])
const loading = ref(false)
const error = ref('')
// 创建
const showCreate = ref(false)
const newName = ref('')
const createOpen = ref(false)
const keyName = ref('')
const creating = ref(false)
const createdKey = ref('')
const createError = ref('')
const nameInput = ref<{ focus: () => void } | null>(null)
// 吊销
const revokeTarget = ref<APIKey | null>(null)
const revoking = ref(false)
const created = ref<{ name: string; key: string; key_prefix: string } | null>(null)
const copied = ref(false)
async function load() {
loading.value = true
try {
const data = await unwrap<{ items: APIKey[] }>(client.get('/keys'))
keys.value = data.items
} catch (e: any) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
const { data } = await http.get('/keys')
keys.value = data.data.items
} catch (e) {
toast.err(errMsg(e))
}
}
async function create() {
createError.value = ''
if (!newName.value.trim()) {
createError.value = '请填写密钥名称'
await nextTick()
nameInput.value?.focus()
return
}
async function createKey() {
if (!keyName.value) return
creating.value = true
try {
const data = await unwrap<{ key: string }>(client.post('/keys', { name: newName.value.trim() }))
createdKey.value = data.key
newName.value = ''
const { data } = await http.post('/keys', { name: keyName.value })
created.value = data.data
createOpen.value = false
keyName.value = ''
await load()
} catch (e: any) {
createError.value = e.response?.data?.error?.message || '创建失败'
} catch (e) {
toast.err(errMsg(e))
} finally {
creating.value = false
}
}
async function revoke() {
if (!revokeTarget.value) return
revoking.value = true
async function revoke(k: ApiKey) {
if (!confirm(`吊销密钥 ${k.name}?吊销后立即失效。`)) return
try {
await unwrap(client.delete(`/keys/${revokeTarget.value.id}`))
toast.success(`密钥 ${revokeTarget.value.key_prefix}… 已吊销`)
revokeTarget.value = null
await http.delete(`/keys/${k.id}`)
toast.ok('密钥已吊销')
await load()
} catch (e: any) {
toast.error(e.message || '吊销失败')
} finally {
revoking.value = false
} catch (e) {
toast.err(errMsg(e))
}
}
async function copyKey(text: string) {
async function copyKey() {
if (!created.value) return
try {
await navigator.clipboard.writeText(text)
await navigator.clipboard.writeText(created.value.key)
copied.value = true
setTimeout(() => (copied.value = false), 1500)
} catch {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
ta.remove()
toast.err('复制失败,请手动复制')
}
toast.success('密钥已复制')
}
onMounted(load)
const fmtDate = (s: string | null) =>
s ? new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(s)) : '从未使用'
</script>
<template>
<div>
<div class="flex items-end justify-between">
<div class="mx-auto max-w-5xl">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">API 密钥</h1>
<p class="mt-1 text-[13px] text-paper-500">密钥仅以 SHA-256 哈希存储,明文只在创建时展示一次</p>
<h1 class="text-lg font-semibold">API Keys</h1>
<p class="text-sm text-zinc-500">密钥明文仅在创建时展示一次,请立即保存</p>
</div>
<Button @click="showCreate = true">新建密钥</Button>
<Button @click="createOpen = true">新建密钥</Button>
</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="mt-6 overflow-hidden rounded-lg border border-ink-700">
<table class="w-full text-left text-[13px]">
<caption class="sr-only">API 密钥列表</caption>
<thead>
<tr class="border-b border-ink-700 bg-ink-900 text-xs text-paper-500">
<th scope="col" class="px-4 py-3 font-medium">名称</th>
<th scope="col" class="px-4 py-3 font-medium">密钥前缀</th>
<th scope="col" class="px-4 py-3 font-medium">每日限额</th>
<th scope="col" class="px-4 py-3 font-medium">最近使用</th>
<th scope="col" class="px-4 py-3 font-medium">状态</th>
<th scope="col" class="px-4 py-3 text-right font-medium">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-ink-800 bg-ink-900/50">
<tr v-for="k in keys" :key="k.id" class="transition-colors hover:bg-ink-850">
<td class="px-4 py-3 font-medium text-paper-100">{{ k.name }}</td>
<td class="px-4 py-3"><code class="font-mono text-[12.5px] text-signal-300" translate="no">{{ k.key_prefix }}…</code></td>
<td class="num px-4 py-3 text-paper-500" translate="no">
{{ k.quota_tokens_per_day ? `${(k.quota_tokens_per_day / 1000).toFixed(0)}k tok` : '—' }}
/ {{ k.quota_requests_per_day ? `${k.quota_requests_per_day} req` : '—' }}
</td>
<td class="num px-4 py-3 text-paper-500" translate="no">{{ fmtDate(k.last_used_at) }}</td>
<td class="px-4 py-3"><Badge :tone="k.status" /></td>
<td class="px-4 py-3 text-right">
<button
v-if="k.status === 'active'"
class="touch-manipulation text-xs text-ember-400 transition-colors hover:text-ember-300 focus-visible:ring-2 focus-visible:ring-ember-400/60 focus:outline-none cursor-pointer"
@click="revokeTarget = k"
>吊销</button>
<span v-else class="text-xs text-paper-600">已吊销</span>
</td>
</tr>
<tr v-if="!keys.length">
<td colspan="6" class="px-4 py-12 text-center text-[13px] text-paper-600">
{{ loading ? '加载中…' : '还没有密钥 — 点击右上角「新建密钥」创建第一个' }}
</td>
</tr>
</tbody>
</table>
<div class="card">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500">
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
<th scope="col" class="px-4 py-2.5 font-medium">前缀</th>
<th scope="col" class="px-4 py-2.5 font-medium">状态</th>
<th scope="col" class="px-4 py-2.5 font-medium">最近使用</th>
<th scope="col" class="px-4 py-2.5 font-medium">创建时间</th>
<th scope="col" class="px-4 py-2.5" />
</tr>
</thead>
<tbody>
<tr v-for="k in keys" :key="k.id" class="table-row">
<td class="px-4 py-2.5 text-zinc-200">{{ k.name }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-400">{{ k.key_prefix }}…</td>
<td class="px-4 py-2.5">
<Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge>
</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(k.last_used_at) }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(k.created_at) }}</td>
<td class="px-4 py-2.5 text-right">
<button
v-if="k.status === 'active'"
class="text-xs text-zinc-500 hover:text-red-400"
@click="revoke(k)"
>
吊销
</button>
</td>
</tr>
<tr v-if="keys.length === 0">
<td colspan="6" class="px-4 py-10 text-center text-sm text-zinc-600">
还没有密钥,点击右上角「新建密钥」
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 创建模态 -->
<Modal :open="showCreate" title="新建 API 密钥" @close="showCreate = false">
<template v-if="!createdKey">
<Input
ref="nameInput"
v-model="newName"
label="密钥名称"
name="key-name"
placeholder="例如:本地开发"
hint="用于在用量明细中区分来源"
:error="createError || undefined"
autocomplete="off"
:spellcheck="false"
@keydown.enter.prevent="create"
/>
<div class="mt-5 flex justify-end gap-2">
<Button variant="ghost" @click="showCreate = false">取消</Button>
<Button :loading="creating" @click="create">创建密钥</Button>
</div>
</template>
<template v-else>
<p class="text-[13px] leading-relaxed text-paper-500">密钥已生成。出于安全考虑,<span class="text-paper-300">明文只会展示这一次</span>,请立即复制保存。</p>
<div class="mt-3 flex items-center gap-2 rounded-md border border-mint-500/40 bg-mint-400/10 px-3 py-2.5">
<code class="min-w-0 flex-1 break-all font-mono text-[12.5px] text-mint-300" translate="no">{{ createdKey }}</code>
<Button variant="outline" size="sm" @click="copyKey(createdKey)">复制</Button>
</div>
<div class="mt-5 flex justify-end">
<Button @click="showCreate = false; createdKey = ''">完成</Button>
</div>
<!-- 新建密钥 -->
<Modal :open="createOpen" title="新建密钥" @close="createOpen = false">
<Input v-model="keyName" label="密钥名称" placeholder="例如 dev / prod" @keyup.enter="createKey" />
<template #footer>
<Button variant="ghost" @click="createOpen = false">取消</Button>
<Button :loading="creating" @click="createKey">创建</Button>
</template>
</Modal>
<!-- 吊销确认 -->
<Modal :open="!!revokeTarget" title="吊销密钥" @close="revokeTarget = null">
<p class="text-[13px] leading-relaxed text-paper-500">
吊销后 <code class="font-mono text-paper-300" translate="no">{{ revokeTarget?.key_prefix }}…</code> 将立即失效,使用它的请求会返回 401。此操作不可撤销。
</p>
<div class="mt-5 flex justify-end gap-2">
<Button variant="ghost" @click="revokeTarget = null">取消</Button>
<Button variant="danger" :loading="revoking" @click="revoke">确认吊销</Button>
<!-- 一次性展示密钥 -->
<Modal :open="!!created" title="密钥已创建" @close="created = null">
<div class="space-y-4">
<p class="text-xs text-zinc-500">请复制并妥善保存,关闭后不再显示。</p>
<div class="flex items-center gap-2">
<code class="mono-num flex-1 truncate rounded-md border border-accent/40 bg-zinc-900 px-3 py-2 text-xs text-emerald-300">
{{ created?.key }}
</code>
<Button size="sm" @click="copyKey">
<PhCheck v-if="copied" :size="14" />
<PhCopy v-else :size="14" />
{{ copied ? '已复制' : '复制' }}
</Button>
</div>
</div>
<template #footer>
<Button @click="created = null">完成</Button>
</template>
</Modal>
</div>
</template>
+126 -128
View File
@@ -1,149 +1,147 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 { 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'
interface LogItem {
id: number
request_id: string
model: string
protocol: string
input_tokens: number
output_tokens: number
cache_read_tokens: number
cost: number
latency_ms: number
status: string
created_at: string
}
const route = useRoute()
const router = useRouter()
const logs = ref<LogItem[]>([])
const toast = useToastStore()
const summary = ref({ today: { requests: 0, tokens: 0, cost: 0 }, month: { requests: 0, tokens: 0, cost: 0 } })
const group = ref<'day' | 'model'>('day')
const chart = ref<{ label: string; value: number }[]>([])
const logs = ref<UsageLog[]>([])
const page = ref(1)
const total = ref(0)
const page = ref(Number(route.query.page) || 1)
const pageSize = 20
const modelFilter = ref((route.query.model as string) || '')
const models = ref<string[]>([])
const loading = ref(false)
const pages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
const fmtDate = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
// 筛选/分页同步到 URL(可深链、可分享)
watch([page, modelFilter], () => {
router.replace({
query: {
...(modelFilter.value ? { model: modelFilter.value } : {}),
...(page.value > 1 ? { page: String(page.value) } : {}),
},
})
})
const modelFilter = ref('')
const pageSize = 15
async function load() {
loading.value = true
try {
const data = await unwrap<{ items: LogItem[]; total: number }>(
client.get('/usage/logs', { params: { page: page.value, page_size: pageSize, model: modelFilter.value || undefined } }),
)
logs.value = data.items ?? []
total.value = data.total
} catch {
// 加载失败静默,空态兜底
} finally {
loading.value = false
const [s, st, l] = await Promise.all([
http.get('/usage/summary'),
http.get(`/usage/stats?group=${group.value}`),
http.get(`/usage/logs?page=${page.value}&page_size=${pageSize}${modelFilter.value ? '&model=' + modelFilter.value : ''}`),
])
summary.value = s.data.data
const items = st.data.data.items as { date?: string; model?: string; cost: number; requests: number }[]
chart.value = items.map((x) => ({ label: (x.date || x.model || '') as string, value: x.cost }))
logs.value = l.data.data.items
total.value = l.data.data.total
} catch (e) {
toast.err(errMsg(e))
}
}
async function loadModels() {
try {
const data = await unwrap<{ items: string[] }>(client.get('/user/models'))
models.value = data.items ?? []
} catch { /* ignore */ }
function switchGroup(g: 'day' | 'model') {
group.value = g
load()
}
onMounted(() => {
function goPage(p: number) {
page.value = p
load()
loadModels()
})
}
const fmtCost = (n: number) => usd.format(n)
const fmtDateStr = (s: string) => fmtDate.format(new Date(s))
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">请求级记录 · 按当时价格入账</p>
<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>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.today.requests) }}</p>
</div>
<label class="flex items-center gap-2">
<span class="text-xs text-paper-600">模型</span>
<select
<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(summary.today.tokens) }}</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(summary.month.requests) }}</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">{{ fmtCost(summary.month.cost) }}</p>
</div>
</div>
<div class="card p-5">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-sm font-semibold">成本分布</h2>
<div class="flex gap-1 rounded-md border border-zinc-800 p-0.5">
<button
class="rounded px-2.5 py-1 text-xs transition"
:class="group === 'day' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500'"
@click="switchGroup('day')"
>
按天
</button>
<button
class="rounded px-2.5 py-1 text-xs transition"
:class="group === 'model' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500'"
@click="switchGroup('model')"
>
按模型
</button>
</div>
</div>
<TrendChart :points="chart" :format="(v) => '$' + v.toExponential(2)" />
</div>
<div class="card">
<div class="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
<h2 class="text-sm font-semibold">请求明细</h2>
<input
v-model="modelFilter"
class="h-9 touch-manipulation rounded-md border border-ink-600 bg-ink-900 px-3 text-[13px] text-paper-300 transition-colors hover:border-ink-700 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
@change="page = 1; load()"
>
<option value="">全部模型</option>
<option v-for="m in models" :key="m" :value="m" translate="no">{{ m }}</option>
</select>
</label>
</div>
<div class="mt-6 overflow-hidden rounded-lg border border-ink-700">
<table class="w-full text-left text-[13px]">
<caption class="sr-only">用量明细记录</caption>
<thead>
<tr class="border-b border-ink-700 bg-ink-900 text-xs text-paper-500">
<th scope="col" class="px-4 py-3 font-medium">时间</th>
<th scope="col" class="px-4 py-3 font-medium">模型</th>
<th scope="col" class="px-4 py-3 font-medium">协议</th>
<th scope="col" class="px-4 py-3 text-right font-medium">输入 tok</th>
<th scope="col" class="px-4 py-3 text-right font-medium">输出 tok</th>
<th scope="col" class="px-4 py-3 text-right font-medium">成本</th>
<th scope="col" class="px-4 py-3 text-right font-medium">耗时</th>
<th scope="col" class="px-4 py-3 font-medium">状态</th>
</tr>
</thead>
<tbody class="divide-y divide-ink-800 bg-ink-900/50">
<tr v-for="l in logs" :key="l.id" class="transition-colors hover:bg-ink-850">
<td class="num px-4 py-3 text-paper-500" translate="no">{{ fmtDateStr(l.created_at) }}</td>
<td class="px-4 py-3"><code class="font-mono text-[12.5px] text-paper-100" translate="no">{{ l.model }}</code></td>
<td class="px-4 py-3 font-mono text-[12px] text-paper-500" translate="no">{{ l.protocol }}</td>
<td class="num px-4 py-3 text-right text-paper-300" translate="no">{{ l.input_tokens }}</td>
<td class="num px-4 py-3 text-right text-paper-300" translate="no">{{ l.output_tokens }}</td>
<td class="num px-4 py-3 text-right text-signal-300" translate="no">{{ fmtCost(l.cost) }}</td>
<td class="num px-4 py-3 text-right text-paper-500" translate="no">{{ l.latency_ms }}ms</td>
<td class="px-4 py-3"><Badge :tone="l.status === 'success' ? 'success' : 'error'" /></td>
</tr>
<tr v-if="!logs.length">
<td colspan="8" class="px-4 py-12 text-center text-[13px] text-paper-600">{{ loading ? '加载中…' : '暂无用量记录' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between">
<p class="num text-xs text-paper-600">共 {{ total }} 条</p>
<nav aria-label="分页" class="flex items-center gap-1.5">
<button
class="touch-manipulation rounded-md border border-ink-600 px-3 py-1.5 text-xs text-paper-300 transition-colors hover:border-signal-400 disabled:opacity-40 cursor-pointer disabled:cursor-default focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
:disabled="page <= 1"
:aria-label="`上一页,当前第 ${page} 页`"
@click="page--; load()"
>上一页</button>
<span class="num px-2 text-xs text-paper-500" aria-current="page">第 {{ page }} / {{ pages }} 页</span>
<button
class="touch-manipulation rounded-md border border-ink-600 px-3 py-1.5 text-xs text-paper-300 transition-colors hover:border-signal-400 disabled:opacity-40 cursor-pointer disabled:cursor-default focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
:disabled="page >= pages"
:aria-label="`下一页,当前第 ${page} 页`"
@click="page++; load()"
>下一页</button>
</nav>
placeholder="按模型过滤"
class="h-8 w-48 rounded-md border border-zinc-700 bg-zinc-900 px-2.5 font-mono text-xs outline-none focus:border-accent"
@keyup.enter="page = 1; load()"
/>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500">
<th scope="col" class="px-4 py-2.5 font-medium">模型</th>
<th scope="col" class="px-4 py-2.5 font-medium">协议</th>
<th scope="col" class="px-4 py-2.5 font-medium">Token 入/出</th>
<th scope="col" class="px-4 py-2.5 font-medium">成本</th>
<th scope="col" class="px-4 py-2.5 font-medium">耗时</th>
<th scope="col" class="px-4 py-2.5 font-medium">状态</th>
<th scope="col" class="px-4 py-2.5 font-medium">时间</th>
</tr>
</thead>
<tbody>
<tr v-for="l in logs" :key="l.id" class="table-row">
<td class="px-4 py-2.5 font-mono text-xs text-zinc-200">{{ l.model }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ l.protocol }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-zinc-400">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-zinc-300">{{ fmtCost(l.cost) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-zinc-500">{{ l.latency_ms }}ms</td>
<td class="px-4 py-2.5"><Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge></td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(l.created_at) }}</td>
</tr>
<tr v-if="logs.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-sm text-zinc-600">暂无请求记录</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between border-t border-zinc-800 px-4 py-3">
<span class="font-mono text-xs text-zinc-600">共 {{ total }} 条</span>
<div class="flex gap-2">
<Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button>
<Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button>
</div>
</div>
</div>
</div>
</template>