feat: 普通用户用量统计 + 管理后台用量明细

后端
- /api/usage/stats:当前用户按日聚合统计(请求数/输入/输出/缓存 tokens/费用)
- /api/usage/logs:当前用户用量明细分页
- /api/admin/usage/logs:全量明细(分页 + 协议/状态/模型/用户筛选 + 用户名关联)
- /api/admin/usage/summary:全量汇总(按用户分组)
- dao:UsageFilter 支持筛选分页;DailyUsageDAO.ListAll
- 路由加固:新增 middleware.AdminOnly,既有 /api/admin/* 全部迁移到
  带管理员角色校验的分组(此前登录即可访问,属安全隐患)

前端
- 普通用户「用量统计」页:统计卡片 + 纯 CSS 每日请求量条形图 + 明细表格分页
- 管理后台「用量明细」页:汇总卡片 + 协议/状态/模型/用户筛选 +
  明细表格 + 原始请求/响应查看弹窗(记录开关开启时)
- stores/usage.ts 与类型定义;控制台/管理菜单挂载
This commit is contained in:
Sakurasan
2026-09-01 02:55:55 +08:00
parent f9e9a1572f
commit a259e5eb4d
9 changed files with 955 additions and 23 deletions
+95
View File
@@ -0,0 +1,95 @@
// src/stores/usage.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import request from '@/api/client'
import type { UsageStatsData, UsageLogItem, AdminUsageSummary } from '@/types'
export const useUsageStore = defineStore('usage', () => {
const loading = ref(false)
const error = ref<string | null>(null)
// 普通用户:每日统计
const stats = ref<UsageStatsData | null>(null)
// 普通用户:自身明细
const myLogs = ref<UsageLogItem[]>([])
const myLogsTotal = ref(0)
// 管理后台:全量明细
const adminLogs = ref<UsageLogItem[]>([])
const adminLogsTotal = ref(0)
const adminSummary = ref<AdminUsageSummary | null>(null)
async function fetchStats(days = 30) {
loading.value = true
error.value = null
try {
const res = await request.get('/usage/stats', { params: { days } })
stats.value = res.data?.data ?? null
} catch (err: any) {
error.value = err.response?.data?.error || '获取用量统计失败'
throw err
} finally {
loading.value = false
}
}
async function fetchMyLogs(pageSize = 20, page = 1) {
loading.value = true
error.value = null
try {
const res = await request.get('/usage/logs', { params: { pageSize, page } })
myLogs.value = res.data?.data ?? []
myLogsTotal.value = res.data?.total ?? 0
} catch (err: any) {
error.value = err.response?.data?.error || '获取用量明细失败'
throw err
} finally {
loading.value = false
}
}
async function fetchAdminLogs(params: Record<string, any> = {}) {
loading.value = true
error.value = null
try {
const res = await request.get('/admin/usage/logs', { params })
adminLogs.value = res.data?.data ?? []
adminLogsTotal.value = res.data?.total ?? 0
} catch (err: any) {
error.value = err.response?.data?.error || '获取用量明细失败'
throw err
} finally {
loading.value = false
}
}
async function fetchAdminSummary(params: Record<string, any> = {}) {
loading.value = true
error.value = null
try {
const res = await request.get('/admin/usage/summary', { params })
adminSummary.value = res.data?.data ?? null
} catch (err: any) {
error.value = err.response?.data?.error || '获取用量汇总失败'
throw err
} finally {
loading.value = false
}
}
return {
loading,
error,
stats,
myLogs,
myLogsTotal,
adminLogs,
adminLogsTotal,
adminSummary,
fetchStats,
fetchMyLogs,
fetchAdminLogs,
fetchAdminSummary,
}
})
+55
View File
@@ -171,3 +171,58 @@ export interface ModelSummary {
missing: { channel: string; model_id: number; upstream_model: string }[]
denied_count: number
}
// ---- 用量统计 ----
export interface UsageDaily {
id?: number
user_id?: number
model_id?: number
date: string
requests: number
input_tokens: number
output_tokens: number
cache_read_tokens: number
cost: number
}
export interface UsageTotals {
requests: number
input_tokens: number
output_tokens: number
cache_read_tokens: number
cost: number
}
export interface UsageStatsData {
dates: string[]
daily: Record<string, UsageDaily>
totals: UsageTotals
}
export interface UsageLogItem {
id: number
request_id?: string
user_id: number
channel_id: number
model_id: number
model_name: string
protocol: string
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_creation_tokens: number
cost: number
latency_ms: number
status: string
error_code?: string | null
created_at: string
username?: string
raw_request?: string
raw_response?: string
}
export interface AdminUsageSummary {
totals: UsageTotals
per_user: Record<string, { user_id: number; requests: number; input_tokens: number; output_tokens: number; cost: number }>
}
+5
View File
@@ -8,6 +8,7 @@ import {
GlobeIcon,
BoxesIcon,
SlidersHorizontalIcon,
ChartColumnBig,
} from '@lucide/vue'
export type MenuLink = { label: string; to: string; icon?: Component }
@@ -40,6 +41,7 @@ export const routes: RouteRecordRaw[] = [
redirect: '/dashboard/overview',
children: [
{ path: 'overview', name: 'Overview', component: () => import('@/views/dashboard/Overview.vue'), meta: { title: '仪表盘' } },
{ path: 'usage', name: 'UsageStats', component: () => import('@/views/dashboard/UsageStats.vue'), meta: { title: '用量统计' } },
{ path: 'apikeys', name: 'ApiKeys', component: () => import('@/views/dashboard/ApiKeys.vue'), meta: { title: 'API Keys' } },
{
path: 'manager',
@@ -52,6 +54,7 @@ export const routes: RouteRecordRaw[] = [
{ path: 'users/view', name: 'UserView', component: () => import('@/views/dashboard/UserView.vue'), meta: { title: '用户详情' } },
{ path: 'channels', name: 'Channels', component: () => import('@/views/dashboard/ChannelsView.vue'), meta: { title: '渠道管理' } },
{ path: 'models', name: 'Models', component: () => import('@/views/dashboard/Models.vue'), meta: { title: '模型定价' } },
{ path: 'usage-logs', name: 'UsageLogs', component: () => import('@/views/dashboard/UsageLogs.vue'), meta: { title: '用量明细' } },
{ path: 'config', name: 'SystemConfig', component: () => import('@/views/dashboard/SystemConfig.vue'), meta: { title: '系统配置' } },
],
},
@@ -71,6 +74,7 @@ export const routes: RouteRecordRaw[] = [
// 控制台菜单(所有登录用户)
export const consoleMenu: MenuLink[] = [
{ label: '仪表盘', to: '/dashboard/overview', icon: GaugeIcon },
{ label: '用量统计', to: '/dashboard/usage', icon: ChartColumnBig },
{ label: 'API Keys', to: '/dashboard/apikeys', icon: KeyRoundIcon },
{ label: '账户设置', to: '/dashboard/settings/profile', icon: SettingsIcon },
]
@@ -80,5 +84,6 @@ export const adminMenu: MenuLink[] = [
{ label: '用户管理', to: '/dashboard/manager/users', icon: UsersRoundIcon },
{ label: '渠道管理', to: '/dashboard/manager/channels', icon: GlobeIcon },
{ label: '模型定价', to: '/dashboard/manager/models', icon: BoxesIcon },
{ label: '用量明细', to: '/dashboard/manager/usage-logs', icon: ChartColumnBig },
{ label: '系统配置', to: '/dashboard/manager/config', icon: SlidersHorizontalIcon },
]
+240
View File
@@ -0,0 +1,240 @@
<template>
<div class="space-y-5">
<BreadcrumbHeader />
<!-- 汇总卡片 -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
<p class="text-xs text-base-content/50">请求总数</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(summary?.requests) }}</p>
</div>
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
<p class="text-xs text-base-content/50">输入 Tokens</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(summary?.input_tokens) }}</p>
</div>
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
<p class="text-xs text-base-content/50">输出 Tokens</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(summary?.output_tokens) }}</p>
</div>
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
<p class="text-xs text-base-content/50">总费用 (USD)</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtCost(summary?.cost) }}</p>
</div>
</div>
<!-- 筛选栏 -->
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
<div class="flex flex-wrap items-center gap-2">
<select v-model="filters.protocol" class="select select-sm border-base-300 bg-base-100" @change="applyFilters">
<option value="">全部协议</option>
<option value="chat">chat</option>
<option value="messages">messages</option>
<option value="responses">responses</option>
</select>
<select v-model="filters.status" class="select select-sm border-base-300 bg-base-100" @change="applyFilters">
<option value="">全部状态</option>
<option value="success">成功</option>
<option value="error">失败</option>
<option value="canceled">已取消</option>
</select>
<input
v-model="filters.model"
class="input input-sm w-44 border-base-300 bg-base-100"
placeholder="模型名称"
@keyup.enter="applyFilters"
/>
<input
v-model="filters.userId"
class="input input-sm w-32 border-base-300 bg-base-100"
placeholder="用户 ID"
@keyup.enter="applyFilters"
/>
<button class="btn btn-primary btn-sm" @click="applyFilters">
<SearchIcon class="h-4 w-4" aria-hidden="true" />筛选
</button>
<button class="btn btn-ghost btn-sm" @click="resetFilters">
<RotateCcwIcon class="h-4 w-4" aria-hidden="true" />重置
</button>
</div>
</div>
<!-- 明细表格 -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div v-if="store.loading && !store.adminLogs.length" class="px-4 py-12 text-center text-sm text-base-content/50">加载中…</div>
<div v-else-if="store.adminLogs.length" class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr class="text-xs uppercase tracking-wider text-base-content/50">
<th>ID</th>
<th>用户</th>
<th>时间</th>
<th>模型</th>
<th>协议</th>
<th class="text-right">输入</th>
<th class="text-right">输出</th>
<th class="text-right">费用</th>
<th>状态</th>
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="l in store.adminLogs" :key="l.id" class="border-base-300/40 hover:bg-base-200/50">
<td class="tabular-nums text-base-content/60">{{ l.id }}</td>
<td class="whitespace-nowrap font-medium">
<span v-if="l.username">{{ l.username }}</span>
<span v-else class="text-base-content/50">#{{ l.user_id }}</span>
</td>
<td class="whitespace-nowrap tabular-nums text-base-content/70">{{ fmtTime(l.created_at) }}</td>
<td class="max-w-40 truncate" :title="l.model_name">{{ l.model_name }}</td>
<td><span class="badge badge-ghost badge-sm">{{ l.protocol }}</span></td>
<td class="text-right tabular-nums">{{ fmtInt(l.input_tokens) }}</td>
<td class="text-right tabular-nums">{{ fmtInt(l.output_tokens) }}</td>
<td class="text-right tabular-nums">{{ fmtCost(l.cost) }}</td>
<td><span class="badge badge-sm" :class="statusClass(l.status)">{{ statusLabel(l.status) }}</span></td>
<td class="pr-3">
<div class="flex items-center justify-end gap-1">
<button
v-if="l.raw_request || l.raw_response"
class="btn btn-ghost btn-xs btn-square"
:aria-label="`View raw data for request ${l.id}`"
@click="viewRaw(l)"
>
<FileTextIcon class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="px-4 py-12 text-center text-sm text-base-content/50">暂无用量记录</div>
<Pagination
v-if="store.adminLogsTotal > 0"
:current-page="page"
:total-items="store.adminLogsTotal"
:page-size="pageSize"
:page-size-options="[10, 20, 50, 100]"
@change-page="changePage"
/>
</div>
<!-- 原始请求/响应 查看弹窗 -->
<dialog ref="rawModal" class="modal">
<div class="modal-box max-w-3xl">
<form method="dialog">
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close">✕</button>
</form>
<h3 class="text-lg font-semibold">原始数据 #{{ currentRaw?.id }}</h3>
<div class="mt-4 space-y-4 text-sm">
<div v-if="currentRaw?.raw_request">
<h4 class="mb-1 font-medium text-base-content/70">原始请求</h4>
<pre class="max-h-64 overflow-auto rounded-lg bg-base-200/50 p-3 text-xs leading-relaxed">{{ currentRaw.raw_request }}</pre>
</div>
<div v-if="currentRaw?.raw_response">
<h4 class="mb-1 font-medium text-base-content/70">原始响应</h4>
<pre class="max-h-64 overflow-auto rounded-lg bg-base-200/50 p-3 text-xs leading-relaxed">{{ currentRaw.raw_response }}</pre>
</div>
<p v-if="!currentRaw?.raw_request && !currentRaw?.raw_response" class="text-base-content/50">该请求未记录原始数据(仅管理员且开关开启时记录)。</p>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button aria-label="Close">close</button>
</form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'
import Pagination from '@/components/common/Pagination.vue'
import { useUsageStore } from '@/stores/usage'
import type { UsageLogItem } from '@/types'
import { SearchIcon, RotateCcwIcon, FileTextIcon } from '@lucide/vue'
const store = useUsageStore()
const page = ref(1)
const pageSize = ref(20)
const filters = reactive({ protocol: '', status: '', model: '', userId: '' })
const summary = computed(() => store.adminSummary?.totals)
// 原始数据弹窗
const rawModal = ref<HTMLDialogElement | null>(null)
const currentRaw = ref<UsageLogItem | null>(null)
function viewRaw(item: UsageLogItem) {
currentRaw.value = item
rawModal.value?.showModal()
}
async function loadLogs() {
const params: Record<string, any> = { page: page.value, pageSize: pageSize.value }
if (filters.protocol) params.protocol = filters.protocol
if (filters.status) params.status = filters.status
if (filters.model) params.model = filters.model
if (filters.userId) params.user_id = filters.userId
try {
await store.fetchAdminLogs(params)
} catch { /* store 已抛错 */ }
}
async function loadSummary() {
try {
await store.fetchAdminSummary()
} catch { /* 同上 */ }
}
function applyFilters() {
page.value = 1
loadLogs()
}
function resetFilters() {
filters.protocol = ''
filters.status = ''
filters.model = ''
filters.userId = ''
applyFilters()
}
function changePage(p: number, s: number) {
page.value = p
pageSize.value = s
loadLogs()
}
function fmtInt(n?: number): string {
return (n ?? 0).toLocaleString()
}
function fmtCost(n?: number): string {
return `$${(n ?? 0).toFixed(6)}`
}
function fmtTime(t?: string): string {
if (!t) return '—'
const d = new Date(t)
const pad = (x: number) => String(x).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function statusLabel(s: string): string {
switch (s) {
case 'success': return '成功'
case 'error': return '失败'
case 'canceled': return '已取消'
default: return s
}
}
function statusClass(s: string): string {
switch (s) {
case 'success': return 'badge-success badge-soft'
case 'error': return 'badge-error badge-soft'
case 'canceled': return 'badge-warning badge-soft'
default: return 'badge-ghost'
}
}
onMounted(() => {
loadLogs()
loadSummary()
})
</script>
+180
View File
@@ -0,0 +1,180 @@
<template>
<div class="space-y-5">
<BreadcrumbHeader />
<div v-if="store.loading && !store.stats" class="py-16 text-center text-sm text-base-content/50">
加载中…
</div>
<template v-else>
<!-- 统计卡片 -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-5">
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
<p class="text-xs text-base-content/50">请求次数</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.requests) }}</p>
</div>
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
<p class="text-xs text-base-content/50">输入 Tokens</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.input_tokens) }}</p>
</div>
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
<p class="text-xs text-base-content/50">输出 Tokens</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.output_tokens) }}</p>
</div>
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
<p class="text-xs text-base-content/50">缓存 Tokens</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtInt(totals.cache_read_tokens) }}</p>
</div>
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
<p class="text-xs text-base-content/50">费用 (USD)</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{{ fmtCost(totals.cost) }}</p>
</div>
</div>
<!-- 每日请求量条形图(纯 CSS) -->
<div class="card border border-base-300/60 bg-base-100 p-5 shadow-sm">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-sm font-semibold">每日请求量</h3>
<select v-model="days" class="select select-sm border-base-300 bg-base-100" @change="loadStats">
<option :value="7">近 7 天</option>
<option :value="30">近 30 天</option>
<option :value="90">近 90 天</option>
</select>
</div>
<div v-if="barItems.length" class="flex h-40 items-end gap-1">
<div v-for="b in barItems" :key="b.date" class="flex flex-1 flex-col items-center gap-1" :title="`${b.date}: ${b.requests} 次`">
<div class="w-full rounded-t bg-primary/70 transition-all" :style="{ height: b.height + 'px' }"></div>
<span class="text-[9px] leading-none text-base-content/40">{{ b.label }}</span>
</div>
</div>
<div v-else class="py-10 text-center text-sm text-base-content/50">暂无用量数据</div>
</div>
<!-- 明细表格 -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="flex items-center justify-between px-5 pt-4">
<h3 class="text-sm font-semibold">请求明细</h3>
</div>
<div v-if="store.myLogs.length" class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr class="text-xs uppercase tracking-wider text-base-content/50">
<th>时间</th>
<th>模型</th>
<th>协议</th>
<th class="text-right">输入</th>
<th class="text-right">输出</th>
<th class="text-right">缓存</th>
<th class="text-right">费用</th>
<th>状态</th>
<th class="text-right">延迟</th>
</tr>
</thead>
<tbody>
<tr v-for="l in store.myLogs" :key="l.id" class="border-base-300/40 hover:bg-base-200/50">
<td class="whitespace-nowrap tabular-nums text-base-content/70">{{ fmtTime(l.created_at) }}</td>
<td class="max-w-40 truncate font-medium">{{ l.model_name }}</td>
<td><span class="badge badge-ghost badge-sm">{{ l.protocol }}</span></td>
<td class="text-right tabular-nums">{{ fmtInt(l.input_tokens) }}</td>
<td class="text-right tabular-nums">{{ fmtInt(l.output_tokens) }}</td>
<td class="text-right tabular-nums">{{ fmtInt(l.cache_read_tokens) }}</td>
<td class="text-right tabular-nums">{{ fmtCost(l.cost) }}</td>
<td>
<span class="badge badge-sm" :class="statusClass(l.status)">
{{ statusLabel(l.status) }}
</span>
</td>
<td class="text-right tabular-nums text-base-content/70">{{ l.latency_ms }}ms</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="px-5 py-12 text-center text-sm text-base-content/50">暂无请求记录</div>
<Pagination v-if="myLogsTotal > 0" :current-page="page" :total-items="myLogsTotal" :page-size="pageSize"
:page-size-options="[10, 20, 50]" @change-page="changePage" />
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'
import Pagination from '@/components/common/Pagination.vue'
import { useUsageStore } from '@/stores/usage'
const store = useUsageStore()
const days = ref(30)
const page = ref(1)
const pageSize = ref(20)
const myLogsTotal = computed(() => store.myLogsTotal)
const totals = computed(() => store.stats?.totals ?? {
requests: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cost: 0,
})
const barItems = computed(() => {
const stats = store.stats
if (!stats) return []
const max = Math.max(1, ...stats.dates.map(d => stats.daily[d]?.requests ?? 0))
return stats.dates.slice(-14).map(d => ({
date: d,
requests: stats.daily[d]?.requests ?? 0,
height: Math.round(((stats.daily[d]?.requests ?? 0) / max) * 120),
label: d.slice(5).replace('-', '/'),
}))
})
async function loadStats() {
try {
await store.fetchStats(days.value)
} catch { /* toast 由 store 抛错,页面保持静默 */ }
}
async function loadLogs() {
try {
await store.fetchMyLogs(pageSize.value, page.value)
} catch { /* 同上 */ }
}
const changePage = (p: number, s: number) => {
page.value = p
pageSize.value = s
loadLogs()
}
function fmtInt(n?: number): string {
return (n ?? 0).toLocaleString()
}
function fmtCost(n?: number): string {
return `$${(n ?? 0).toFixed(6)}`
}
function fmtTime(t?: string): string {
if (!t) return '—'
const d = new Date(t)
const pad = (x: number) => String(x).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function statusLabel(s: string): string {
switch (s) {
case 'success': return '成功'
case 'error': return '失败'
case 'canceled': return '已取消'
default: return s
}
}
function statusClass(s: string): string {
switch (s) {
case 'success': return 'badge-success badge-soft'
case 'error': return 'badge-error badge-soft'
case 'canceled': return 'badge-warning badge-soft'
default: return 'badge-ghost'
}
}
onMounted(() => {
loadStats()
loadLogs()
})
</script>