mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-07 10:12:27 +08:00
Initial commit
This commit is contained in:
978
frontend/src/views/shared/Dashboard.vue
Normal file
978
frontend/src/views/shared/Dashboard.vue
Normal file
@@ -0,0 +1,978 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 页面头部:统计卡片 + 公告 -->
|
||||
<div class="flex gap-6 items-start">
|
||||
<!-- 左侧统计区域 -->
|
||||
<div ref="statsPanelRef" class="flex-1 min-w-0 flex flex-col">
|
||||
<Badge :variant="authStore.user?.role === 'admin' ? 'default' : 'secondary'" class="uppercase tracking-[0.45em] mb-4 self-start">
|
||||
{{ authStore.user?.role === 'admin' ? 'ADMIN MODE' : 'PERSONAL MODE' }}
|
||||
</Badge>
|
||||
|
||||
<!-- 主要统计卡片 -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<template v-if="loading && stats.length === 0">
|
||||
<Card v-for="i in 4" :key="'skeleton-' + i" class="p-5">
|
||||
<Skeleton class="h-4 w-20 mb-4" />
|
||||
<Skeleton class="h-8 w-32 mb-2" />
|
||||
<Skeleton class="h-4 w-16" />
|
||||
</Card>
|
||||
</template>
|
||||
<Card
|
||||
v-for="(stat, index) in stats"
|
||||
v-else
|
||||
:key="stat.name"
|
||||
class="relative overflow-hidden p-5"
|
||||
:class="statCardBorders[index % statCardBorders.length]"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute -right-4 -top-6 h-28 w-28 rounded-full blur-3xl opacity-40"
|
||||
:class="statCardGlows[index % statCardGlows.length]"
|
||||
/>
|
||||
<div class="flex items-start justify-between relative">
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.4em] text-muted-foreground">{{ stat.name }}</p>
|
||||
<p class="mt-4 text-3xl font-semibold text-foreground">{{ stat.value }}</p>
|
||||
<p v-if="stat.subValue" class="mt-1 text-sm text-muted-foreground">{{ stat.subValue }}</p>
|
||||
<div v-if="stat.change || stat.extraBadge" class="mt-2 flex items-center gap-1.5">
|
||||
<Badge
|
||||
v-if="stat.change"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ stat.change }}
|
||||
</Badge>
|
||||
<Badge v-if="stat.extraBadge" variant="secondary">
|
||||
{{ stat.extraBadge }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-card/50 p-3 shadow-inner backdrop-blur-sm" :class="getStatIconColor(index)">
|
||||
<component :is="stat.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 管理员:系统健康摘要 -->
|
||||
<div v-if="isAdmin && systemHealth" class="mt-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-foreground">本月系统健康</h3>
|
||||
<Badge variant="outline" class="uppercase tracking-[0.3em] text-[10px]">Monthly</Badge>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Card class="p-4 border-book-cloth/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">平均响应</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ systemHealth.avg_response_time }}s</p>
|
||||
</div>
|
||||
<Clock class="h-4 w-4 text-book-cloth" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4 border-kraft/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">错误率</p>
|
||||
<p class="mt-2 text-xl font-semibold" :class="systemHealth.error_rate > 5 ? 'text-destructive' : 'text-foreground'">{{ systemHealth.error_rate }}%</p>
|
||||
</div>
|
||||
<AlertTriangle class="h-4 w-4 text-kraft" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4 border-book-cloth/25">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">转移次数</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ systemHealth.fallback_count }}</p>
|
||||
</div>
|
||||
<Shuffle class="h-4 w-4 text-kraft" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card v-if="costStats" class="p-4 border-manilla/40">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">实际成本</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatCurrency(costStats.total_actual_cost) }}</p>
|
||||
<Badge v-if="costStats.cost_savings > 0" variant="success" class="mt-1 text-[10px]">
|
||||
节省 {{ formatCurrency(costStats.cost_savings) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<DollarSign class="h-4 w-4 text-book-cloth" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 普通用户:缓存统计 -->
|
||||
<div v-else-if="!isAdmin && cacheStats && cacheStats.total_cache_tokens > 0" class="mt-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-foreground">本月缓存使用</h3>
|
||||
<Badge variant="outline" class="uppercase tracking-[0.3em] text-[10px]">Monthly</Badge>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Card class="p-4 border-book-cloth/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">缓存命中率</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ cacheStats.cache_hit_rate || 0 }}%</p>
|
||||
</div>
|
||||
<Database class="h-4 w-4 text-book-cloth" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4 border-kraft/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">缓存读取</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatTokens(cacheStats.cache_read_tokens) }}</p>
|
||||
</div>
|
||||
<Hash class="h-4 w-4 text-kraft" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4 border-book-cloth/25">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">缓存创建</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatTokens(cacheStats.cache_creation_tokens) }}</p>
|
||||
</div>
|
||||
<Database class="h-4 w-4 text-kraft" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card v-if="tokenBreakdown" class="p-4 border-manilla/40">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">总Token</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatTokens((tokenBreakdown.input || 0) + (tokenBreakdown.output || 0)) }}</p>
|
||||
<p class="mt-1 text-[10px] text-muted-foreground">输入 {{ formatTokens(tokenBreakdown.input || 0) }} / 输出 {{ formatTokens(tokenBreakdown.output || 0) }}</p>
|
||||
</div>
|
||||
<Hash class="h-4 w-4 text-book-cloth" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧系统公告 -->
|
||||
<div
|
||||
id="announcements-section"
|
||||
class="w-[340px] flex-shrink-0 flex flex-col min-h-0"
|
||||
:style="announcementsContainerStyle"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between flex-shrink-0">
|
||||
<h3 class="text-sm font-medium text-foreground">系统公告</h3>
|
||||
<Badge variant="outline" class="uppercase tracking-[0.3em] text-[10px]">Live</Badge>
|
||||
</div>
|
||||
|
||||
<Card class="overflow-hidden p-4 flex flex-col flex-1 min-h-0 h-full">
|
||||
<div v-if="loadingAnnouncements" class="py-8 text-center">
|
||||
<Loader2 class="h-5 w-5 animate-spin mx-auto text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="announcements.length === 0" class="py-8 text-center">
|
||||
<Bell class="h-8 w-8 mx-auto text-muted-foreground/40" />
|
||||
<p class="mt-2 text-xs text-muted-foreground">暂无公告</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="-mx-4 px-4 flex-1 overflow-y-auto scrollbar-thin min-h-0 pb-2">
|
||||
<div ref="announcementsTimelineRef" class="relative pl-5">
|
||||
<div
|
||||
v-if="announcements.length > 1"
|
||||
class="absolute left-[7px] w-[2px] bg-slate-200 dark:bg-muted"
|
||||
:style="timelineLineStyle"
|
||||
></div>
|
||||
|
||||
<button
|
||||
v-for="announcement in announcements"
|
||||
:key="announcement.id"
|
||||
data-announcement-item
|
||||
type="button"
|
||||
class="relative w-full text-left mb-3 last:mb-0"
|
||||
@click="viewAnnouncementDetail(announcement)"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<div class="absolute left-[-18px] top-1 z-10">
|
||||
<span
|
||||
data-announcement-marker
|
||||
class="flex h-3 w-3 items-center justify-center rounded-full border-2 border-white dark:border-slate-900"
|
||||
:class="[
|
||||
announcement.is_pinned
|
||||
? 'bg-amber-500 dark:bg-amber-400'
|
||||
: announcement.is_read
|
||||
? 'bg-slate-300 dark:bg-slate-600'
|
||||
: getAnnouncementDotColor(announcement.type)
|
||||
]"
|
||||
>
|
||||
<span
|
||||
v-if="!announcement.is_read && !announcement.is_pinned"
|
||||
class="h-1.5 w-1.5 rounded-full bg-white"
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 rounded-lg p-2 transition"
|
||||
:class="[
|
||||
announcement.is_pinned
|
||||
? 'hover:bg-amber-50/50 dark:hover:bg-amber-900/10'
|
||||
: 'hover:bg-slate-50/50 dark:hover:bg-slate-800/30'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h4 class="text-xs font-medium text-foreground line-clamp-1 flex-1">
|
||||
{{ announcement.title }}
|
||||
</h4>
|
||||
<span
|
||||
v-if="announcement.is_pinned"
|
||||
class="flex-shrink-0 rounded-full bg-amber-100 dark:bg-amber-900/30 px-1.5 py-0.5 text-[9px] font-medium text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
置顶
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-muted-foreground leading-relaxed line-clamp-2 mb-1">
|
||||
{{ getPlainText(announcement.content) }}
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground/70">
|
||||
{{ formatAnnouncementDate(announcement.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 趋势图表区域 -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- 请求次数和费用趋势 -->
|
||||
<Card class="p-5">
|
||||
<h4 class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider">请求次数 / 费用趋势</h4>
|
||||
<div v-if="loadingDaily" class="flex items-center justify-center h-[280px]">
|
||||
<Skeleton class="h-full w-full" />
|
||||
</div>
|
||||
<div v-else style="height: 280px;">
|
||||
<LineChart v-if="chartData.requests" :data="chartData.requests" :options="chartOptions.requests" />
|
||||
<div v-else class="flex h-full items-center justify-center text-xs text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 每日模型成本(堆叠柱状图) -->
|
||||
<Card class="p-5">
|
||||
<h4 class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider">每日模型成本</h4>
|
||||
<div v-if="loadingDaily" class="flex items-center justify-center h-[280px]">
|
||||
<Skeleton class="h-full w-full" />
|
||||
</div>
|
||||
<div v-else style="height: 280px;">
|
||||
<BarChart
|
||||
v-if="dailyModelCostChartData.labels && dailyModelCostChartData.labels.length > 0"
|
||||
:data="dailyModelCostChartData"
|
||||
:options="dailyModelCostChartOptions"
|
||||
/>
|
||||
<div v-else class="flex h-full items-center justify-center text-xs text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 每日统计表格 -->
|
||||
<Card class="overflow-hidden mt-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="text-left">日期</TableHead>
|
||||
<TableHead class="text-center">请求次数</TableHead>
|
||||
<TableHead class="text-center">Tokens</TableHead>
|
||||
<TableHead class="text-center">费用</TableHead>
|
||||
<TableHead class="text-center">平均响应</TableHead>
|
||||
<TableHead class="text-center">使用模型</TableHead>
|
||||
<TableHead class="text-center">使用提供商</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="loadingDaily">
|
||||
<TableCell colspan="7" class="text-center py-8">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<Skeleton class="h-5 w-5 rounded-full" />
|
||||
<span class="text-muted-foreground text-xs">加载中...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-else-if="dailyStats.length === 0">
|
||||
<TableCell colspan="7" class="text-center py-8 text-muted-foreground text-xs">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<template v-else>
|
||||
<TableRow v-for="stat in dailyStats.slice().reverse()" :key="stat.date">
|
||||
<TableCell class="font-medium text-xs">{{ formatDate(stat.date) }}</TableCell>
|
||||
<TableCell class="text-center text-xs">{{ stat.requests.toLocaleString() }}</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge variant="secondary" class="text-[10px]">{{ formatTokens(stat.tokens) }}</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge variant="success" class="text-[10px]">${{ stat.cost.toFixed(4) }}</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge variant="outline" class="text-[10px]">{{ formatResponseTime(stat.avg_response_time) }}</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center text-xs">{{ stat.unique_models }}</TableCell>
|
||||
<TableCell class="text-center text-xs">{{ stat.unique_providers }}</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 汇总信息 -->
|
||||
<div v-if="dailyStats.length > 0" class="border-t border-border bg-muted/30 backdrop-blur-sm px-4 py-3 text-xs">
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground text-[10px]">总请求</div>
|
||||
<div class="font-semibold text-foreground">{{ totalStats.requests.toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground text-[10px]">总Tokens</div>
|
||||
<div class="font-semibold text-book-cloth dark:text-kraft">{{ formatTokens(totalStats.tokens) }}</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground text-[10px]">总费用</div>
|
||||
<div class="font-semibold text-amber-600 dark:text-amber-400">${{ totalStats.cost.toFixed(4) }}</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground text-[10px]">平均响应</div>
|
||||
<div class="font-semibold text-book-cloth dark:text-kraft">{{ formatResponseTime(totalStats.avgResponseTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 公告详情对话框 -->
|
||||
<Dialog v-model="detailDialogOpen" size="lg">
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<component
|
||||
v-if="selectedAnnouncement"
|
||||
:is="getAnnouncementIcon(selectedAnnouncement.type)"
|
||||
class="h-5 w-5 flex-shrink-0"
|
||||
:class="getAnnouncementIconColor(selectedAnnouncement.type)"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight truncate">{{ selectedAnnouncement?.title || '公告详情' }}</h3>
|
||||
<p class="text-xs text-muted-foreground">系统公告</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="selectedAnnouncement" class="space-y-4">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ formatFullDate(selectedAnnouncement.created_at) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-html="renderMarkdown(selectedAnnouncement.content)"
|
||||
class="prose prose-sm dark:prose-invert max-w-none"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="outline" @click="detailDialogOpen = false" class="h-10 px-5">关闭</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { dashboardApi, type DashboardStat, type DailyStat } from '@/api/dashboard'
|
||||
import { announcementApi, type Announcement } from '@/api/announcements'
|
||||
import {
|
||||
Card,
|
||||
Badge,
|
||||
Button,
|
||||
Skeleton,
|
||||
Dialog,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from '@/components/ui'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
import BarChart from '@/components/charts/BarChart.vue'
|
||||
import {
|
||||
Users,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
DollarSign,
|
||||
Key,
|
||||
Hash,
|
||||
Bell,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
Wrench,
|
||||
Loader2,
|
||||
Clock,
|
||||
Database,
|
||||
Shuffle
|
||||
} from 'lucide-vue-next'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import { marked } from 'marked'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
import type { ChartData, ChartOptions, ChartDataset, TooltipItem } from 'chart.js'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const statsPanelRef = ref<HTMLElement | null>(null)
|
||||
const announcementsHeight = ref<number | null>(null)
|
||||
const announcementsTimelineRef = ref<HTMLElement | null>(null)
|
||||
const timelineLineStyle = ref<{ top: string; bottom: string }>({ top: '0px', bottom: '0px' })
|
||||
|
||||
const announcementsContainerStyle = computed(() => {
|
||||
if (!announcementsHeight.value) return {}
|
||||
// 设置固定高度,与左侧统计面板保持一致
|
||||
return { height: `${announcementsHeight.value}px` }
|
||||
})
|
||||
|
||||
let statsPanelObserver: ResizeObserver | null = null
|
||||
let announcementsTimelineObserver: ResizeObserver | null = null
|
||||
|
||||
function updateAnnouncementsHeight() {
|
||||
if (typeof window === 'undefined') return
|
||||
const panel = statsPanelRef.value
|
||||
if (!panel) return
|
||||
const { height } = panel.getBoundingClientRect()
|
||||
if (height <= 0) return
|
||||
announcementsHeight.value = Math.round(height)
|
||||
nextTick(() => updateTimelineLine())
|
||||
}
|
||||
|
||||
function updateTimelineLine() {
|
||||
if (typeof window === 'undefined') return
|
||||
const container = announcementsTimelineRef.value
|
||||
if (!container) return
|
||||
const items = container.querySelectorAll<HTMLElement>('[data-announcement-item]')
|
||||
if (items.length < 2) {
|
||||
timelineLineStyle.value = { top: '0px', bottom: '0px' }
|
||||
return
|
||||
}
|
||||
const firstMarker = items[0].querySelector<HTMLElement>('[data-announcement-marker]')
|
||||
const lastMarker = items[items.length - 1].querySelector<HTMLElement>('[data-announcement-marker]')
|
||||
if (!firstMarker || !lastMarker) return
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const firstRect = firstMarker.getBoundingClientRect()
|
||||
const lastRect = lastMarker.getBoundingClientRect()
|
||||
const topOffset = Math.max(0, firstRect.top + firstRect.height / 2 - containerRect.top)
|
||||
const bottomOffset = Math.max(0, containerRect.bottom - (lastRect.top + lastRect.height / 2))
|
||||
timelineLineStyle.value = { top: `${topOffset}px`, bottom: `${bottomOffset}px` }
|
||||
}
|
||||
|
||||
function handleWindowResize() {
|
||||
updateAnnouncementsHeight()
|
||||
updateTimelineLine()
|
||||
}
|
||||
|
||||
function setupResizeObserver() {
|
||||
if (typeof window === 'undefined') return
|
||||
const panel = statsPanelRef.value
|
||||
if (!panel || !('ResizeObserver' in window)) return
|
||||
statsPanelObserver = new ResizeObserver(() => updateAnnouncementsHeight())
|
||||
statsPanelObserver.observe(panel)
|
||||
updateAnnouncementsHeight()
|
||||
}
|
||||
|
||||
function setupTimelineResizeObserver() {
|
||||
if (typeof window === 'undefined' || !('ResizeObserver' in window)) return
|
||||
const container = announcementsTimelineRef.value
|
||||
announcementsTimelineObserver?.disconnect()
|
||||
announcementsTimelineObserver = null
|
||||
if (!container) return
|
||||
announcementsTimelineObserver = new ResizeObserver(() => updateTimelineLine())
|
||||
announcementsTimelineObserver.observe(container)
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
||||
|
||||
const statCardBorders = [
|
||||
'border-book-cloth/30 dark:border-book-cloth/25',
|
||||
'border-kraft/30 dark:border-kraft/25',
|
||||
'border-manilla/40 dark:border-manilla/30',
|
||||
'border-book-cloth/25 dark:border-kraft/25'
|
||||
]
|
||||
|
||||
const statCardGlows = [
|
||||
'bg-book-cloth/30',
|
||||
'bg-kraft/30',
|
||||
'bg-manilla/35',
|
||||
'bg-kraft/30'
|
||||
]
|
||||
|
||||
const getStatIconColor = (index: number): string => {
|
||||
const colors = ['text-book-cloth', 'text-kraft', 'text-book-cloth', 'text-kraft']
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
const stats = ref<DashboardStat[]>([])
|
||||
const todayStats = ref<{
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
actual_cost?: number
|
||||
cache_creation_tokens?: number
|
||||
cache_read_tokens?: number
|
||||
}>({ requests: 0, tokens: 0, cost: 0 })
|
||||
|
||||
const systemHealth = ref<{
|
||||
avg_response_time: number
|
||||
error_rate: number
|
||||
error_requests: number
|
||||
fallback_count: number
|
||||
total_requests: number
|
||||
} | null>(null)
|
||||
|
||||
const costStats = ref<{
|
||||
total_cost: number
|
||||
total_actual_cost: number
|
||||
cost_savings: number
|
||||
} | null>(null)
|
||||
|
||||
const cacheStats = ref<{
|
||||
cache_creation_tokens: number
|
||||
cache_read_tokens: number
|
||||
cache_creation_cost?: number
|
||||
cache_read_cost?: number
|
||||
cache_hit_rate?: number
|
||||
total_cache_tokens: number
|
||||
} | null>(null)
|
||||
|
||||
const tokenBreakdown = ref<{
|
||||
input: number
|
||||
output: number
|
||||
cache_creation: number
|
||||
cache_read: number
|
||||
} | null>(null)
|
||||
|
||||
const activeUsers = ref(0)
|
||||
const dailyStats = ref<DailyStat[]>([])
|
||||
const selectedDays = ref(7)
|
||||
const loadingDaily = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
|
||||
// 公告
|
||||
const announcements = ref<Announcement[]>([])
|
||||
const loadingAnnouncements = ref(false)
|
||||
const selectedAnnouncement = ref<Announcement | null>(null)
|
||||
const detailDialogOpen = ref(false)
|
||||
|
||||
const iconMap: Record<string, any> = {
|
||||
Users, Activity, TrendingUp, DollarSign, Key, Hash, Database
|
||||
}
|
||||
|
||||
const totalStats = computed(() => {
|
||||
if (dailyStats.value.length === 0) {
|
||||
return { requests: 0, tokens: 0, cost: 0, avgResponseTime: 0 }
|
||||
}
|
||||
const totals = dailyStats.value.reduce((acc, stat) => {
|
||||
acc.requests += stat.requests
|
||||
acc.tokens += stat.tokens
|
||||
acc.cost += stat.cost
|
||||
acc.totalResponseTime += stat.avg_response_time * stat.requests
|
||||
return acc
|
||||
}, { requests: 0, tokens: 0, cost: 0, totalResponseTime: 0 })
|
||||
return {
|
||||
requests: totals.requests,
|
||||
tokens: totals.tokens,
|
||||
cost: totals.cost,
|
||||
avgResponseTime: totals.requests > 0 ? totals.totalResponseTime / totals.requests : 0
|
||||
}
|
||||
})
|
||||
|
||||
// 图表数据
|
||||
const chartData = computed(() => {
|
||||
if (dailyStats.value.length === 0) {
|
||||
return { requests: null }
|
||||
}
|
||||
|
||||
const labels = dailyStats.value.map(stat => formatDateForChart(stat.date))
|
||||
const requests = dailyStats.value.map(stat => stat.requests)
|
||||
const costs = dailyStats.value.map(stat => stat.cost)
|
||||
|
||||
return {
|
||||
requests: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '请求次数',
|
||||
data: requests,
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.4,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: '费用 ($)',
|
||||
data: costs,
|
||||
borderColor: 'rgb(34, 197, 94)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
tension: 0.4,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
} as ChartData<'line'>
|
||||
}
|
||||
})
|
||||
|
||||
// 每日模型成本(堆叠柱状图)
|
||||
const MODEL_COLORS = [
|
||||
'rgba(59, 130, 246, 0.8)', // blue
|
||||
'rgba(239, 68, 68, 0.8)', // red
|
||||
'rgba(16, 185, 129, 0.8)', // green
|
||||
'rgba(245, 158, 11, 0.8)', // amber
|
||||
'rgba(139, 92, 246, 0.8)', // purple
|
||||
'rgba(6, 182, 212, 0.8)', // cyan
|
||||
'rgba(132, 204, 22, 0.8)', // lime
|
||||
'rgba(249, 115, 22, 0.8)' // orange
|
||||
]
|
||||
|
||||
const dailyModelCostChartData = computed<ChartData<'bar'>>(() => {
|
||||
if (dailyStats.value.length === 0) {
|
||||
return { labels: [], datasets: [] }
|
||||
}
|
||||
|
||||
// 收集所有出现过的模型
|
||||
const allModels = new Set<string>()
|
||||
dailyStats.value.forEach(day => {
|
||||
day.model_breakdown?.forEach(mb => allModels.add(mb.model))
|
||||
})
|
||||
const modelList = Array.from(allModels)
|
||||
|
||||
// 按总费用降序排列模型
|
||||
const modelTotalCost = new Map<string, number>()
|
||||
dailyStats.value.forEach(day => {
|
||||
day.model_breakdown?.forEach(mb => {
|
||||
modelTotalCost.set(mb.model, (modelTotalCost.get(mb.model) || 0) + mb.cost)
|
||||
})
|
||||
})
|
||||
modelList.sort((a, b) => (modelTotalCost.get(b) || 0) - (modelTotalCost.get(a) || 0))
|
||||
|
||||
// 为每个模型创建一个 dataset
|
||||
const datasets: ChartDataset<'bar', number[]>[] = modelList.map((model, index) => ({
|
||||
label: model.replace('claude-', '').replace('gpt-', ''),
|
||||
data: dailyStats.value.map(day => {
|
||||
const found = day.model_breakdown?.find(mb => mb.model === model)
|
||||
return found ? found.cost : 0
|
||||
}),
|
||||
backgroundColor: MODEL_COLORS[index % MODEL_COLORS.length],
|
||||
borderRadius: 2,
|
||||
stack: 'stack0',
|
||||
barPercentage: 0.6,
|
||||
categoryPercentage: 0.7
|
||||
}))
|
||||
|
||||
return {
|
||||
labels: dailyStats.value.map(stat => formatDateForChart(stat.date)),
|
||||
datasets
|
||||
}
|
||||
})
|
||||
|
||||
const dailyModelCostChartOptions = computed<ChartOptions<'bar'>>(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
ticks: { font: { size: 10 } }
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
title: { display: true, text: '费用 ($)', color: 'rgb(107, 114, 128)', font: { size: 10 } },
|
||||
ticks: { font: { size: 10 } }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: { font: { size: 10 }, boxWidth: 12, padding: 8 }
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: TooltipItem<'bar'>) => {
|
||||
const value = typeof context.raw === 'number' ? context.raw : 0
|
||||
if (value === 0) return ''
|
||||
return `${context.dataset.label}: $${value.toFixed(4)}`
|
||||
},
|
||||
footer: (items: TooltipItem<'bar'>[]) => {
|
||||
const total = items.reduce((sum, item) => {
|
||||
const val = typeof item.raw === 'number' ? item.raw : 0
|
||||
return sum + val
|
||||
}, 0)
|
||||
return `Total: $${total.toFixed(4)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
requests: {
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: { display: true, text: '请求次数', color: 'rgb(107, 114, 128)', font: { size: 10 } }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: { display: true, text: '费用 ($)', color: 'rgb(107, 114, 128)', font: { size: 10 } },
|
||||
grid: { drawOnChartArea: false }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { labels: { font: { size: 11 } } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const label = context.dataset.label || ''
|
||||
const value = context.parsed.y
|
||||
if (label.includes('费用')) return `${label}: $${value.toFixed(4)}`
|
||||
return `${label}: ${value.toLocaleString()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} as ChartOptions<'line'>
|
||||
}))
|
||||
|
||||
onMounted(async () => {
|
||||
setupResizeObserver()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', handleWindowResize)
|
||||
}
|
||||
await Promise.all([
|
||||
loadDashboardData(),
|
||||
loadDailyStats(),
|
||||
loadAnnouncements()
|
||||
])
|
||||
await nextTick()
|
||||
setupTimelineResizeObserver()
|
||||
updateAnnouncementsHeight()
|
||||
updateTimelineLine()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', handleWindowResize)
|
||||
}
|
||||
if (statsPanelObserver && statsPanelRef.value) {
|
||||
statsPanelObserver.unobserve(statsPanelRef.value)
|
||||
}
|
||||
statsPanelObserver?.disconnect()
|
||||
statsPanelObserver = null
|
||||
announcementsTimelineObserver?.disconnect()
|
||||
announcementsTimelineObserver = null
|
||||
})
|
||||
|
||||
async function loadDashboardData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const statsData = await dashboardApi.getStats()
|
||||
stats.value = statsData.stats.map(stat => ({
|
||||
...stat,
|
||||
icon: iconMap[stat.icon] || Activity
|
||||
}))
|
||||
if (statsData.today) todayStats.value = statsData.today
|
||||
if (isAdmin.value) {
|
||||
if (statsData.system_health) systemHealth.value = statsData.system_health
|
||||
if (statsData.cost_stats) costStats.value = statsData.cost_stats
|
||||
if (statsData.cache_stats) cacheStats.value = statsData.cache_stats
|
||||
if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown
|
||||
if (statsData.users) activeUsers.value = statsData.users.active
|
||||
} else {
|
||||
if (statsData.cache_stats) cacheStats.value = statsData.cache_stats
|
||||
if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDailyStats() {
|
||||
loadingDaily.value = true
|
||||
try {
|
||||
const response = await dashboardApi.getDailyStats(selectedDays.value)
|
||||
dailyStats.value = response.daily_stats
|
||||
} catch {
|
||||
dailyStats.value = []
|
||||
} finally {
|
||||
loadingDaily.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
if (date.toDateString() === today.toDateString()) return '今天'
|
||||
if (date.toDateString() === yesterday.toDateString()) return '昨天'
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', weekday: 'short' })
|
||||
}
|
||||
|
||||
function formatDateForChart(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
if (date.toDateString() === today.toDateString()) return '今天'
|
||||
if (date.toDateString() === yesterday.toDateString()) return '昨天'
|
||||
return date.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
|
||||
}
|
||||
|
||||
function formatResponseTime(seconds: number): string {
|
||||
if (seconds === 0) return '-'
|
||||
if (seconds < 1) return `${(seconds * 1000).toFixed(0)}ms`
|
||||
return `${seconds.toFixed(2)}s`
|
||||
}
|
||||
|
||||
// 公告相关
|
||||
async function loadAnnouncements() {
|
||||
loadingAnnouncements.value = true
|
||||
try {
|
||||
const response = await announcementApi.getAnnouncements({ active_only: true, limit: 100 })
|
||||
announcements.value = response.items
|
||||
} catch {
|
||||
announcements.value = []
|
||||
} finally {
|
||||
loadingAnnouncements.value = false
|
||||
await nextTick()
|
||||
setupTimelineResizeObserver()
|
||||
updateTimelineLine()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => announcements.value.length, async () => {
|
||||
await nextTick()
|
||||
setupTimelineResizeObserver()
|
||||
updateTimelineLine()
|
||||
})
|
||||
|
||||
async function viewAnnouncementDetail(announcement: Announcement) {
|
||||
if (!announcement.is_read && !isAdmin.value) {
|
||||
try {
|
||||
await announcementApi.markAsRead(announcement.id)
|
||||
announcement.is_read = true
|
||||
} catch {}
|
||||
}
|
||||
selectedAnnouncement.value = announcement
|
||||
detailDialogOpen.value = true
|
||||
}
|
||||
|
||||
function getPlainText(content: string): string {
|
||||
const cleaned = content
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/`[^`]*`/g, ' ')
|
||||
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
|
||||
.replace(/\[[^\]]*]\(([^)]*)\)/g, '$1')
|
||||
.replace(/[#>*_~]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (cleaned.length <= 100) return cleaned
|
||||
return `${cleaned.slice(0, 100).trim()}...`
|
||||
}
|
||||
|
||||
function getAnnouncementIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'important': return AlertCircle
|
||||
case 'warning': return AlertTriangle
|
||||
case 'maintenance': return Wrench
|
||||
default: return Info
|
||||
}
|
||||
}
|
||||
|
||||
function getAnnouncementIconColor(type: string) {
|
||||
switch (type) {
|
||||
case 'important': return 'text-rose-600 dark:text-rose-400'
|
||||
case 'warning': return 'text-amber-600 dark:text-amber-400'
|
||||
case 'maintenance': return 'text-orange-600 dark:text-orange-400'
|
||||
default: return 'text-primary dark:text-primary'
|
||||
}
|
||||
}
|
||||
|
||||
function formatAnnouncementDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
if (days < 7) return `${days}天前`
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function getAnnouncementDotColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'important': return 'bg-rose-500 dark:bg-rose-400'
|
||||
case 'warning': return 'bg-amber-500 dark:bg-amber-400'
|
||||
case 'maintenance': return 'bg-orange-500 dark:bg-orange-400'
|
||||
default: return 'bg-emerald-500 dark:bg-emerald-400'
|
||||
}
|
||||
}
|
||||
|
||||
function formatFullDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function renderMarkdown(content: string): string {
|
||||
const rawHtml = marked(content) as string
|
||||
return sanitizeMarkdown(rawHtml)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-1,
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.line-clamp-1 { -webkit-line-clamp: 1; }
|
||||
.line-clamp-2 { -webkit-line-clamp: 2; }
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar { width: 5px; }
|
||||
.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb { background: rgb(203 213 225); border-radius: 2px; }
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb { background: rgb(71 85 105); }
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover { background: rgb(148 163 184); }
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover { background: rgb(100 116 139); }
|
||||
|
||||
:deep(.prose) { color: var(--color-text); }
|
||||
:deep(.prose p) { margin-top: 0.75em; margin-bottom: 0.75em; line-height: 1.65; }
|
||||
:deep(.prose ul), :deep(.prose ol) { margin-top: 0.75em; margin-bottom: 0.75em; padding-left: 1.5em; }
|
||||
:deep(.prose li) { margin-top: 0.25em; margin-bottom: 0.25em; }
|
||||
:deep(.prose h1), :deep(.prose h2), :deep(.prose h3), :deep(.prose h4) { margin-top: 1.5em; margin-bottom: 0.75em; font-weight: 600; color: var(--color-text); }
|
||||
:deep(.prose code) { background: var(--color-code-background); color: var(--color-code-text); padding: 0.2em 0.4em; border-radius: 4px; font-size: 0.9em; font-weight: 500; }
|
||||
:deep(.prose pre) { background: var(--color-code-background); padding: 1em; border-radius: 8px; overflow-x: auto; }
|
||||
:deep(.prose a) { color: var(--book-cloth); text-decoration: underline; }
|
||||
:deep(.prose blockquote) { border-left: 3px solid var(--book-cloth); padding-left: 1em; margin-left: 0; font-style: italic; color: var(--cloud-dark); }
|
||||
:deep(.prose strong) { font-weight: 600; }
|
||||
</style>
|
||||
18
frontend/src/views/shared/HealthMonitor.vue
Normal file
18
frontend/src/views/shared/HealthMonitor.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<HealthMonitorCard
|
||||
title="健康监控"
|
||||
:is-admin="isAdminPage"
|
||||
:show-provider-info="isAdminPage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import HealthMonitorCard from '@/features/providers/components/HealthMonitorCard.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const isAdminPage = computed(() => route.path.startsWith('/admin'))
|
||||
</script>
|
||||
411
frontend/src/views/shared/Usage.vue
Normal file
411
frontend/src/views/shared/Usage.vue
Normal file
@@ -0,0 +1,411 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- 活跃度热图 -->
|
||||
<ActivityHeatmapCard
|
||||
:data="activityHeatmapData"
|
||||
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
|
||||
/>
|
||||
|
||||
<!-- 分析统计 -->
|
||||
<!-- 管理员:模型 + 提供商 + API格式(3列) -->
|
||||
<div v-if="isAdminPage" class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<UsageModelTable
|
||||
:data="enhancedModelStats"
|
||||
:is-admin="authStore.isAdmin"
|
||||
/>
|
||||
<UsageProviderTable
|
||||
:data="providerStats"
|
||||
:is-admin="authStore.isAdmin"
|
||||
/>
|
||||
<UsageApiFormatTable
|
||||
:data="apiFormatStats"
|
||||
:is-admin="authStore.isAdmin"
|
||||
/>
|
||||
</div>
|
||||
<!-- 用户:模型 + API格式(2列) -->
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<UsageModelTable
|
||||
:data="enhancedModelStats"
|
||||
:is-admin="authStore.isAdmin"
|
||||
/>
|
||||
<UsageApiFormatTable
|
||||
:data="apiFormatStats"
|
||||
:is-admin="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 请求详情 -->
|
||||
<UsageRecordsTable
|
||||
:records="displayRecords"
|
||||
:is-admin="isAdminPage"
|
||||
:show-actual-cost="authStore.isAdmin"
|
||||
:loading="isLoadingRecords"
|
||||
:selected-period="selectedPeriod"
|
||||
:filter-user="filterUser"
|
||||
:filter-model="filterModel"
|
||||
:filter-provider="filterProvider"
|
||||
:filter-status="filterStatus"
|
||||
:available-users="availableUsers"
|
||||
:available-models="availableModels"
|
||||
:available-providers="availableProviders"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total-records="totalRecords"
|
||||
:page-size-options="pageSizeOptions"
|
||||
@update:selected-period="handlePeriodChange"
|
||||
@update:filter-user="handleFilterUserChange"
|
||||
@update:filter-model="handleFilterModelChange"
|
||||
@update:filter-provider="handleFilterProviderChange"
|
||||
@update:filter-status="handleFilterStatusChange"
|
||||
@update:current-page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
@refresh="refreshData"
|
||||
@export="exportData"
|
||||
@show-detail="showRequestDetail"
|
||||
/>
|
||||
|
||||
<!-- 请求详情抽屉 - 仅管理员可见 -->
|
||||
<RequestDetailDrawer
|
||||
v-if="isAdminPage"
|
||||
:isOpen="detailModalOpen"
|
||||
:requestId="selectedRequestId"
|
||||
@close="detailModalOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usageApi } from '@/api/usage'
|
||||
import { usersApi } from '@/api/users'
|
||||
import { meApi } from '@/api/me'
|
||||
import {
|
||||
UsageModelTable,
|
||||
UsageProviderTable,
|
||||
UsageApiFormatTable,
|
||||
UsageRecordsTable,
|
||||
ActivityHeatmapCard,
|
||||
RequestDetailDrawer
|
||||
} from '@/features/usage/components'
|
||||
import {
|
||||
useUsageData,
|
||||
getDateRangeFromPeriod
|
||||
} from '@/features/usage/composables'
|
||||
import type { PeriodValue, FilterStatusValue } from '@/features/usage/types'
|
||||
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 判断是否是管理员页面
|
||||
const isAdminPage = computed(() => route.path.startsWith('/admin'))
|
||||
|
||||
// 时间段选择
|
||||
const selectedPeriod = ref<PeriodValue>('today')
|
||||
|
||||
// 分页状态
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const pageSizeOptions = [10, 20, 50, 100]
|
||||
|
||||
// 筛选状态
|
||||
const filterUser = ref('__all__')
|
||||
const filterModel = ref('__all__')
|
||||
const filterProvider = ref('__all__')
|
||||
const filterStatus = ref<FilterStatusValue>('__all__')
|
||||
|
||||
// 用户列表(仅管理员页面使用)
|
||||
const availableUsers = ref<UserOption[]>([])
|
||||
|
||||
// 使用 composables
|
||||
const {
|
||||
isLoadingRecords,
|
||||
providerStats,
|
||||
apiFormatStats,
|
||||
currentRecords,
|
||||
totalRecords,
|
||||
enhancedModelStats,
|
||||
activityHeatmapData,
|
||||
availableModels,
|
||||
availableProviders,
|
||||
loadStats,
|
||||
loadRecords
|
||||
} = useUsageData({ isAdminPage })
|
||||
|
||||
// 用户页面需要前端筛选
|
||||
const filteredRecords = computed(() => {
|
||||
if (!isAdminPage.value) {
|
||||
let records = [...currentRecords.value]
|
||||
|
||||
if (filterModel.value !== '__all__') {
|
||||
records = records.filter(record => record.model === filterModel.value)
|
||||
}
|
||||
|
||||
if (filterProvider.value !== '__all__') {
|
||||
records = records.filter(record => record.provider === filterProvider.value)
|
||||
}
|
||||
|
||||
if (filterStatus.value !== '__all__') {
|
||||
if (filterStatus.value === 'stream') {
|
||||
records = records.filter(record =>
|
||||
record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
||||
)
|
||||
} else if (filterStatus.value === 'standard') {
|
||||
records = records.filter(record =>
|
||||
!record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
||||
)
|
||||
} else if (filterStatus.value === 'error') {
|
||||
records = records.filter(record =>
|
||||
record.error_message || (record.status_code && record.status_code >= 400)
|
||||
)
|
||||
} else if (filterStatus.value === 'active') {
|
||||
records = records.filter(record =>
|
||||
record.status === 'pending' || record.status === 'streaming'
|
||||
)
|
||||
} else if (filterStatus.value === 'pending') {
|
||||
records = records.filter(record => record.status === 'pending')
|
||||
} else if (filterStatus.value === 'streaming') {
|
||||
records = records.filter(record => record.status === 'streaming')
|
||||
} else if (filterStatus.value === 'completed') {
|
||||
records = records.filter(record => record.status === 'completed')
|
||||
} else if (filterStatus.value === 'failed') {
|
||||
records = records.filter(record => record.status === 'failed')
|
||||
}
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
return currentRecords.value
|
||||
})
|
||||
|
||||
// 获取活跃请求的 ID 列表
|
||||
const activeRequestIds = computed(() => {
|
||||
return currentRecords.value
|
||||
.filter(record => record.status === 'pending' || record.status === 'streaming')
|
||||
.map(record => record.id)
|
||||
})
|
||||
|
||||
// 检查是否有活跃请求
|
||||
const hasActiveRequests = computed(() => activeRequestIds.value.length > 0)
|
||||
|
||||
// 自动刷新定时器
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次
|
||||
|
||||
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
|
||||
async function pollActiveRequests() {
|
||||
if (!hasActiveRequests.value) return
|
||||
|
||||
try {
|
||||
// 根据页面类型选择不同的 API
|
||||
const idsParam = activeRequestIds.value.join(',')
|
||||
const { requests } = isAdminPage.value
|
||||
? await usageApi.getActiveRequests(activeRequestIds.value)
|
||||
: await meApi.getActiveRequests(idsParam)
|
||||
|
||||
// 检查是否有状态变化
|
||||
let hasChanges = false
|
||||
for (const update of requests) {
|
||||
const record = currentRecords.value.find(r => r.id === update.id)
|
||||
if (record && record.status !== update.status) {
|
||||
hasChanges = true
|
||||
// 如果状态变为 completed 或 failed,需要刷新获取完整数据
|
||||
if (update.status === 'completed' || update.status === 'failed') {
|
||||
break
|
||||
}
|
||||
// 否则只更新状态和 token 信息
|
||||
record.status = update.status
|
||||
record.input_tokens = update.input_tokens
|
||||
record.output_tokens = update.output_tokens
|
||||
record.cost = update.cost
|
||||
record.response_time_ms = update.response_time_ms ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有请求完成或失败,刷新整个列表获取完整数据
|
||||
if (hasChanges && requests.some(r => r.status === 'completed' || r.status === 'failed')) {
|
||||
await refreshData()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('轮询活跃请求状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 启动自动刷新
|
||||
function startAutoRefresh() {
|
||||
if (autoRefreshTimer) return
|
||||
autoRefreshTimer = setInterval(pollActiveRequests, AUTO_REFRESH_INTERVAL)
|
||||
}
|
||||
|
||||
// 停止自动刷新
|
||||
function stopAutoRefresh() {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 监听活跃请求状态,自动启动/停止刷新
|
||||
watch(hasActiveRequests, (hasActive) => {
|
||||
if (hasActive) {
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
|
||||
// 用户页面的前端分页
|
||||
const paginatedRecords = computed(() => {
|
||||
if (!isAdminPage.value) {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return filteredRecords.value.slice(start, end)
|
||||
}
|
||||
return currentRecords.value
|
||||
})
|
||||
|
||||
// 显示的记录
|
||||
const displayRecords = computed(() => paginatedRecords.value)
|
||||
|
||||
|
||||
// 详情弹窗状态
|
||||
const detailModalOpen = ref(false)
|
||||
const selectedRequestId = ref<string | null>(null)
|
||||
|
||||
// 初始化加载
|
||||
onMounted(async () => {
|
||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||
await loadStats(dateRange)
|
||||
|
||||
// 管理员页面加载用户列表和第一页记录
|
||||
if (isAdminPage.value) {
|
||||
// 并行加载用户列表和记录
|
||||
const [users] = await Promise.all([
|
||||
usersApi.getAllUsers(),
|
||||
loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
])
|
||||
availableUsers.value = users.map(u => ({ id: u.id, username: u.username, email: u.email }))
|
||||
}
|
||||
})
|
||||
|
||||
// 处理时间段变化
|
||||
async function handlePeriodChange(value: string) {
|
||||
selectedPeriod.value = value as PeriodValue
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
|
||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||
await loadStats(dateRange)
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
// 处理分页变化
|
||||
async function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
// 处理每页大小变化
|
||||
async function handlePageSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前筛选参数
|
||||
function getCurrentFilters() {
|
||||
return {
|
||||
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
||||
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
||||
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
||||
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 处理筛选变化
|
||||
async function handleFilterUserChange(value: string) {
|
||||
filterUser.value = value
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFilterModelChange(value: string) {
|
||||
filterModel.value = value
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFilterProviderChange(value: string) {
|
||||
filterProvider.value = value
|
||||
currentPage.value = 1
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFilterStatusChange(value: string) {
|
||||
filterStatus.value = value as FilterStatusValue
|
||||
currentPage.value = 1
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
async function refreshData() {
|
||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||
await loadStats(dateRange)
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
// 显示请求详情
|
||||
function showRequestDetail(id: string) {
|
||||
if (!isAdminPage.value) return
|
||||
selectedRequestId.value = id
|
||||
detailModalOpen.value = true
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
async function exportData(format: 'csv' | 'json') {
|
||||
try {
|
||||
const blob = await usageApi.exportUsage(format)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `usage-stats.${format}`
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
Reference in New Issue
Block a user