Files
opencloud/src/components/profile/ContributionHeatmap.vue
T
2026-05-30 00:39:20 +08:00

408 lines
12 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.
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { NButton, 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="oc-panel-card 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">
<div class="flex flex-wrap items-center gap-2">
<NButton
secondary
strong
type="default"
:class="viewMode === 'year' ? 'oc-panel-button oc-panel-button--teal' : 'oc-panel-button oc-panel-button--neutral'"
@click="viewMode = 'year'"
>
年度视图
</NButton>
<NButton
secondary
strong
type="default"
:class="viewMode === 'month' ? 'oc-panel-button oc-panel-button--teal' : 'oc-panel-button oc-panel-button--neutral'"
@click="viewMode = 'month'"
>
月度视图
</NButton>
</div>
<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>