feat: enhance profile heatmap and caching
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user