组件: - Button: transition-all→明确属性列表, touch-action, aria-busy - Input: name/spellcheck/inputmode/aria-invalid/aria-describedby, focus-visible ring, label 关联, 暴露 focus() 供错误定位, required 标记 - Modal: Escape 关闭 + focus trap + 焦点归还, overscroll-behavior: contain, aria-labelledby, body 滚动锁, touch-action - 新增 Toast(aria-live polite) + pinia toast store 页面: - ConsoleLayout: skip link, <main> 语义标签, aside/nav aria-label, aria-current, 装饰 SVG aria-hidden, Intl 金额, translate=no 品牌/代码 - Dashboard: Intl.NumberFormat, 骨架屏 loading, aria-live 错误 - Keys: 字段级错误+焦点定位, toast 反馈(复制/吊销), name 属性 - Usage: select 加 label, 分页/筛选同步 URL(可深链), Intl 日期/金额, 表格 caption - Login/Register: 字段级校验+错误定位, spellcheck=false, inputmode, autocomplete - Landing: router-link 替换 href, aria-hidden, text-balance, scroll-mt - index.html: theme-color + color-scheme 验证: playwright 22 项断言全过, WCAG AA 对比度 9 组全过, 零控制台错误
169 lines
8.0 KiB
Vue
169 lines
8.0 KiB
Vue
<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'
|
||
|
||
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent])
|
||
|
||
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<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 () => {
|
||
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 } })),
|
||
])
|
||
balance.value = b
|
||
stats.value = s.items ?? []
|
||
recentLogs.value = logs.items ?? []
|
||
} catch (e: any) {
|
||
error.value = e.message || '加载失败'
|
||
} finally {
|
||
loaded.value = true
|
||
}
|
||
})
|
||
|
||
const fmtCost = (n: number) => usd.format(n)
|
||
</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>
|
||
|
||
<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>
|
||
|
||
<!-- 指标行 -->
|
||
<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>
|
||
</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" />
|
||
</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>
|
||
<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">
|
||
<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>
|
||
</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>
|
||
</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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|