重组前端功能目录并统一代码规范工具链 (#10)

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-09-29 18:09:38 +08:00
parent 720ad29f06
commit 0af0256c97
101 changed files with 14644 additions and 10309 deletions
@@ -0,0 +1,468 @@
<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
activeDate?: string | null
title?: string
}>(),
{
activeDate: null,
title: '上传活跃度',
},
)
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 contributionQuartiles = computed(() => {
const counts = visibleCells.value
.filter(cell => cell.inRange && cell.count > 0)
.map(cell => cell.count)
.sort((a, b) => a - b)
if (!counts.length) return [0, 0, 0] as const
const percentile = (value: number) => {
const index = Math.ceil(counts.length * value) - 1
return counts[Math.max(0, index)] || 0
}
return [percentile(0.25), percentile(0.5), percentile(0.75)] as const
})
function getLevel(count: number) {
if (count <= 0) return 0
const [firstQuartile, secondQuartile, thirdQuartile] = contributionQuartiles.value
if (count <= firstQuartile) return 1
if (count <= secondQuartile) return 2
if (count <= thirdQuartile) return 3
return 4
}
function getCellClass(cell: { count: number; inRange: boolean }) {
if (!cell.inRange) return 'bg-transparent border-transparent'
const level = getLevel(cell.count)
return `oc-heatmap-cell--level-${level}`
}
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">
<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="oc-heatmap-cell relative h-3.5 w-3.5 border 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="oc-heatmap-legend-cell oc-heatmap-cell--level-0"></span>
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-1"></span>
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-2"></span>
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-3"></span>
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-4"></span>
<span>多</span>
</div>
</div>
</template>
<style scoped>
.oc-heatmap-cell,
.oc-heatmap-legend-cell {
transition:
background-color var(--oc-duration-fast) var(--oc-ease-standard),
border-color var(--oc-duration-fast) var(--oc-ease-standard),
transform var(--oc-duration-fast) var(--oc-ease-standard);
}
.oc-heatmap-legend-cell {
width: 0.75rem;
height: 0.75rem;
border-width: 1px;
}
.oc-heatmap-cell--level-0 {
background: var(--oc-heatmap-empty);
border-color: var(--oc-heatmap-empty-border);
}
.oc-heatmap-cell--level-1 {
background: var(--oc-heatmap-level-1);
border-color: var(--oc-heatmap-level-1-border);
}
.oc-heatmap-cell--level-2 {
background: var(--oc-heatmap-level-2);
border-color: var(--oc-heatmap-level-2-border);
}
.oc-heatmap-cell--level-3 {
background: var(--oc-heatmap-level-3);
border-color: var(--oc-heatmap-level-3-border);
}
.oc-heatmap-cell--level-4 {
background: var(--oc-heatmap-level-4);
border-color: var(--oc-heatmap-level-4-border);
}
</style>