Files
Aether/frontend/src/components/stats/ActivityHeatmap.vue
T
elky 066ea87d72 feat: revamp analytics dashboards and harden database migrations
Add dashboard and overview analytics, health monitoring, provider expense tracking, and announcement updates across the gateway and frontend.

Keep schema migrations free of historical backfills while preserving automatic backfill execution. Bound migration deadlines, run schema preparation before Compose replacement, and anonymize deleted dashboard users.

Include the current documentation cleanup and regression coverage.
2026-10-01 11:48:17 +08:00

507 lines
14 KiB
Vue

<template>
<div class="space-y-4 w-full">
<!-- Tooltip 使用 Teleport 确保不受父容器 overflow 影响 -->
<Teleport to="body">
<div
v-if="tooltip.visible && tooltip.day"
ref="tooltipRef"
class="fixed z-50 w-[200px] max-w-[calc(100vw-1rem)] break-words rounded-lg border border-border/70 bg-background px-3 py-2 text-xs shadow-lg backdrop-blur pointer-events-none"
:style="tooltipStyle"
>
<p class="font-medium">
{{ formatDay(tooltip.day.date) }}
</p>
<p class="mt-0.5">
{{ t('heatmap.requests', { count: tooltip.day.requests }) }}
<template v-if="tooltip.day.total_tokens != null">
· {{ formatTokens(tooltip.day.total_tokens) }}
</template>
</p>
<p
v-if="tooltip.day.total_cost != null"
class="text-[11px] text-muted-foreground"
>
{{ t('heatmap.cost', { value: formatCurrency(tooltip.day.total_cost) }) }}
</p>
</div>
</Teleport>
<div
v-if="showHeader"
class="flex flex-wrap items-center justify-between gap-4"
>
<div class="min-w-0 break-words">
<p class="text-sm font-semibold">
{{ title }}
</p>
<p
v-if="subtitle"
class="text-xs text-muted-foreground"
>
{{ subtitle }}
</p>
</div>
<div
v-if="weekColumns.length > 0"
class="flex items-center gap-1 text-[11px] text-muted-foreground flex-shrink-0"
>
<span class="flex-shrink-0">{{ t('heatmap.less') }}</span>
<div
v-for="(level, index) in legendLevels"
:key="index"
class="w-3 h-3 rounded-[3px] flex-shrink-0"
:style="getLegendStyle(level)"
/>
<span class="flex-shrink-0">{{ t('heatmap.more') }}</span>
</div>
</div>
<div
v-if="weekColumns.length > 0"
ref="heatmapScroller"
class="flex w-full gap-3 overflow-x-auto"
:class="compact ? 'overflow-y-hidden pb-1' : ''"
>
<div
class="flex flex-col text-[10px] text-muted-foreground flex-shrink-0"
:class="compact ? 'sticky left-0 z-10 bg-card pr-2' : ''"
:style="[verticalGapStyle, compact ? { paddingTop: '24px' } : undefined]"
>
<!-- Placeholder to align with month markers -->
<div
v-if="!compact"
class="text-[10px] mb-3 invisible"
>
M
</div>
<span
v-for="(weekday, index) in weekdayLabels"
:key="index"
:style="dayLabelStyle"
class="flex items-center"
:class="{ invisible: index % 2 === 0 }"
>{{ weekday }}</span>
</div>
<div class="flex-1 min-w-[200px]">
<div
ref="heatmapWrapper"
class="relative block w-full"
>
<div
v-if="compact"
class="relative mb-2 h-4 text-[10px] leading-4 text-muted-foreground/80"
:style="{ width: `${gridWidth}px` }"
>
<span
v-for="marker in compactMonthMarkers"
:key="marker.weekIndex"
class="absolute top-0 whitespace-nowrap"
:style="{ left: `${marker.left}px` }"
>{{ marker.label }}</span>
</div>
<div
v-else
class="flex text-[10px] text-muted-foreground/80 mb-3"
:style="horizontalGapStyle"
>
<div
v-for="(week, weekIndex) in weekColumns"
:key="`month-${weekIndex}`"
:style="monthCellStyle"
class="whitespace-nowrap text-left"
:class="compact ? 'shrink-0' : ''"
>
<span v-if="monthMarkers[weekIndex]">{{ monthMarkers[weekIndex] }}</span>
</div>
</div>
<div
class="flex"
:style="horizontalGapStyle"
>
<div
v-for="(week, weekIndex) in weekColumns"
:key="weekIndex"
class="flex flex-col"
:class="compact ? 'shrink-0' : ''"
:style="verticalGapStyle"
>
<div
v-for="(day, dayIndex) in week"
:key="dayIndex"
class="relative group"
>
<div
v-if="day"
class="cursor-pointer cell-emerge rounded-[4px]"
:class="compact
? 'transition-[filter] duration-150 hover:brightness-90'
: 'transition-all duration-200 hover:shadow-lg'"
:style="[cellStyle, getCellStyle(day.requests), getCellAnimationDelay(weekIndex, dayIndex)]"
:title="buildTooltip(day)"
@mouseenter="handleHover(day, $event)"
@mouseleave="clearHover"
/>
<div
v-else
:style="cellStyle"
class="bg-transparent rounded-[4px]"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<p
v-else
class="text-xs text-muted-foreground"
>
{{ t('heatmap.empty') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { ActivityHeatmap, ActivityHeatmapDay } from '@/types/activity'
import { formatCurrency, formatTokens } from '@/utils/format'
import { useI18n } from '@/i18n'
const props = withDefaults(defineProps<{
data?: ActivityHeatmap | null
title?: string
subtitle?: string
showHeader?: boolean
compact?: boolean
}>(), {
data: undefined,
title: undefined,
subtitle: undefined,
showHeader: true,
compact: false
})
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
const { locale, t } = useI18n()
const weekdayLabels = computed(() => {
const formatter = new Intl.DateTimeFormat(locale.value, { weekday: 'short', timeZone: 'UTC' })
return Array.from({ length: 7 }, (_, day) => formatter.format(new Date(Date.UTC(2024, 0, 7 + day))))
})
function formatDay(value: string): string {
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeZone: 'UTC' })
.format(new Date(`${value}T00:00:00Z`))
}
type DayWithMeta = ActivityHeatmapDay & { dateObj: Date }
const heatmapScroller = ref<HTMLElement | null>(null)
const heatmapWrapper = ref<HTMLElement | null>(null)
const heatmapWidth = ref(0)
const cellSize = ref(10)
const cellGap = ref(props.compact ? 2 : 4)
const tooltipRef = ref<HTMLElement | null>(null)
const tooltip = ref<{ day: ActivityHeatmapDay | null; x: number; y: number; visible: boolean; below: boolean }>({
day: null,
x: 0,
y: 0,
visible: false,
below: false,
})
const tooltipStyle = computed(() => ({
top: `${tooltip.value.y}px`,
left: `${tooltip.value.x}px`,
transform: tooltip.value.below ? 'translate(-50%, 0)' : 'translate(-50%, -100%)',
}))
const cellStyle = computed(() => ({
width: `${cellSize.value}px`,
height: `${cellSize.value}px`,
}))
const dayLabelStyle = computed(() => ({
height: `${cellSize.value}px`,
lineHeight: `${cellSize.value}px`,
}))
const monthCellStyle = computed(() => ({
width: `${cellSize.value}px`,
}))
const horizontalGapStyle = computed(() => ({
gap: `${cellGap.value}px`,
}))
const verticalGapStyle = computed(() => ({
rowGap: `${cellGap.value}px`,
}))
const weekColumns = computed(() => {
if (!props.data || !props.data.days || props.data.days.length === 0) {
return []
}
const dayEntries: DayWithMeta[] = props.data.days.map(day => ({
...day,
dateObj: new Date(`${day.date}T00:00:00Z`)
}))
const firstDay = dayEntries[0]?.dateObj
const padding: (DayWithMeta | null)[] = []
if (firstDay) {
const weekday = firstDay.getUTCDay() // 周日=0, 周一=1, ..., 周六=6
for (let i = 0; i < weekday; i++) {
padding.push(null)
}
}
const paddedDays: (DayWithMeta | null)[] = [...padding, ...dayEntries]
const remainder = paddedDays.length % 7
if (remainder !== 0) {
for (let i = remainder; i < 7; i++) {
paddedDays.push(null)
}
}
const chunked: (DayWithMeta | null)[][] = []
for (let i = 0; i < paddedDays.length; i += 7) {
chunked.push(paddedDays.slice(i, i + 7))
}
// Trim trailing empty weeks (weeks with all null cells)
let lastIndex = chunked.length - 1
while (lastIndex >= 0) {
const week = chunked[lastIndex]
const hasAnyDay = week.some(day => day !== null)
if (hasAnyDay) {
break
}
lastIndex--
}
return chunked.slice(0, lastIndex + 1)
})
const monthMarkers = computed(() => {
const markers: Record<number, string> = {}
const columns = weekColumns.value
const formatter = new Intl.DateTimeFormat(locale.value, { month: 'short', timeZone: 'UTC' })
let lastMonth: number | null = null
columns.forEach((week, index) => {
const firstValid = week.find((day): day is DayWithMeta => day !== null)
if (!firstValid) {
return
}
const month = firstValid.dateObj.getUTCMonth()
if (month === lastMonth) {
return
}
markers[index] = formatter.format(firstValid.dateObj)
lastMonth = month
})
return markers
})
const gridWidth = computed(() => {
const columns = weekColumns.value.length
return columns * cellSize.value + Math.max(0, columns - 1) * cellGap.value
})
const compactMonthMarkers = computed(() => {
const markers = Object.entries(monthMarkers.value).map(([index, label]) => {
const weekIndex = Number(index)
// At 10px, digits and Latin letters need less room than full-width month labels.
const width = Array.from(label).reduce((total, character) => {
return total + (character.charCodeAt(0) <= 0xff ? 6 : 10)
}, 0)
return {
weekIndex,
label,
width,
left: Math.max(0, Math.min(weekIndex * (cellSize.value + cellGap.value), gridWidth.value - width)),
}
})
// Keep the most recent month visible when neighboring labels would overlap.
let nextLeft = Infinity
return markers.reverse().filter(marker => {
if (marker.left + marker.width + 8 > nextLeft) return false
nextLeft = marker.left
return true
}).reverse()
})
watch(
[() => props.compact, heatmapScroller, heatmapWidth, gridWidth],
async () => {
if (!props.compact) return
await nextTick()
const scroller = heatmapScroller.value
if (scroller) scroller.scrollLeft = scroller.scrollWidth
},
{ flush: 'post' }
)
let resizeObserver: ResizeObserver | null = null
let mediaQuery: MediaQueryList | null = null
let mediaQueryHandler: ((event?: MediaQueryListEvent) => void) | null = null
const recalcCellSize = () => {
const columnCount = weekColumns.value.length
if (!columnCount || !heatmapWidth.value) {
return
}
const totalGap = Math.max(columnCount - 1, 0) * cellGap.value
const availableSpace = Math.max(heatmapWidth.value - totalGap, 0)
const rawSize = availableSpace / columnCount
// 自适应尺寸,最小 6px
cellSize.value = Math.max(6, rawSize)
}
watch(
[() => heatmapWidth.value, () => weekColumns.value.length, () => cellGap.value],
() => {
recalcCellSize()
},
{ immediate: true }
)
watch(
() => heatmapWrapper.value,
el => {
resizeObserver?.disconnect()
if (el && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(entries => {
if (!entries.length) {
return
}
heatmapWidth.value = entries[0].contentRect.width
recalcCellSize()
})
resizeObserver.observe(el)
} else {
heatmapWidth.value = 0
}
},
{ immediate: true }
)
onMounted(() => {
if (typeof window === 'undefined') {
return
}
mediaQuery = window.matchMedia('(min-width: 640px)')
const updateGap = () => {
cellGap.value = !props.compact && mediaQuery && mediaQuery.matches ? 4 : 2
recalcCellSize()
}
mediaQueryHandler = () => updateGap()
updateGap()
mediaQuery?.addEventListener('change', mediaQueryHandler)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
if (mediaQuery && mediaQueryHandler) {
mediaQuery.removeEventListener('change', mediaQueryHandler)
}
})
async function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
tooltip.value = { day, x: cellRect.left, y: cellRect.top, visible: true, below: false }
await nextTick()
if (!tooltip.value.visible || tooltip.value.day?.date !== day.date) return
const tooltipWidth = tooltipRef.value?.offsetWidth || 200
const tooltipHeight = tooltipRef.value?.offsetHeight || 72
// Calculate horizontal position (centered on cell)
let left = cellRect.left + cellRect.width / 2
const minLeft = tooltipWidth / 2 + 8
const maxLeft = window.innerWidth - tooltipWidth / 2 - 8
left = Math.min(Math.max(left, minLeft), maxLeft)
// Calculate vertical position
let top = cellRect.top - 12
let below = false
// If tooltip would go above viewport, show it below the cell
if (top - tooltipHeight < 0) {
top = cellRect.bottom + 12
below = true
}
tooltip.value = {
day,
x: left,
y: top,
visible: true,
below,
}
}
function clearHover() {
tooltip.value.visible = false
}
function getLegendStyle(alpha: number) {
return {
backgroundColor: `rgba(var(--color-primary-rgb), ${alpha})`
}
}
function getCellStyle(requests: number) {
const max = props.data?.max_requests || 1
if (!requests || max === 0) {
return {
backgroundColor: `rgba(var(--color-primary-rgb), 0.08)`
}
}
const ratio = Math.min(1, requests / max)
const minAlpha = 0.2
const maxAlpha = 0.95
const alpha = minAlpha + (maxAlpha - minAlpha) * ratio
return {
backgroundColor: `rgba(var(--color-primary-rgb), ${alpha})`
}
}
function buildTooltip(day: ActivityHeatmapDay): string {
const dateLabel = formatDay(day.date)
const parts = [dateLabel, t('heatmap.requests', { count: day.requests })]
if (day.total_tokens != null) parts.push(`${formatTokens(day.total_tokens)} tokens`)
if (day.total_cost != null) parts.push(formatCurrency(day.total_cost))
if (day.actual_total_cost !== undefined) {
parts.push(t('heatmap.actualCost', { value: formatCurrency(day.actual_total_cost) }))
}
return parts.join(' · ')
}
function getCellAnimationDelay(weekIndex: number, dayIndex: number) {
const delay = Math.min(120, weekIndex * 2 + dayIndex * 4)
return {
animationDelay: `${delay}ms`
}
}
</script>
<style scoped>
.cell-emerge {
opacity: 0;
animation: cellEmerge 0.18s ease-out forwards;
}
@keyframes cellEmerge {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>