Hugo 音乐播放器 Shortcode

在 Hugo 博客中实现一个美观的音乐播放器 Shortcode,支持多种音频格式,具有现代化的 UI 设计和完整的播放控制功能。

2025-06-25 11:38 · 王明明

Hugo-PaperMod主题的搜索页搜索功能修复

美化过的搜索页源码:Drifting-PaperMod 排查步骤: 确保主题文件夹或根目录内/assets/js文件夹内有前端搜索脚本 (fastsSearch.js 和 fuse.js); 检查主题文件夹或根目录内 /layouts/_defaults/index.json 的模板内容,确认其正确生成了用于前端搜索的数据结构; 检查 hugo.yaml 配置文件中关于 index.json 输出格式的相关配置,确认是否有配置遗漏导致未生成 index.json。 经过排查,本站之前hugo.yaml中漏了关于 index.json 输出格式的相关配置,在hugo.yaml尾部添加如下配置,重新部署后搜索功能恢复正常: ...

2025-05-21 14:47 · 王明明

Hugo 书影音引用块 Shortcodes

Hugo 博客中创建一个名为 quote 的 Shortcode,用于在文章中优雅地引用书籍、影视和音乐的语录。

2025-04-25 17:33 · 王明明

Python脚本一键压缩Hugo博客图片

然而图片大小的压缩并没有解决这费劲的图片加载速度……莫非还是得要用图床?

2025-04-02 16:39 · 王明明

iCloud+Obsidian+Git插件实现iOS端Hugo博客更新

20250528 更新:icloud 同步方案在一段时间后一直出错,最终还是用了 Working Copy。 真贵啊真贵啊真贵啊! 实现思路 本博客通过 Hugo 搭建,使用 GitHub 托管,并借助 Cloudflare Pages 实现自动化构建。因此,在手机端将修改内容推送至 GitHub 仓库后,即可完成文章发布和修改操作。 ...

2025-01-12 18:31 · 王明明

hugo-Papermod添加瞬间Moments页面

❗️ 20250325更新: 本文已过时,请使用主题Drifting-PaperMod 实现功能: 标签筛选Waline 评论区显示每条 moment 评论数量 Step 1 - 添加 moments 页面模板 layouts/moments/list.html html 1{{ define "main" }} 2 3 <!-- 如果需要引入 moments.css,请保持路径一致或根据自己项目结构调整 --> 4 {{ $css := resources.Get "css/extended/moments.css" | minify | fingerprint }} 5 <link 6 crossorigin="anonymous" 7 href="{{ $css.RelPermalink }}" 8 integrity="{{ $css.Data.Integrity }}" 9 rel="stylesheet" 10 /> 11 12 {{ $dateformat := .Params.DateFormat }} 13 14 <article class="post-single"> 15 <header class="page-header"> 16 <!-- 可根据需求添加页面标题、描述等 --> 17 <!-- <h1>{{ .Title }}</h1> --> 18 </header> 19 20 <div class="tags-filter"> 21 <ul> 22 <li><a href="#" class="tag-filter all-tags">全部</a></li> <!-- "全部"选项 --> 23 {{ $tags := slice }} <!-- 用于存储所有标签 --> 24 25 {{ range .Pages }} 26 {{ range .Params.tags }} 27 {{ if not (in $tags .) }} 28 {{ $tags = $tags | append . }} 29 {{ end }} 30 {{ end }} 31 {{ end }} 32 33 <!-- 按字母顺序排序标签 --> 34 {{ $tags = $tags | sort }} 35 36 {{ range $tags }} 37 <li><a href="#" class="tag-filter">{{ . }}</a></li> <!-- 标签项 --> 38 {{ end }} 39 </ul> 40 </div> 41 42 <div class="post-content"> 43 <div class="moments-list"> 44 {{ range .Pages }} 45 {{ if .Content }} 46 <!-- 卡片容器 --> 47 <div class="moment-card"> 48 <!-- 头部:头像 + 作者名 --> 49 <div class="moment-header"> 50 <div class="left-content"> 51 <img 52 src="{{ site.Params.label.avatar }}" 53 alt="{{ site.Params.author }}" 54 class="moment-avatar" 55 > 56 <span class="moment-author"> 57 {{ site.Params.author }} 58 </span> 59 </div> 60 </div> 61 62 <!-- 动态主体内容(Hugo 渲染后的 .Content) --> 63 <div class="moment-body"> 64 <div class="moment-content-wrapper"> 65 {{ .Content | safeHTML }} 66 </div> 67 <div class="moment-loading"> 68 <div class="loading-spinner"></div> 69 <div class="skeleton-content"> 70 <div class="skeleton-line" style="width: 90%"></div> 71 <div class="skeleton-line" style="width: 75%"></div> 72 <div class="skeleton-line" style="width: 60%"></div> 73 </div> 74 </div> 75 <div class="moment-error" style="display: none;"> 76 <span>图片加载失败</span> 77 <button onclick="retryLoad(this)">重试</button> 78 </div> 79 </div> 80 81 <!-- 标签(如果有) --> 82 {{ if .Params.tags }} 83 <div class="moment-tags"> 84 {{ range $tag := .Params.tags }} 85 <span class="moment-tag">{{ $tag }}</span> 86 {{ end }} 87 </div> 88 {{ end }} 89 90 <!-- 底部:时间 + 评论按钮 --> 91 <div class="moment-bottom"> 92 <div class="moment-time"> 93 <span> 94 {{ .Param "date" | time.Format (default site.Params.DateFormat $dateformat) }} 95 </span> 96 </div> 97 <!-- 如果没有 hideComment 参数,则显示评论按钮 --> 98 {{ if not (.Param "hideComment") }} 99 <button 100 class="moment-comment-btn" 101 onclick="showComment(this)" 102 data-slug="{{ .Param "slug" }}" 103 data-path="{{ .Param "slug" }}" 104 > 105 <!-- 评论图标 SVG --> 106 <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M281.535354 387.361616c-31.806061 0-57.664646 26.763636-57.664647 59.733333 0 32.969697 25.858586 59.733333 57.664647 59.733334s57.664646-26.763636 57.664646-59.733334c0-33.09899-25.858586-59.733333-57.664646-59.733333z m230.529292 0c-31.806061 0-57.664646 26.763636-57.664646 59.733333 0 32.969697 25.729293 59.733333 57.664646 59.733334 31.806061 0 57.535354-26.763636 57.535354-59.733334 0-33.09899-25.858586-59.733333-57.535354-59.733333z m230.4 0c-31.806061 0-57.664646 26.763636-57.664646 59.733333 0 32.969697 25.858586 59.733333 57.664646 59.733334s57.664646-26.763636 57.664647-59.733334c-0.129293-33.09899-25.858586-59.733333-57.664647-59.733333z m115.2-270.222222H166.335354c-63.612121 0-115.2 53.527273-115.2 119.59596v390.981818c0 65.939394 52.751515 126.836364 117.785858 126.836363h175.579798c30.513131 32.581818 157.220202 149.979798 157.220202 149.979798 5.559596 5.818182 14.739394 5.818182 20.29899 0 0 0 92.832323-91.410101 153.212121-149.979798h179.717172c65.034343 0 117.785859-60.89697 117.785859-126.836363V236.606061c0.129293-65.939394-51.458586-119.466667-115.070708-119.466667z m57.535354 510.577778c0 32.969697-27.668687 67.620202-60.250505 67.620202H678.335354c-21.462626 0-40.727273 21.979798-40.727273 21.979798l-124.121212 114.941414-124.121212-114.941414s-23.660606-21.979798-43.830303-21.979798H168.921212c-32.581818 0-60.250505-34.650505-60.250505-67.620202V236.606061c0-32.969697 25.729293-59.733333 57.664647-59.733334h691.329292c31.806061 0 57.535354 26.763636 57.535354 59.733334v391.111111z m0 0"></path></svg> 107 <!-- 评论按钮文字 --> 108 <span class="comment-text">评论 ({{ .Params.comments_count | default "0" }})</span> 109 </button> 110 {{ end }} 111 </div> 112 113 <!-- 评论容器:点击按钮后会在这里渲染 Waline 评论 --> 114 <div 115 class="waline-container" 116 id="waline-{{ .Param "slug" }}" 117 data-path="{{ .Param "slug" }}" 118 ></div> 119 </div> 120 {{ end }} 121 {{ end }} 122 </div> 123 </div> 124 </article> 125 126 <!-- JavaScript 代码 --> 127 <script> 128 document.addEventListener('DOMContentLoaded', function() { 129 // 图片懒加载和预加载处理 130 const imageObserver = new IntersectionObserver((entries, observer) => { 131 entries.forEach(entry => { 132 if (entry.isIntersecting) { 133 const img = entry.target; 134 const wrapper = img.closest('.moment-content-wrapper'); 135 const loadingEl = wrapper.nextElementSibling; 136 const errorEl = loadingEl.nextElementSibling; 137 138 // 显示加载状态 139 loadingEl.style.display = 'flex'; 140 errorEl.style.display = 'none'; 141 142 // 创建新的Image对象用于预加载 143 const tempImg = new Image(); 144 tempImg.onload = function() { 145 img.src = img.dataset.src; 146 img.classList.add('loaded'); 147 loadingEl.style.display = 'none'; 148 observer.unobserve(img); 149 }; 150 tempImg.onerror = function() { 151 loadingEl.style.display = 'none'; 152 errorEl.style.display = 'block'; 153 }; 154 tempImg.src = img.dataset.src; 155 } 156 }); 157 }, { 158 rootMargin: '50px 0px', 159 threshold: 0.1 160 }); 161 162 // 处理所有图片元素 163 document.querySelectorAll('.moment-content-wrapper').forEach(wrapper => { 164 const loadingEl = wrapper.nextElementSibling; 165 const images = wrapper.querySelectorAll('img'); 166 167 if (images.length === 0) { 168 loadingEl.style.display = 'none'; 169 return; 170 } 171 172 let loadedImages = 0; 173 images.forEach(img => { 174 if (img.src) { 175 img.dataset.src = img.src; 176 img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; // 透明占位图 177 imageObserver.observe(img); 178 179 // 监听图片加载完成 180 img.onload = () => { 181 loadedImages++; 182 if (loadedImages === images.length) { 183 loadingEl.style.display = 'none'; 184 } 185 }; 186 } 187 }); 188 }); 189 190 // 重试加载功能 191 window.retryLoad = function(button) { 192 const errorEl = button.closest('.moment-error'); 193 const loadingEl = errorEl.previousElementSibling; 194 const wrapper = loadingEl.previousElementSibling; 195 const img = wrapper.querySelector('img'); 196 197 errorEl.style.display = 'none'; 198 loadingEl.style.display = 'flex'; 199 200 const tempImg = new Image(); 201 tempImg.onload = function() { 202 img.src = img.dataset.src; 203 img.classList.add('loaded'); 204 loadingEl.style.display = 'none'; 205 }; 206 tempImg.onerror = function() { 207 loadingEl.style.display = 'none'; 208 errorEl.style.display = 'block'; 209 }; 210 tempImg.src = img.dataset.src; 211 }; 212 213 // 新增分页相关变量 214 let currentPage = 1; 215 const pageSize = {{ .Site.Params.moments.pageSize | default 8 }}; 216 let filteredMoments = []; 217 let isLoading = false; 218 let isAllLoaded = false; 219 let loadingTimeout = null; 220 const loadingHint = document.createElement('div'); 221 loadingHint.className = 'scroll-hint-container'; 222 loadingHint.innerHTML = '<div class="loading-hint">加载更多...</div>'; 223 document.querySelector('.moments-list').after(loadingHint); 224 225 // 显示分页内容的方法 226 function displayMoments() { 227 const end = currentPage * pageSize; 228 const totalItems = filteredMoments.length; 229 230 // 优化显示逻辑,只处理新增的内容 231 const start = (currentPage - 1) * pageSize; 232 filteredMoments.slice(start, end).forEach(moment => { 233 moment.style.display = 'block'; 234 // 触发图片懒加载重新检查 235 moment.querySelectorAll('img[data-src]').forEach(img => { 236 imageObserver.observe(img); 237 }); 238 }); 239 240 // 更新底部提示 241 const hasMore = end < totalItems; 242 isAllLoaded = !hasMore; 243 244 if (totalItems === 0) { 245 loadingHint.innerHTML = '<div class="end-divider">暂无内容</div>'; 246 } else if (isAllLoaded) { 247 loadingHint.innerHTML = '<div class="end-divider">———— · 已到底部 · ————</div>'; 248 } else { 249 loadingHint.innerHTML = '<div class="loading-hint">加载更多...</div>'; 250 } 251 loadingHint.style.display = 'block'; 252 } 253 254 // 优化的滚动事件处理 255 function checkScroll() { 256 if (isLoading || isAllLoaded || filteredMoments.length === 0) return; 257 258 const { scrollTop, scrollHeight, clientHeight } = document.documentElement; 259 const threshold = 200; // 增加阈值,提前开始加载 260 const end = currentPage * pageSize; 261 262 if (end < filteredMoments.length && scrollTop + clientHeight >= scrollHeight - threshold) { 263 isLoading = true; 264 loadingHint.innerHTML = '<div class="loading-hint"><div class="loading-spinner"></div>加载中...</div>'; 265 266 // 清除之前的超时 267 if (loadingTimeout) { 268 clearTimeout(loadingTimeout); 269 } 270 271 // 使用 requestAnimationFrame 和防抖优化性能 272 requestAnimationFrame(() => { 273 loadingTimeout = setTimeout(() => { 274 currentPage++; 275 displayMoments(); 276 isLoading = false; 277 loadingTimeout = null; 278 }, 300); 279 }); 280 } 281 } 282 283 // 点击标签时筛选逻辑 284 const tags = document.querySelectorAll('.tag-filter'); // 获取所有标签 285 const moments = document.querySelectorAll('.moment-card'); // 获取所有 moment 卡片 286 const allTags = document.querySelector('.all-tags'); // 获取"全部"按钮 287 const momentTags = document.querySelectorAll('.moment-tag'); // 获取所有卡片内的标签 288 289 // 默认选中"全部"标签 290 if (allTags) { 291 allTags.classList.add('selected'); 292 } 293 294 // 点击标签时进行筛选 295 tags.forEach(tag => { 296 tag.addEventListener('click', function(e) { 297 e.preventDefault(); 298 const selectedTag = tag.textContent.trim(); 299 300 filteredMoments = Array.from(moments).filter(moment => { 301 const momentTags = moment.querySelectorAll('.moment-tag'); 302 return selectedTag === '全部' || 303 Array.from(momentTags).some(t => t.textContent === selectedTag); 304 }); 305 306 currentPage = 1; 307 displayMoments(); 308 window.scrollTo(0, 0); // 筛选后回到顶部 309 310 tags.forEach(t => t.classList.remove('selected')); 311 tag.classList.add('selected'); 312 }); 313 }); 314 315 // 为卡片内的标签添加点击事件 316 momentTags.forEach(tag => { 317 tag.style.cursor = 'pointer'; 318 tag.addEventListener('click', function() { 319 const tagText = this.textContent.trim(); 320 // 找到对应的顶部标签并触发点击 321 tags.forEach(headerTag => { 322 if (headerTag.textContent.trim() === tagText) { 323 headerTag.click(); 324 } 325 }); 326 }); 327 }); 328 329 // 初始加载 330 filteredMoments = Array.from(moments); 331 displayMoments(); 332 // 确保提示容器正确插入 333 const existingHint = document.querySelector('.scroll-hint-container'); 334 if (!existingHint) { 335 const hintContainer = document.createElement('div'); 336 hintContainer.className = 'scroll-hint-container'; 337 document.querySelector('.moments-list').after(hintContainer); 338 } 339 window.addEventListener('scroll', checkScroll); 340 }); 341 </script> 342 343 <!-- 在页面底部引入 Waline 评论脚本并初始化 --> 344 <script type="module"> 345 import { init } from 'https://unpkg.com/@waline/client@v3/dist/waline.js'; 346 347 const walineParams = { 348 /* 这里根据你自己的 Waline 配置进行调整 */ 349 serverURL: '{{ .Site.Params.waline.serverURL }}', 350 lang: '{{ .Site.Params.waline.lang | default "zh-CN" }}', 351 visitor: '{{ .Site.Params.waline.visitor | default "匿名者" }}', 352 emoji: [ 353 {{- range .Site.Params.waline.emoji }} 354 '{{ . }}', 355 {{- end }} 356 ], 357 requiredMeta: [ 358 {{- range .Site.Params.waline.requiredMeta }} 359 '{{ . }}', 360 {{- end }} 361 ], 362 locale: { 363 admin: '{{ .Site.Params.waline.locale.admin | default "作者本人" }}', 364 placeholder: '{{ .Site.Params.waline.locale.placeholder | default "🍗所以我配有一条评论吗!" }}', 365 }, 366 dark: '{{ .Site.Params.waline.dark | default "html.dark" }}', 367 }; 368 369 // 点击"添加评论"按钮时,显示对应卡片下的评论区 370 window.showComment = function(element) { 371 const slug = element.getAttribute('data-slug'); 372 const path = element.getAttribute('data-path'); 373 const commentElement = document.getElementById('waline-' + slug); 374 375 // 如果已激活则清空 376 if (commentElement.classList.contains('active')) { 377 commentElement.classList.remove('active'); 378 commentElement.innerHTML = ''; 379 return; 380 } 381 382 // 移除其它所有已激活评论区 383 const allComments = document.querySelectorAll('.waline-container'); 384 allComments.forEach(el => { 385 el.classList.remove('active'); 386 el.innerHTML = ''; 387 }); 388 389 // 激活当前评论区 390 commentElement.classList.add('active'); 391 392 // 初始化 Waline 393 init({ 394 el: commentElement, 395 serverURL: walineParams.serverURL, 396 lang: walineParams.lang, 397 visitor: walineParams.visitor, 398 emoji: walineParams.emoji, 399 requiredMeta: walineParams.requiredMeta, 400 locale: walineParams.locale, 401 path: path, 402 dark: walineParams.dark, 403 }); 404 } 405 </script> 406 407 <!-- 获取评论数 --> 408 <script> 409 document.addEventListener('DOMContentLoaded', function() { 410 // 获取所有评论按钮 411 const commentBtns = document.querySelectorAll('.moment-comment-btn'); 412 413 // 确保有找到评论按钮 414 if (commentBtns.length > 0) { 415 commentBtns.forEach(button => { 416 const slug = button.getAttribute('data-slug'); // 获取按钮对应的 slug 417 const commentText = button.querySelector('.comment-text'); // 获取按钮中的评论文本 418 const serverURL = '{{ .Site.Params.waline.serverURL }}'; // 获取 Waline 服务器地址 419 420 // 输出调试信息,查看是否有多个按钮 421 console.log(`Processing button with slug: ${slug}`); 422 423 if (slug && commentText) { 424 // 假设你有一个获取评论数量的 API 或接口 425 fetch(`${serverURL}/api/comment?type=count&url=${slug}`) 426 .then(response => response.json()) 427 .then(data => { 428 if (commentText) { 429 // 从 API 返回的数据中提取评论数 430 const commentCount = data.data && data.data[0] ? data.data[0] : 0; // 如果没有数据或评论数,默认为 0 431 432 // 输出调试信息,查看评论数 433 console.log(`Fetched comment count: ${commentCount}`); 434 435 // 更新评论按钮上的评论数量 436 commentText.textContent = `评论 (${commentCount})`; // 更新评论数 437 } 438 }) 439 .catch(error => { 440 console.error('Error fetching comment count:', error); 441 if (commentText) { 442 // 如果 API 请求失败,保持评论数为 0 443 commentText.textContent = `评论 (0)`; 444 } 445 }); 446 } else { 447 console.error('Slug or commentText missing for button:', button); 448 } 449 }); 450 } else { 451 console.error('No comment buttons found'); 452 } 453 }); 454 </script> 455 456{{ end }} Step2 - Build options content/mements/_index.md ...

2025-01-12 17:14 · 王明明

Hugo-PaperMod在首页添加热力图

实现功能: 根据颜色深浅表现当日字数的多少鼠标悬停展示当日总字数、文章数和瞬间数点击弹出当天写的文章列表 Step 1 - 使用脚本生成 json 文件 heatmap.py(我放在网站项目根目录) ...

2025-01-09 19:05 · 王明明

在Hugo-PaperMod中加入Waline评论区

❗️ 20250325更新: 本文已过时,请使用主题Drifting-PaperMod Waline服务端部署 LeanCloud 设置 参考Waline官方文档: LeanCloud 设置 (数据库) Vercel部署 参考Waline官方文档: Vercel 部署 (服务端) ...

2025-01-08 22:58 · 王明明