2025-12-10 20:52:44 +08:00
|
|
|
<template>
|
2025-12-13 22:26:09 +08:00
|
|
|
<div class="space-y-4 w-full">
|
|
|
|
|
<!-- Tooltip 使用 Teleport 确保不受父容器 overflow 影响 -->
|
|
|
|
|
<Teleport to="body">
|
|
|
|
|
<div
|
|
|
|
|
v-if="tooltip.visible && tooltip.day"
|
2026-09-07 08:53:51 +08:00
|
|
|
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"
|
2025-12-13 22:26:09 +08:00
|
|
|
:style="tooltipStyle"
|
|
|
|
|
>
|
|
|
|
|
<p class="font-medium">
|
2026-09-07 08:53:51 +08:00
|
|
|
{{ formatDay(tooltip.day.date) }}
|
2025-12-13 22:26:09 +08:00
|
|
|
</p>
|
|
|
|
|
<p class="mt-0.5">
|
2026-10-01 11:48:17 +08:00
|
|
|
{{ t('heatmap.requests', { count: tooltip.day.requests }) }}
|
|
|
|
|
<template v-if="tooltip.day.total_tokens != null">
|
|
|
|
|
· {{ formatTokens(tooltip.day.total_tokens) }}
|
|
|
|
|
</template>
|
2025-12-13 22:26:09 +08:00
|
|
|
</p>
|
2026-10-01 11:48:17 +08:00
|
|
|
<p
|
|
|
|
|
v-if="tooltip.day.total_cost != null"
|
|
|
|
|
class="text-[11px] text-muted-foreground"
|
|
|
|
|
>
|
2026-09-07 08:53:51 +08:00
|
|
|
{{ t('heatmap.cost', { value: formatCurrency(tooltip.day.total_cost) }) }}
|
2025-12-13 22:26:09 +08:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</Teleport>
|
|
|
|
|
|
2025-12-12 16:15:16 +08:00
|
|
|
<div
|
|
|
|
|
v-if="showHeader"
|
2026-09-07 08:53:51 +08:00
|
|
|
class="flex flex-wrap items-center justify-between gap-4"
|
2025-12-12 16:15:16 +08:00
|
|
|
>
|
2026-09-07 08:53:51 +08:00
|
|
|
<div class="min-w-0 break-words">
|
2025-12-12 16:15:16 +08:00
|
|
|
<p class="text-sm font-semibold">
|
|
|
|
|
{{ title }}
|
|
|
|
|
</p>
|
|
|
|
|
<p
|
|
|
|
|
v-if="subtitle"
|
|
|
|
|
class="text-xs text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
{{ subtitle }}
|
|
|
|
|
</p>
|
2025-12-10 20:52:44 +08:00
|
|
|
</div>
|
2025-12-12 16:15:16 +08:00
|
|
|
<div
|
|
|
|
|
v-if="weekColumns.length > 0"
|
|
|
|
|
class="flex items-center gap-1 text-[11px] text-muted-foreground flex-shrink-0"
|
|
|
|
|
>
|
2026-09-07 08:53:51 +08:00
|
|
|
<span class="flex-shrink-0">{{ t('heatmap.less') }}</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
<div
|
|
|
|
|
v-for="(level, index) in legendLevels"
|
|
|
|
|
:key="index"
|
|
|
|
|
class="w-3 h-3 rounded-[3px] flex-shrink-0"
|
|
|
|
|
:style="getLegendStyle(level)"
|
2025-12-12 16:15:16 +08:00
|
|
|
/>
|
2026-09-07 08:53:51 +08:00
|
|
|
<span class="flex-shrink-0">{{ t('heatmap.more') }}</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-12-12 16:15:16 +08:00
|
|
|
<div
|
|
|
|
|
v-if="weekColumns.length > 0"
|
2026-10-01 11:48:17 +08:00
|
|
|
ref="heatmapScroller"
|
2026-09-07 08:53:51 +08:00
|
|
|
class="flex w-full gap-3 overflow-x-auto"
|
2026-10-01 11:48:17 +08:00
|
|
|
:class="compact ? 'overflow-y-hidden pb-1' : ''"
|
2025-12-12 16:15:16 +08:00
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
class="flex flex-col text-[10px] text-muted-foreground flex-shrink-0"
|
2026-10-01 11:48:17 +08:00
|
|
|
:class="compact ? 'sticky left-0 z-10 bg-card pr-2' : ''"
|
|
|
|
|
:style="[verticalGapStyle, compact ? { paddingTop: '24px' } : undefined]"
|
2025-12-12 16:15:16 +08:00
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
<!-- Placeholder to align with month markers -->
|
2026-10-01 11:48:17 +08:00
|
|
|
<div
|
|
|
|
|
v-if="!compact"
|
|
|
|
|
class="text-[10px] mb-3 invisible"
|
|
|
|
|
>
|
2025-12-12 16:15:16 +08:00
|
|
|
M
|
|
|
|
|
</div>
|
|
|
|
|
<span
|
2026-09-07 08:53:51 +08:00
|
|
|
v-for="(weekday, index) in weekdayLabels"
|
|
|
|
|
:key="index"
|
2025-12-12 16:15:16 +08:00
|
|
|
:style="dayLabelStyle"
|
|
|
|
|
class="flex items-center"
|
2026-09-07 08:53:51 +08:00
|
|
|
:class="{ invisible: index % 2 === 0 }"
|
|
|
|
|
>{{ weekday }}</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
</div>
|
2025-12-13 22:26:09 +08:00
|
|
|
<div class="flex-1 min-w-[200px]">
|
2025-12-12 16:15:16 +08:00
|
|
|
<div
|
|
|
|
|
ref="heatmapWrapper"
|
|
|
|
|
class="relative block w-full"
|
|
|
|
|
>
|
|
|
|
|
<div
|
2026-10-01 11:48:17 +08:00
|
|
|
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
|
2025-12-12 16:15:16 +08:00
|
|
|
class="flex text-[10px] text-muted-foreground/80 mb-3"
|
|
|
|
|
:style="horizontalGapStyle"
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
<div
|
|
|
|
|
v-for="(week, weekIndex) in weekColumns"
|
|
|
|
|
:key="`month-${weekIndex}`"
|
|
|
|
|
:style="monthCellStyle"
|
2026-09-07 08:53:51 +08:00
|
|
|
class="whitespace-nowrap text-left"
|
2026-10-01 11:48:17 +08:00
|
|
|
:class="compact ? 'shrink-0' : ''"
|
2025-12-10 20:52:44 +08:00
|
|
|
>
|
|
|
|
|
<span v-if="monthMarkers[weekIndex]">{{ monthMarkers[weekIndex] }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-12-12 16:15:16 +08:00
|
|
|
<div
|
|
|
|
|
class="flex"
|
|
|
|
|
:style="horizontalGapStyle"
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
v-for="(week, weekIndex) in weekColumns"
|
|
|
|
|
:key="weekIndex"
|
|
|
|
|
class="flex flex-col"
|
2026-10-01 11:48:17 +08:00
|
|
|
:class="compact ? 'shrink-0' : ''"
|
2025-12-12 16:15:16 +08:00
|
|
|
:style="verticalGapStyle"
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
v-for="(day, dayIndex) in week"
|
|
|
|
|
:key="dayIndex"
|
|
|
|
|
class="relative group"
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
<div
|
|
|
|
|
v-if="day"
|
2026-10-01 11:48:17 +08:00
|
|
|
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)]"
|
2025-12-10 20:52:44 +08:00
|
|
|
:title="buildTooltip(day)"
|
|
|
|
|
@mouseenter="handleHover(day, $event)"
|
|
|
|
|
@mouseleave="clearHover"
|
2025-12-12 16:15:16 +08:00
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
v-else
|
2026-10-01 11:48:17 +08:00
|
|
|
:style="cellStyle"
|
|
|
|
|
class="bg-transparent rounded-[4px]"
|
2025-12-12 16:15:16 +08:00
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-12-12 16:15:16 +08:00
|
|
|
<p
|
|
|
|
|
v-else
|
|
|
|
|
class="text-xs text-muted-foreground"
|
|
|
|
|
>
|
2026-09-07 08:53:51 +08:00
|
|
|
{{ t('heatmap.empty') }}
|
2025-12-12 16:15:16 +08:00
|
|
|
</p>
|
2025-12-10 20:52:44 +08:00
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-09-07 08:53:51 +08:00
|
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
2025-12-10 20:52:44 +08:00
|
|
|
import type { ActivityHeatmap, ActivityHeatmapDay } from '@/types/activity'
|
|
|
|
|
import { formatCurrency, formatTokens } from '@/utils/format'
|
2026-09-07 08:53:51 +08:00
|
|
|
import { useI18n } from '@/i18n'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<{
|
|
|
|
|
data?: ActivityHeatmap | null
|
|
|
|
|
title?: string
|
|
|
|
|
subtitle?: string
|
|
|
|
|
showHeader?: boolean
|
2026-10-01 11:48:17 +08:00
|
|
|
compact?: boolean
|
2025-12-10 20:52:44 +08:00
|
|
|
}>(), {
|
2025-12-12 20:21:56 +08:00
|
|
|
data: undefined,
|
|
|
|
|
title: undefined,
|
|
|
|
|
subtitle: undefined,
|
2026-10-01 11:48:17 +08:00
|
|
|
showHeader: true,
|
|
|
|
|
compact: false
|
2025-12-10 20:52:44 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
2026-09-07 08:53:51 +08:00
|
|
|
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`))
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
type DayWithMeta = ActivityHeatmapDay & { dateObj: Date }
|
2026-10-01 11:48:17 +08:00
|
|
|
const heatmapScroller = ref<HTMLElement | null>(null)
|
2025-12-10 20:52:44 +08:00
|
|
|
const heatmapWrapper = ref<HTMLElement | null>(null)
|
|
|
|
|
const heatmapWidth = ref(0)
|
|
|
|
|
const cellSize = ref(10)
|
2026-10-01 11:48:17 +08:00
|
|
|
const cellGap = ref(props.compact ? 2 : 4)
|
2026-09-07 08:53:51 +08:00
|
|
|
const tooltipRef = ref<HTMLElement | null>(null)
|
2025-12-10 20:52:44 +08:00
|
|
|
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%)',
|
|
|
|
|
}))
|
|
|
|
|
|
2026-10-01 11:48:17 +08:00
|
|
|
const cellStyle = computed(() => ({
|
2025-12-10 20:52:44 +08:00
|
|
|
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
|
2026-09-07 08:53:51 +08:00
|
|
|
const formatter = new Intl.DateTimeFormat(locale.value, { month: 'short', timeZone: 'UTC' })
|
2025-12-10 20:52:44 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-07 08:53:51 +08:00
|
|
|
markers[index] = formatter.format(firstValid.dateObj)
|
2025-12-10 20:52:44 +08:00
|
|
|
lastMonth = month
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return markers
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-01 11:48:17 +08:00
|
|
|
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' }
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
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
|
2025-12-13 22:26:09 +08:00
|
|
|
// 自适应尺寸,最小 6px
|
|
|
|
|
cellSize.value = Math.max(6, rawSize)
|
2025-12-10 20:52:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 = () => {
|
2026-10-01 11:48:17 +08:00
|
|
|
cellGap.value = !props.compact && mediaQuery && mediaQuery.matches ? 4 : 2
|
2025-12-10 20:52:44 +08:00
|
|
|
recalcCellSize()
|
|
|
|
|
}
|
|
|
|
|
mediaQueryHandler = () => updateGap()
|
|
|
|
|
updateGap()
|
|
|
|
|
mediaQuery?.addEventListener('change', mediaQueryHandler)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
resizeObserver?.disconnect()
|
|
|
|
|
if (mediaQuery && mediaQueryHandler) {
|
|
|
|
|
mediaQuery.removeEventListener('change', mediaQueryHandler)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-07 08:53:51 +08:00
|
|
|
async function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
|
2025-12-10 20:52:44 +08:00
|
|
|
const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
2026-09-07 08:53:51 +08:00
|
|
|
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
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
// 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 {
|
2026-09-07 08:53:51 +08:00
|
|
|
const dateLabel = formatDay(day.date)
|
2026-10-01 11:48:17 +08:00
|
|
|
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))
|
2025-12-10 20:52:44 +08:00
|
|
|
if (day.actual_total_cost !== undefined) {
|
2026-09-07 08:53:51 +08:00
|
|
|
parts.push(t('heatmap.actualCost', { value: formatCurrency(day.actual_total_cost) }))
|
2025-12-10 20:52:44 +08:00
|
|
|
}
|
|
|
|
|
return parts.join(' · ')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getCellAnimationDelay(weekIndex: number, dayIndex: number) {
|
2026-06-09 16:52:58 +08:00
|
|
|
const delay = Math.min(120, weekIndex * 2 + dayIndex * 4)
|
2025-12-10 20:52:44 +08:00
|
|
|
return {
|
2026-06-09 16:52:58 +08:00
|
|
|
animationDelay: `${delay}ms`
|
2025-12-10 20:52:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.cell-emerge {
|
|
|
|
|
opacity: 0;
|
2026-06-09 16:52:58 +08:00
|
|
|
animation: cellEmerge 0.18s ease-out forwards;
|
2025-12-10 20:52:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes cellEmerge {
|
|
|
|
|
0% {
|
|
|
|
|
opacity: 0;
|
|
|
|
|
}
|
|
|
|
|
100% {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|