Files
code-utils/frontend/src/components/GitHeatmap.vue
李琦 7c4109f687 feat: 提交详情能看 diff,也能丢给 AI 审这一次改动。文件检查可单独排除路径,TODO 只认大写,避免把 todo 表和模块当成待办标记。
热力图按容器宽度铺满一年,不再横向滚动。托盘右键换成可换皮肤的弹层;更新下载显示进度,退出后再由脚本拉起安装器,避免还占着 exe。
2026-08-19 15:46:03 +08:00

138 lines
4.7 KiB
Vue

<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({ days: { type: Array, default: () => [] }, selected: String })
const emit = defineEmits(['select'])
const { locale, t } = useI18n()
const root = ref(null)
const hover = ref(null)
const cell = ref(13)
const gap = ref(3)
let ro
function ymd(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const zhMonths = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
const enMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
function monthText(m) {
return locale.value === 'zh-CN' ? zhMonths[m] : enMonths[m]
}
const weekdays = computed(() => [
t('weekdays.sun'),
t('weekdays.mon'),
t('weekdays.tue'),
t('weekdays.wed'),
t('weekdays.thu'),
t('weekdays.fri'),
t('weekdays.sat'),
])
const matrix = computed(() => {
const end = new Date()
end.setHours(0, 0, 0, 0)
const start = new Date(end)
start.setDate(start.getDate() - 364)
start.setDate(start.getDate() - start.getDay())
const counts = new Map(props.days.map(x => [x.date, x]))
const cells = []
let week = 0
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const date = ymd(d)
const day = counts.get(date)
const count = Number(day?.count || 0)
cells.push({ date, count, added: Number(day?.added || 0), deleted: Number(day?.deleted || 0), week, dow: d.getDay(), level: Math.min(4, count === 0 ? 0 : count < 2 ? 1 : count < 5 ? 2 : count < 10 ? 3 : 4) })
if (d.getDay() === 6) week++
}
const weeks = Math.max(1, ...cells.map(x => x.week + 1))
const labels = []
let lastMonth = -1
for (const item of cells) {
const m = Number(item.date.slice(5, 7)) - 1
if (m === lastMonth) continue
const prev = labels[labels.length - 1]
if (prev && prev.week === item.week) {
prev.text = monthText(m)
} else {
if (prev) prev.span = Math.max(1, item.week - prev.week)
labels.push({ week: item.week, span: 1, text: monthText(m) })
}
lastMonth = m
}
if (labels.length) labels[labels.length - 1].span = Math.max(1, weeks - labels[labels.length - 1].week)
return { cells, labels, weeks }
})
const monthLabels = computed(() => {
const unit = cell.value + gap.value
const total = matrix.value.weeks * unit - gap.value
return matrix.value.labels.map((m, i, arr) => {
const next = arr[i + 1]
let left = m.week * unit
let width = Math.max(24, (next ? next.week * unit : total) - left)
if (left + width > total) {
width = Math.min(width, total)
left = Math.max(0, total - width)
}
return { week: m.week, text: m.text, left, width }
})
})
function fit() {
const el = root.value
if (!el) return
const weeks = matrix.value.weeks || 53
const avail = Math.max(0, el.clientWidth - 34)
const nextGap = avail < 720 ? 2 : 3
const nextCell = Math.floor((avail - (weeks - 1) * nextGap) / weeks)
gap.value = nextGap
cell.value = Math.max(4, Math.min(14, nextCell > 0 ? nextCell : 4))
}
function pick(item) {
if (!item.date) return
emit('select', item.date === props.selected ? '' : item.date)
}
onMounted(() => {
fit()
ro = new ResizeObserver(fit)
if (root.value) ro.observe(root.value)
})
onUnmounted(() => ro?.disconnect())
watch(() => matrix.value.weeks, fit)
</script>
<template>
<div ref="root" class="heat-grid-wrap" :style="{ '--weeks': matrix.weeks, '--cell': cell + 'px', '--gap': gap + 'px' }">
<div class="heat-months">
<span v-for="m in monthLabels" :key="m.week + m.text" :style="{ left: m.left + 'px', width: m.width + 'px' }">{{ m.text }}</span>
</div>
<div class="heat-body">
<div class="heat-weekdays"><span v-for="(d, i) in weekdays" :key="i">{{ d }}</span></div>
<div class="heat-grid">
<button
v-for="item in matrix.cells"
:key="item.date"
type="button"
class="heat-cell"
:class="['l' + item.level, { selected: item.date === selected }]"
:style="{ gridColumnStart: item.week + 1, gridRowStart: item.dow + 1 }"
:title="item.date"
@mouseenter="hover = item"
@mouseleave="hover = null"
@click="pick(item)"
/>
</div>
</div>
<p class="heat-tip">
<template v-if="hover">{{ hover.date }} · {{ hover.count }} {{ t('commitsUnit') }}<template v-if="hover.count"> · <b class="positive">+{{ hover.added }}</b> <b class="negative">-{{ hover.deleted }}</b></template></template>
<template v-else>{{ t('heatHint') }}</template>
</p>
</div>
</template>