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:
@@ -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>
|
||||
Reference in New Issue
Block a user