简体中文

概览

感谢大大的小蜗牛的 文章仓库

最终效果

https://www.qladgk.com/stats

博客数据统计页面截图

功能概述

统计页面包含四个核心模块:

模块名称可视化形式数据维度交互功能
写作日历热力图每日写作情况悬停显示详情
分类占比扇形图文章分类分布高亮显示
年度统计柱状图历年文章数量自适应缩放
标签云标签频率展示标签使用频率点击跳转

技术架构

我采用了纯静态生成方案来实现统计功能,主要考虑以下几点:

  1. 性能优势:所有数据在构建时生成,运行时无需查询
  2. 部署简单:不需要额外的数据库或服务器
  3. 易于维护:数据随博客内容自动更新
功能架构

整个功能分为四个核心模块,每个模块都有其特定的实现方式和展示效果...

查看完整代码

数据收集

数据收集是整个统计功能的基础。由于博客采用 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;
}

数据解析

获取到文件路径后,需要解析每个文件的内容。这里我们关注两个部分:

  1. frontmatter 中的元数据(日期、标签等)
  2. 文章内容的字数统计

使用 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 提交记录的可视化组件,它能直观地展示写作频率和强度。实现这个组件需要考虑几个关键点:

  1. 时间范围的确定(过去一年)
  2. 数据的颜色映射(字数多少对应不同深浅)
  3. 交互效果(悬停显示详情)
热力图示例

实现代码

热力图的核心实现包括日期单元格的创建和提示框的显示:

// 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 绘制,通过计算每个分类的占比来生成扇形路径。这里的关键点是:

  1. 计算每个扇形的角度和路径
  2. 设置合适的颜色区分
  3. 添加交互动画效果
扇形图示例
// 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 元素实现,主要考虑以下几点:

  1. 固定显示近20年的数据范围
  2. 根据最大值动态计算高度
  3. 支持横向滚动和响应式布局
  4. 添加悬停效果和年份标签
柱形图示例
// 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 解析文章元数据
  • 利用 Setreduce 处理统计数据

可视化实现

  • 热力图:展示写作频率
  • 扇形图:分类占比分布
  • 柱状图:年度数据趋势
  • 标签云:标签使用频率

交互优化

  • 统一的动画过渡效果
  • 合适的悬停交互
  • 深色模式适配
  • 响应式布局

后续规划

计划添加以下功能:

  • 文章阅读量统计
  • 评论数据分析

查看完整代码

0
0
0
0