导航: 统一分区侧栏 + 新增用量统计页
- ShellLayout 按所在分区显示独立菜单: 用户控制台(/console) 与 管理后台(/admin) 不混排, 各带跨区跳转(管理→/返回控制台→) - /console 与 /admin 共用 ShellLayout, 移除 ConsoleLayout/AdminLayout 薄包装 - 新增 /admin/usage 用量统计页(全局请求明细, 按模型过滤+分页) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user