Hugo 博客 + GitHub Pages 完整部署方案

★环境准备

  • 设备:MacBook(macOS)
  • 工具
    • Git
    • Hugo
    • GitHub 账号

安装 Hugo

brew install hugo

确认安装成功:

hugo version

★创建 Hugo 博客

mkdir blog && cd blog
hugo new site .

初始化 Git:

git init

★选择 & 添加主题

推荐 PaperMod 主题:

git submodule add https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod

编辑 config.toml,添加:

theme = "PaperMod"
baseURL = "https://yuuniji.github.io"

★编写博客文章

创建第一篇文章:

hugo new posts/hello-world.md

编辑 content/posts/hello-world.md

---
title: "Hello World"
date: 2025-02-22T12:00:00
draft: false
---

这是我的第一篇博客!

★本地预览(第一次部署请跳过)

hugo server -D

在浏览器访问:

http://localhost:1313

★发布到 GitHub

1. 创建 GitHub 仓库

  • blog(存放 Hugo 源码)
  • yuuniji.github.io(存放生成的静态文件)

2. 推送 blog 仓库

git remote add origin https://github.com/yuuniji/blog.git
git branch -M main
git add .
git commit -m "Initial commit"
git push -u origin main

3. 生成静态文件

hugo -D

静态文件位于 public/ 目录。

4. 推送到 yuuniji.github.io

cd public
git init
git remote add origin https://github.com/yuuniji/yuuniji.github.io.git
git checkout -b main
git add .
git commit -m "Deploy Hugo site"
git push -f origin main

★配置 GitHub Pages

  • 进入 yuuniji.github.io 仓库 Settings → Pages
  • 选择 main 分支,保存后等待部署完成。
  • 访问 https://yuuniji.github.io 查看博客。

★自动化部署(可选)

1. 在 blog 仓库添加 deploy.sh

nano deploy.sh

粘贴以下内容:

#!/bin/bash
hugo -D
cd public
git add .
git commit -m "Deploy: $(date)"
git push origin main
cd ..

保存并退出(按 Ctrl + X,然后 Y,再回车)。

2. 赋予执行权限

chmod +x deploy.sh

3. 运行部署脚本

./deploy.sh

★大功告成!

以后更新博客时,只需:

  1. blog/ 目录下写文章 hugo new posts/xxx.md
  2. 运行 ./deploy.sh
  3. 访问 https://yuuniji.github.io 查看更新

💡 有问题随时复习此笔记! 🚀

★Hugo 博客多语言 & 功能扩展指南

快速链接

baseURL = "https://yuuniji.github.io"
title = "叶泽伟的博客"
theme = "PaperMod"
defaultContentLanguage = "zh"
hasCJKLanguage = true

[languages]
  [languages.zh]
    languageName = "中文"
    weight = 1
    contentDir = "content/zh"
    [languages.zh.params]
      archivesTitle = "归档"
      archivesDescription = "按年份和月份查看文章归档"
      [languages.zh.params.homeInfoParams]
        Title = "你好 👋"
        Content = "欢迎来到我的博客!这里有技术、日语学习和生活记录。"
      [[languages.zh.params.socialIcons]]
        name = "portfolio"
        url = "https://xxx.github.io/about/"
      [[languages.zh.params.socialIcons]]
        name = "youtube"
        url = "https://www.youtube.com/xxx"
      [[languages.zh.params.socialIcons]]
        name = "instagram"
        url = "https://www.instagram.com/xxx/"
      [[languages.zh.params.socialIcons]]
        name = "linkedin"
        url = "https://www.linkedin.com/in/xxx"
      [[languages.zh.params.socialIcons]]
        name = "x"
        url = "https://x.com/xxx"
      [[languages.zh.params.socialIcons]]
        name = "github"
        url = "https://github.com/xxx"
    [languages.zh.menu]
      [[languages.zh.menu.main]]
        name = "首页"
        url = "/"
        weight = 1
      [[languages.zh.menu.main]]
        name = "标签"
        url = "/tags/"
        weight = 2
      [[languages.zh.menu.main]]
        name = "归档"
        url = "/archives/"
        weight = 3
      [[languages.zh.menu.main]]
        name = "搜索"
        url = "/search/"
        weight = 4

  [languages.en]
    languageName = "English"
    weight = 2
    contentDir = "content/en"
    [languages.en.params]
      archivesTitle = "Archives"
      archivesDescription = "Browse articles by year and month"
      [languages.en.params.homeInfoParams]
        Title = "Hi there 👋"
        Content = "Welcome to my blog! Here you'll find tech content, Japanese learning, and life experiences."
      [[languages.en.params.socialIcons]]
        name = "portfolio"
        url = "https://xxx.github.io/about/"
      [[languages.en.params.socialIcons]]
        name = "youtube"
        url = "https://www.youtube.com/xxx"
      [[languages.en.params.socialIcons]]
        name = "instagram"
        url = "https://www.instagram.com/xxx/"
      [[languages.en.params.socialIcons]]
        name = "linkedin"
        url = "https://www.linkedin.com/in/xxx"
      [[languages.en.params.socialIcons]]
        name = "x"
        url = "https://x.com/xxx"
      [[languages.en.params.socialIcons]]
        name = "github"
        url = "https://github.com/xxx"
    [languages.en.menu]
      [[languages.en.menu.main]]
        name = "Home"
        url = "/"
        weight = 1
      [[languages.en.menu.main]]
        name = "Tags"
        url = "/tags/"
        weight = 2
      [[languages.en.menu.main]]
        name = "Archives"
        url = "/archives/"
        weight = 3
      [[languages.en.menu.main]]
        name = "Search"
        url = "/search/"
        weight = 4

  [languages.ja]
    languageName = "日本語"
    weight = 3
    contentDir = "content/ja"
    [languages.ja.params]
      archivesTitle = "アーカイブ"
      archivesDescription = "年と月ごとに記事を表示"
      [languages.ja.params.homeInfoParams]
        Title = "こんにちは 👋"
        Content = "私のブログへようこそ!ここには技術、日本語学習、生活記録があります。"
      [[languages.ja.params.socialIcons]]
        name = "portfolio"
        url = "https://xxx.github.io/about/"
      [[languages.ja.params.socialIcons]]
        name = "youtube"
        url = "https://www.youtube.com/xxx"
      [[languages.ja.params.socialIcons]]
        name = "instagram"
        url = "https://www.instagram.com/xxx/"
      [[languages.ja.params.socialIcons]]
        name = "linkedin"
        url = "https://www.linkedin.com/in/xxx"
      [[languages.ja.params.socialIcons]]
        name = "x"
        url = "https://x.com/xxx"
      [[languages.ja.params.socialIcons]]
        name = "github"
        url = "https://github.com/xxx"
    [languages.ja.menu]
      [[languages.ja.menu.main]]
        name = "ホーム"
        url = "/"
        weight = 1
      [[languages.ja.menu.main]]
        name = "タグ"
        url = "/tags/"
        weight = 2
      [[languages.ja.menu.main]]
        name = "アーカイブ"
        url = "/archives/"
        weight = 3
      [[languages.ja.menu.main]]
        name = "検索"
        url = "/search/"
        weight = 4

[outputs]
  home = ["HTML", "RSS", "JSON", "SITEMAP"]
  section = ["HTML", "RSS"]
  page = ["HTML"] 

[markup]
  [markup.goldmark]
    [markup.goldmark.renderer]
      unsafe = true  # 允许 HTML 解析

[params]
  defaultTheme = "auto"
  ShowAllPagesInArchive = true
  ShowShareButtons = true
  ShowReadingTime = true
  ShowToc = true
  TocOpen = false
  ShowBreadCrumbs = true
  ShowCodeCopyButtons = true
  ShowPostNavLinks = true
  ShowRssButtonInSectionTermList = true
  archives = true
  [params.assets]
    favicon = "favicon.ico"
  [params.fuseOpts]
    isCaseSensitive = false
    shouldSort = true
    location = 0
    distance = 1_000
    threshold = 0.4
    minMatchCharLength = 0
    keys = [ "title", "permalink", "summary", "content" ]

[sitemap]
  changeFreq = ''
  disable = false
  filename = 'sitemap.xml'
  priority = -1

[content]
  archivesDir = "content/archives"

1. 目录结构

按照我的 hugo.toml 配置,我的 content 目录结构应该是:

content/
  ├── zh/
  │   ├── _index.md
  │   ├── about.md
  │   ├── archives.md
  │   ├── search.md
  │   ├── categories/_index.md
  │   ├── posts/_index.md
  │   ├── tags/_index.md
  ├── en/
  │   ├── _index.md
  │   ├── about.md
  │   ├── archives.md
  │   ├── search.md
  │   ├── categories/_index.md
  │   ├── posts/_index.md
  │   ├── tags/_index.md
  ├── ja/
      ├── _index.md
      ├── about.md
      ├── archives.md
      ├── search.md
      ├── categories/_index.md
      ├── posts/_index.md
      ├── tags/_index.md

同时,你需要创建 archetypes 目录来适配不同语言:

archetypes/
  ├── default.md
  ├── zh.md
  ├── en.md
  ├── ja.md

2. archetypes 目录配置

每个语言的 archetypes 文件用于设置默认的 front matter。

默认 archetypes/default.md

---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
tags: []
categories: []
---

archetypes/zh.md(中文)

---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
tags: []
categories: []
lang: "zh"
---

archetypes/en.md(英文)

---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
tags: []
categories: []
lang: "en"
---

archetypes/ja.md(日语)

---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
tags: []
categories: []
lang: "ja"
---

3. 创建不同语言的文章

使用 --kind 选项指定 archetypes,并将文章创建到对应的 content/ 目录。

中文文章

hugo new content/zh/posts/my-first-post1.md

这会在 content/zh/posts/ 目录下创建 my-first-post.md,并使用 archetypes/zh.md 作为模板。

英文文章

hugo new content/en/posts/my-first-post1.md

这会在 content/en/posts/ 目录下创建 my-first-post.md,并使用 archetypes/en.md 作为模板。

日语文章

hugo new content/ja/posts/my-first-post1.md

这会在 content/ja/posts/ 目录下创建 my-first-post.md,并使用 archetypes/ja.md 作为模板。

自动化创建不同语言的文章

create_multilingual_post.sh 脚本

#!/bin/bash

# 检查传入参数是否存在
if [ -z "$1" ]; then
    echo "请输入文章标题。"
    exit 1
fi

TITLE="$1"
DATE=$(date +%Y-%m-%d)

# 创建文章文件名(使用短横线分隔)
POST_NAME=$(echo "$TITLE" | tr " " "-")

# 生成中文文章
mkdir -p "content/zh/posts"
cat > "content/zh/posts/$POST_NAME.md" <<EOL
---
title: "$TITLE"
date: $DATE
lang: "zh"
draft: true
tags: []
categories: []
---
这里是中文版本的内容。
EOL

# 生成英文文章
mkdir -p "content/en/posts"
cat > "content/en/posts/$POST_NAME.md" <<EOL
---
title: "$TITLE"
date: $DATE
lang: "en"
draft: true
tags: []
categories: []
---
This is the English version of the post.
EOL

# 生成日语文章
mkdir -p "content/ja/posts"
cat > "content/ja/posts/$POST_NAME.md" <<EOL
---
title: "$TITLE"
date: $DATE
lang: "ja"
draft: true
tags: []
categories: []
---
この記事は日本語バージョンです。
EOL

echo "已成功创建三种语言版本的文章:$TITLE"

在终端中,先切换到脚本所在的目录,并授予执行权限:

chmod +x create_multilingual_post.sh

执行以下命令,并提供文章标题:

./create_multilingual_post.sh "你的文章标题"

总结

  • 创建不同语言的文章 需要 hugo new --kind <language> <path>
  • archetypes 目录 应该有 zh.mden.mdja.md 以匹配 content/ 目录。
  • 保持 contentDir 结构一致,避免 Hugo 生成文章时找不到正确的模板。

这样,你就可以正确管理 Hugo 多语言博客的文章创建了!🚀

★升级

◇添加侧边栏目录

PaperMod 主题本身不支持侧边栏目录,但你可以利用 arashsm79/hugo-PaperMod-Mod 仓库中的 sidetoc.css 文件来实现侧边栏目录。步骤如下:

  1. 下载 sidetoc.css 文件:sidetoc.css
  2. 将文件放置到你的 Hugo 站点的 assets/css/extended/ 目录中。如果目录不存在,请创建它。
  3. 重新构建站点(运行 hugo 命令),目录应出现在文章的侧边。

注意事项

  • 确保文章包含足够的标题(如 # H1## H2 等),否则目录不会生成。
  • sidetoc.css 使用 CSS Grid 和 position: sticky 实现侧边固定效果,适合大屏幕显示。
  • 如果外观或位置不满意,可以自定义 sidetoc.css 文件。

解决长 TOC 不可滚动的难题

  • 研究表明,目录过长时可以通过设置最大高度和溢出滚动来解决。
  • 倾向于在自定义 CSS 中添加 max-heightoverflow-y: auto
  • PaperMod 官方不直接支持长目录滚动,需依赖社区修改。

实现步骤

以下是详细的实现步骤,确保长目录可滚动:

  1. 确认 TOC 的 HTML 结构

    • 在浏览器中打开文章页面,右键点击目录区域,选择“检查”(Inspect)或“审查元素”。
    • 找到包含目录的 HTML 元素,通常是 <nav id="TableOfContents">。Hugo 的默认 TOC 生成器会创建此 ID。
    • 检查是否有其他包裹元素(如 <div class="toc"> 或类似),这些可能是 sidetoc.css 添加的。如果有,记录类名或 ID 以便后续调整。
  2. 添加自定义 CSS

    • PaperMod 主题支持通过 assets/css/extended/ 目录添加自定义 CSS。用户可以创建或编辑此目录下的文件,例如 custom.css
    • custom.css 中添加以下代码:
      #TableOfContents {
        max-height: 80vh; /* 最大高度为视口高度的 80%,可根据需要调整 */
        overflow-y: auto; /* 垂直方向溢出时显示滚动条 */
      }
      
    • 如果 TOC 被包裹在其他容器中(例如 <div class="toc">),调整选择器为:
      .toc {
        max-height: 80vh;
        overflow-y: auto;
      }
      
    • max-height: 80vh 是建议值,用户可以根据侧边栏布局调整,例如 70vh500px,以适应不同屏幕大小。

◇名言警句

创建 quoteszh.html、quotesen.html、quotesja.html 文件并放置到你的 Hugo 站点的 layouts/shortcodes/ 目录中,然后在 content/lang/_index.md 中引用即可。

<!-- layouts/shortcodes/quoteszh.html -->
<blockquote>
    <p>{{ .Inner }}</p>
    <footer>{{ .Get "source" }}</footer>
</blockquote>

<div id="quote" class="quote-box"></div>
<script>
    const quotes = [
        ""
        ""
        ""
    ]
    // 随机选择一个引语
    const randomQuote = quotes[Math.floor(Math.random() * quotes.length)];

    // 显示引语
    document.getElementById('quote').innerHTML = randomQuote;
</script>

<style>
    .quote-box {
        max-width: 600px;
        font-size: 0.8rem;
        text-align: left;

    .quote-box {
        animation: fadeIn 1.5s ease-in-out;
    }

    @keyframes fadeIn {
        0% {
            opacity: 0;
        }

        100% {
            opacity: 1;
        }
    }
</style>

★其他

◇代码边框圆角

/assets/css/common/post-single.css 中修改:

.post-content .highlight pre {
  background-color: var(--theme) !important;
  margin: 0;
}

◇添加多语言(中文、英文、日文)的网站运行时间

/layouts/partials/footer.html 中的

添加如下代码:

  <span id="runtime_span"></span> <!-- 用于显示网站运行时间的容器 -->
  <script type="text/javascript">
  function show_runtime() {
      // 每隔 1 秒执行一次 show_runtime,实现实时更新
      setTimeout(show_runtime, 1000);

      // 设置网站起始运行时间(2025年2月22日 00:00:00)
      const startDate = new Date("2025/02/22 00:00:00");

      // 获取当前时间
      const now = new Date();

      // 计算时间差(毫秒)
      const diff = now.getTime() - startDate.getTime();

      // 计算运行的天、小时、分钟、秒
      const days = Math.floor(diff / (24 * 60 * 60 * 1000));                     // 天数
      const hours = Math.floor((diff % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000)); // 小时
      const minutes = Math.floor((diff % (60 * 60 * 1000)) / (60 * 1000));        // 分钟
      const seconds = Math.floor((diff % (60 * 1000)) / 1000);                    // 秒

      // 获取当前 HTML 页面设置的语言(由 Hugo 生成,如 <html lang="ja">)
      const lang = document.documentElement.lang || "en"; // 默认英文

      // 各语言的显示文本模板
      const translations = {
          zh: `网站已运行 ${days}${hours} 小时 ${minutes}${seconds} 秒`,
          ja: `サイトは稼働してから ${days}${hours}時間 ${minutes}${seconds}秒`,
          en: `Site has been running for ${days} days ${hours} hours ${minutes} minutes ${seconds} seconds`
      };

      // 根据当前语言选择显示内容,若找不到则使用英文
      const output = translations[lang] || translations["en"];

      // 将显示内容写入页面中
      document.getElementById("runtime_span").innerHTML = output;
  }

  // 初始化运行
  show_runtime();
  </script>

◇评论

好的,下面是为你的 Hugo 博客详细集成 Giscus 评论系统的完整步骤,适用于部署在 GitHub Pages 或其他静态托管平台。


1:准备 GitHub 仓库

  1. 登录你的 GitHub 账号。

  2. 打开你的博客仓库(例如 yourname/yourblog)。

  3. 确保开启 Discussions 功能:

    • 进入仓库设置 → Features → 勾选 Discussions
    • 进入 Discussions 页面,新建一个 Discussion Category(例如 General)。

2:生成 Giscus 嵌入代码

  1. 打开 Giscus 配置页面: 👉 https://giscus.app

  2. 配置如下:

    • Repository:你的博客仓库(如 yourname/yourblog
    • Repository ID / Category / Category ID:根据你 Discussions 中的信息自动生成
    • Discussion Mapping:建议选择 pathname
    • Reaction:开启(👍等)
    • Input Position:bottom(评论框在底部)
    • Themepreferred_color_scheme(自动适应浅/深色)
    • Language:选择 zh-CN(中文),ja(日语),或 en(英文)
  3. 复制生成的 <script> 代码。

3:在 Hugo 模板中插入 Giscus 代码

你可以把 Giscus 的代码放进一个单独文件,例如:layouts/partials/giscus.html

<div id="giscus_container"></div>
<script src="https://giscus.app/client.js"
        data-repo="yourname/yourblog"
        data-repo-id="你的 repo ID"
        data-category="General"
        data-category-id="你的 category ID"
        data-mapping="pathname"
        data-strict="0"
        data-reactions-enabled="1"
        data-emit-metadata="0"
        data-input-position="bottom"
        data-theme="preferred_color_scheme"
        data-lang="zh-CN"
        crossorigin="anonymous"
        async>
</script>

然后在 single.html 文章模板中调用:

<article>
  {{ .Content }}
</article>

{{ partial "giscus.html" . }}

4:重新构建并部署 Hugo 博客

执行:

hugo

◇ Note 的侧边注释功能

1:使用方式示例

这是正文中的一句话重点词继续正文。

这是正文中的一句话<span class="note" data-note="这是边注的内容。">重点词</span>继续正文。

2:添加 CSS 样式

在你的主题或站点的 CSS 文件中添加以下样式(可以放在 assets/css/extended/custom.css 或主题的自定义 CSS 文件中):

.note {
  color: #4CAF50;
  font-weight: bold;
  border-bottom: 1px dotted #4CAF50;
  cursor: help;
  position: relative;
}

.note:hover::after {
  content: attr(data-note);
  position: absolute;
  bottom: 100%;
  left: 0;
  background-color: #4CAF50;
  color: white;
  padding: 5px;
  border-radius: 4px;
  font-size: 12px;
  white-space: nowrap;
  z-index: 1;
}

◇ MarginNote 的侧边注释功能

1:定义 Shortcode(短代码)

在你的 Hugo 项目中创建一个短代码文件:

路径:layouts/shortcodes/marginnote.html

内容如下:

<span class="marginnote" data-note="{{ .Get "note" }}">{{ .Inner }}</span>

使用方式示例:

这是正文中的一句话重点词 继续正文。


2:添加 CSS 样式

在你的主题或站点的 CSS 文件中添加以下样式(可以放在 assets/css/extended/custom.css 或主题的自定义 CSS 文件中):

.marginnote {
  position: relative;
  cursor: pointer;
  color: #007acc;
  border-bottom: 1px dotted #007acc;
}

.marginnote::after {
  content: attr(data-note);
  display: none;
  position: absolute;
  top: 1.5em;
  left: 100%;
  width: 220px;
  padding: 8px 12px;
  margin-left: 10px;
  background: #f9f9f9;
  border: 1px solid #ccc;
  font-size: 0.9em;
  z-index: 999;
  white-space: normal;
  box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}

.marginnote:hover::after {
  display: block;
}

效果说明

  • 当鼠标悬停在正文的关键词上时,边注会在右侧弹出,不会打断读者阅读流程。
  • 不影响 Markdown 文章格式,短代码使用方便。

◇图片点击放大功能

方法很多,这里使用Medium Zoom(推荐)

1:创建部分模板文件

在您的Hugo项目中创建文件:layouts/partials/image-zoom.html

<!-- 在 Hugo 的 layouts/partials/ 目录下创建 image-zoom.html 文件 -->
<!-- layouts/partials/image-zoom.html -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/medium-zoom/1.0.6/medium-zoom.min.js"></script>
<script>
  document.addEventListener('DOMContentLoaded', function() {
    // 为所有文章内容中的图片添加点击放大功能
    mediumZoom('article img', {
      margin: 24,
      background: 'rgba(0, 0, 0, 0.8)',
      scrollOffset: 40
    });
  });
</script>

<!-- 为了使图片看起来可点击,可以添加一些CSS样式 -->
<style>
  article img {
    cursor: zoom-in;
    transition: transform 0.2s ease-in-out;
  }
  article img:hover {
    transform: scale(1.02);
  }
</style>

2:在布局模板中包含该部分

将以下代码添加到您的主布局模板文件(通常位于layouts/_default/baseof.htmllayouts/_default/single.html)中,放在</body>标签之前:

{{ partial "image-zoom.html" . }}

★Hugo 博客集成 InstantClick 实现无刷新跳转

◇目的

提升博客内文章跳转体验,实现“无刷新秒开”,减少页面闪烁。

◇步骤与修改说明

  1. 引入 InstantClick 脚本

    • themes/PaperMod/layouts/partials/footer.html 文件的 {{- partial "extend_footer.html" . }} 之后,插入如下代码:
      <!-- InstantClick 3.1.0 CDN 脚本集成 -->
      <script src="https://cdn.jsdelivr.net/npm/instantclick@3.1.0/instantclick.min.js" data-no-instant></script>
      <script data-no-instant>
      // 统计脚本适配示例(可根据实际情况调整)
      document.addEventListener('instantclick:change', function() {
          // Google Analytics
          if (typeof ga === 'function') {
              ga('set', 'page', location.pathname + location.search);
              ga('send', 'pageview');
          }
          // 百度统计(假设 _hmt 已加载)
          if (typeof _hmt === 'object' && _hmt.push) {
              _hmt.push(['_trackPageview', location.pathname + location.search]);
          }
          // 你可以在这里添加其它统计或初始化代码
      });
      InstantClick.init();
      </script>
      
    • 这样所有博客内的页面跳转都将变为无刷新,体验更丝滑。
  2. 统计脚本适配

    • 针对 Google Analytics、百度统计等常见统计,增加了 instantclick:change 事件监听,保证跳转后统计数据正常。
  3. 可随时移除

    • 若需恢复原状,只需删除上述代码块即可。

◇注意事项

  • 若有其它自定义 JS 或第三方脚本,也可在 instantclick:change 事件中做初始化。
  • InstantClick 不影响 SEO,不影响页面首屏加载速度。

★Hugo 博客实现系统深色/浅色模式自动切换(PaperMod)

◇问题背景

  • Hugo + PaperMod 主题默认只在页面加载时检测系统深浅色模式(prefers-color-scheme),切换系统主题后需要手动刷新页面才能生效,影响体验。

◇实现目标

  • 当用户切换操作系统的深色/浅色模式时,博客页面自动同步切换,无需刷新,体验类似现代主流网站。

◇实现步骤

  1. themes/PaperMod/layouts/partials/footer.html 末尾插入如下 JS 代码:

    <script>
    // 自动响应系统深/浅色切换
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
      const isDark = e.matches;
      document.body.classList.toggle('dark', isDark);
      localStorage.setItem('pref-theme', isDark ? 'dark' : 'light');
      // 如有自定义主题切换按钮,也可在此同步状态
    });
    </script>
    
  2. 原理说明

    • 监听 prefers-color-scheme: darkchange 事件,系统主题切换时自动更新页面主题。
    • 同步更新 localStorage,保证页面刷新后仍保持用户选择。
  3. 注意事项

    • 如果你有主题切换按钮,确保按钮状态与自动切换逻辑兼容。
    • 推荐将这段代码放在所有 <script> 的最后,保证优先级和兼容性。

◇效果

  • 切换系统主题(如 macOS、Windows、移动端等)时,博客页面会立即自动切换,无需刷新,提升用户体验。

★音乐播放器

◇实现目标

  • 固定在右下角的迷你播放器
  • 展开/折叠播放列表
  • 播放/暂停、上一首、下一首控制按钮
  • 保留切换文章时音乐继续播放的状态(通过 localStorage)
  • 音乐播放器毛玻璃背景设计

◇实现步骤

  1. 创建文件 layouts/partials/musicplayer.html:
<!-- layouts/partials/musicplayer.html -->
 
<style scoped>
  .music-player-container {
    position: fixed;
    bottom: 12px;
    right: 12px;
    z-index: 9999;
    font-family: system-ui, sans-serif;
    font-size: 13px;
  }

  .music-player-container .music-panel {
    display: none;
    margin-bottom: 6px;
    background: rgba(255, 255, 255, 0.1);
    border: 1px solid rgba(255, 255, 255, 0.2);
    border-radius: 12px;
    padding: 12px 14px;
    width: 200px;
    max-height: 180px;
    overflow-y: auto;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
    backdrop-filter: blur(16px) saturate(180%);
    -webkit-backdrop-filter: blur(16px) saturate(180%);
  }

  .music-player-container .music-panel.show {
    display: block;
  }

  .music-player-container .music-title {
    font-size: 13px;
    color: var(--primary, #333);
    font-weight: 500;
    margin-bottom: 6px;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  }

  .music-player-container .playlist {
    list-style: none;
    padding: 0;
    margin: 0;
  }

  .music-player-container .playlist li {
    padding: 4px 6px;
    border-radius: 6px;
    cursor: pointer;
    color: var(--content, #666);
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
    transition: all 0.2s ease;
  }

  .music-player-container .playlist li:hover {
    background: rgba(255, 255, 255, 0.15);
    transform: translateY(-1px);
  }

  .music-player-container .playlist li.active {
    background: rgba(255, 255, 255, 0.2);
    color: var(--accent, #007acc);
    font-weight: 600;
    border: 1px solid rgba(255, 255, 255, 0.3);
  }

  .music-player-container .mini-player {
    background: rgba(255, 255, 255, 0.1);
    border: 1px solid rgba(255, 255, 255, 0.2);
    border-radius: 100px;
    display: flex;
    gap: 8px;
    padding: 6px 10px;
    align-items: center;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
    backdrop-filter: blur(20px) saturate(180%);
    -webkit-backdrop-filter: blur(20px) saturate(180%);
    transition: all 0.3s ease;
  }

  .music-player-container .mini-player:hover {
    background: rgba(255, 255, 255, 0.15);
    transform: translateY(-2px);
    box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
  }

  .music-player-container .mini-player button {
    background: none;
    border: none;
    font-size: 16px;
    color: var(--secondary, #888);
    cursor: pointer;
    padding: 4px;
    border-radius: 50%;
    transition: all 0.2s ease;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  }

  .music-player-container .mini-player button:hover {
    color: var(--accent, #007acc);
    background: rgba(255, 255, 255, 0.1);
    transform: scale(1.1);
  }

  .music-player-container .mini-player button:active {
    color: var(--accent-active, #005a99);
    transform: scale(0.95);
  }

  .music-player-container audio {
    display: none;
  }

  /* 滚动条美化 */
  .music-player-container .music-panel::-webkit-scrollbar {
    width: 4px;
  }

  .music-player-container .music-panel::-webkit-scrollbar-track {
    background: rgba(255, 255, 255, 0.1);
    border-radius: 2px;
  }

  .music-player-container .music-panel::-webkit-scrollbar-thumb {
    background: rgba(255, 255, 255, 0.3);
    border-radius: 2px;
  }

  .music-player-container .music-panel::-webkit-scrollbar-thumb:hover {
    background: rgba(255, 255, 255, 0.5);
  }
</style>

<div class="music-player-container" id="musicPlayerContainer">
  <div class="music-panel" id="musicPanel">
    <div class="music-title" id="musicTitle">Loading...</div>
    <ul class="playlist" id="musicPlaylist"></ul>
  </div>
  <div class="mini-player" id="miniPlayer">
    <button id="musicTogglePanel" title="Toggle Playlist">📂</button>
    <button id="musicPrevBtn" title="Previous">⏮️</button>
    <button id="musicPlayBtn" title="Play">▶️</button>
    <button id="musicNextBtn" title="Next">⏭️</button>
  </div>
  <audio id="musicAudio"></audio>
</div>

<script>
  (function() {
    'use strict';
    
    // 命名空间前缀,避免全局冲突
    const MUSIC_PLAYER_NS = 'HugoMusicPlayer_';
    
    const base = "https://yuuniji.github.io/music/lofi_beats/";
    const jsonURL = base + "songs.json";
    const STORAGE_KEY = MUSIC_PLAYER_NS + "state";
    const INTERACT_KEY = MUSIC_PLAYER_NS + "user_interacted";

    async function initMusicPanel() {
      try {
        const res = await fetch(jsonURL);
        const songs = await res.json();
        if (!songs.length) return;

        let currentIndex = 0;
        let isPlaying = false;
        
        // 使用带命名空间的ID选择器
        const audio = document.getElementById("musicAudio");
        const playBtn = document.getElementById("musicPlayBtn");
        const prevBtn = document.getElementById("musicPrevBtn");
        const nextBtn = document.getElementById("musicNextBtn");
        const toggleBtn = document.getElementById("musicTogglePanel");
        const panel = document.getElementById("musicPanel");
        const title = document.getElementById("musicTitle");
        const playlist = document.getElementById("musicPlaylist");

        // 检查元素是否存在
        if (!audio || !playBtn || !prevBtn || !nextBtn || !toggleBtn || !panel || !title || !playlist) {
          console.warn('Music player elements not found');
          return;
        }

        // 恢复保存的状态
        const savedStateStr = localStorage.getItem(STORAGE_KEY);
        if (savedStateStr) {
          try {
            const saved = JSON.parse(savedStateStr);
            if (saved.index >= 0 && saved.index < songs.length) {
              currentIndex = saved.index;
              audio.currentTime = saved.time || 0;
              isPlaying = saved.isPlaying || false;
            }
          } catch (e) {
            console.warn('Failed to parse saved music player state:', e);
          }
        }

        // 构建播放列表
        songs.forEach((song, index) => {
          const li = document.createElement("li");
          li.textContent = song.title;
          li.onclick = function() {
            currentIndex = index;
            loadAndPlay(currentIndex);
            localStorage.setItem(INTERACT_KEY, "true");
          };
          playlist.appendChild(li);
        });

        function highlight(index) {
          const listItems = playlist.querySelectorAll("li");
          listItems.forEach((li, idx) => {
            li.classList.toggle("active", idx === index);
          });
        }

        function loadAndPlay(index) {
          audio.src = base + songs[index].file;
          audio.currentTime = 0;
          audio.play().then(() => {
            isPlaying = true;
            playBtn.textContent = "⏸️";
            title.textContent = songs[index].title;
            highlight(index);
            saveState();
          }).catch((error) => {
            console.warn('Failed to play audio:', error);
            isPlaying = false;
            playBtn.textContent = "▶️";
          });
        }

        function saveState() {
          try {
            localStorage.setItem(STORAGE_KEY, JSON.stringify({
              index: currentIndex,
              time: audio.currentTime,
              isPlaying: !audio.paused
            }));
          } catch (e) {
            console.warn('Failed to save music player state:', e);
          }
        }

        // 初始化显示
        audio.src = base + songs[currentIndex].file;
        title.textContent = songs[currentIndex].title;
        highlight(currentIndex);

        // 更新播放按钮状态的函数
        function updatePlayButton() {
          if (audio.paused) {
            playBtn.textContent = "▶️";
            isPlaying = false;
          } else {
            playBtn.textContent = "⏸️";
            isPlaying = true;
          }
        }

        // 事件监听器
        playBtn.onclick = function() {
          if (audio.paused) {
            audio.play().then(() => {
              updatePlayButton();
              saveState();
            }).catch((error) => {
              console.warn('Failed to play audio:', error);
              updatePlayButton();
            });
          } else {
            audio.pause();
            updatePlayButton();
            saveState();
          }
        };

        prevBtn.onclick = function() {
          currentIndex = (currentIndex - 1 + songs.length) % songs.length;
          loadAndPlay(currentIndex);
        };

        nextBtn.onclick = function() {
          currentIndex = (currentIndex + 1) % songs.length;
          loadAndPlay(currentIndex);
        };

        toggleBtn.onclick = function() {
          panel.classList.toggle("show");
        };

        // 音频事件监听
        audio.ontimeupdate = saveState;
        
        audio.onplay = function() {
          updatePlayButton();
          saveState();
        };
        
        audio.onpause = function() {
          updatePlayButton();
          saveState();
        };
        
        audio.onended = function() {
          currentIndex = (currentIndex + 1) % songs.length;
          loadAndPlay(currentIndex);
        };

        // 页面加载完成后尝试自动播放
        if (document.readyState === 'complete') {
          tryAutoPlay();
        } else {
          window.addEventListener("load", tryAutoPlay);
        }

        function tryAutoPlay() {
          audio.play().then(() => {
            updatePlayButton();
            saveState();
          }).catch(() => {
            updatePlayButton();
          });
        }

      } catch (error) {
        console.error('Failed to initialize music player:', error);
      }
    }

    // 确保DOM加载完成后再初始化
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', initMusicPanel);
    } else {
      initMusicPanel();
    }
  })();
</script>
  1. 在布局模板中包含该部分

将以下代码添加到您的主布局模板文件(通常位于layouts/_default/baseof.htmllayouts/_default/single.html)中,放在</body>标签之前:

{{ partial "musicplayer.html" . }}