Skip to content

配置站点信息

配置站点信息

image-20241226123237145

目录

[toc]

版权声明

警告

本着开源共享、共同学习的精神:

本文是在 博主《youngkbt》 文章:《本站 - 站点信息模块》https://notes.youngkbt.cn/about/website/info 基础上增加了一些自己的实际操作记录和修改,内容依旧属于原作者《youngkbt》 所有。转载无需和我联系,但请注明文章来源。如果侵权之处,请联系博主进行删除,谢谢~(这里万分感谢原作者的优质文章😜,感谢开源,拥抱开源💖)

image-20241226123552435

本人测试环境

2024年12月26日测试

2024年12月23日从官方拉取的项目:

基于官方https://github.com/xugaoyi/vuepress-theme-vdoing搭建的仓库。

image-20241223130417258

image-20241226142619421

前言

本内容介绍如何搭建本站首页的站点信息,以及每篇文章的浏览量统计。

本内容将在首页和每篇的文章页加入了一些元素,目前适用版本是 Vdoing v1.x。

如果你想集成到其他 Vuepress 主题,那么要添加卡片样式,修改挂载元素即可(建议先按照步骤完成一次再考虑集成)。

  • 为什么添加卡片样式?本模块的站点信息是基于 Vdoing 自带的卡片样式,模块并没有添加任何卡片样式,所以想集成到其他主题,则需要参考 Vdoing 卡片样式进行添加,或者按照自己喜欢的样式进行添加
  • 为什么修改挂载元素?本模块的挂载元素是基于 Vdoing 标签提供的 class 或 id,而其他主题的标签不一样,所以自行进行调试

本模块的所有 功能 支持大部分 Vuepress 主题,但是如何将所有功能展示到其他主题页面合适的地方,以及展示的样式等 DOM 技术,需要自己适配。

效果如下:

image-20241226080035909

本站的访问量和文章的浏览量使用了 不蒜子,本地启动的 localhost 有很多人访问过,但无需担心实际部署后的访问量。

不蒜子官网地址(opens new window)

不蒜子文档地址(opens new window)

NOTE

注意

问题:本模块目前有一个功能依赖于 git 的 lastUpdated 功能,该功能已经内置 Vuepress,所以无需担心,唯一值得注意的是:在本地添加了新的文件,最后活动时间的数据可能为 NaN(无法获取的意思)。

解决:只需要在博客项目部署的过程中执行 git commit 命令,因为该命令将会获取一个准确的时间代替 NaN,给本模块使用。

2022-01-17 @Young Kbt

添加meta

为什么添加 meta 头信息呢,因为在 Chrome 85 版本中,为了保护用户的隐私,默认的 Referrer Policy 则变成了 strict-origin-when-cross-origin

所以必须添加 meta,否则文章统计访问量的数据则不正确。

在 docs/.vuepress/config.js 下的 head 中添加如下内容:

js
['meta', { name: 'referrer', content: 'no-referrer-when-downgrade' }],

如图:

image-20241226080510693


自己配置:

image-20241226080454850

添加在线图标

这里使用的是阿里矢量库。

地址:https://www.iconfont.cn/(opens new window)

添加了五个图标

image-20241226080534701

如果你不想使用自己的矢量库项目(不害怕我删图标跑路🤣),那么可以使用我的图标项目网址,当你发现图标失效了,就请来这里获取新的地址,如果还没有更新,请在评论区留言

在 config.js 下的 head 中文件添加如下内容:

js
['link', { rel: 'stylesheet', href: 'https://at.alicdn.com/t/font_3077305_pt8umhrn4k9.css' }]

如图:(图片的内容不一定是最新的,以上方代码块为准)

image-20241226080652226


自己配置:

image-20241226080717501

Vue模板

这里先提供一个在 Vue 里常用的模板代码,即通用代码(了解即可):

vue
<template>
  <div class="busuanzi">
    <span id="busuanzi_container_site_pv" style="display:none">
      本站总访问量
      <span id="busuanzi_value_site_pv"></span>次
      <span class="post-meta-divider">|</span>
    </span>
    <span id="busuanzi_container_site_uv" style="display:none">
      本站访客数
      <span id="busuanzi_value_site_uv"></span>人
    </span>
  </div>
</template>
 
<script>
let script;
export default {
  mounted() {
    script = require("busuanzi.pure.js");
  },
  // 监听,当路由发生变化的时候执行
  watch: {
    $route(to, from) {
      if (to.path != from.path) {
        script.fetch();
      }
    }
  }
};
</script>

主题选择

下面有两种配置方式可以选,分别为:

  • 在线主题:NPM 主题,采用监听路由、插入式的代码
  • 本地主题:站点信息模块与页面一起渲染出来,没有延迟

本地主题不好的一点就是版本升级后曾修改的内容被重置,所以需要记好修改位置、备份,比较麻烦。好处是根据自己的需求在基础上拓展。

在线主题具有通用性,即在任意环境(如本地主题)都有效果。

本次个人使用在线主题配置方式。😜

在线主题

建议:本内容代码块比较长,可以点击代码块的右侧箭头来折叠,然后点击复制图标进行复制即可。

不管使不使用本地主题,都可以配置在线主题的站点模块。

网站信息工具代码

添加网站信息需要的计算代码、获取字数代码等工具类。

首先进入 docs/.vuepress 目录,创建 webSiteInfo 文件夹

image-20241226081318682

然后在 webSiteInfo 目录下创建 busuanzi.js 文件,这个文件用于 获取访问量。

js
var bszCaller, bszTag, scriptTag, ready;

var t,
  e,
  n,
  a = !1,
  c = [];

// 修复Node同构代码的问题
if (typeof document !== "undefined") {
  (ready = function (t) {
    return (
      a ||
      "interactive" === document.readyState ||
      "complete" === document.readyState
        ? t.call(document)
        : c.push(function () {
            return t.call(this);
          }),
      this
    );
  }),
    (e = function () {
      for (var t = 0, e = c.length; t < e; t++) c[t].apply(document);
      c = [];
    }),
    (n = function () {
      a ||
        ((a = !0),
        e.call(window),
        document.removeEventListener
          ? document.removeEventListener("DOMContentLoaded", n, !1)
          : document.attachEvent &&
            (document.detachEvent("onreadystatechange", n),
            window == window.top && (clearInterval(t), (t = null))));
    }),
    document.addEventListener
      ? document.addEventListener("DOMContentLoaded", n, !1)
      : document.attachEvent &&
        (document.attachEvent("onreadystatechange", function () {
          /loaded|complete/.test(document.readyState) && n();
        }),
        window == window.top &&
          (t = setInterval(function () {
            try {
              a || document.documentElement.doScroll("left");
            } catch (t) {
              return;
            }
            n();
          }, 5)));
}

bszCaller = {
  fetch: function (t, e) {
    var n = "BusuanziCallback_" + Math.floor(1099511627776 * Math.random());
    t = t.replace("=BusuanziCallback", "=" + n);
    (scriptTag = document.createElement("SCRIPT")),
      (scriptTag.type = "text/javascript"),
      (scriptTag.defer = !0),
      (scriptTag.src = t),
      document.getElementsByTagName("HEAD")[0].appendChild(scriptTag);
    window[n] = this.evalCall(e);
  },
  evalCall: function (e) {
    return function (t) {
      ready(function () {
        try {
          e(t),
            scriptTag &&
              scriptTag.parentElement &&
              scriptTag.parentElement.removeChild &&
              scriptTag.parentElement.removeChild(scriptTag);
        } catch (t) {
          console.log(t), bszTag.hides();
        }
      });
    };
  },
};

bszTag = {
  bszs: ["site_pv", "page_pv", "site_uv"],
  texts: function (n) {
    this.bszs.map(function (t) {
      var e = document.getElementById("busuanzi_value_" + t);
      e && (e.innerHTML = n[t]);
    });
  },
  hides: function () {
    this.bszs.map(function (t) {
      var e = document.getElementById("busuanzi_container_" + t);
      e && (e.style.display = "none");
    });
  },
  shows: function () {
    this.bszs.map(function (t) {
      var e = document.getElementById("busuanzi_container_" + t);
      e && (e.style.display = "inline");
    });
  },
};

export default () => {
  bszTag && bszTag.hides();
  bszCaller.fetch("//busuanzi.ibruce.info/busuanzi?jsonpCallback=BusuanziCallback", function (t) {
    bszTag.texts(t), bszTag.shows();
  })
};

然后创建 readFile.js 或者 readFile.ts 文件,这个文件用于 统计文章数目 和 网站总字数 等。

添加如下内容:

注意:本次个人使用ts方式。

js

js
const fs = require('fs'); // 文件模块
const path = require('path'); // 路径模块
const matter = require('gray-matter'); // FrontMatter解析器 https://github.com/jonschlinkert/gray-matter
const chalk = require('chalk') // 命令行打印美化
const log = console.log
const docsRoot = path.join(__dirname, '..', '..', '..', 'docs'); // docs文件路径

/**
 * 获取本站的文章数据
 * 获取所有的 md 文档,可以排除指定目录下的文档
 */
function readFileList(excludeFiles = [''], dir = docsRoot, filesList = []) {
  const files = fs.readdirSync(dir);
  files.forEach((item, index) => {
    let filePath = path.join(dir, item);
    const stat = fs.statSync(filePath);
    if (!(excludeFiles instanceof Array)) {
      log(chalk.yellow(`error: 传入的参数不是一个数组。`))
    }
    excludeFiles.forEach((excludeFile) => {
      if (stat.isDirectory() && item !== '.vuepress' && item !== '@pages' && item !== excludeFile) {
        readFileList(excludeFiles, path.join(dir, item), filesList);  //递归读取文件
      } else {
        if (path.basename(dir) !== 'docs') { // 过滤 docs目录级下的文件

          const fileNameArr = path.basename(filePath).split('.')
          let name = null, type = null;
          if (fileNameArr.length === 2) { // 没有序号的文件
            name = fileNameArr[0]
            type = fileNameArr[1]
          } else if (fileNameArr.length === 3) { // 有序号的文件
            name = fileNameArr[1]
            type = fileNameArr[2]
          } else { // 超过两个‘.’的
            log(chalk.yellow(`warning: 该文件 "${filePath}" 没有按照约定命名,将忽略生成相应数据。`))
            return
          }
          if (type === 'md') { // 过滤非 md 文件
            filesList.push({
              name,
              filePath
            });
          }
        }
      }
    });
  });
  return filesList;
}
/**
 * 获取本站的文章总字数
 * 可以排除某个目录下的 md 文档字数
 */
function readTotalFileWords(excludeFiles = ['']) {
  const filesList = readFileList(excludeFiles);
  var wordCount = 0;
  filesList.forEach((item) => {
    const content = getContent(item.filePath);
    var len = counter(content);
    wordCount += len[0] + len[1];
  });
  if (wordCount < 1000) {
    return wordCount;
  }
  return Math.round(wordCount / 100) / 10 + 'k';
}
/**
 * 获取每一个文章的字数
 * 可以排除某个目录下的 md 文档字数
 */
function readEachFileWords(excludeFiles = [''], cn, en) {
  const filesListWords = [];
  const filesList = readFileList(excludeFiles);
  filesList.forEach((item) => {
    const content = getContent(item.filePath);
    var len = counter(content);
    // 计算预计的阅读时间
    var readingTime = readTime(len, cn, en);
    var wordsCount = 0;
    wordsCount = len[0] + len[1];
    if (wordsCount >= 1000) {
      wordsCount = Math.round(wordsCount / 100) / 10 + 'k';
    }
    // fileMatterObj => {content:'剔除frontmatter后的文件内容字符串', data:{<frontmatter对象>}, ...}
    const fileMatterObj = matter(content, {});
    const matterData = fileMatterObj.data;
    filesListWords.push({ ...item, wordsCount, readingTime, ...matterData });
  });
  return filesListWords;
}

/**
 * 计算预计的阅读时间
 */
function readTime(len, cn = 300, en = 160) {
  var readingTime = len[0] / cn + len[1] / en;
  if (readingTime > 60 && readingTime < 60 * 24) {   // 大于一个小时,小于一天
    let hour = parseInt(readingTime / 60);
    let minute = parseInt((readingTime - hour * 60));
    if (minute === 0) {
      return hour + 'h';
    }
    return hour + 'h' + minute + 'm';
  } else if (readingTime > 60 * 24) {      // 大于一天
    let day = parseInt(readingTime / (60 * 24));
    let hour = parseInt((readingTime - day * 24 * 60) / 60);
    if (hour === 0) {
      return day + 'd';
    }
    return day + 'd' + hour + 'h';
  }
  return readingTime < 1 ? '1' : parseInt((readingTime * 10)) / 10 + 'm';   // 取一位小数
}

/**
 * 读取文件内容
 */
function getContent(filePath) {
  return fs.readFileSync(filePath, 'utf8');
}
/**
 * 获取文件内容的字数
 * cn:中文
 * en:一整句英文(没有空格隔开的英文为 1 个)
 */
function counter(content) {
  const cn = (content.match(/[\u4E00-\u9FA5]/g) || []).length;
  const en = (content.replace(/[\u4E00-\u9FA5]/g, '').match(/[a-zA-Z0-9_\u0392-\u03c9\u0400-\u04FF]+|[\u4E00-\u9FFF\u3400-\u4dbf\uf900-\ufaff\u3040-\u309f\uac00-\ud7af\u0400-\u04FF]+|[\u00E4\u00C4\u00E5\u00C5\u00F6\u00D6]+|\w+/g) || []).length;
  return [cn, en];
}

module.exports = {
  readFileList,
  readTotalFileWords,
  readEachFileWords,
}

ts

ts
import fs from 'fs'; // 文件模块
import path from 'path'; // 路径模块
import matter from 'gray-matter'; // FrontMatter解析器 https://github.com/jonschlinkert/gray-matter
import chalk from 'chalk' // 命令行打印美化
const log = console.log
const docsRoot = path.join(__dirname, '..', '..', '..', 'docs'); // docs文件路径

/**
 * 获取本站的文章数据
 * 获取所有的 md 文档,可以排除指定目录下的文档
 */
function readFileList(excludeFiles: Array<string> = [''], dir: string = docsRoot, filesList: Array<Object> = []) {
  const files = fs.readdirSync(dir);
  files.forEach((item, index) => {
    let filePath = path.join(dir, item);
    const stat = fs.statSync(filePath);
    if (!(excludeFiles instanceof Array)) {
      log(chalk.yellow(`error: 传入的参数不是一个数组。`))
    }
    excludeFiles.forEach((excludeFile) => {
      if (stat.isDirectory() && item !== '.vuepress' && item !== '@pages' && item !== excludeFile) {
        readFileList(excludeFiles, path.join(dir, item), filesList);  //递归读取文件
      } else {
        if (path.basename(dir) !== 'docs') { // 过滤 docs目录级下的文件

          const fileNameArr = path.basename(filePath).split('.')
          let name = null, type = null;
          if (fileNameArr.length === 2) { // 没有序号的文件
            name = fileNameArr[0]
            type = fileNameArr[1]
          } else if (fileNameArr.length === 3) { // 有序号的文件
            name = fileNameArr[1]
            type = fileNameArr[2]
          } else { // 超过两个‘.’的
            log(chalk.yellow(`warning: 该文件 "${filePath}" 没有按照约定命名,将忽略生成相应数据。`))
            return
          }
          if (type === 'md') { // 过滤非 md 文件
            filesList.push({
              name,
              filePath
            });
          }
        }
      }
    });
  });
  return filesList;
}
/**
 * 获取本站的文章总字数
 * 可以排除某个目录下的 md 文档字数
 */
function readTotalFileWords(excludeFiles = ['']) {
  const filesList = readFileList(excludeFiles);
  let wordCount = 0;
  filesList.forEach((item: any) => {
    const content = getContent(item.filePath);
    let len = counter(content);
    wordCount += len[0] + len[1];
  });
  if (wordCount < 1000) {
    return wordCount;
  }
  return Math.round(wordCount / 100) / 10 + 'k';
}
/**
 * 获取每一个文章的字数
 * 可以排除某个目录下的 md 文档字数
 */
function readEachFileWords(excludeFiles: Array<string> = [''], cn: number, en: number) {
  const filesListWords = [];
  const filesList = readFileList(excludeFiles);
  filesList.forEach((item: any) => {
    const content = getContent(item.filePath);
    let len = counter(content);
    // 计算预计的阅读时间
    let readingTime = readTime(len, cn, en);
    let wordsCount: any = 0;
    wordsCount = len[0] + len[1];
    if (wordsCount >= 1000) {
      wordsCount = Math.round(wordsCount / 100) / 10 + 'k';
    }
    // fileMatterObj => {content:'剔除frontmatter后的文件内容字符串', data:{<frontmatter对象>}, ...}
    const fileMatterObj = matter(content, {});
    const matterData = fileMatterObj.data;
    filesListWords.push({ ...item, wordsCount, readingTime, ...matterData });
  });
  return filesListWords;
}

/**
 * 计算预计的阅读时间
 */
function readTime(len: Array<number>, cn: number = 300, en: number = 160) {
  let readingTime = len[0] / cn + len[1] / en;
  if (readingTime > 60 && readingTime < 60 * 24) {   // 大于一个小时,小于一天
    let hour = Math.trunc(readingTime / 60);
    let minute = Math.trunc(readingTime - hour * 60);
    if (minute === 0) {
      return hour + 'h';
    }
    return hour + 'h' + minute + 'm';
  } else if (readingTime > 60 * 24) {      // 大于一天
    let day = Math.trunc(readingTime / (60 * 24));
    let hour = Math.trunc((readingTime - day * 24 * 60) / 60);
    if (hour === 0) {
      return day + 'd';
    }
    return day + 'd' + hour + 'h';
  }
  return readingTime < 1 ? '1' : Math.trunc(readingTime * 10) / 10 + 'm';   // 取一位小数
}

/**
 * 读取文件内容
 */
function getContent(filePath: string) {
  return fs.readFileSync(filePath, 'utf8');
}
/**
 * 获取文件内容的字数
 * cn:中文
 * en:一整句英文(没有空格隔开的英文为 1 个)
 */
function counter(content: string) {
  const cn = (content.match(/[\u4E00-\u9FA5]/g) || []).length;
  const en = (content.replace(/[\u4E00-\u9FA5]/g, '').match(/[a-zA-Z0-9_\u0392-\u03c9\u0400-\u04FF]+|[\u4E00-\u9FFF\u3400-\u4dbf\uf900-\ufaff\u3040-\u309f\uac00-\ud7af\u0400-\u04FF]+|[\u00E4\u00C4\u00E5\u00C5\u00F6\u00D6]+|\w+/g) || []).length;
  return [cn, en];
}

export {
  readFileList,
  readTotalFileWords,
  readEachFileWords,
}

接着继续在该目录下创建第三个文件 utils.js,该文件用于计算 已运行时间 和 最后活动时间。

添加如下内容:

js
// 日期格式化(只获取年月日)
export function dateFormat(date) {
  if (!(date instanceof Date)) {
    date = new Date(date);
  }
  return `${date.getUTCFullYear()}-${zero(date.getUTCMonth() + 1)}-${zero(date.getUTCDate())}`;
}

// 小于10补0
export function zero(d) {
  return d.toString().padStart(2, '0');
}

/**
 * 计算最后活动时间
 */
export function lastUpdatePosts(posts) {
  posts.sort((prev, next) => {
    return compareDate(prev, next);
  });
  return posts;
}

// 获取时间的时间戳
export function getTimeNum(post) {
  let dateStr = post.lastUpdated || post.frontmatter.date;
  let date = new Date(dateStr);
  if (date == "Invalid Date" && dateStr) { // 修复new Date()在Safari下出现Invalid Date的问题
    date = new Date(dateStr.replace(/-/g, '/'));
  }
  return date.getTime();
}

// 比对时间
export function compareDate(a, b) {
  return getTimeNum(b) - getTimeNum(a);
}

/**
 * 获取两个日期相差多少天
 */
export function dayDiff(startDate, endDate) {
  if (!endDate) {
    endDate = startDate;
    startDate = new Date();
  }
  startDate = dateFormat(startDate);
  endDate = dateFormat(endDate);
  let day = parseInt(Math.abs(new Date(startDate) - new Date(endDate)) / (1000 * 60 * 60 * 24));
  return day;
}

/**
 * 计算相差多少年/月/日/时/分/秒
 */
export function timeDiff(startDate, endDate) {
  if (!endDate) {
    endDate = startDate;
    startDate = new Date();
  }
  if (!(startDate instanceof Date)) {
    startDate = new Date(startDate);
  }
  if (!(endDate instanceof Date)) {
    endDate = new Date(endDate);
  }
  // 计算时间戳的差
  const diffValue = parseInt((Math.abs(endDate - startDate) / 1000));
  if (diffValue == 0) {
    return '刚刚';
  } else if (diffValue < 60) {
    return diffValue + ' 秒';
  } else if (parseInt(diffValue / 60) < 60) {
    return parseInt(diffValue / 60) + ' 分';
  } else if (parseInt(diffValue / (60 * 60)) < 24) {
    return parseInt(diffValue / (60 * 60)) + ' 时';
  } else if (parseInt(diffValue / (60 * 60 * 24)) < getDays(startDate.getMonth, startDate.getFullYear)) {
    return parseInt(diffValue / (60 * 60 * 24)) + ' 天';
  } else if (parseInt(diffValue / (60 * 60 * 24 * getDays(startDate.getMonth, startDate.getFullYear))) < 12) {
    return parseInt(diffValue / (60 * 60 * 24 * getDays(startDate.getMonth, startDate.getFullYear))) + ' 月';
  } else {
    return parseInt(diffValue / (60 * 60 * 24 * getDays(startDate.getMonth, startDate.getFullYear) * 12)) + ' 年';
  }
}

/**
 * 判断当前月的天数(28、29、30、31)
 */
export function getDays(mouth, year) {
  let days = 30;
  if (mouth === 2) {
    days = year % 4 === 0 ? 29 : 28;
  } else if (mouth === 1 || mouth === 3 || mouth === 5 || mouth === 7 || mouth === 8 || mouth === 10 || mouth === 12) {
    // 月份为:1,3,5,7,8,10,12 时,为大月.则天数为 31;
    days = 31;
  }
  return days;
}

目前就三个文件,最终效果如图:

image-20241226081530171

站点信息代码

这一步的文件目录不能随便移动,因为该目录是 Vuepress 规定的。

首先进入 docs/.vuepress 目录,创建 components 文件夹

image-20241226081612173

创建一个 vue 文件:WebInfo.vue,这就是首页的站点信息模块。

并添加如下内容:

vue
<template>
  <!-- Young Kbt -->
  <div class="web-info card-box">
    <div class="webinfo-title">
      <i
        class="iconfont icon-award"
        style="font-size: 0.875rem; font-weight: 900; width: 1.25em"
      ></i>
      <span>站点信息</span>
    </div>
    <div class="webinfo-item">
      <div class="webinfo-item-title">文章数目:</div>
      <div class="webinfo-content">{{ mdFileCount }} 篇</div>
    </div>

    <div class="webinfo-item">
      <div class="webinfo-item-title">已运行时间:</div>
      <div class="webinfo-content">
        {{ createToNowDay != 0 ? createToNowDay + " 天" : "不到一天" }}
      </div>
    </div>

    <div class="webinfo-item">
      <div class="webinfo-item-title">本站总字数:</div>
      <div class="webinfo-content">{{ totalWords }} 字</div>
    </div>

    <div class="webinfo-item">
      <div class="webinfo-item-title">最后活动时间:</div>
      <div class="webinfo-content">
        {{ lastActiveDate == "刚刚" ? "刚刚" : lastActiveDate + "前" }}
      </div>
    </div>

    <div v-if="indexView" class="webinfo-item">
      <div class="webinfo-item-title">本站被访问了:</div>
      <div class="webinfo-content">
        <span id="busuanzi_value_site_pv" class="web-site-pv"
          ><i title="正在获取..." class="loading iconfont icon-loading"></i>
        </span>

      </div>
    </div>

    <div v-if="indexView" class="webinfo-item">
      <div class="webinfo-item-title">您的访问排名:</div>
      <div class="webinfo-content busuanzi">
        <span id="busuanzi_value_site_uv" class="web-site-uv"
          ><i title="正在获取..." class="loading iconfont icon-loading"></i>
        </span>

      </div>
    </div>
  </div>
</template>

<script>
import { dayDiff, timeDiff, lastUpdatePosts } from "../webSiteInfo/utils";
import fetch from "../webSiteInfo/busuanzi"; // 统计量
export default {
  data() {
    return {
      // Young Kbt
      mdFileCount: 0, // markdown 文档总数
      createToNowDay: 0, // 博客创建时间距今多少天
      lastActiveDate: "", // 最后活动时间
      totalWords: 0, // 本站总字数
      indexView: true, // 开启访问量和排名统计
    };
  },
  computed: {
    $lastUpdatePosts() {
      return lastUpdatePosts(this.$filterPosts);
    },
  },
  mounted() {
    // Young Kbt
    if (Object.keys(this.$themeConfig.blogInfo).length > 0) {
      const {
        blogCreate,
        mdFileCountType,
        totalWords,
        moutedEvent,
        eachFileWords,
        indexIteration,
        indexView,
      } = this.$themeConfig.blogInfo;
      this.createToNowDay = dayDiff(blogCreate);
      if (mdFileCountType != "archives") {
        this.mdFileCount = mdFileCountType.length;
      } else {
        this.mdFileCount = this.$filterPosts.length;
      }
      if (totalWords == "archives" && eachFileWords) {
        let archivesWords = 0;
        eachFileWords.forEach((itemFile) => {
          if (itemFile.wordsCount < 1000) {
            archivesWords += itemFile.wordsCount;
          } else {
            let wordsCount = itemFile.wordsCount.slice(
              0,
              itemFile.wordsCount.length - 1
            );
            archivesWords += wordsCount * 1000;
          }
        });
        this.totalWords = Math.round(archivesWords / 100) / 10 + "k";
      } else if (totalWords == "archives") {
        this.totalWords = 0;
        console.log(
          "如果 totalWords = 'archives',必须传入 eachFileWords,显然您并没有传入!"
        );
      } else {
        this.totalWords = totalWords;
      }
      // 最后一次活动时间
      this.lastActiveDate = timeDiff(this.$lastUpdatePosts[0].lastUpdated);
      this.mountedWebInfo(moutedEvent);
      // 获取访问量和排名
      this.indexView = indexView == undefined ? true : indexView;
      if (this.indexView) {
        this.getIndexViewCouter(indexIteration);
      }
    }
  },
  methods: {
    /**
     * 挂载站点信息模块
     */
    mountedWebInfo(moutedEvent = ".tags-wrapper") {
      let interval = setInterval(() => {
        const tagsWrapper = document.querySelector(moutedEvent);
        const webInfo = document.querySelector(".web-info");
        if (tagsWrapper && webInfo) {
          if (!this.isSiblilngNode(tagsWrapper, webInfo)) {
            tagsWrapper.parentNode.insertBefore(
              webInfo,
              tagsWrapper.nextSibling
            );
            clearInterval(interval);
          }
        }
      }, 200);
    },
    /**
     * 挂载在兄弟元素后面,说明当前组件是 siblingNode 变量
     */
    isSiblilngNode(element, siblingNode) {
      if (element.siblingNode == siblingNode) {
        return true;
      } else {
        return false;
      }
    },
    /**
     * 首页的统计量
     */
    getIndexViewCouter(iterationTime = 3000) {
      fetch();
      var i = 0;
      var defaultCouter = "9999";
      // 如果只需要第一次获取数据(可能获取失败),可注释掉 setTimeout 内容,此内容是第一次获取失败后,重新获取访问量
      // 可能会导致访问量再次 + 1 原因:取决于 setTimeout 的时间(需求调节),setTimeout 太快导致第一个获取的数据没返回,就第二次获取,导致结果返回 + 2 的数据
      setTimeout(() => {
        let indexUv = document.querySelector(".web-site-pv");
        let indexPv = document.querySelector(".web-site-uv");
        if (
          indexPv &&
          indexUv &&
          indexPv.innerText == "" &&
          indexUv.innerText == ""
        ) {
          let interval = setInterval(() => {
            // 再次判断原因:防止进入 setInterval 的瞬间,访问量获取成功
            if (
              indexPv &&
              indexUv &&
              indexPv.innerText == "" &&
              indexUv.innerText == ""
            ) {
              i += iterationTime;
              if (i > iterationTime * 5) {
                indexPv.innerText = defaultCouter;
                indexUv.innerText = defaultCouter;
                clearInterval(interval); // 5 次后无法获取,则取消获取
              }
              if (indexPv.innerText == "" && indexUv.innerText == "") {
                // 手动获取访问量
                fetch();
              } else {
                clearInterval(interval);
              }
            } else {
              clearInterval(interval);
            }
          }, iterationTime);
          // 绑定 beforeDestroy 生命钩子,清除定时器
          this.$once("hook:beforeDestroy", () => {
            clearInterval(interval);
            interval = null;
          });
        }
      }, iterationTime);
    },
    beforeMount() {
      let webInfo = document.querySelector(".web-info");
      webInfo && webInfo.parentNode.removeChild(webInfo);
    },
  },
};
</script>

<style scoped>
.web-info {
  font-size: 0.875rem;
  padding: 0.95rem;
}
.webinfo-title {
  text-align: center;
  color: #888;
  font-weight: bold;
  padding: 0 0 10px 0;
}
.webinfo-item {
  padding: 8px 0 0;
  margin: 0;
}
.webinfo-item-title {
  display: inline-block;
}
.webinfo-content {
  display: inline-block;
  float: right;
}
@keyframes turn {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}
.loading {
  display: inline-block;
  animation: turn 1s linear infinite;
  -webkit-animation: turn 1s linear infinite;
}
</style>

继续创建一个 vue 文件:PageInfo.vue,这就是文章页的信息模块:文章浏览量、字数代码、预阅读时间。

vue
<template></template>

<script>
import fetch from "../webSiteInfo/busuanzi";
export default {
  mounted() {
    // 首页不初始页面信息
    if (this.$route.path != "/") {
      this.initPageInfo();
    }
  },
  watch: {
    $route(to, from) {
      // 如果页面是非首页,# 号也会触发路由变化,这里要排除掉
      if (
        to.path !== "/" &&
        to.path !== from.path &&
        this.$themeConfig.blogInfo
      ) {
        this.initPageInfo();
      }
    },
  },
  methods: {
    /**
     * 初始化页面信息
     */
    initPageInfo() {
      if (this.$frontmatter.article == undefined || this.$frontmatter.article) {
        // 排除掉 article 为 false 的文章
        const { eachFileWords, pageView, pageIteration, readingTime } =
          this.$themeConfig.blogInfo;
        // 下面两个 if 可以调换位置,从而让文章的浏览量和字数交换位置
        if (eachFileWords) {
          try {
            eachFileWords.forEach((itemFile) => {
              if (itemFile.permalink == this.$frontmatter.permalink) {
                // this.addPageWordsCount 和 if 可以调换位置,从而让文章的字数和预阅读时间交换位置
                this.addPageWordsCount(itemFile.wordsCount);
                if (readingTime || readingTime == undefined) {
                  this.addReadTimeCount(itemFile.readingTime);
                }
                throw new Error();
              }
            });
          } catch (error) {}
        }
        if (pageView || pageView == undefined) {
          this.addPageView();
          this.getPageViewCouter(pageIteration);
        }
        return;
      }
    },
    /**
     * 文章页的访问量
     */
    getPageViewCouter(iterationTime = 3000) {
      fetch();
      let i = 0;
      var defaultCouter = "9999";
      // 如果只需要第一次获取数据(可能获取失败),可注释掉 setTimeout 内容,此内容是第一次获取失败后,重新获取访问量
      // 可能会导致访问量再次 + 1 原因:取决于 setTimeout 的时间(需求调节),setTimeout 太快导致第一个获取的数据没返回,就第二次获取,导致结果返回 + 2 的数据
      setTimeout(() => {
        let pageView = document.querySelector(".view-data");
        if (pageView && pageView.innerText == "") {
          let interval = setInterval(() => {
            // 再次判断原因:防止进入 setInterval 的瞬间,访问量获取成功
            if (pageView && pageView.innerText == "") {
              i += iterationTime;
              if (i > iterationTime * 5) {
                pageView.innerText = defaultCouter;
                clearInterval(interval); // 5 次后无法获取,则取消获取
              }
              if (pageView.innerText == "") {
                // 手动获取访问量
                fetch();
              } else {
                clearInterval(interval);
              }
            } else {
              clearInterval(interval);
            }
          }, iterationTime);
          // 绑定 beforeDestroy 生命钩子,清除定时器
          this.$once("hook:beforeDestroy", () => {
            clearInterval(interval);
            interval = null;
          });
        }
      }, iterationTime);
    },
    /**
     * 添加浏览量元素
     */
    addPageView() {
      let pageView = document.querySelector(".page-view");
      if (pageView) {
        pageView.innerHTML =
          '<a style="color: #888; margin-left: 3px" href="javascript:;" id="busuanzi_value_page_pv" class="view-data"><i title="正在获取..." class="loading iconfont icon-loading"></i></a>';
      } else {
        // 创建访问量的元素
        let template = document.createElement("div");
        template.title = "浏览量";
        template.className = "page-view iconfont icon-view";
        template.style.float = "left";
        template.style.marginLeft = "20px";
        template.style.fontSize = "0.8rem";
        template.innerHTML =
          '<a style="color: #888; margin-left: 3px" href="javascript:;" id="busuanzi_value_page_pv" class="view-data"><i title="正在获取..." class="loading iconfont icon-loading"></i></a>';
        // 添加 loading 效果
        let style = document.createElement("style");
        style.innerHTML = `@keyframes turn {
        0% {
          transform: rotate(0deg);
        }
        100% {
          transform: rotate(360deg);
        }
      }
      .loading {
        display: inline-block;
        animation: turn 1s linear infinite;
        -webkit-animation: turn 1s linear infinite;
      }`;
        document.head.appendChild(style);
        this.mountedView(template);
      }
    },
    /**
     * 添加当前文章页的字数元素
     */
    addPageWordsCount(wordsCount = 0) {
      let words = document.querySelector(".book-words");
      if (words) {
        words.innerHTML = `<a href="javascript:;" style="margin-left: 3px; color: #888">${wordsCount}</a>`;
      } else {
        let template = document.createElement("div");
        template.title = "文章字数";
        template.className = "book-words iconfont icon-book";
        template.style.float = "left";
        template.style.marginLeft = "20px";
        template.style.fontSize = "0.8rem";

        template.innerHTML = `<a href="javascript:;" style="margin-left: 3px; color: #888">${wordsCount}</a>`;
        this.mountedView(template);
      }
    },
    /**
     * 添加预计的阅读时间
     */
    addReadTimeCount(readTimeCount = 0) {
      let reading = document.querySelector(".reading-time");
      if (reading) {
        reading.innerHTML = `<a href="javascript:;" style="margin-left: 3px; color: #888">${readTimeCount}</a>`;
      } else {
        let template = document.createElement("div");
        template.title = "预阅读时长";
        template.className = "reading-time iconfont icon-shijian";
        template.style.float = "left";
        template.style.marginLeft = "20px";
        template.style.fontSize = "0.8rem";
        template.innerHTML = `<a href="javascript:;" style="margin-left: 3px; color: #888">${readTimeCount}</a>`;
        this.mountedView(template);
      }
    },
    /**
     * 挂载目标到页面上
     */
    mountedView(
      template,
      mountedIntervalTime = 100,
      moutedParentEvent = ".articleInfo-wrap > .articleInfo > .info"
    ) {
      let i = 0;
      let parentElement = document.querySelector(moutedParentEvent);
      if (parentElement) {
        if (!this.isMountedView(template, parentElement)) {
          parentElement.appendChild(template);
        }
      } else {
        let interval = setInterval(() => {
          parentElement = document.querySelector(moutedParentEvent);
          if (parentElement) {
            if (!this.isMountedView(template, parentElement)) {
              parentElement.appendChild(template);
              clearInterval(interval);
            }
          } else if (i > 1 * 10) {
            // 10 秒后清除
            clearInterval(interval);
          }
        }, mountedIntervalTime);
        // 绑定 beforeDestroy 生命钩子,清除定时器
        this.$once("hook:beforeDestroy", () => {
          clearInterval(interval);
          interval = null;
        });
      }
    },
    /**
     * 如果元素存在,则删除
     */
    removeElement(selector) {
      var element = document.querySelector(selector);
      element && element.parentNode.removeChild(element);
    },
    /**
     * 目标是否已经挂载在页面上
     */
    isMountedView(element, parentElement) {
      if (element.parentNode == parentElement) {
        return true;
      } else {
        return false;
      }
    },
  },
  // 防止重写编译时,导致页面信息重复出现问题
  beforeMount() {
    clearInterval(this.interval);
    this.removeElement(".page-view");
    this.removeElement(".book-words");
    this.removeElement(".reading-time");
  },
};
</script>

<style></style>

最终效果如图:

image-20241226081735510

创建好了两个 vue 组件,我们需要使用它们。

使用 WebInfo.vue 组件

打开 docs/index.md

image-20241226081752568

移到最下方,添加如下内容:

vue
<ClientOnly>
  <WebInfo/>
</ClientOnly>

使用 PageInfo.vue 组件

在 docs/.vuepress/config.js(新版是 config.ts)的 plugins 中添加配置。

js

js
module.exports = {
    plugins: [
        {
            name: 'custom-plugins',
            globalUIComponents: ["PageInfo"] // 2.x 版本 globalUIComponents 改名为 clientAppRootComponentFiles
        }
    ]
}

ts (本次使用这个)

ts
import { UserPlugins } from 'vuepress/config'
plugins: <UserPlugins>[
    [
    	{
        	name: 'custom-plugins',
        	globalUIComponents: ["PageInfo"] // 2.x 版本 globalUIComponents 改名为 clientAppRootComponentFiles
    	}
    ]
]

站点信息配置

上面都按照步骤写好代码、使用组件了,那么就可以走最后一步配置我们的站点信息。

进入到 docs/.vuepress/config.js(新版为 config.ts)文件。

引入之前写好的工具代码文件:(路径要准确,这里仅仅是模板)

js

js
const { readFileList, readTotalFileWords, readEachFileWords } = require('./webSiteInfo/readFile');

ts (本次)

ts
import { readFileList, readTotalFileWords, readEachFileWords } from './webSiteInfo/readFile';

如图(演示 JS 代码块):

image-20241226081918081

在 themeConfig 中添加如下内容:

js
// 站点配置(首页 & 文章页)
blogInfo: {
  blogCreate: '2021-10-19', // 博客创建时间
  indexView: true,  // 开启首页的访问量和排名统计,默认 true(开启)
  pageView: true,  // 开启文章页的浏览量统计,默认 true(开启)
  readingTime: true,  // 开启文章页的预计阅读时间,条件:开启 eachFileWords,默认 true(开启)。可在 eachFileWords 的 readEachFileWords 的第二个和第三个参数自定义,默认 1 分钟 300 中文、160 英文
  eachFileWords: readEachFileWords([''], 300, 160),  // 开启每个文章页的字数。readEachFileWords(['xx']) 关闭 xx 目录(可多个,可不传参数)下的文章页字数和阅读时长,后面两个参数分别是 1 分钟里能阅读的中文字数和英文字数。无默认值。readEachFileWords() 方法默认排除了 article 为 false 的文章
  mdFileCountType: 'archives',  // 开启文档数。1. archives 获取归档的文档数(默认)。2. 数组 readFileList(['xx']) 排除 xx 目录(可多个,可不传参数),获取其他目录的文档数。提示:readFileList() 获取 docs 下所有的 md 文档(除了 `.vuepress` 和 `@pages` 目录下的文档)
  totalWords: 'archives',  // 开启本站文档总字数。1. archives 获取归档的文档数(使用 archives 条件:传入 eachFileWords,否则报错)。2. readTotalFileWords(['xx']) 排除 xx 目录(可多个,可不传参数),获取其他目录的文章字数。无默认值
  moutedEvent: '.tags-wrapper',   // 首页的站点模块挂载在某个元素后面(支持多种选择器),指的是挂载在哪个兄弟元素的后面,默认是热门标签 '.tags-wrapper' 下面,提示:'.categories-wrapper' 会挂载在文章分类下面。'.blogger-wrapper' 会挂载在博客头像模块下面
  // 下面两个选项:第一次获取访问量失败后的迭代时间
  indexIteration: 2500,   // 如果首页获取访问量失败,则每隔多少时间后获取一次访问量,直到获取成功或获取 10 次后。默认 3 秒。注意:设置时间太低,可能导致访问量 + 2、+ 3 ......
  pageIteration: 2500,    // 如果文章页获取访问量失败,则每隔多少时间后获取一次访问量,直到获取成功或获取 10 次后。默认 3 秒。注意:设置时间太低,可能导致访问量 + 2、+ 3 ......
  // 说明:成功获取一次访问量,访问量 + 1,所以第一次获取失败后,设置的每个隔段重新获取时间,将会影响访问量的次数。如 100 可能每次获取访问量 + 3
},

如图(图片内容不一定是最新,最新的是代码块内容):

image-20241226081952327

属性配置的具体介绍请看 属性配置

本地主题

如果已经看完了在线主题的内容,其实本内容的大小不变,只是位置变换、一些代码重组。

配置了在线主题,就不需要配置本地主题,反之亦然。

工具类

在 vdoing/util 目录下创建 webSiteInfo.js,添加如下内容:

js
// 日期格式化(只获取年月日)
export function dateFormat(date) {
  if (!(date instanceof Date)) {
    date = new Date(date);
  }
  return `${date.getUTCFullYear()}-${zero(date.getUTCMonth() + 1)}-${zero(date.getUTCDate())}`;
}

// 小于10补0
export function zero(d) {
  return d.toString().padStart(2, '0');
}

/**
 * 计算最后活动时间
 */
export function lastUpdatePosts(posts) {
  posts.sort((prev, next) => {
    return compareDate(prev, next);
  });
  return posts;
}

// 获取时间的时间戳
export function getTimeNum(post) {
  let dateStr = post.lastUpdated || post.frontmatter.date;
  let date = new Date(dateStr);
  if (date == "Invalid Date" && dateStr) { // 修复new Date()在Safari下出现Invalid Date的问题
    date = new Date(dateStr.replace(/-/g, '/'));
  }
  return date.getTime();
}

// 比对时间
export function compareDate(a, b) {
  return getTimeNum(b) - getTimeNum(a);
}

/**
 * 获取两个日期相差多少天
 */
export function dayDiff(startDate, endDate) {
  if (!endDate) {
    endDate = startDate;
    startDate = new Date();
  }
  startDate = dateFormat(startDate);
  endDate = dateFormat(endDate);
  let day = parseInt(Math.abs(new Date(startDate) - new Date(endDate)) / (1000 * 60 * 60 * 24));
  return day;
}

/**
 * 计算相差多少年/月/日/时/分/秒
 */
export function timeDiff(startDate, endDate) {
  if (!endDate) {
    endDate = startDate;
    startDate = new Date();
  }
  if (!(startDate instanceof Date)) {
    startDate = new Date(startDate);
  }
  if (!(endDate instanceof Date)) {
    endDate = new Date(endDate);
  }
  // 计算时间戳的差
  const diffValue = parseInt((Math.abs(endDate - startDate) / 1000));
  if (diffValue == 0) {
    return '刚刚';
  } else if (diffValue < 60) {
    return diffValue + ' 秒';
  } else if (parseInt(diffValue / 60) < 60) {
    return parseInt(diffValue / 60) + ' 分';
  } else if (parseInt(diffValue / (60 * 60)) < 24) {
    return parseInt(diffValue / (60 * 60)) + ' 时';
  } else if (parseInt(diffValue / (60 * 60 * 24)) < getDays(startDate.getMonth, startDate.getFullYear)) {
    return parseInt(diffValue / (60 * 60 * 24)) + ' 天';
  } else if (parseInt(diffValue / (60 * 60 * 24 * getDays(startDate.getMonth, startDate.getFullYear))) < 12) {
    return parseInt(diffValue / (60 * 60 * 24 * getDays(startDate.getMonth, startDate.getFullYear))) + ' 月';
  } else {
    return parseInt(diffValue / (60 * 60 * 24 * getDays(startDate.getMonth, startDate.getFullYear) * 12)) + ' 年';
  }
}

/**
 * 判断当前月的天数(28、29、30、31)
 */
export function getDays(mouth, year) {
  let days = 30;
  if (mouth === 2) {
    days = year % 4 === 0 ? 29 : 28;
  } else if (mouth === 1 || mouth === 3 || mouth === 5 || mouth === 7 || mouth === 8 || mouth === 10 || mouth === 12) {
    // 月份为:1,3,5,7,8,10,12 时,为大月.则天数为 31;
    days = 31;
  }
  return days;
}

/**
 * 已运行时间低于一天显示时分秒
 * 目前该函数没有使用,低于一天直接显示不到一天
 */
export function getTime(startDate, endDate) {
  if (day < 0) {
    let hour = parseInt(Math.abs(new Date(startDate) - new Date(endDate)) / (1000 * 60 * 60));
    if (hour > 0) {
      let minute = parseInt(Math.abs(new Date(startDate) - new Date(endDate) - hour * 60 * 60 * 1000) / (1000 * 60));
      if (minute > 0) {
        let second = parseInt(Math.abs(new Date(startDate) - new Date(endDate) - hour * 60 * 60 * 1000 - minute * 60 * 1000) / (1000));
        if (second != 0) {
          return hour + ' 小时 ' + minute + ' 分钟 ' + second + ' 秒';
        } else {
          return hour + ' 小时 ' + minute + ' 分钟 ';
        }
      } else {
        return hour + ' 小时 ';
      }
    } else {
      let minute = parseInt(Math.abs(new Date(startDate) - new Date(endDate) - hour * 60 * 60 * 1000) / (1000 * 60));
      if (minute > 0) {
        let second = parseInt(Math.abs(new Date(startDate) - new Date(endDate) - hour * 60 * 60 * 1000 - minute * 60 * 1000) / (1000));
        if (second != 0) {
          return + minute + ' 分钟 ' + second + ' 秒';
        } else {
          return minute + ' 分钟 ';
        }
      } else {
        return parseInt(Math.abs(new Date(startDate) - new Date(endDate) - hour * 60 * 60 * 1000 - minute * 60 * 1000) / (1000)) + ' 秒 ';
      }
    }
  }
}

var bszCaller, bszTag, scriptTag, ready;

var t,
  e,
  n,
  a = !1,
  c = [];

// 修复Node同构代码的问题
if (typeof document !== "undefined") {
  (ready = function (t) {
    return (
      a ||
      "interactive" === document.readyState ||
      "complete" === document.readyState
        ? t.call(document)
        : c.push(function () {
            return t.call(this);
          }),
      this
    );
  }),
    (e = function () {
      for (var t = 0, e = c.length; t < e; t++) c[t].apply(document);
      c = [];
    }),
    (n = function () {
      a ||
        ((a = !0),
        e.call(window),
        document.removeEventListener
          ? document.removeEventListener("DOMContentLoaded", n, !1)
          : document.attachEvent &&
            (document.detachEvent("onreadystatechange", n),
            window == window.top && (clearInterval(t), (t = null))));
    }),
    document.addEventListener
      ? document.addEventListener("DOMContentLoaded", n, !1)
      : document.attachEvent &&
        (document.attachEvent("onreadystatechange", function () {
          /loaded|complete/.test(document.readyState) && n();
        }),
        window == window.top &&
          (t = setInterval(function () {
            try {
              a || document.documentElement.doScroll("left");
            } catch (t) {
              return;
            }
            n();
          }, 5)));
}

bszCaller = {
  fetch: function (t, e) {
    var n = "BusuanziCallback_" + Math.floor(1099511627776 * Math.random());
    t = t.replace("=BusuanziCallback", "=" + n);
    (scriptTag = document.createElement("SCRIPT")),
      (scriptTag.type = "text/javascript"),
      (scriptTag.defer = !0),
      (scriptTag.src = t),
      document.getElementsByTagName("HEAD")[0].appendChild(scriptTag);
    window[n] = this.evalCall(e);
  },
  evalCall: function (e) {
    return function (t) {
      ready(function () {
        try {
          e(t),
            scriptTag &&
              scriptTag.parentElement &&
              scriptTag.parentElement.removeChild &&
              scriptTag.parentElement.removeChild(scriptTag);
        } catch (t) {
          console.log(t), bszTag.hides();
        }
      });
    };
  },
};

export function fetch() {
  bszTag && bszTag.hides();
  bszCaller.fetch("//busuanzi.ibruce.info/busuanzi?jsonpCallback=BusuanziCallback", function (t) {
    bszTag.texts(t), bszTag.shows();
  })
};

bszTag = {
  bszs: ["site_pv", "page_pv", "site_uv"],
  texts: function (n) {
    this.bszs.map(function (t) {
      var e = document.getElementById("busuanzi_value_" + t);
      e && (e.innerHTML = n[t]);
    });
  },
  hides: function () {
    this.bszs.map(function (t) {
      var e = document.getElementById("busuanzi_container_" + t);
      e && (e.style.display = "none");
    });
  },
  shows: function () {
    this.bszs.map(function (t) {
      var e = document.getElementById("busuanzi_container_" + t);
      e && (e.style.display = "inline");
    });
  },
};

Vue组件创建

需要两个 Vue 组件,分别是首页的站点信息模块和文章页信息模块。

在 vdoing/components 目录下创建 WebInfo.vue 文件,添加如下内容:

vue
<template>
  <!-- Young Kbt -->
  <div class="web-info card-box">
    <div class="webinfo-title">
      <i
        class="iconfont icon-award"
        style="font-size: 0.875rem; font-weight: 900; width: 1.25em"
      ></i>
      <span>站点信息</span>
    </div>
    <div class="webinfo-item">
      <div class="webinfo-item-title">文章数目:</div>
      <div class="webinfo-content">{{ mdFileCount }} 篇</div>
    </div>

    <div class="webinfo-item">
      <div class="webinfo-item-title">已运行时间:</div>
      <div class="webinfo-content">
        {{ createToNowDay != 0 ? createToNowDay + " 天" : "不到一天" }}
      </div>
    </div>

    <div class="webinfo-item">
      <div class="webinfo-item-title">本站总字数:</div>
      <div class="webinfo-content">{{ totalWords }} 字</div>
    </div>

    <div class="webinfo-item">
      <div class="webinfo-item-title">最后活动时间:</div>
      <div class="webinfo-content">
        {{ lastActiveDate == "刚刚" ? "刚刚" : lastActiveDate + "前" }}
      </div>
    </div>

    <div v-if="indexView" class="webinfo-item">
      <div class="webinfo-item-title">本站被访问了:</div>
      <div class="webinfo-content">
        <span id="busuanzi_value_site_pv" class="web-site-pv"
          ><i title="正在获取..." class="loading iconfont icon-loading"></i>
        </span>

      </div>
    </div>

    <div v-if="indexView" class="webinfo-item">
      <div class="webinfo-item-title">您的访问排名:</div>
      <div class="webinfo-content busuanzi">
        <span id="busuanzi_value_site_uv" class="web-site-uv"
          ><i title="正在获取..." class="loading iconfont icon-loading"></i>
        </span>

      </div>
    </div>
  </div>
</template>

<script>
import { dayDiff, timeDiff, lastUpdatePosts, fetch } from "../util/webSiteInfo";
export default {
  data() {
    return {
      // Young Kbt
      mdFileCount: 0, // markdown 文档总数
      createToNowDay: 0, // 博客创建时间距今多少天
      lastActiveDate: "", // 最后活动时间
      totalWords: 0, // 本站总字数
      indexView: true, // 开启访问量和排名统计
    };
  },
  computed: {
    $lastUpdatePosts() {
      return lastUpdatePosts(this.$filterPosts);
    },
  },
  mounted() {
    // Young Kbt
    if (Object.keys(this.$themeConfig.blogInfo).length > 0) {
      const {
        blogCreate,
        mdFileCountType,
        totalWords,
        moutedEvent,
        eachFileWords,
        indexIteration,
        indexView,
      } = this.$themeConfig.blogInfo;
      this.createToNowDay = dayDiff(blogCreate);
      if (mdFileCountType != "archives") {
        this.mdFileCount = mdFileCountType.length;
      } else {
        this.mdFileCount = this.$filterPosts.length;
      }
      if (totalWords == "archives" && eachFileWords) {
        let archivesWords = 0;
        eachFileWords.forEach((itemFile) => {
          if (itemFile.wordsCount < 1000) {
            archivesWords += itemFile.wordsCount;
          } else {
            let wordsCount = itemFile.wordsCount.slice(
              0,
              itemFile.wordsCount.length - 1
            );
            archivesWords += wordsCount * 1000;
          }
        });
        this.totalWords = Math.round(archivesWords / 100) / 10 + "k";
      } else if (totalWords == "archives") {
        this.totalWords = 0;
        console.log(
          "如果 totalWords = 'archives',必须传入 eachFileWords,显然您并没有传入!"
        );
      } else {
        this.totalWords = totalWords;
      }
      // 最后一次活动时间
      this.lastActiveDate = timeDiff(this.$lastUpdatePosts[0].lastUpdated);
      this.mountedWebInfo(moutedEvent);
      // 获取访问量和排名
      this.indexView = indexView == undefined ? true : indexView;
      if (this.indexView) {
        this.getIndexViewCouter(indexIteration);
      }
    }
  },
  methods: {
    /**
     * 挂载站点信息模块
     */
    mountedWebInfo(moutedEvent = ".tags-wrapper") {
      let interval = setInterval(() => {
        const tagsWrapper = document.querySelector(moutedEvent);
        const webInfo = document.querySelector(".web-info");
        if (tagsWrapper && webInfo) {
          if (!this.isSiblilngNode(tagsWrapper, webInfo)) {
            tagsWrapper.parentNode.insertBefore(
              webInfo,
              tagsWrapper.nextSibling
            );
            clearInterval(interval);
          }
        }
      }, 200);
    },
    /**
     * 挂载在兄弟元素后面,说明当前组件是 siblingNode 变量
     */
    isSiblilngNode(element, siblingNode) {
      if (element.siblingNode == siblingNode) {
        return true;
      } else {
        return false;
      }
    },
    /**
     * 首页的统计量
     */
    getIndexViewCouter(iterationTime = 3000) {
      fetch();
      var i = 0;
      var defaultCouter = "9999";
      // 如果只需要第一次获取数据(可能获取失败),可注释掉 setTimeout 内容,此内容是第一次获取失败后,重新获取访问量
      // 可能会导致访问量再次 + 1 原因:取决于 setTimeout 的时间(需求调节),setTimeout 太快导致第一个获取的数据没返回,就第二次获取,导致结果返回 + 2 的数据
      setTimeout(() => {
        let indexUv = document.querySelector(".web-site-pv");
        let indexPv = document.querySelector(".web-site-uv");
        if (
          indexPv &&
          indexUv &&
          indexPv.innerText == "" &&
          indexUv.innerText == ""
        ) {
          let interval = setInterval(() => {
            // 再次判断原因:防止进入 setInterval 的瞬间,访问量获取成功
            if (
              indexPv &&
              indexUv &&
              indexPv.innerText == "" &&
              indexUv.innerText == ""
            ) {
              i += iterationTime;
              if (i > iterationTime * 5) {
                indexPv.innerText = defaultCouter;
                indexUv.innerText = defaultCouter;
                clearInterval(interval); // 5 次后无法获取,则取消获取
              }
              if (indexPv.innerText == "" && indexUv.innerText == "") {
                // 手动获取访问量
                fetch();
              } else {
                clearInterval(interval);
              }
            } else {
              clearInterval(interval);
            }
          }, iterationTime);
          // 绑定 beforeDestroy 生命钩子,清除定时器
          this.$once("hook:beforeDestroy", () => {
            clearInterval(interval);
            interval = null;
          });
        }
      }, iterationTime);
    },
   
  },
};
</script>

<style scoped>
.web-info {
  font-size: 0.875rem;
  padding: 0.95rem;
}
.webinfo-title {
  text-align: center;
  color: #888;
  font-weight: bold;
  padding: 0 0 10px 0;
}
.webinfo-item {
  padding: 8px 0 0;
  margin: 0;
}
.webinfo-item-title {
  display: inline-block;
}
.webinfo-content {
  display: inline-block;
  float: right;
}
@keyframes turn {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}
.loading {
  display: inline-block;
  animation: turn 1s linear infinite;
  -webkit-animation: turn 1s linear infinite;
}
</style>

继续在 vdoing/components 目录下创建 PageInfo.vue 文件,添加如下内容:

vue
<template>
  <div class="page-view">
    <!-- 文章字数 -->
    <div title="文章字数" class="book-words iconfont icon-book">
      <a href="javascript:;" style="margin-left: 3px; color: #888">{{
        wordsCount
      }}</a>
    </div>

    <!-- 预阅读时长 -->
    <div
      v-if="readingTime"
      title="预阅读时长"
      class="reading-time iconfont icon-shijian"
    >
      <a href="javascript:;" style="margin-left: 3px; color: #888">{{
        readingTime
      }}</a>
    </div>
    <!-- 浏览量 -->
    <div v-if="pageView" title="浏览量" class="page-view iconfont icon-view">
      <a
        style="color: #888; margin-left: 3px"
        href="javascript:;"
        id="busuanzi_value_page_pv"
        class="view-data"
        ><i title="正在获取..." class="loading iconfont icon-loading"></i
      ></a>
    </div>
  </div>
</template>

<script>
import { fetch } from "../util/webSiteInfo";
export default {
  data() {
    return {
      // Young Kbt
      wordsCount: 0,
      readingTime: 0,
      pageView: true,
      pageIteration: 3000,
    };
  },
  mounted() {
    this.initPageInfo();
  },
  watch: {
    $route(to, from) {
      if (
        to.path !== "/" &&
        to.path != from.path &&
        this.$themeConfig.blogInfo
      ) {
        this.initPageInfo();
      }
    },
  },
  methods: {
    /**
     * 初始化页面信息
     */
    initPageInfo() {
      this.$filterPosts.forEach((itemPage) => {
        if (itemPage.path == this.$route.path) {
          const { eachFileWords, pageView, pageIteration, readingTime } =
            this.$themeConfig.blogInfo;
          this.pageIteration = pageIteration;
          if (eachFileWords) {
            eachFileWords.forEach((itemFile) => {
              if (itemFile.permalink == itemPage.frontmatter.permalink) {
                this.wordsCount = itemFile.wordsCount;
                if (readingTime || readingTime == undefined) {
                  this.readingTime = itemFile.readingTime;
                } else {
                  this.readingTime = false;
                }
              }
            });
          }
          this.pageView = pageView == undefined ? true : pageView;
          if (this.pageView) {
            this.getPageViewCouter(this.pageIteration);
          }
          return;
        }
      });
    },
    /**
     * 文章页的访问量
     */
    getPageViewCouter(iterationTime = 3000) {
      fetch();
      let i = 0;
      var defaultCouter = "9999";
      // 如果只需要第一次获取数据(可能获取失败),可注释掉 setTimeout 内容,此内容是第一次获取失败后,重新获取访问量
      // 可能会导致访问量再次 + 1 原因:取决于 setTimeout 的时间(需求调节),setTimeout 太快导致第一个获取的数据没返回,就第二次获取,导致结果返回 + 2 的数据
      setTimeout(() => {
        let pageView = document.querySelector(".view-data");
        if (pageView && pageView.innerText == "") {
          let interval = setInterval(() => {
            // 再次判断原因:防止进入 setInterval 的瞬间,访问量获取成功
            if (pageView && pageView.innerText == "") {
              i += iterationTime;
              if (i > iterationTime * 5) {
                pageView.innerText = defaultCouter;
                clearInterval(interval); // 5 次后无法获取,则取消获取
              }
              if (pageView.innerText == "") {
                // 手动获取访问量
                fetch();
              } else {
                clearInterval(interval);
              }
            } else {
              clearInterval(interval);
            }
          }, iterationTime);
          // 绑定 beforeDestroy 生命钩子,清除定时器
          this.$once("hook:beforeDestroy", () => {
            clearInterval(interval);
            interval = null;
          });
        }
      }, iterationTime);
    },
  },
};
</script>

<style scoped>
.page-view > div {
  float: left;
  margin-left: 20px;
  font-size: 0.8rem;
}

@keyframes turn {
  0% {
    transform: rotate(0deg);
  }

  100% {
    transform: rotate(360deg);
  }
}

.loading {
  display: inline-block;
  animation: turn 1s linear infinite;
  -webkit-animation: turn 1s linear infinite;
}
</style>

Vue组件引用

写好两个组件,那么我们需要使用它们。

引入 WebInfo.vue 组件

打开 vdoing/components/Home.vue 文件。

大概在 174 行处引入 WebInfo.vue 组件:

js
import WebInfo from './WebInfo.vue';

1

大概在 242 行处找到 components 注册该组件:

js
components: { ......, WebInfo },

1

大概在 153 行处(div 的 class 为 custom-html-box 的上方),添加如下内容:

js
<webInfo />

1

三个效果图:

image-20241226124321368

引入 PageInfo.vue 组件

打开 vdoing/components/ArticleInfo.vue 文件。

大概在 67 行处引入 PagesView.vue 组件:

js
import PageInfo from './PageInfo.vue';

1

大概在 69 行处添加 components 注册该组件(data() 上方):

js
components: { PageInfo },

1

大概在 61 行处,添加如下内容:

js
<PageInfo style="margin-left: 0" />

1

效果图:

image-20241226124353587

核心配置文件

在 docs/.vuepress 目录下创建 webSiteInfo 文件夹,并在文件夹里创建 readFile.js 文件。

添加如下内容:

js

js
const fs = require('fs'); // 文件模块
const path = require('path'); // 路径模块
const matter = require('gray-matter'); // FrontMatter解析器 https://github.com/jonschlinkert/gray-matter
const chalk = require('chalk') // 命令行打印美化
const log = console.log
const docsRoot = path.join(__dirname, '..', '..', '..', 'docs'); // docs文件路径

/**
 * 获取本站的文章数据
 * 获取所有的 md 文档,可以排除指定目录下的文档
 */
function readFileList(excludeFiles = [''], dir = docsRoot, filesList = []) {
  const files = fs.readdirSync(dir);
  files.forEach((item, index) => {
    let filePath = path.join(dir, item);
    const stat = fs.statSync(filePath);
    if (!(excludeFiles instanceof Array)) {
      log(chalk.yellow(`error: 传入的参数不是一个数组。`))
    }
    excludeFiles.forEach((excludeFile) => {
      if (stat.isDirectory() && item !== '.vuepress' && item !== '@pages' && item !== excludeFile) {
        readFileList(excludeFiles, path.join(dir, item), filesList);  //递归读取文件
      } else {
        if (path.basename(dir) !== 'docs') { // 过滤 docs目录级下的文件

          const fileNameArr = path.basename(filePath).split('.')
          let name = null, type = null;
          if (fileNameArr.length === 2) { // 没有序号的文件
            name = fileNameArr[0]
            type = fileNameArr[1]
          } else if (fileNameArr.length === 3) { // 有序号的文件
            name = fileNameArr[1]
            type = fileNameArr[2]
          } else { // 超过两个‘.’的
            log(chalk.yellow(`warning: 该文件 "${filePath}" 没有按照约定命名,将忽略生成相应数据。`))
            return
          }
          if (type === 'md') { // 过滤非 md 文件
            filesList.push({
              name,
              filePath
            });
          }
        }
      }
    });
  });
  return filesList;
}
/**
 * 获取本站的文章总字数
 * 可以排除某个目录下的 md 文档字数
 */
function readTotalFileWords(excludeFiles = ['']) {
  const filesList = readFileList(excludeFiles);
  var wordCount = 0;
  filesList.forEach((item) => {
    const content = getContent(item.filePath);
    var len = counter(content);
    wordCount += len[0] + len[1];
  });
  if (wordCount < 1000) {
    return wordCount;
  }
  return Math.round(wordCount / 100) / 10 + 'k';
}
/**
 * 获取每一个文章的字数
 * 可以排除某个目录下的 md 文档字数
 */
function readEachFileWords(excludeFiles = [''], cn, en) {
  const filesListWords = [];
  const filesList = readFileList(excludeFiles);
  filesList.forEach((item) => {
    const content = getContent(item.filePath);
    var len = counter(content);
    // 计算预计的阅读时间
    var readingTime = readTime(len, cn, en);
    var wordsCount = 0;
    wordsCount = len[0] + len[1];
    if (wordsCount >= 1000) {
      wordsCount = Math.round(wordsCount / 100) / 10 + 'k';
    }
    // fileMatterObj => {content:'剔除frontmatter后的文件内容字符串', data:{<frontmatter对象>}, ...}
    const fileMatterObj = matter(content, {});
    const matterData = fileMatterObj.data;
    filesListWords.push({ ...item, wordsCount, readingTime, ...matterData });
  });
  return filesListWords;
}

/**
 * 计算预计的阅读时间
 */
function readTime(len, cn = 300, en = 160) {
  var readingTime = len[0] / cn + len[1] / en;
  if (readingTime > 60 && readingTime < 60 * 24) {   // 大于一个小时,小于一天
    let hour = parseInt(readingTime / 60);
    let minute = parseInt((readingTime - hour * 60));
    if (minute === 0) {
      return hour + 'h';
    }
    return hour + 'h' + minute + 'm';
  } else if (readingTime > 60 * 24) {      // 大于一天
    let day = parseInt(readingTime / (60 * 24));
    let hour = parseInt((readingTime - day * 24 * 60) / 60);
    if (hour === 0) {
      return day + 'd';
    }
    return day + 'd' + hour + 'h';
  }
  return readingTime < 1 ? '1' : parseInt((readingTime * 10)) / 10 + 'm';   // 取一位小数
}

/**
 * 读取文件内容
 */
function getContent(filePath) {
  return fs.readFileSync(filePath, 'utf8');
}
/**
 * 获取文件内容的字数
 * cn:中文
 * en:一整句英文(没有空格隔开的英文为 1 个)
 */
function counter(content) {
  const cn = (content.match(/[\u4E00-\u9FA5]/g) || []).length;
  const en = (content.replace(/[\u4E00-\u9FA5]/g, '').match(/[a-zA-Z0-9_\u0392-\u03c9\u0400-\u04FF]+|[\u4E00-\u9FFF\u3400-\u4dbf\uf900-\ufaff\u3040-\u309f\uac00-\ud7af\u0400-\u04FF]+|[\u00E4\u00C4\u00E5\u00C5\u00F6\u00D6]+|\w+/g) || []).length;
  return [cn, en];
}

module.exports = {
  readFileList,
  readTotalFileWords,
  readEachFileWords,
}

配置站点信息

最后一步,在 docs/.vuepress/config.js(新版为 config.ts)文件,引入写好的 readFile.js 文件(路径要准确,这里仅仅是模板)

js
const { readFileList, readTotalFileWords, readEachFileWords } = require('./webSiteInfo/readFile');

1

如图(演示 JS 代码块):

image-20241226124432334

在 themeConfig 中添加如下内容:

js
// 站点配置(首页 & 文章页)
blogInfo: {
  blogCreate: '2021-10-19', // 博客创建时间
  indexView: true,  // 开启首页的访问量和排名统计,默认 true(开启)
  pageView: true,  // 开启文章页的浏览量统计,默认 true(开启)
  readingTime: true,  // 开启文章页的预计阅读时间,条件:开启 eachFileWords,默认 true(开启)。可在 eachFileWords 的 readEachFileWords 的第二个和第三个参数自定义,默认 1 分钟 300 中文、160 英文
  eachFileWords: readEachFileWords([''], 300, 160),  // 开启每个文章页的字数。readEachFileWords(['xx']) 关闭 xx 目录(可多个,可不传参数)下的文章页字数和阅读时长,后面两个参数分别是 1 分钟里能阅读的中文字数和英文字数。无默认值。readEachFileWords() 方法默认排除了 article 为 false 的文章
  mdFileCountType: 'archives',  // 开启文档数。1. archives 获取归档的文档数(默认)。2. 数组 readFileList(['xx']) 排除 xx 目录(可多个,可不传参数),获取其他目录的文档数。提示:readFileList() 获取 docs 下所有的 md 文档(除了 `.vuepress` 和 `@pages` 目录下的文档)
  totalWords: 'archives',  // 开启本站文档总字数。1. archives 获取归档的文档数(使用 archives 条件:传入 eachFileWords,否则报错)。2. readTotalFileWords(['xx']) 排除 xx 目录(可多个,可不传参数),获取其他目录的文章字数。无默认值
  moutedEvent: '.tags-wrapper',   // 首页的站点模块挂载在某个元素后面(支持多种选择器),指的是挂载在哪个兄弟元素的后面,默认是热门标签 '.tags-wrapper' 下面,提示:'.categories-wrapper' 会挂载在文章分类下面。'.blogger-wrapper' 会挂载在博客头像模块下面
  // 下面两个选项:第一次获取访问量失败后的迭代时间
  indexIteration: 2500,   // 如果首页获取访问量失败,则每隔多少时间后获取一次访问量,直到获取成功或获取 10 次后。默认 3 秒。注意:设置时间太低,可能导致访问量 + 2、+ 3 ......
  pageIteration: 2500,    // 如果文章页获取访问量失败,则每隔多少时间后获取一次访问量,直到获取成功或获取 10 次后。默认 3 秒。注意:设置时间太低,可能导致访问量 + 2、+ 3 ......
  // 说明:成功获取一次访问量,访问量 + 1,所以第一次获取失败后,设置的每个隔段重新获取时间,将会影响访问量的次数。如 100 可能每次获取访问量 + 3
},

如图(图片内容不一定是最新):

image-20241226124456436

属性配置的具体介绍请看 属性配置

属性配置

blogCreate

  • 类型:string
  • 默认值:当前时间(new Date()
  • 格式:yyyy-mm-dd

博客创建时间。如果不添加时间,页面上显示 0 天。

mdFileCountType

  • 类型:string | readFileList()
  • 参数:数组
  • 默认值:archives

文章数目。如果不添加内容,页面上显示归档的文章数目。

readFileList 是一个 js 文件,需要引入,参数是 目录的全名,最终效果会 排除该目录里的文章数,可多选,逗号隔开。也可不传参数。

温馨提示:readFileList() 不传参数会获取 docs 下所有的 md 文档(除了 .vuepress@pages 目录下的文档)。

totalWords

  • 类型:string | readFileWords()
  • 参数:数组
  • 默认值:null

本站文档总字数。如果不添加内容,页面上显示 0 字。

string 仅支持 archives,并且使用该类型有条件:必须使用 eachFileWords,否则报错。

readFileWords 是一个 js 文件,需要引入,参数是目录的全名,最终效果会 排除该目录里的文章字数,可多选,逗号隔开。也可不传参数。

moutedEvent

  • 类型:string
  • 默认值:.tags-wrapper

选择挂载的元素属性,支持多种选择器(id、class ......),该模块会挂载到该元素后面,形成兄弟元素。(仅支持首页的元素)。

温馨提示:.categories-wrapper 会挂载在文章分类下面;.blogger-wrapper 会挂载在头像模块下面;.icons 会挂载在头像下方、图标上方。

默认是热门标签 .tags-wrapper 下面。

indexView

  • 类型:boolean
  • 默认值:true

开启首页的访问量和排名统计,默认 true(开启)。

pageView

  • 类型 boolean
  • 默认值:true

开启文章页的浏览量统计,默认 true(开启)。

eachFileWords

  • 类型:readEachFileWords()
  • 参数:数组
  • 默认值:null

开启每个文章页的字数。如果不添加内容,则不开启。

readEachFileWords(['xx']) 关闭 xx 目录(可多个,可不传参数)下的文章页字数和阅读时长。

readEachFileWords() 第一个参数是数组,后面两个参数分别是 1 分钟里能阅读的中文字数和英文字数,配合 readingTime 使用。

readEachFileWords() 方法默认排除了 article 为 false 的文章。

readingTime

  • 类型:boolean
  • 默认值:true
  • 条件:使用 eachFileWords

开启文章页的预计阅读时间。默认阅读中文 1 分钟 300 个字,英文 1 分钟 160 个字。如果想自定义阅读文字时长,请在 eachFileWordsreadEachFileWords() 传入后面两个参数。分别为 1 分钟阅读的中文和英文个数。

indexIteration

  • 类型:number
  • 默认值:3000

如果首页获取访问量失败,则每隔多少时间后获取一次访问量,直到获取成功或获取 10 次后。默认 3 秒。

注意:设置时间太低,可能导致访问量 + 2、+ 3 ......

pageIteration

  • 类型:number
  • 默认值:3000

如果文章页获取访问量失败,则每隔多少时间后获取一次访问量,直到获取成功或获取 10 次后。默认 3 秒。

注意:设置时间太低,可能导致访问量 + 2、+ 3 ......

说明:成功获取一次访问量,访问量 + 1,所以第一次获取失败后,设置的每个隔段重新获取时间,将会影响访问量的次数。如 100 可能每次获取访问量 + 3。

结束语

如果你还有疑惑,可以去我的 GitHub 仓库或者 Gitee 仓库查看源码。

如果你有更好的方式,评论区留言告诉我,或者加入 Vdoing 主题的 QQ 群:694387113。谢谢!

自己配置完的效果

image-20241226124533268

其它方案-自建busuanzi

2024年12月26日记录

image-20241226124734140

image-20241226124748139

最近更新