概览
最终效果

功能概述
统计页面包含四个核心模块:
模块名称 | 可视化形式 | 数据维度 | 交互功能 |
---|---|---|---|
写作日历 | 热力图 | 每日写作情况 | 悬停显示详情 |
分类占比 | 扇形图 | 文章分类分布 | 高亮显示 |
年度统计 | 柱状图 | 历年文章数量 | 自适应缩放 |
标签云 | 标签频率展示 | 标签使用频率 | 点击跳转 |
技术架构
我采用了纯静态生成方案来实现统计功能,主要考虑以下几点:
- 性能优势:所有数据在构建时生成,运行时无需查询
- 部署简单:不需要额外的数据库或服务器
- 易于维护:数据随博客内容自动更新

整个功能分为四个核心模块,每个模块都有其特定的实现方式和展示效果...
数据收集
数据收集是整个统计功能的基础。由于博客采用 MDX 文件存储方式,我们可以利用 Node.js 的文件系统功能来读取和处理数据。

文件读取
首先需要递归遍历 blog 目录来获取所有的 MDX 文件。这里使用 fs.readdirSync
配合递归实现,确保能获取到所有层级的文章:
// filepath: /apps/gkBlog/src/pages/stats.tsx
import fs from "fs";
import path from "path";
import frontMatter from "front-matter";
function getAllMdxFiles(directory: string): string[] {
const files: string[] = [];
const entries = fs.readdirSync(directory, { withFileTypes: true });
entries.forEach((entry) => {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...getAllMdxFiles(fullPath));
} else if (entry.isFile() && entry.name === "index.mdx") {
files.push(fullPath);
}
});
return files;
}
数据解析
获取到文件路径后,需要解析每个文件的内容。这里我们关注两个部分:
- frontmatter 中的元数据(日期、标签等)
- 文章内容的字数统计
使用 front-matter
库来解析文件内容:
// filepath: /apps/gkBlog/src/pages/stats.tsx
// 文章前置数据类型定义
type PostFrontMatter = {
date: string;
title: string;
category: string;
tags: string[];
};
// 在 getStaticProps 中解析数据
export async function getStaticProps() {
const postsDirectory = path.join(process.cwd(), "src/pages/blog");
const filePaths = getAllMdxFiles(postsDirectory);
const allPostsData = filePaths.map((filePath) => {
const fileContents = fs.readFileSync(filePath, "utf8");
const { attributes, body } = frontMatter<PostFrontMatter>(fileContents);
return {
...attributes,
wordCount: body.replace(/\s+/g, "").length,
tags: attributes.tags || [],
};
});
return {
props: {
allPostsData,
},
};
}
统计处理
有了原始数据后,我们需要进行多个维度的统计。主要包括:
- 基础统计:文章总数、分类数量等
- 时间维度:年度文章数量分布
- 分类维度:不同分类的文章占比
// filepath: /apps/gkBlog/src/pages/stats.tsx
function StatsPage({ allPostsData }: { allPostsData: PostData[] }) {
const stats = {
totalPosts: allPostsData.length,
totalCategories: new Set(allPostsData.map(post => post.category)).size,
totalTags: new Set(allPostsData.flatMap(post => post.tags)).size,
totalWordCount: allPostsData.reduce((sum, post) => sum + post.wordCount, 0),
// 文章年度分布
postsByYear: getPostsByYear(allPostsData),
// 分类统计
postsByCategory: getPostsByCategory(allPostsData),
// 标签统计
tags: allPostsData.flatMap(post => post.tags)
};
return (
// ...渲染统计组件
);
}
这里使用了 Set
来计算独特的分类和标签数量,并使用 reduce
来计算总字数。我们还将文章按年份和分类进行统计,以便后续使用。
到此为止,我们已经收集了所有必要的数据。接下来,就可以使用这些数据来创建各种图表和可视化组件。
热力图实现
热力图是一个类似 GitHub 提交记录的可视化组件,它能直观地展示写作频率和强度。实现这个组件需要考虑几个关键点:
- 时间范围的确定(过去一年)
- 数据的颜色映射(字数多少对应不同深浅)
- 交互效果(悬停显示详情)

实现代码
热力图的核心实现包括日期单元格的创建和提示框的显示:
// filepath: /apps/gkBlog/src/components/stats/Heatmap.tsx
function createDay({ date, title, count, posts }: DayProps) {
const day = document.createElement("div");
day.className = cn(
"heatmap_day",
count === 0 && "heatmap_day_level_0",
count > 0 && count < 1000 && "heatmap_day_level_1",
count >= 1000 && count < 2000 && "heatmap_day_level_2",
count >= 2000 && count < 3000 && "heatmap_day_level_3",
count >= 3000 && "heatmap_day_level_4"
);
const tooltip = createTooltip(title, count, posts);
day.appendChild(tooltip);
return day;
}
export function Heatmap({ data }: HeatmapProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
container.innerHTML = "";
// 创建星期标签
const weekLabels = document.createElement("div");
weekLabels.className =
"absolute -left-6 top-0 flex flex-col gap-2 text-xs text-gray-400";
// 添加星期标签
WEEK_DAYS.forEach((day) => {
const label = document.createElement("div");
label.className = "h-3";
label.textContent = day;
weekLabels.appendChild(label);
});
container.appendChild(weekLabels);
// 创建日期网格
let currentDate = new Date(startDate);
while (currentDate <= today) {
const dateString = formatDate(currentDate);
const dayData = data.find((d) => d.date === dateString);
container.appendChild(
createDay({
date: dateString,
count: dayData?.wordCount || 0,
title: dayData?.title,
posts: dayData ? 1 : 0,
})
);
currentDate.setDate(currentDate.getDate() + 1);
}
}, [data]);
return (
<div className="relative pl-8">
<div ref={containerRef} className="heatmap" />
</div>
);
}
样式处理
热力图的样式主要分为三部分:基础布局、颜色层级和提示框:
/* 热力图基础布局 */
.heatmap {
display: grid;
grid-template-columns: repeat(53, 1fr);
gap: 2px;
}
/* 颜色层级 */
.heatmap_day_level_0 {
background-color: #ebedf0;
}
.dark .heatmap_day_level_0 {
background-color: #2d333b;
}
.heatmap_day_level_4 {
background-color: #216e39;
}
.dark .heatmap_day_level_4 {
background-color: #4ae883;
}
/* 提示框样式 */
.heatmap_tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
opacity: 0;
transition: opacity 0.2s ease;
}
.heatmap_day:hover .heatmap_tooltip {
opacity: 1;
}
扇形图和柱状图
扇形图实现
扇形图使用 SVG 绘制,通过计算每个分类的占比来生成扇形路径。这里的关键点是:
- 计算每个扇形的角度和路径
- 设置合适的颜色区分
- 添加交互动画效果

// filepath: /apps/gkBlog/src/components/stats/PieChart.tsx
import { cn } from "@/lib/utils";
interface PieChartProps {
data: Array<{
category: string;
count: number;
}>;
}
export function PieChart({ data }: PieChartProps) {
const total = data.reduce((sum, item) => sum + item.count, 0);
const colors = ["#e34c26", "#f1e05a", "#2b7489", "#3572A5"];
const getPath = (percentage: number, offset: number) => {
const r = 40;
const cx = 50,
cy = 50;
const startAngle = (offset * 3.6 - 90) * (Math.PI / 180);
const endAngle = ((offset + percentage) * 3.6 - 90) * (Math.PI / 180);
return [
`M ${cx} ${cy}`,
`L ${cx + r * Math.cos(startAngle)} ${cy + r * Math.sin(startAngle)}`,
`A ${r} ${r} 0 ${percentage > 50 ? 1 : 0} 1 ${cx + r * Math.cos(endAngle)} ${cy + r * Math.sin(endAngle)}`,
"Z",
].join(" ");
};
return (
<svg viewBox="0 0 100 100">
{data.map(({ category, count }, index) => {
const percentage = (count / total) * 100;
const offset = data
.slice(0, index)
.reduce((sum, item) => sum + (item.count / total) * 100, 0);
return (
<path
key={category}
d={getPath(percentage, offset)}
fill={colors[index % colors.length]}
className={cn(
"transition-all duration-300",
"hover:scale-105 hover:brightness-110",
"origin-center cursor-pointer"
)}
/>
);
})}
</svg>
);
}
柱状图实现
柱状图使用 div 元素实现,主要考虑以下几点:
- 固定显示近20年的数据范围
- 根据最大值动态计算高度
- 支持横向滚动和响应式布局
- 添加悬停效果和年份标签

// filepath: /apps/gkBlog/src/components/stats/BarChart.tsx
interface BarChartProps {
data: Array<{
year: string;
count: number;
}>;
}
export function BarChart({ data }: BarChartProps) {
const years = Array.from(
{ length: 20 },
(_, i) => new Date().getFullYear() - 19 + i
).map(String);
const maxCount = Math.max(...data.map((item) => item.count));
return (
<div className="overflow-x-auto scrollbar-thin">
<div className="relative min-w-[800px] h-[300px]">
{years.map((year) => {
const item = data.find((d) => d.year === year);
const height = item ? (item.count / maxCount) * 100 : 0;
return (
<div key={year} className="group relative inline-block w-8">
<div
className="bg-blue-500 hover:brightness-110 absolute bottom-0 w-6"
style={{ height: `${height}%` }}
/>
<span className="absolute -bottom-6 -rotate-45 text-sm">
{year}
</span>
</div>
);
})}
</div>
</div>
);
}
样式处理
为图表组件添加必要的样式支持:
// filepath: /apps/gkBlog/src/styles/stats.css
/* 图表容器 */
.charts-container {
display: grid;
gap: 2rem;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
/* 扇形图样式 */
.pie-chart {
aspect-ratio: 1;
max-width: 300px;
margin: 0 auto;
}
.pie-chart path {
transition: all 0.3s ease;
}
/* 柱状图样式 */
.bar-chart {
width: 100%;
overflow-x: auto;
scrollbar-width: thin;
}
.bar-chart-container {
padding-bottom: 2rem;
}
/* 深色模式适配 */
@media (prefers-color-scheme: dark) {
.bar-chart::-webkit-scrollbar-track {
background: #1a1a1a;
}
}
标签云和状态
标签云实现
标签云采用类似 shields.io 的样式,根据使用频率设置不同的显示效果:

// filepath: /apps/gkBlog/src/components/stats/TagCloud.tsx
interface TagCloudProps {
tags: string[];
}
export function TagCloud({ tags }: TagCloudProps) {
const sortedTags = Object.entries(
tags.reduce<Record<string, number>>((acc, tag) => {
acc[tag] = (acc[tag] || 0) + 1;
return acc;
}, {})
)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
const maxCount = Math.max(...sortedTags.map((t) => t.count));
return (
<div className="flex flex-wrap gap-2">
{sortedTags.map(({ name, count }) => (
<a
key={name}
href={`/blog/tag/${encodeURIComponent(name)}`}
className="inline-flex hover:scale-105 transition-transform"
>
<span className="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded-l-md">
{name}
</span>
<span
className={cn(
"px-2 py-1 text-white rounded-r-md",
getTagColorClass(count, maxCount)
)}
>
{count}
</span>
</a>
))}
</div>
);
}
状态展示
使用 shields.io 生成博客的各项状态指标:
<img
alt="License"
src="https://img.shields.io/badge/License-MIT-green"
className="h-5"
/>
<img
alt="WebSite"
src="https://img.shields.io/website?style=flat-square&url=https%3A%2F%2Fwww.qladgk.com"
className="h-5"
/>
总结
通过这次更新,我实现了一个完全静态生成的博客统计页面。整个实现过程主要关注以下几点:
数据处理
- 使用
fs
模块递归读取 MDX 文件 - 通过
front-matter
解析文章元数据 - 利用
Set
和reduce
处理统计数据
可视化实现
- 热力图:展示写作频率
- 扇形图:分类占比分布
- 柱状图:年度数据趋势
- 标签云:标签使用频率
交互优化
- 统一的动画过渡效果
- 合适的悬停交互
- 深色模式适配
- 响应式布局
后续规划
计划添加以下功能:
- 文章阅读量统计
- 评论数据分析