导航: 统一分区侧栏 + 新增用量统计页

- ShellLayout 按所在分区显示独立菜单:
  用户控制台(/console) 与 管理后台(/admin) 不混排, 各带跨区跳转(管理→/返回控制台→)
- /console 与 /admin 共用 ShellLayout, 移除 ConsoleLayout/AdminLayout 薄包装
- 新增 /admin/usage 用量统计页(全局请求明细, 按模型过滤+分页)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
SakurasanandClaude committed 2026-08-15 20:55:56 +08:00
1 parent a8e2cd214c
commit 512e46664c
5 files changed
+159 -77

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { protocolName } from '@/lib/protocol'
import { fmtCost, fmtTime } from '@/lib/format'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import type { UsageLog } from '@/types'
const toast = useToastStore()
const logs = ref<UsageLog[]>([])
const total = ref(0)
const page = ref(1)
const modelFilter = ref('')
const pageSize = 15
async function load() {
try {
const { data } = await http.get(
`/admin/usage?page=${page.value}&page_size=${pageSize}${modelFilter.value ? '&model=' + modelFilter.value : ''}`,
)
logs.value = data.data.items
total.value = data.data.total
} catch (e) {
toast.err(errMsg(e))
}
}
function search() {
page.value = 1
load()
}
function goPage(p: number) {
page.value = p
load()
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-lg font-semibold">用量统计</h1>
<p class="text-sm text-muted">全局请求明细与成本</p>
</div>
<div class="flex gap-2">
<input
v-model="modelFilter"
placeholder="按模型过滤"
class="h-10 w-48 rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent"
@keyup.enter="search"
/>
<Button variant="ghost" @click="search">搜索</Button>
</div>
</div>
<div class="card">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-edge text-left text-xs text-muted">
<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">API 格式</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 text-xs text-ink">{{ l.user }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-ink">{{ l.model }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolName(l.protocol) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-ink">{{ fmtCost(l.cost) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ 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-muted">{{ fmtTime(l.created_at) }}</td>
</tr>
<tr v-if="logs.length === 0">
<td colspan="8" class="px-4 py-10 text-center text-sm text-muted">暂无请求记录</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between border-t border-edge px-4 py-3">
<span class="font-mono text-xs text-muted">共 {{ 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>