Files
openteam/web/src/views/console/UsageView.vue
T
Sakurasan 360c6b33a6 M0+M1: 基建 + 用户/密钥/核心代理
后端 (Go/Gin/GORM):
- 配置(viper+env)、SQLite/Postgres 迁移、argon2id、AES-GCM 渠道密钥、JWT+refresh cookie
- 用户注册/登录/刷新/登出、API Key CRUD(仅存哈希、明文一次展示)
- 代理网关: /v1/chat/completions、/v1/responses、/v1/models 直通 OpenAI 渠道
  非流式+流式(SSE 零缓冲转发), 用量捕获(chat 末块/responses completed 嵌套),
  OpenAI 错误格式(401/402/404/502), 余额检查
- 异步批量记账 + 余额流水 + 日聚合, admin 用户/余额/配置 API
- 单测: crypto/jwt/apikey/流式 usage 提取

前端 (Vue3+TS+Vite+Tailwind v4):
- taste-skill 设计 tokens: 深色仪表盘, 石墨+信号铜色, Outfit+JetBrains Mono
- Landing/登录/注册, 控制台(仪表盘图表/密钥管理/用量明细)
- 基础组件 Button/Input/Badge/Modal, ECharts 用量图

部署: docker-compose(nginx+api+postgres), 双 Dockerfile, nginx SSE 反代
联调: scripts/mockupstream 本地 mock 上游, 端到端验证通过
2026-08-15 13:10:47 +08:00

132 lines
5.0 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import client, { unwrap } from '../../api/client'
import Badge from '../../components/ui/Badge.vue'
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 logs = ref<LogItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const modelFilter = ref('')
const models = ref<string[]>([])
const loading = ref(false)
const pages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
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
if (!modelFilter.value) {
const m = await unwrap<{ items: string[] }>(client.get('/usage/logs', { params: { page_size: 1 } })).catch(() => ({ items: [] }))
void m
}
} catch {
// ignore
} finally {
loading.value = false
}
}
async function loadModels() {
try {
const data = await unwrap<{ items: string[] }>(client.get('/user/models'))
models.value = data.items ?? []
} catch { /* ignore */ }
}
onMounted(() => {
load()
loadModels()
})
const fmtCost = (n: number) => (n >= 0.01 ? n.toFixed(4) : n.toExponential(2))
const fmtDate = (s: string) => new Date(s).toLocaleString('zh-CN', { hour12: false })
</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>
<select
v-model="modelFilter"
class="h-9 rounded-md border border-ink-600 bg-ink-900 px-3 text-[13px] text-paper-300 focus:border-signal-400 focus:outline-none"
@change="page = 1; load()"
>
<option value="">全部模型</option>
<option v-for="m in models" :key="m" :value="m">{{ m }}</option>
</select>
</div>
<div class="mt-6 overflow-hidden rounded-lg border border-ink-700">
<table class="w-full text-left text-[13px]">
<thead>
<tr class="border-b border-ink-700 bg-ink-900 text-xs text-paper-500">
<th class="px-4 py-3 font-medium">时间</th>
<th class="px-4 py-3 font-medium">模型</th>
<th class="px-4 py-3 font-medium">协议</th>
<th class="px-4 py-3 text-right font-medium">输入 tok</th>
<th class="px-4 py-3 text-right font-medium">输出 tok</th>
<th class="px-4 py-3 text-right font-medium">成本</th>
<th class="px-4 py-3 text-right font-medium">耗时</th>
<th 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">{{ fmtDate(l.created_at) }}</td>
<td class="px-4 py-3"><code class="font-mono text-[12.5px] text-paper-100">{{ l.model }}</code></td>
<td class="px-4 py-3 font-mono text-[12px] text-paper-500">{{ l.protocol }}</td>
<td class="num px-4 py-3 text-right text-paper-300">{{ l.input_tokens }}</td>
<td class="num px-4 py-3 text-right text-paper-300">{{ l.output_tokens }}</td>
<td class="num px-4 py-3 text-right text-signal-300">${{ fmtCost(l.cost) }}</td>
<td class="num px-4 py-3 text-right text-paper-500">{{ 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>
<div class="flex items-center gap-1.5">
<button
class="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"
:disabled="page <= 1"
@click="page--; load()"
>上一页</button>
<span class="num px-2 text-xs text-paper-500">{{ page }} / {{ pages }}</span>
<button
class="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"
:disabled="page >= pages"
@click="page++; load()"
>下一页</button>
</div>
</div>
</div>
</template>