e087dd46e2
Reviewed-on: #1
408 lines
12 KiB
Vue
408 lines
12 KiB
Vue
<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>
|