feat: enhance profile heatmap and caching

This commit is contained in:
2026-05-22 00:55:23 +08:00
parent f35baf4a67
commit 7cdf07447c
3 changed files with 1106 additions and 3 deletions
@@ -0,0 +1,395 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { NButton, NButtonGroup, NSelect } from 'naive-ui'
export interface ContributionHeatmapCell {
date: string
count: number
}
interface MonthStat {
key: string
label: string
totalCount: number
activeDays: number
}
const today = new Date()
const currentYear = today.getFullYear()
const currentMonth = today.getMonth() + 1
const props = withDefaults(defineProps<{
cells: ContributionHeatmapCell[]
totalCount: number
activeDays: number
currentStreak: number
bestStreak: number
activeDate?: string | null
title?: string
subtitle?: string
}>(), {
activeDate: null,
title: '上传活跃度',
subtitle: '按上传日期统计',
})
const emit = defineEmits<{
selectDate: [date: string]
}>()
const viewMode = ref<'year' | 'month'>('year')
const selectedYear = ref(currentYear)
const selectedMonth = ref(currentMonth)
const hoveredMonthKey = ref<string | null>(null)
function parseLocalDate(dateString: string) {
const [year, month, day] = dateString.split('-').map(Number)
return new Date(year, (month || 1) - 1, day || 1)
}
function formatDayKey(date: Date) {
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
}
function toMonthKey(dateString: string) {
const date = parseLocalDate(dateString)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
}
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
function startOfWeek(date: Date) {
const result = startOfDay(date)
const weekday = result.getDay()
const offset = weekday === 0 ? 6 : weekday - 1
result.setDate(result.getDate() - offset)
return result
}
const dailyCounts = computed(() => {
const counts = new Map<string, number>()
for (const cell of props.cells) {
counts.set(cell.date, cell.count)
}
return counts
})
const availableYears = computed(() => {
const years = new Set<number>([currentYear])
for (const cell of props.cells) {
years.add(parseLocalDate(cell.date).getFullYear())
}
return Array.from(years).sort((a, b) => b - a)
})
const yearOptions = computed(() => {
return availableYears.value.map(year => ({
label: `${year}`,
value: year,
}))
})
const monthOptions = Array.from({ length: 12 }, (_, index) => ({
label: `${index + 1}`,
value: index + 1,
}))
watch(availableYears, years => {
if (!years.includes(selectedYear.value)) {
selectedYear.value = years[0] || currentYear
}
}, { immediate: true })
const visibleCells = computed(() => {
if (viewMode.value === 'year') {
const start = new Date(selectedYear.value, 0, 1)
const end = new Date(selectedYear.value, 11, 31)
const alignedStart = startOfWeek(start)
const alignedEnd = startOfWeek(end)
alignedEnd.setDate(alignedEnd.getDate() + 6)
const cells: Array<ContributionHeatmapCell & { inRange: boolean; isToday: boolean }> = []
const cursor = new Date(alignedStart)
while (cursor <= alignedEnd) {
const key = formatDayKey(cursor)
const inRange = cursor >= start && cursor <= end
cells.push({
date: key,
count: dailyCounts.value.get(key) || 0,
inRange,
isToday: key === formatDayKey(today),
})
cursor.setDate(cursor.getDate() + 1)
}
return cells
}
const start = new Date(selectedYear.value, selectedMonth.value - 1, 1)
const end = new Date(selectedYear.value, selectedMonth.value, 0)
const alignedStart = startOfWeek(start)
const alignedEnd = startOfWeek(end)
alignedEnd.setDate(alignedEnd.getDate() + 6)
const cells: Array<ContributionHeatmapCell & { inRange: boolean; isToday: boolean }> = []
const cursor = new Date(alignedStart)
while (cursor <= alignedEnd) {
const key = formatDayKey(cursor)
const inRange = cursor >= start && cursor <= end
cells.push({
date: key,
count: dailyCounts.value.get(key) || 0,
inRange,
isToday: key === formatDayKey(today),
})
cursor.setDate(cursor.getDate() + 1)
}
return cells
})
const maxCount = computed(() => {
return visibleCells.value.reduce((max, cell) => Math.max(max, cell.count), 0)
})
function getLevel(count: number) {
if (count <= 0) return 0
const max = Math.max(maxCount.value, 1)
const ratio = count / max
if (ratio >= 0.75 || count >= 8) return 4
if (ratio >= 0.5 || count >= 5) return 3
if (ratio >= 0.25 || count >= 3) return 2
return 1
}
function getCellClass(cell: { count: number; inRange: boolean }) {
if (!cell.inRange) return 'bg-transparent border-transparent'
const level = getLevel(cell.count)
if (level === 0) return 'bg-zinc-100 border-zinc-200'
if (level === 1) return 'bg-[#e0f2fe] border-[#bae6fd]'
if (level === 2) return 'bg-[#7dd3fc] border-[#38bdf8]'
if (level === 3) return 'bg-[#0ea5e9] border-[#0284c7]'
return 'bg-[#0f3d63] border-[#0b2f4d]'
}
const weeks = computed(() => {
const buckets: Array<typeof visibleCells.value> = []
for (let index = 0; index < visibleCells.value.length; index += 7) {
buckets.push(visibleCells.value.slice(index, index + 7))
}
return buckets
})
const monthStats = computed(() => {
const stats = new Map<string, MonthStat>()
for (const cell of visibleCells.value) {
if (!cell.inRange) continue
const monthKey = toMonthKey(cell.date)
const date = parseLocalDate(cell.date)
const label = `${date.getMonth() + 1}`
const existing = stats.get(monthKey)
if (existing) {
existing.totalCount += cell.count
if (cell.count > 0) existing.activeDays += 1
continue
}
stats.set(monthKey, {
key: monthKey,
label,
totalCount: cell.count,
activeDays: cell.count > 0 ? 1 : 0,
})
}
return stats
})
const monthLabels = computed(() => {
if (viewMode.value !== 'year') return []
let lastMonth = -1
return weeks.value.map(week => {
const firstInRange = week.find(cell => cell.inRange)
if (!firstInRange) return null
const date = parseLocalDate(firstInRange.date)
const month = date.getMonth()
const labelMonth = month + 1
const monthKey = toMonthKey(firstInRange.date)
if (month === lastMonth) return null
lastMonth = month
return {
key: monthKey,
label: labelMonth === 1 ? `${selectedYear.value}/1月` : `${labelMonth}`,
}
})
})
const monthPreview = computed(() => {
if (!hoveredMonthKey.value) return null
return monthStats.value.get(hoveredMonthKey.value) || null
})
const panelSummary = computed(() => {
if (viewMode.value === 'year') {
return `${selectedYear.value} 年完整视图,包含尚未来到的日期。`
}
return `${selectedYear.value}${selectedMonth.value} 月完整视图。`
})
function formatTooltip(cell: ContributionHeatmapCell & { inRange: boolean; isToday: boolean }) {
const date = parseLocalDate(cell.date)
const label = date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
if (!cell.inRange) {
return `${label}:不在当前视图范围内`
}
if (cell.count === 0) {
return `${label}${cell.isToday ? '(今天)' : ''}:当天没有上传`
}
return `${label}${cell.isToday ? '(今天)' : ''}:上传 ${cell.count}`
}
function handleCellClick(cell: ContributionHeatmapCell & { inRange: boolean }) {
if (!cell.inRange) return
emit('selectDate', cell.date)
}
function formatMonthTooltip(monthKey: string) {
const stats = monthStats.value.get(monthKey)
if (!stats) return ''
return `${stats.label}:共上传 ${stats.totalCount} 张,活跃于 ${stats.activeDays}`
}
</script>
<template>
<div class="border border-slate-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-6">
<div class="min-w-0 flex-1">
<h3 class="text-2xl font-bold text-slate-900">{{ title }}</h3>
<p class="mt-3 text-sm leading-7 text-slate-500">
共上传 {{ totalCount }} 活跃于 {{ activeDays }} 颜色越深代表当天上传的照片越多
</p>
<p class="mt-2 text-sm text-slate-500">{{ panelSummary }}</p>
</div>
<div class="flex flex-wrap items-center justify-end gap-3">
<NButtonGroup>
<NButton :type="viewMode === 'year' ? 'primary' : 'default'" secondary strong @click="viewMode = 'year'">
年度视图
</NButton>
<NButton :type="viewMode === 'month' ? 'primary' : 'default'" secondary strong @click="viewMode = 'month'">
月度视图
</NButton>
</NButtonGroup>
<NSelect
v-model:value="selectedYear"
:options="yearOptions"
class="w-28"
/>
<NSelect
v-if="viewMode === 'month'"
v-model:value="selectedMonth"
:options="monthOptions"
class="w-24"
/>
</div>
</div>
<div class="mt-4 flex min-h-6 items-center justify-between gap-4 text-xs text-slate-500">
<span>点击某一天可筛选当天上传记录</span>
<span v-if="monthPreview" class="text-right">
{{ monthPreview.label }}{{ monthPreview.totalCount }} / {{ monthPreview.activeDays }}
</span>
<span v-else class="text-right">
<template v-if="viewMode === 'year'">悬停月份标签可预览当月上传摘要</template>
<template v-else>当前显示整个月的上传情况</template>
</span>
</div>
<div class="mt-8 overflow-x-auto">
<div :class="viewMode === 'year' ? 'min-w-[980px]' : 'min-w-[360px]'">
<div
v-if="viewMode === 'year'"
class="mb-2 grid grid-flow-col auto-cols-[14px] gap-1 pl-8 text-[11px] text-slate-400"
>
<div
v-for="(month, index) in monthLabels"
:key="`${month?.key || 'empty'}-${index}`"
class="w-[14px] whitespace-nowrap"
:title="month ? formatMonthTooltip(month.key) : ''"
@mouseenter="hoveredMonthKey = month?.key || null"
@mouseleave="hoveredMonthKey = null"
>
{{ month?.label || '' }}
</div>
</div>
<div class="flex gap-3">
<div class="grid grid-rows-7 gap-1 pt-[2px] text-[11px] text-slate-400">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<div class="grid grid-flow-col auto-cols-[14px] gap-1">
<div
v-for="(week, weekIndex) in weeks"
:key="weekIndex"
class="grid grid-rows-7 gap-1"
>
<button
v-for="cell in week"
:key="cell.date"
type="button"
class="relative h-3.5 w-3.5 border transition-transform hover:scale-125"
:class="[
getCellClass(cell),
props.activeDate === cell.date ? 'ring-2 ring-slate-900 ring-offset-1' : '',
cell.isToday && cell.inRange ? 'outline outline-1 outline-slate-900 outline-offset-1' : '',
]"
:title="formatTooltip(cell)"
@click="handleCellClick(cell)"
></button>
</div>
</div>
</div>
</div>
</div>
<div class="mt-5 flex items-center justify-end gap-2 text-xs text-slate-500">
<span></span>
<span class="h-3 w-3 border border-slate-200 bg-slate-100"></span>
<span class="h-3 w-3 border border-sky-200 bg-sky-100"></span>
<span class="h-3 w-3 border border-sky-400 bg-sky-300"></span>
<span class="h-3 w-3 border border-teal-600 bg-teal-500"></span>
<span class="h-3 w-3 border border-slate-900 bg-slate-900"></span>
<span></span>
</div>
</div>
</template>