Files
Aether/frontend/src/views/shared/Dashboard.vue
2025-12-10 20:52:44 +08:00

979 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>