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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-15 20:55:56 +08:00
co-authored by Claude
parent a8e2cd214c
commit 512e46664c
5 changed files with 159 additions and 77 deletions
+49 -5
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { PhList } from '@phosphor-icons/vue'
import { useAuthStore } from '@/stores/auth'
import { fmtMoney } from '@/lib/format'
@@ -9,14 +9,52 @@ import ThemeToggle from '@/components/ui/ThemeToggle.vue'
interface NavItem {
to: string
label: string
jump?: boolean // 跨区跳转(如 进入管理 / 返回控制台),不做激活态
}
defineProps<{ sections: { title: string; items: NavItem[] }[] }>()
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const balance = computed(() => fmtMoney(auth.user?.balance ?? 0))
const sidebarOpen = ref(false)
// 按所在分区显示对应菜单:用户控制台 / 管理后台,二者不混排
const isAdminSection = computed(() => route.path.startsWith('/admin'))
const sections = computed(() => {
if (isAdminSection.value) {
return [
{
title: '管理后台',
items: [
{ to: '/admin/overview', label: '总览' },
{ to: '/admin/channels', label: '渠道管理' },
{ to: '/admin/models', label: '模型定价' },
{ to: '/admin/users', label: '用户管理' },
{ to: '/admin/usage', label: '用量统计' },
{ to: '/admin/config', label: '系统配置' },
],
},
{ title: '用户', items: [{ to: '/console/dashboard', label: '返回控制台', jump: true }] },
]
}
const user: { title: string; items: NavItem[] }[] = [
{
title: '管理',
items: [
{ to: '/console/dashboard', label: '仪表盘' },
{ to: '/console/keys', label: 'API 密钥' },
{ to: '/console/usage', label: '用量明细' },
{ to: '/console/settings', label: '账户设置' },
],
},
]
if (auth.isAdmin) {
user[0].items.push({ to: '/admin/overview', label: '管理', jump: true })
}
return user
})
function navTo() {
sidebarOpen.value = false
}
@@ -55,14 +93,20 @@ async function logout() {
v-for="n in sec.items"
:key="n.to"
:to="n.to"
class="mb-0.5 flex items-center rounded-md px-2 py-1.5 text-sm text-muted transition hover:bg-surface2 hover:text-ink"
active-class="bg-surface2 text-ink"
class="mb-0.5 flex items-center justify-between rounded-md px-2 py-1.5 text-sm text-muted transition hover:bg-surface2 hover:text-ink"
:class="n.jump ? 'text-xs' : ''"
:active-class="n.jump ? '' : 'bg-surface2 text-ink'"
@click="navTo"
>
{{ n.label }}
<span>{{ n.label }}</span>
<span v-if="n.jump" class="opacity-60" aria-hidden="true">→</span>
</router-link>
</template>
</nav>
<div class="border-t border-edge px-3 py-3">
<p class="px-2 font-mono text-[11px] text-muted">{{ auth.user?.username }}</p>
</div>
</aside>
<div class="flex min-h-[100dvh] flex-1 flex-col md:ml-56">
+6 -2
View File
@@ -1,6 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const ShellLayout = () => import('@/components/layout/ShellLayout.vue')
export const router = createRouter({
history: createWebHistory(),
routes: [
@@ -9,18 +11,19 @@ export const router = createRouter({
{ path: '/register', name: 'register', component: () => import('@/views/RegisterView.vue'), meta: { guest: true } },
{
path: '/console',
component: () => import('@/views/console/ConsoleLayout.vue'),
component: ShellLayout,
meta: { auth: true },
children: [
{ path: '', redirect: '/console/dashboard' },
{ path: 'dashboard', name: 'dashboard', component: () => import('@/views/console/DashboardView.vue') },
{ path: 'keys', name: 'keys', component: () => import('@/views/console/KeysView.vue') },
{ path: 'usage', name: 'usage', component: () => import('@/views/console/UsageView.vue') },
{ path: 'settings', name: 'settings', component: () => import('@/views/console/SettingsView.vue') },
],
},
{
path: '/admin',
component: () => import('@/views/admin/AdminLayout.vue'),
component: ShellLayout,
meta: { auth: true, admin: true },
children: [
{ path: '', redirect: '/admin/overview' },
@@ -28,6 +31,7 @@ export const router = createRouter({
{ path: 'channels', name: 'admin-channels', component: () => import('@/views/admin/ChannelsView.vue') },
{ path: 'models', name: 'admin-models', component: () => import('@/views/admin/ModelsView.vue') },
{ path: 'users', name: 'admin-users', component: () => import('@/views/admin/UsersView.vue') },
{ path: 'usage', name: 'admin-usage', component: () => import('@/views/admin/UsageView.vue') },
{ path: 'config', name: 'admin-config', component: () => import('@/views/admin/ConfigView.vue') },
],
},
-32
View File
@@ -1,32 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import ShellLayout from '@/components/layout/ShellLayout.vue'
const auth = useAuthStore()
const sections = computed(() => {
const s: { title: string; items: { to: string; label: string }[] }[] = [
{ title: '管理', items: [
{ to: '/admin/overview', label: '运营总览' },
{ to: '/admin/channels', label: '渠道' },
{ to: '/admin/models', label: '模型与定价' },
{ to: '/admin/users', label: '用户' },
{ to: '/admin/config', label: '系统配置' },
] },
]
if (auth.user) {
s.push({ title: '控制台', items: [
{ to: '/console/dashboard', label: '仪表盘' },
{ to: '/console/keys', label: 'API Keys' },
{ to: '/console/usage', label: '用量' },
] })
}
return s
})
</script>
<template>
<ShellLayout :sections="sections">
<router-view />
</ShellLayout>
</template>
+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>
-38
View File
@@ -1,38 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import ShellLayout from '@/components/layout/ShellLayout.vue'
const auth = useAuthStore()
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
})
</script>
<template>
<ShellLayout :sections="sections">
<router-view />
</ShellLayout>
</template>