[{"data":1,"prerenderedAt":343},["ShallowReactive",2],{"content:\u002Fposts\u002Fgithub-heatmap":3,"surround:\u002Fposts\u002Fgithub-heatmap":332},{"id":4,"title":5,"body":6,"categories":304,"date":306,"description":307,"draft":308,"extension":309,"image":310,"meta":311,"navigation":313,"path":314,"permalink":315,"pinned":308,"published":315,"readingTime":316,"recommend":315,"references":315,"seo":321,"sitemap":322,"stem":323,"tags":324,"type":329,"updated":330,"__hash__":331},"content\u002Fposts\u002Fposts\u002Fgithub-heatmap.md","Firefly 魔改：GitHub 贡献热力图侧边栏",{"type":7,"value":8,"toc":288},"minimark",[9,19,22,25,32,37,45,56,66,69,75,86,90,99,103,106,110,116,127,131,149,158,171,179,184,193,197,204,212,216,222,229],[10,11,15],"alert",{"title":12,"type":13,"icon":14},"AI 迁移提示","info","tabler:robot",[16,17,18],"p",{},"本文由 AI 协助从旧站迁移，尚未完成逐篇人工审校；内容如有疏漏，将在复核后修订。",[10,20],{"title":21,"type":13},"> 本文部分内容由 AI 辅助整理，已结合实际操作进行校验，请根据自身环境谨慎使用。",[16,23,24],{},"GitHub 贡献热力图是很多开发者主页的标配，在博客侧边栏放一个，既能展示你的活跃度，也让访客更直观地了解你的开源参与情况。",[16,26,27],{},[28,29],"img",{"alt":30,"src":31},"GitHub 热力图效果","https:\u002F\u002Fimg.olinl.com\u002Ffile\u002Fpost-img\u002Fgithub-heatmap\u002F0001.webp",[33,34,36],"h2",{"id":35},"一组件文件","一、组件文件",[16,38,39,40,44],{},"组件源码位于 ",[41,42,43],"code",{"code":43},"src\u002Fcomponents\u002Fwidget\u002FGitHubHeatmap.astro","，是一个 Astro 服务端渲染组件 + 客户端交互脚本。",[16,46,47,48,55],{},"使用 ",[49,50,54],"a",{"href":51,"rel":52},"https:\u002F\u002Fgithub-contributions-api.jogruber.de\u002Fv4\u002F",[53],"nofollow","jogruber 公开 API"," 拉取数据，无需 Token：",[57,58,64],"pre",{"className":59,"code":61,"language":62,"meta":63},[60],"language-typescript","const url = `https:\u002F\u002Fgithub-contributions-api.jogruber.de\u002Fv4\u002F${username}?y=last`;\nconst response = await fetch(url, { signal: AbortSignal.timeout(8000) });\n","typescript","",[41,65,61],{"__ignoreMap":63},[16,67,68],{},"服务端 8 秒超时兜底，失败时返回空数据，前端显示降级提示。数据结构：",[57,70,73],{"className":71,"code":72,"language":62,"meta":63},[60],"type ContributionDay = {\n  date: string;\n  count: number;\n  level: number;     \u002F\u002F 0-4 贡献等级\n  tooltip: string;\n};\n\ntype CalendarData = {\n  days: ContributionDay[];\n  totalContributions: number;\n  activeDays: number;\n};\n",[41,74,72],{"__ignoreMap":63},[16,76,77,78,81,82,85],{},"按 7 天一列排列网格，每格根据 ",[41,79,80],{"code":80},"level"," 设置透明度，颜色取自 ",[41,83,84],{"code":84},"var(--primary)"," 跟随主题。",[87,88,89],"h3",{"id":89},"完整组件代码",[57,91,97],{"className":92,"code":94,"language":95,"meta":96},[93],"language-astro","---\nimport { Icon } from \"astro-icon\u002Fcomponents\";\nimport WidgetLayout from \"@\u002Fcomponents\u002Fcommon\u002FWidgetLayout.astro\";\nimport { profileConfig } from \"@\u002Fconfig\";\n\nconst githubLink = profileConfig.links.find((item) =>\n    \u002Fgithub\\.com\u002Fi.test(item.url),\n);\nconst githubUsername =\n    githubLink?.url.match(\u002Fgithub\\.com\\\u002F([^\u002F?#]+)\u002Fi)?.[1] || \"\";\nconst githubProfileUrl = githubUsername\n    ? `https:\u002F\u002Fgithub.com\u002F${githubUsername}`\n    : githubLink?.url || \"https:\u002F\u002Fgithub.com\";\n\nconst HEATMAP_DAYS = 100;\n\ntype ContributionDay = {\n    date: string;\n    count: number;\n    level: number;\n    tooltip: string;\n};\n\ntype CalendarData = {\n    days: ContributionDay[];\n    totalContributions: number;\n    activeDays: number;\n};\n\nfunction parseISODate(date: string) {\n    return new Date(`${date}T00:00:00.000Z`);\n}\n\nfunction formatISODate(date: Date) {\n    return date.toISOString().slice(0, 10);\n}\n\nfunction addDays(date: Date, days: number) {\n    const next = new Date(date);\n    next.setUTCDate(next.getUTCDate() + days);\n    return next;\n}\n\nfunction formatTooltipDate(date: string) {\n    return parseISODate(date).toLocaleDateString(\"zh-CN\", {\n        month: \"long\",\n        day: \"numeric\",\n        timeZone: \"UTC\",\n    });\n}\n\n\u002F\u002F 通过 jogruber 公开 API 服务端拉取 GitHub 贡献数据\n\u002F\u002F 免费、无需 token、8 秒超时\nasync function getCalendarData(username: string): Promise\u003CCalendarData> {\n    try {\n        const controller = new AbortController();\n        const timeoutId = setTimeout(() => controller.abort(), 8000);\n\n        const response = await fetch(\n            `https:\u002F\u002Fgithub-contributions-api.jogruber.de\u002Fv4\u002F${username}?y=last`,\n            { signal: controller.signal },\n        );\n        clearTimeout(timeoutId);\n\n        if (!response.ok) throw new Error(`HTTP ${response.status}`);\n\n        const json = await response.json();\n\n        const today = new Date();\n        const endDate = new Date(\n            Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()),\n        );\n        const startDate = new Date(endDate);\n        startDate.setUTCDate(startDate.getUTCDate() - (HEATMAP_DAYS - 1));\n\n        const dayMap = new Map\u003Cstring, { count: number; level: number }>();\n        if (json.contributions && Array.isArray(json.contributions)) {\n            for (const entry of json.contributions) {\n                dayMap.set(entry.date, {\n                    count: entry.count ?? 0,\n                    level: entry.level ?? 0,\n                });\n            }\n        }\n\n        const days: ContributionDay[] = [];\n        for (let d = new Date(startDate); d \u003C= endDate; d = addDays(d, 1)) {\n            const date = formatISODate(d);\n            const data = dayMap.get(date) || { count: 0, level: 0 };\n            days.push({\n                date,\n                count: data.count,\n                level: data.level,\n                tooltip:\n                    data.count > 0\n                        ? `${formatTooltipDate(date)} · ${data.count} 次提交`\n                        : `${formatTooltipDate(date)} · 无提交`,\n            });\n        }\n\n        const totalContributions = days.reduce((s, d) => s + d.count, 0);\n        const activeDays = days.filter((d) => d.count > 0).length;\n\n        return { days, totalContributions, activeDays };\n    } catch {\n        return { days: [], totalContributions: 0, activeDays: 0 };\n    }\n}\n\nfunction buildWeeks(days: ContributionDay[]) {\n    if (days.length === 0) return [] as Array\u003CArray\u003CContributionDay | null>>;\n    \u002F\u002F 按周排列，不足的用 null 占位\n    const rangeStart = parseISODate(days[0].date);\n    const lastDay = days.at(-1);\n    const rangeEnd = parseISODate(lastDay ? lastDay.date : days[0].date);\n    const calendarStart = addDays(rangeStart, -rangeStart.getUTCDay());\n    const calendarEnd = addDays(rangeEnd, 6 - rangeEnd.getUTCDay());\n    const dayMap = new Map(days.map((day) => [day.date, day]));\n    const weeks: Array\u003CArray\u003CContributionDay | null>> = [];\n\n    for (\n        let weekStart = calendarStart;\n        weekStart \u003C= calendarEnd;\n        weekStart = addDays(weekStart, 7)\n    ) {\n        const week: Array\u003CContributionDay | null> = [];\n        for (let offset = 0; offset \u003C 7; offset++) {\n            const current = addDays(weekStart, offset);\n            const date = formatISODate(current);\n            week.push(\n                current \u003C rangeStart || current > rangeEnd\n                    ? null\n                    : (dayMap.get(date) ?? null),\n            );\n        }\n        weeks.push(week);\n    }\n    return weeks;\n}\n\nexport interface Props {\n    class?: string;\n    style?: string;\n}\n\nconst { class: className, style } = Astro.props;\nconst calendarData = await getCalendarData(githubUsername);\nconst weeks = buildWeeks(calendarData.days);\n\nconst opacityLevels = [\"0\", \"0.45\", \"0.65\", \"0.85\", \"1\"];\n---\n\n\u003CWidgetLayout id=\"github-heatmap\" class={`${className} !overflow-visible`} style={style}>\n    {\n        githubUsername ? (\n            calendarData.days.length > 0 ? (\n                \u003Cdiv class=\"ghc-shell\" style={`--week-count: ${weeks.length};`}>\n                    \u003Cdiv class=\"flex items-center justify-between mb-2 text-xs text-neutral-600 dark:text-neutral-400\">\n                        \u003Ca href={githubProfileUrl} target=\"_blank\" rel=\"noreferrer\"\n                           class=\"flex items-center gap-1 font-bold hover:text-(--primary) transition-colors\">\n                            \u003CIcon name=\"fa7-brands:github\" class=\"text-sm\" \u002F>\n                            \u003Cspan>GitHub\u003C\u002Fspan>\n                        \u003C\u002Fa>\n                        \u003Cspan class=\"font-mono text-[0.7rem] opacity-80\">\n                            {calendarData.activeDays}\u002F{HEATMAP_DAYS}d\n                        \u003C\u002Fspan>\n                    \u003C\u002Fdiv>\n\n                    \u003Cdiv class=\"ghc-grid\" role=\"grid\">\n                        {weeks.map((week) => (\n                            \u003Cdiv class=\"ghc-week\" role=\"row\">\n                                {week.map((day) =>\n                                    day ? (\n                                        \u003Cdiv class=\"ghc-day\"\n                                             style={day.count === 0\n                                                 ? \"background-color: var(--btn-plain-bg-hover)\"\n                                                 : `background-color: var(--primary); opacity: ${opacityLevels[day.level]}`}\n                                             data-tooltip={day.tooltip}>\n                                        \u003C\u002Fdiv>\n                                    ) : (\n                                        \u003Cspan class=\"ghc-placeholder\" aria-hidden=\"true\" \u002F>\n                                    ),\n                                )}\n                            \u003C\u002Fdiv>\n                        ))}\n                    \u003C\u002Fdiv>\n                \u003C\u002Fdiv>\n            ) : (\n                \u003Cdiv class=\"rounded-2xl border border-dashed border-black\u002F10 px-4 py-6 text-sm text-neutral-500 dark:border-white\u002F10 dark:text-neutral-400\">\n                    暂时未获取到 GitHub 提交数据。\n                \u003C\u002Fdiv>\n            )\n        ) : (\n            \u003Cdiv class=\"rounded-2xl border border-dashed border-black\u002F10 px-4 py-6 text-sm text-neutral-500 dark:border-white\u002F10 dark:text-neutral-400\">\n                请先在个人资料链接中配置 GitHub 主页地址。\n            \u003C\u002Fdiv>\n        )\n    }\n\u003C\u002FWidgetLayout>\n\n\u003Cscript is:inline>\n  (() => {\n    const grid = document.getElementById('github-heatmap');\n    if (!grid) return;\n\n    let tooltipEl = document.getElementById('ghc-tooltip');\n    if (!tooltipEl) {\n      tooltipEl = document.createElement('div');\n      tooltipEl.id = 'ghc-tooltip';\n      Object.assign(tooltipEl.style, {\n        position: 'fixed', padding: '4px 8px', borderRadius: '6px',\n        fontSize: '0.75rem', lineHeight: '1.2', background: 'rgba(0,0,0,0.8)',\n        color: '#fff', boxShadow: '0 2px 8px rgba(0,0,0,0.15)',\n        pointerEvents: 'none', opacity: '0', transition: 'opacity 0.15s ease',\n        zIndex: '9999', whiteSpace: 'nowrap',\n      });\n      document.body.appendChild(tooltipEl);\n    }\n\n    grid.querySelectorAll('.ghc-day[data-tooltip]').forEach(cell => {\n      cell.addEventListener('mouseenter', () => {\n        cell.style.boxShadow = '0 0 1px 1.5px var(--primary, oklch(0.55 0.15 250))';\n        tooltipEl.textContent = cell.getAttribute('data-tooltip');\n        tooltipEl.style.opacity = '1';\n        const r = cell.getBoundingClientRect();\n        tooltipEl.style.left = r.left + r.width \u002F 2 - tooltipEl.offsetWidth \u002F 2 + 'px';\n        tooltipEl.style.top = r.top - tooltipEl.offsetHeight - 6 + 'px';\n      });\n      cell.addEventListener('mouseleave', () => {\n        cell.style.boxShadow = '';\n        tooltipEl.style.opacity = '0';\n      });\n    });\n  })();\n\u003C\u002Fscript>\n\n\u003Cstyle>\n    :global(.ghc-shell) { padding-top: 0.15rem; }\n    :global(.ghc-grid) {\n        --ghc-gap: 0.25rem;\n        display: grid;\n        grid-template-columns: repeat(var(--week-count), minmax(0, 1fr));\n        column-gap: var(--ghc-gap);\n        width: 100%;\n    }\n    :global(.ghc-week) {\n        display: grid;\n        grid-template-rows: repeat(7, minmax(0, 1fr));\n        row-gap: var(--ghc-gap);\n    }\n    :global(.ghc-day), :global(.ghc-placeholder) {\n        width: 100%;\n        aspect-ratio: 1 \u002F 1;\n        border-radius: 3px;\n    }\n    :global(.ghc-day) { cursor: pointer; transition: box-shadow 0.15s ease; }\n    :global(.ghc-placeholder) { display: block; opacity: 0; }\n\u003C\u002Fstyle>\n","astro","title=\"src\u002Fcomponents\u002Fwidget\u002FGitHubHeatmap.astro\"",[41,98,94],{"__ignoreMap":63},[33,100,102],{"id":101},"二注册组件","二、注册组件",[16,104,105],{},"新增组件需要在两处注册。",[87,107,109],{"id":108},"sidebarastro-导入","SideBar.astro 导入",[16,111,112,115],{},[41,113,114],{"code":114},"src\u002Fcomponents\u002Flayout\u002FSideBar.astro"," 的 frontmatter 导入区，按字母顺序与其他 widget 一起引入：",[57,117,125],{"className":118,"code":120,"highlights":121,"language":123,"meta":124},[119],"language-js","import Advertisement from \"@\u002Fcomponents\u002Fwidget\u002FAdvertisement.astro\";\nimport Announcement from \"@\u002Fcomponents\u002Fwidget\u002FAnnouncement.astro\";\nimport Calendar from \"@\u002Fcomponents\u002Fwidget\u002FCalendar.astro\";\nimport Categories from \"@\u002Fcomponents\u002Fwidget\u002FCategories.astro\";\nimport Dynamic from \"@\u002Fcomponents\u002Fwidget\u002FDynamic.astro\";\nimport GitHubHeatmap from \"@\u002Fcomponents\u002Fwidget\u002FGitHubHeatmap.astro\";\nimport Music from \"@\u002Fcomponents\u002Fwidget\u002FMusic.astro\";\nimport Profile from \"@\u002Fcomponents\u002Fwidget\u002FProfile.astro\";\nimport QuoteOfTheDay from \"@\u002Fcomponents\u002Fwidget\u002FQuoteOfTheDay.astro\";\nimport Schedule from \"@\u002Fcomponents\u002Fwidget\u002FSchedule.astro\";\n",[122],6,"js","title=\"src\u002Fcomponents\u002Flayout\u002FSideBar.astro\" ins=",[41,126,120],{"__ignoreMap":63},[87,128,130],{"id":129},"sidebarastro-组件映射表","SideBar.astro 组件映射表",[16,132,133,136,137,140,141,144,145,148],{},[41,134,135],{"code":135},"SideBar.astro"," 通过 ",[41,138,139],{"code":139},"componentMap"," 动态渲染已注册的组件，而 ",[41,142,143],{"code":143},"SideBar"," 本身在 ",[41,146,147],{"code":147},"src\u002Flayouts\u002FMainGridLayout.astro"," 中导入并使用：",[57,150,156],{"className":151,"code":152,"highlights":153,"language":123,"meta":155},[119],"import Live2DWidget from \"@components\u002Ffeatures\u002FLive2DWidget.astro\";\nimport SpineModel from \"@components\u002Ffeatures\u002FSpineModel.astro\";\nimport Footer from \"@components\u002Flayout\u002FFooter.astro\";\nimport Navbar from \"@components\u002Flayout\u002FNavbar.astro\";\nimport SideBar from \"@components\u002Flayout\u002FSideBar.astro\";\nimport type { MarkdownHeading } from \"astro\";\nimport { Icon } from \"astro-icon\u002Fcomponents\";\n",[154],5,"title=\"src\u002Flayouts\u002FMainGridLayout.astro\" ins=",[41,157,152],{"__ignoreMap":63},[16,159,160,161,164,165,167,168],{},"组件渲染链：",[41,162,163],{"code":163},"MainGridLayout.astro"," → ",[41,166,135],{"code":135},"（componentMap） → ",[41,169,170],{"code":170},"GitHubHeatmap.astro",[57,172,177],{"className":173,"code":174,"highlights":175,"language":123,"meta":124},[119],"const componentMap = {\n    profile: Profile,\n    announcement: Announcement,\n    categories: Categories,\n    tags: Tags,\n    \u002F\u002F ...\n    githubHeatmap: GitHubHeatmap,\n    timeGreeting: TimeGreeting,\n    dynamic: Dynamic,\n    schedule: Schedule,\n    quoteOfTheDay: QuoteOfTheDay,\n};\n",[176],7,[41,178,174],{"__ignoreMap":63},[16,180,181,183],{},[41,182,43],{"code":43}," — 标题栏颜色适配深浅模式：",[57,185,191],{"className":186,"code":187,"highlights":188,"language":95,"meta":190},[93],"    \u003Cdiv class=\"flex items-center justify-between mb-2 text-xs\">\n    \u003Cdiv class=\"flex items-center justify-between mb-2 text-xs text-neutral-600 dark:text-neutral-400\">\n",[189],1,"title=\"src\u002Fcomponents\u002Fwidget\u002FGitHubHeatmap.astro\" del= ins={2}",[41,192,187],{"__ignoreMap":63},[33,194,196],{"id":195},"三注册类型","三、注册类型",[16,198,199,200,203],{},"在 ",[41,201,202],{"code":202},"src\u002Ftypes\u002FsidebarConfig.ts"," 的类型联合中添加组件名：",[57,205,210],{"className":206,"code":207,"highlights":208,"language":62,"meta":209},[60],"export type WidgetComponentType =\n    | \"calendar\"\n    | \"music\"\n    | \"siteInfo\"\n    | \"githubHeatmap\"\n    | \"timeGreeting\";\n",[154],"title=\"src\u002Ftypes\u002FsidebarConfig.ts\" ins=",[41,211,207],{"__ignoreMap":63},[33,213,215],{"id":214},"四配置启用","四、配置启用",[16,217,199,218,221],{},[41,219,220],{"code":220},"src\u002Fconfig\u002FsidebarConfig.ts"," 的组件配置数组中添加：",[57,223,227],{"className":224,"code":225,"language":62,"meta":226},[60],"{\n    \u002F\u002F 组件类型：GitHub 活跃度热力图\n    type: \"githubHeatmap\",\n    \u002F\u002F 是否启用该组件\n    enable: true,\n    \u002F\u002F 组件位置\n    position: \"top\",\n    \u002F\u002F 是否在文章详情页显示\n    showOnPostPage: false,\n},\n","title=\"src\u002Fconfig\u002FsidebarConfig.ts\"",[41,228,225],{"__ignoreMap":63},[10,230,232,239,243,251,259,267,275,279],{"title":231,"type":13},"用户名自动提取",[16,233,234,235,238],{},"组件会自动从 ",[41,236,237],{"code":237},"profileConfig.links"," 中匹配 GitHub 链接提取用户名，无需单独填写。",[33,240,242],{"id":241},"五相关文件","五、相关文件",[16,244,245,246],{},"组件：",[49,247,250],{"href":248,"rel":249},"https:\u002F\u002Fgithub.com\u002Folinll\u002Ffirefly-blog\u002Fblob\u002Fmaster\u002Fsrc\u002Fcomponents\u002Fwidget\u002FGitHubHeatmap.astro",[53],"\u002Fsrc\u002Fcomponents\u002Fwidget\u002FGitHubHeatmap.astro",[16,252,253,254],{},"侧边栏：",[49,255,258],{"href":256,"rel":257},"https:\u002F\u002Fgithub.com\u002Folinll\u002Ffirefly-blog\u002Fblob\u002Fmaster\u002Fsrc\u002Fcomponents\u002Flayout\u002FSideBar.astro",[53],"\u002Fsrc\u002Fcomponents\u002Flayout\u002FSideBar.astro",[16,260,261,262],{},"配置：",[49,263,266],{"href":264,"rel":265},"https:\u002F\u002Fgithub.com\u002Folinll\u002Ffirefly-blog\u002Fblob\u002Fmaster\u002Fsrc\u002Fconfig\u002FsidebarConfig.ts",[53],"\u002Fsrc\u002Fconfig\u002FsidebarConfig.ts",[16,268,269,270],{},"相关源码：",[49,271,274],{"href":272,"rel":273},"https:\u002F\u002Fgithub.com\u002Folinll\u002Ffirefly-blog",[53],"olinll\u002Ffirefly-blog",[33,276,278],{"id":277},"最后","🔗 最后",[16,280,281,282,287],{},"这个组件的实现参考了 ",[49,283,286],{"href":284,"rel":285},"https:\u002F\u002Fwinered-0v0.com",[53],"Wine-Red"," 的思路，数据层使用开源 API，前端渲染与主题色保持统一。",{"title":63,"searchDepth":289,"depth":289,"links":290},4,[291,296,300,301,302,303],{"id":35,"depth":292,"text":36,"children":293},2,[294],{"id":89,"depth":295,"text":89},3,{"id":101,"depth":292,"text":102,"children":297},[298,299],{"id":108,"depth":295,"text":109},{"id":129,"depth":295,"text":130},{"id":195,"depth":292,"text":196},{"id":214,"depth":292,"text":215},{"id":241,"depth":292,"text":242},{"id":277,"depth":292,"text":278},[305],"Firefly","2026-07-04 13:10:29","在侧边栏展示近 100 天的 GitHub 贡献数据，无需 Token 即可拉取。",false,"md","https:\u002F\u002Fimg.olinl.com\u002Ffile\u002Fpost-img\u002Fgithub-heatmap\u002Fcover.webp",{"slots":312},{},true,"\u002Fposts\u002Fgithub-heatmap",null,{"text":317,"minutes":318,"time":319,"words":320},"8 min read",7.2,432000,1440,{"title":5,"description":307},{"loc":314},"posts\u002Fposts\u002Fgithub-heatmap",[305,325,326,327,328],"博客","二开","GitHub","小部件","tech","2026-07-22 19:30:00","O0a4wPfsBEc01xm86t-irEp8HWz3ItVKkHk8v-5We8I",[333,338],{"title":334,"path":335,"stem":336,"date":337,"type":329,"children":-1},"Firefly 魔改：时段问候实时时钟组件","\u002Fposts\u002Ftime-greeting","posts\u002Fposts\u002Ftime-greeting","2026-07-04 13:09:20",{"title":339,"path":340,"stem":341,"date":342,"type":329,"children":-1},"Firefly 魔改：IP 定位欢迎弹窗组件","\u002Fposts\u002Fwelcome-toast","posts\u002Fposts\u002Fwelcome-toast","2026-07-04 15:51:31",1788712222707]