fixed 文章结构
This commit is contained in:
1 parent
6ad43b2c08
commit
ba93d0faa4
813 files changed
+243
-164
No files matched your search
@@ -0,0 +1,472 @@
|
||||
---
|
||||
pid: 138
|
||||
title: 如何hugo静态实现友联朋友圈功能
|
||||
date: 2026-01-21T06:30:00.000Z
|
||||
slug: 138
|
||||
draft: false
|
||||
categories:
|
||||
- 教程
|
||||
- 折腾
|
||||
tags:
|
||||
- Hugo
|
||||
- 朋友圈
|
||||
- 静态博客
|
||||
author: 小赵同学
|
||||
layout: post
|
||||
ai_comment: >-
|
||||
这篇分享太实用了!作为同样从动态博客迁移到Hugo的用户,简直感同身受——就像从热闹的咖啡馆搬进了高效图书馆,却开始怀念邻桌的闲聊。作者这种"既要又要"的折腾精神太可爱了,连烂代码都害羞的样子莫名真实。最喜欢这种保姆级教程,连我这种前端小白都跃跃欲试想给自己的博客加个"伪动态"广场了!
|
||||
---
|
||||
|
||||
前段时间把博客从 Typecho 迁移到了 Hugo,整体感觉就是快,静态博客真的省心。但是,心里总觉得少点什么。没错,就是那个能看到朋友们最新动态的“朋友圈”功能。
|
||||
|
||||
在 Typecho 时代,有现成的插件可以用,转到 Hugo 后,发现很多现成的主题并没有集成这个功能,或者样式不是我喜欢的。最近逛博客,看到 [liushen.fun](https://blog.liushen.fun/fcircle/) 的友圈样式挺不错的,卡片风格,毛玻璃特效,甚是喜欢。于是心血来潮,想着能不能自己在 Hugo 上也整一个。
|
||||
|
||||
本来想着直接把仓库开源给大家参考,但因为仓库里有一些私密的配置和魔改的烂代码,实在不好意思(也不方便)公开。所以,既然大家想要,那我就把**完整的代码**和**实现步骤**都贴在这里,主打一个“喂饭级”教程,大家直接复制粘贴就能用!
|
||||
|
||||
## 实现思路
|
||||
|
||||
因为 Hugo 是静态博客,没有数据库,所以不能像 Typecho 那样实时读取数据库。我的思路是:
|
||||
|
||||
1. **后端抓取**:利用 GitHub Actions 定时运行一个 Node.js 脚本。
|
||||
2. **数据处理**:脚本读取 `links.yaml`(或者你的友链 JSON),抓取每个朋友的 RSS Feed,提取文章标题、链接、时间、摘要和封面图。
|
||||
3. **数据生成**:将处理好的数据保存为 `friend_circle_data.json` 文件。
|
||||
4. **前端渲染**:在 Hugo 的页面模板中,通过 AJAX 请求这个 JSON 文件,动态渲染成卡片列表。
|
||||
|
||||
这样既保持了博客的静态特性,又实现了动态更新。
|
||||
|
||||
## 第一步:后端脚本 (Node.js)
|
||||
|
||||
首先,我们需要一个脚本来干脏活累活。我在项目根目录的 `scripts` 文件夹下新建了一个 `generate_circle_data.js`。
|
||||
|
||||
你需要先安装一个依赖:
|
||||
```bash
|
||||
npm install rss-parser
|
||||
```
|
||||
|
||||
下面是完整的脚本代码,支持自动识别 RSS(如果友链里没写),支持提取文章里的第一张图作为封面,支持提取摘要:
|
||||
|
||||
```javascript
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Parser = require('rss-parser');
|
||||
|
||||
// 你的友链数据文件路径,根据实际情况修改
|
||||
const LINK_LITE_PATH = path.join(__dirname, '../themes/Ying/static/json/link_lite.json');
|
||||
// 输出文件路径
|
||||
const OUTPUT_PATH = path.join(__dirname, '../themes/Ying/static/json/friend_circle_data.json');
|
||||
|
||||
const MAX_POSTS_PER_FRIEND = 5; // 每个朋友取最近5篇
|
||||
const MAX_TOTAL_POSTS = 100; // 总共展示100篇
|
||||
|
||||
const parser = new Parser({
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchFeed(url) {
|
||||
try {
|
||||
const feed = await parser.parseURL(url);
|
||||
return feed;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Starting Friend Circle generation...');
|
||||
|
||||
if (!fs.existsSync(LINK_LITE_PATH)) {
|
||||
console.error('link_lite.json not found!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(LINK_LITE_PATH, 'utf8'));
|
||||
// 假设 data.friends 是一个数组: [name, url, avatar, rss?]
|
||||
// 如果你的格式不一样,请自行调整下面的 map 逻辑
|
||||
|
||||
let allPosts = [];
|
||||
const BATCH_SIZE = 5; // 并发控制
|
||||
const friends = data.friends;
|
||||
|
||||
for (let i = 0; i < friends.length; i += BATCH_SIZE) {
|
||||
const batch = friends.slice(i, i + BATCH_SIZE);
|
||||
const promises = batch.map(async (friend) => {
|
||||
const name = friend[0];
|
||||
const blogUrl = friend[1];
|
||||
const avatar = friend[2];
|
||||
let rssUrl = friend.length >= 4 ? friend[3] : null;
|
||||
|
||||
// 如果没有提供 RSS,尝试盲猜
|
||||
const candidates = [];
|
||||
if (rssUrl) {
|
||||
candidates.push(rssUrl);
|
||||
} else {
|
||||
const cleanUrl = blogUrl.replace(/\/$/, '');
|
||||
candidates.push(`${cleanUrl}/atom.xml`);
|
||||
candidates.push(`${cleanUrl}/rss.xml`);
|
||||
candidates.push(`${cleanUrl}/feed`);
|
||||
candidates.push(`${cleanUrl}/index.xml`);
|
||||
}
|
||||
|
||||
let feed = null;
|
||||
for (const url of candidates) {
|
||||
feed = await fetchFeed(url);
|
||||
if (feed) break;
|
||||
}
|
||||
|
||||
if (!feed) {
|
||||
console.log(`[${name}] No valid RSS found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 提取文章
|
||||
const posts = feed.items.slice(0, MAX_POSTS_PER_FRIEND).map(item => {
|
||||
let img = null;
|
||||
// 优先从 content 中提取图片
|
||||
const content = item['content:encoded'] || item.content || item.description || '';
|
||||
const imgMatch = content.match(/<img[^>]+src=['"]([^'"]+)['"]/i);
|
||||
|
||||
if (imgMatch) {
|
||||
img = imgMatch[1];
|
||||
} else if (item.enclosure && item.enclosure.url && item.enclosure.type && item.enclosure.type.startsWith('image')) {
|
||||
img = item.enclosure.url;
|
||||
}
|
||||
|
||||
// 提取摘要
|
||||
let snippet = '';
|
||||
if (content) {
|
||||
snippet = content.replace(/<[^>]+>/g, '');
|
||||
snippet = snippet.replace(/\s+/g, ' ').trim();
|
||||
if (snippet.length > 120) {
|
||||
snippet = snippet.substring(0, 120) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
date: item.isoDate || item.pubDate,
|
||||
author: name,
|
||||
avatar: avatar,
|
||||
blogUrl: blogUrl,
|
||||
image: img,
|
||||
description: snippet
|
||||
};
|
||||
});
|
||||
|
||||
allPosts = allPosts.concat(posts);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
// 按时间倒序排序
|
||||
allPosts.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
|
||||
const finalPosts = allPosts.slice(0, MAX_TOTAL_POSTS);
|
||||
|
||||
const output = {
|
||||
updated: new Date().toISOString(),
|
||||
posts: finalPosts
|
||||
};
|
||||
|
||||
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(output, null, 2));
|
||||
console.log(`Saved ${finalPosts.length} posts to ${OUTPUT_PATH}`);
|
||||
}
|
||||
|
||||
main();
|
||||
```
|
||||
|
||||
## 第二步:自动化 (GitHub Actions)
|
||||
|
||||
脚本写好了,总不能每天自己手动跑吧?这时候 GitHub Actions 就派上用场了。
|
||||
|
||||
在 `.github/workflows` 下创建一个 `upy.yml` (或者其他名字),设置定时任务。我是设置每天跑一次,或者每次 push 代码时触发。
|
||||
|
||||
```yaml
|
||||
- name: Install Node.js dependencies
|
||||
run: npm install rss-parser
|
||||
|
||||
- name: Generate Friend Circle Data
|
||||
run: node scripts/generate_circle_data.js
|
||||
```
|
||||
|
||||
把这两步加到你现有的构建流程里,放在 `hugo` 命令之前就行。
|
||||
|
||||
## 第三步:前端颜值 (HTML & CSS)
|
||||
|
||||
这一步是重头戏!为了达到那个卡片式、毛玻璃、还有丝滑动效的效果,我可是调了半天 CSS。
|
||||
|
||||
我直接把我的 `themes/Ying/layouts/_default/circles.html` 文件内容贴出来。这个文件集成了 HTML 结构、CSS 样式和 JS 逻辑(包括 PJAX 适配和加载更多功能)。
|
||||
|
||||
你可以直接在你的 Hugo 主题里新建一个 `layouts/_default/circles.html`,然后复制下面的代码:
|
||||
|
||||
```html
|
||||
{{ define "main" }}
|
||||
<div id="pjax-container">
|
||||
<div class="post-content" itemprop="articleBody">
|
||||
{{ .Content }}
|
||||
<div id="friend-circle-container" class="friend-circle-list">
|
||||
<div class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>正在探索朋友们的新动态...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* 布局:PC端两列,间距紧凑 */
|
||||
.friend-circle-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 移动端一列 */
|
||||
@media (max-width: 768px) {
|
||||
.friend-circle-list {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片核心样式:毛玻璃 + 动效 */
|
||||
.fc-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
height: 160px;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.3); /* 虚线边框 */
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
animation: ukFadeInUp 0.8s cubic-bezier(0.165, 0.84, 0.44, 1) forwards;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 暗黑模式适配 */
|
||||
[data-theme="dark"] .fc-item {
|
||||
background: rgba(35, 35, 35, 0.65);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 鼠标悬停特效 */
|
||||
.fc-item:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-color: #000;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 标题样式 */
|
||||
.fc-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
z-index: 2;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-right: 60px;
|
||||
}
|
||||
|
||||
/* 背景装饰图(文章封面) */
|
||||
.fc-bg-image {
|
||||
position: absolute;
|
||||
right: -20px;
|
||||
bottom: -20px;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
opacity: 0.15;
|
||||
transition: all 0.5s ease;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
filter: grayscale(20%);
|
||||
}
|
||||
|
||||
.fc-item:hover .fc-bg-image {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
filter: grayscale(0%);
|
||||
}
|
||||
|
||||
/* 底部信息栏 */
|
||||
.fc-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* 作者胶囊标签 */
|
||||
.fc-author-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
padding: 4px 10px 4px 4px;
|
||||
border-radius: 20px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .fc-author-pill {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.fc-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.fc-author-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 加载更多按钮 */
|
||||
.fc-load-more-btn {
|
||||
grid-column: 1 / -1;
|
||||
padding: 12px 30px;
|
||||
margin: 30px auto 10px;
|
||||
background: #000;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 30px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* 动画定义 */
|
||||
@keyframes ukFadeInUp {
|
||||
0% { opacity: 0; transform: translateY(40px) scale(0.95); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px; height: 40px;
|
||||
border: 3px solid rgba(127,127,127,0.2);
|
||||
border-top-color: #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s infinite linear;
|
||||
margin: 0 auto 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 时间格式化:YYYY-MM-DD
|
||||
function timeAgo(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
let allPosts = [];
|
||||
let currentIndex = 0;
|
||||
const BATCH_SIZE = 10;
|
||||
|
||||
function renderPosts(posts) {
|
||||
const container = document.getElementById('friend-circle-container');
|
||||
const existingBtn = document.getElementById('fc-load-more-btn');
|
||||
if (existingBtn) existingBtn.remove();
|
||||
|
||||
let html = '';
|
||||
posts.forEach((post, index) => {
|
||||
const dateStr = timeAgo(post.date);
|
||||
const delay = index * 0.05; // 瀑布流延迟动画
|
||||
const bgImageSrc = post.image || post.avatar; // 有封面图用封面,没封面用头像
|
||||
|
||||
html += `
|
||||
<a href="${post.link}" target="_blank" class="fc-item" style="animation-delay: ${delay}s">
|
||||
<div class="fc-title" title="${post.title}">${post.title}</div>
|
||||
<img src="${bgImageSrc}" class="fc-bg-image" loading="lazy" onerror="this.style.opacity=0">
|
||||
<div class="fc-bottom">
|
||||
<div class="fc-author-pill">
|
||||
<img src="${post.avatar}" class="fc-avatar" loading="lazy">
|
||||
<span class="fc-author-name">${post.author}</span>
|
||||
</div>
|
||||
<div class="fc-date">${dateStr}</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
});
|
||||
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
|
||||
if (currentIndex < allPosts.length) {
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'fc-load-more-btn';
|
||||
btn.className = 'fc-load-more-btn';
|
||||
btn.innerText = '加载更多';
|
||||
btn.onclick = loadMore;
|
||||
container.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
const nextBatch = allPosts.slice(currentIndex, currentIndex + BATCH_SIZE);
|
||||
currentIndex += BATCH_SIZE;
|
||||
renderPosts(nextBatch);
|
||||
}
|
||||
|
||||
function loadFriendCircle() {
|
||||
const container = document.getElementById('friend-circle-container');
|
||||
if (!container) return;
|
||||
|
||||
fetch('/json/friend_circle_data.json?t=' + new Date().getTime())
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
allPosts = data.posts;
|
||||
currentIndex = 0;
|
||||
container.innerHTML = ''; // 清除 loading
|
||||
loadMore();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error:', err);
|
||||
container.innerHTML = '加载失败';
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化加载
|
||||
document.addEventListener('DOMContentLoaded', loadFriendCircle);
|
||||
// 适配 PJAX
|
||||
document.addEventListener('pjax:complete', loadFriendCircle);
|
||||
</script>
|
||||
</div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
## 避坑指南
|
||||
|
||||
1. **CORS 问题**:因为我们是请求同域名的 JSON 文件,所以一般不会有跨域问题。但如果你把 JSON 传到了其他 CDN,记得配置 CORS。
|
||||
2. **PJAX 白屏**:如果你像我一样用了 PJAX,一定要加 `document.addEventListener('pjax:complete', ...)` 这一行,否则跳转回来会白屏。
|
||||
3. **样式冲突**:CSS 里的 `.friend-circle-list` 使用了 Grid 布局,如果你的主题有全局 CSS 冲突,可能需要微调。
|
||||
|
||||
## 总结
|
||||
|
||||
这一套下来,其实核心就三块:Node 脚本抓数据、Github Action 跑脚本、HTML 渲染页面。
|
||||
|
||||
虽然代码看起来有点多,但逻辑是很清晰的。把这些文件放到对应位置,基本上就能跑起来了。如果你也喜欢这种卡片风格,不妨试一试!
|
||||
|
||||
好了,代码都毫无保留地交出来了,希望能帮到同样在折腾 Hugo 的朋友们。如果有问题,欢迎在评论区留言(虽然我也不一定能解决哈哈)。
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: 博客六周年:从折腾到回归平淡
|
||||
date: 2026-01-22T06:00:00.000Z
|
||||
slug: blog-6th-anniversary
|
||||
draft: false
|
||||
categories:
|
||||
- 生活
|
||||
tags:
|
||||
- 博客
|
||||
- 年终总结
|
||||
author: 小赵同学
|
||||
ai_comment: >-
|
||||
看完忍不住嘴角上扬,技术博主的成长史简直就是一部大型真香现场!从花里胡哨到极简主义,从折腾服务器到躺平用Hugo,这不就是我们所有人的互联网修行之路吗?最后那句保持热爱简直暴击,作为读者已经搬好小板凳等你下一个六年的吐槽大会了~
|
||||
---
|
||||
|
||||
不知不觉,博客已经六岁了。
|
||||
|
||||
时间过得真快,回看第一篇文章的时间,仿佛还在昨天。2020年,那时的我还是个刚踏入大学校园的懵懂新生,怀揣着对互联网的好奇,搭建了这个属于自己的小天地。
|
||||
|
||||
## 关于折腾
|
||||
|
||||
这六年,如果要用一个词来形容,那一定是“折腾”。
|
||||
|
||||
最开始的时候,总觉得博客要越花哨越好。各种特效、各种插件,恨不得把网页塞得满满当当。那时候最快乐的事,就是给博客加个新功能,然后沾沾自喜地看半天。
|
||||
|
||||
后来,经历了无数次的“换主题”。Typecho 的主题被我换了个遍,正如我之前文章里写的,“又又又双叒叕换主题啦”。那时候不懂代码,就到处找教程,魔改 CSS,把好好的主题改得面目全非,但乐在其中。
|
||||
|
||||
直到后来,随着文章数量的增加,我开始意识到,博客的本质应该是内容,而不是花哨的外表。于是,我开始做减法,追求极致的简约。
|
||||
|
||||
## 关于迁移
|
||||
|
||||
最大的技术变动,莫过于从 Typecho 迁移到了 Hugo。
|
||||
|
||||
以前用 Typecho,虽然方便,但总要操心服务器、数据库、SSL 证书。服务器到期了要续费,数据库怕崩了要备份。对于我这种“贫穷”的博主来说,维护成本确实是个问题。
|
||||
|
||||
后来狠下心,把博客全站静态化,迁移到了 Hugo。虽然刚开始接触 Hugo 的时候,被那晦涩的文档折磨得不轻,甚至一度想放弃。但好在坚持下来了,还顺手把之前的 Ying 主题移植了过来。
|
||||
|
||||
现在的博客,托管在 GitHub,自动构建,全球 CDN 加速。不用担心服务器到期,不用担心数据库崩溃,甚至连 SSL 证书都是自动续期的。这种“省心”的感觉,是用过静态博客后就回不去的。
|
||||
|
||||
## 关于成长
|
||||
|
||||
博客记录了我从学生到“打工人”的转变。
|
||||
|
||||
翻看以前的博文,有高考前的紧张,有军训时的吐槽,有大学生活的琐碎,也有毕业时的迷茫。每一篇文章,都是当时心境的真实写照。
|
||||
|
||||
现在的我,已经毕业一年多了,每天忙碌于工作和生活之间,留给博客的时间越来越少。有时候想写点什么,打开编辑器,却又不知道从何下笔。
|
||||
|
||||
但这个博客,我会一直保留下去。它就像一个老朋友,默默地记录着我的成长轨迹。也许更新频率会变慢,但它永远都在。
|
||||
|
||||
## 写在最后
|
||||
|
||||
感谢这六年来,一直关注和支持我的朋友们。是你们的评论和鼓励,让我坚持到了现在。
|
||||
|
||||
未来的日子里,希望我们都能保持那份“折腾”的热爱,但也别忘了生活的本质。
|
||||
|
||||
六年,你好。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 585 KiB |
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: 为什么我觉得网页昼夜切换那么重要
|
||||
date: '2026-01-23T19:34:10+08:00'
|
||||
slug: why-dark-mode-matters
|
||||
draft: false
|
||||
categories:
|
||||
- 技术
|
||||
- 生活
|
||||
tags:
|
||||
- TailwindCSS
|
||||
- 暗黑模式
|
||||
- 用户体验
|
||||
- 吐槽
|
||||
author: 小赵同学
|
||||
layout: post
|
||||
status: public
|
||||
ai_comment: >-
|
||||
作为一个深夜刷手机的资深选手,这篇简直是我的互联网嘴替!被闪光弹袭击的体验太真实了,现在看到不支持暗黑模式的网站都会虎躯一震。Tailwind
|
||||
CSS的黑暗魔法听起来好心动,不过更心疼博主每天要接受公司大白屏的“酷刑”——建议把这篇甩给产品经理当需求文档(笑)。最后说句掏心窝的:所有深夜写代码还惦记读者眼睛的博主,都是人间天使!
|
||||
---
|
||||
|
||||
这篇是我下班在地铁通勤路上编写的,我认为一个网站支持暗黑模式(Dark Mode),真的很重要。
|
||||
|
||||
不知道大家有没有这样的经历:大晚上躲在被窝里刷手机,打开一个网页,突然一道白光刺瞎双眼,那种感觉简直就像是被闪光弹击中一样。从那一刻起,我就深刻意识到,博客的昼夜切换不仅仅是一个“酷炫”的功能,更是对博友眼睛最基本的尊重。
|
||||
|
||||
## 心中的一个疙瘩
|
||||
|
||||
其实,我的这个博客主题 **Ying** 在此之前一直是没有暗黑模式的。这一直是我心中的一个疙瘩。
|
||||
|
||||
这个主题我也用了快三年了,早期的代码里堆积了大量魔改的 CSS 样式,可以说是“屎山”也不为过。每次想加个暗黑模式,一看到那错综复杂的层叠样式表,我就头大,只能作罢。
|
||||
|
||||
但每次逛别人的博客,看到右上角那个精致的太阳/月亮切换按钮,轻轻一点,页面瞬间从明亮转为深邃,我都羡慕不已。尤其是在深夜,那种深色背景带来的沉浸感和舒适感,真的是白色背景无法比拟的。每次回到自己的博客,看到那惨白惨白的背景,总感觉少了点什么,既不护眼,也不够酷。
|
||||
|
||||
## Tailwind CSS 4:真香警告
|
||||
|
||||
最近这段时间,我开始接触并深度使用 **Tailwind CSS v4**。不得不说,这个原子化的 CSS 框架真的是太优秀了。
|
||||
|
||||
以前写 CSS,总是要在各种类名之间纠结,还要处理复杂的层叠关系。用了 Tailwind 之后,原子化的写法简直不要太爽,想要什么样式直接堆 class 就行,开发效率直线起飞。
|
||||
|
||||
最让我惊喜的是它对昼夜切换(Dark Mode)的适配,简直是“手拿把掐”。只需要在 class 前面加一个 `dark:` 前缀,就能轻松定义深色模式下的样式。
|
||||
|
||||
特别是官网推荐的 **Color Slate** 色系,简直是为夜晚而生。它不是那种死黑死黑的纯黑(#000000),而是一种带有蓝灰色调的高级灰。我毫不犹豫地选择了它作为 Ying 主题暗夜模式的主色调。这种颜色既保留了深色的沉稳,又不会让文字显得过于刺眼,阅读体验极佳。这种“高级感”正是我一直想要的。
|
||||
|
||||

|
||||
|
||||
## 来自“瞎眼”打工人的吐槽
|
||||
|
||||
说完了开心的技术探索,再来吐槽一下糟心的工作。
|
||||
|
||||
我们公司的产品是一个 Web 端的数据运维平台。作为一名苦逼的打工人,我每天的工作就是盯着这个屏幕,监控数据、排查故障。
|
||||
|
||||
关键是,这个平台它**没有暗黑模式**!
|
||||
|
||||
你能想象吗?每天对着一个高亮的大白屏超过 8 个小时,尤其是有时候加班到晚上,办公室灯光一关,那个屏幕亮得简直像个探照灯,直射我的视网膜。每天下班回家,眼睛都酸胀得不行,感觉快要瞎了。
|
||||
|
||||
跟同事吐槽过无数次,希望能加个暗黑模式,结果永远是现在能跑起来已经很不错了。
|
||||
|
||||
无奈之下,我只能自力更生,给浏览器装了个 **Dark Reader** 插件,强制把公司的网页转成暗黑模式。虽然有时候颜色渲染会有点怪,图标也会变糊,但至少,我的眼睛算是保住了。
|
||||
|
||||
## 结语
|
||||
|
||||
正是因为自己在工作中饱受“光污染”的折磨,我才更坚定地要把博客的暗黑模式做好。
|
||||
|
||||
所以,无论是为了满足自己的强迫症,还是为了给深夜来访的朋友们一双“护眼符”,我都必须把 Ying 主题的昼夜切换功能做出来,而且要用最舒服的 Slate 配色,做到极致。
|
||||
|
||||
悄悄告诉你,我已经在 Ying 主题中实现了暗黑模式,并且用 Slate 配色,这里有个小机关,点击头像即可切换哦!
|
||||
|
||||
希望今晚的你,能在这个深色的页面里,读得舒服,睡得安稳。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 256 KiB |
@@ -0,0 +1,261 @@
|
||||
---
|
||||
title: Artalk评论系统实现段落评论功能
|
||||
date: 2026-01-24T11:40:00.000Z
|
||||
draft: false
|
||||
tags:
|
||||
- Artalk
|
||||
- 博客优化
|
||||
- JS
|
||||
- CSS
|
||||
categories:
|
||||
- 折腾记录
|
||||
ai_comment: >-
|
||||
读完后也想给我的博客加这个功能了!作为话痨型读者,看到好段落不能立刻吐槽实在太难受了。不过前端小白的我看着代码瑟瑟发抖,可能最后还是选择用番茄小说当树洞(笑)。暗黑模式适配好评,深夜码字党的眼睛会感谢你!期待看到评论区变成大型弹幕现场~
|
||||
---
|
||||
|
||||
最近在番茄小说上看书,发现它的**段落评论**功能是真的好用。看到有共鸣的句子,选中就能直接看别人的评论或者自己吐槽两句。
|
||||
|
||||

|
||||
|
||||
我就在想,我的博客是不是也能整一个?毕竟现在还在坚持写博客的,谁还没点表达欲呢?
|
||||
|
||||
下午没事折腾了一下,试着给我的 Artalk 评论系统加上了这个功能。效果还凑合,选中文字后会弹出一个“引用评论”的按钮,点击就能自动跳转到评论区并引用选中的内容。
|
||||
|
||||

|
||||
|
||||
稍微整理了一下代码,分享给有需要的朋友。我是个前端小白,代码写得比较粗糙,大佬们见笑了。无论你用什么主题,只要是 Artalk 评论系统,理论上都能适配。
|
||||
|
||||
## 实现思路
|
||||
|
||||
逻辑其实挺简单的,主要是以下几步:
|
||||
|
||||
1. **监听选择**:监听用户的鼠标选取操作 (`mouseup`)。
|
||||
2. **计算位置**:获取选中范围的坐标,把按钮定位到鼠标或者选区上方。
|
||||
3. **点击交互**:点击按钮后,获取选中的文字,平滑滚动到评论区,把文字填进输入框。
|
||||
4. **细节优化**:做了一些防抖处理,简单适配了一下移动端和暗黑模式(这个必须有!)。
|
||||
|
||||
## 代码实现
|
||||
|
||||
### 1. HTML 结构
|
||||
|
||||
不需要手动写 HTML,直接用 JS 动态生成插入到 body 里就行,这样省事点。
|
||||
|
||||
### 2. CSS 样式
|
||||
|
||||
这块我调了半天。一开始用了毛玻璃效果 (`backdrop-filter`),结果发现页面滚动的时候有点掉帧,为了性能我把它去掉了,改成了更实用的深色/浅色背景,顺便加了 `will-change` 属性开启硬件加速。
|
||||
|
||||
把下面这段代码加到你的自定义 CSS 里:
|
||||
|
||||
```css
|
||||
/* 选中弹出按钮样式 */
|
||||
#selection-popup {
|
||||
position: fixed;
|
||||
display: none;
|
||||
background: rgba(30, 30, 30, 0.9);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
z-index: 2147483647; /* 堆叠层级拉满 */
|
||||
transform: translate(-50%, -100%);
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
will-change: transform, opacity; /* 性能优化关键 */
|
||||
animation: popIn 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
|
||||
}
|
||||
|
||||
/* 弹出动画 */
|
||||
@keyframes popIn {
|
||||
0% { transform: translate(-50%, -80%) scale(0.9); opacity: 0; }
|
||||
100% { transform: translate(-50%, -100%) scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 小三角箭头 */
|
||||
#selection-popup::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
border-width: 6px;
|
||||
border-style: solid;
|
||||
border-color: rgba(30, 30, 30, 0.9) transparent transparent transparent;
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
#selection-popup:hover {
|
||||
transform: translate(-50%, -110%) scale(1.05);
|
||||
background: #000;
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
/* 暗黑模式适配 */
|
||||
[data-theme="dark"] #selection-popup {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
[data-theme="dark"] #selection-popup::after {
|
||||
border-color: rgba(255, 255, 255, 0.95) transparent transparent transparent;
|
||||
}
|
||||
|
||||
[data-theme="dark"] #selection-popup:hover {
|
||||
background: #fff;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. JavaScript 逻辑
|
||||
|
||||
把这段 JS 加到你的 `main.js` 或者 footer 的 `<script>` 标签里。
|
||||
|
||||
**注意**:为了性能,我这里用了 `requestAnimationFrame`,而且去掉了 `getBoundingClientRect` 这种耗性能的操作,直接用鼠标坐标定位,感觉会流畅一些。
|
||||
|
||||
```javascript
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initSelectionPopup();
|
||||
});
|
||||
|
||||
let selectionPopup = null;
|
||||
let selectionTimeout;
|
||||
|
||||
function initSelectionPopup() {
|
||||
// 避免重复创建
|
||||
if (document.getElementById('selection-popup')) return;
|
||||
|
||||
// 动态创建按钮
|
||||
selectionPopup = document.createElement('div');
|
||||
selectionPopup.id = 'selection-popup';
|
||||
// 这里用了 RemixIcon,你可以换成你自己的图标库
|
||||
selectionPopup.innerHTML = '<i class="ri-chat-quote-line"></i> <span>引用评论</span>';
|
||||
document.body.appendChild(selectionPopup);
|
||||
|
||||
// 防止在按钮上点击时触发选区清除
|
||||
selectionPopup.addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// 点击按钮的逻辑
|
||||
selectionPopup.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
let selection = window.getSelection().toString();
|
||||
if (selection) {
|
||||
// 简单的文本清理
|
||||
selection = selection.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line !== '')
|
||||
.join('\n');
|
||||
|
||||
// 限制长度,防止太长刷屏
|
||||
if (selection.length > 500) {
|
||||
selection = selection.substring(0, 500) + '...';
|
||||
}
|
||||
|
||||
scrollToCommentsAndQuote(selection);
|
||||
hideSelectionPopup();
|
||||
window.getSelection().removeAllRanges(); // 清除选区
|
||||
}
|
||||
});
|
||||
|
||||
// 监听全局选择事件
|
||||
document.addEventListener('mouseup', handleSelectionChange);
|
||||
document.addEventListener('keyup', handleSelectionChange);
|
||||
|
||||
// 页面滚动时隐藏
|
||||
document.addEventListener('scroll', hideSelectionPopup, { passive: true });
|
||||
}
|
||||
|
||||
function hideSelectionPopup() {
|
||||
if (selectionPopup) {
|
||||
selectionPopup.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectionChange(e) {
|
||||
// 防抖处理,别频繁触发
|
||||
clearTimeout(selectionTimeout);
|
||||
selectionTimeout = setTimeout(() => {
|
||||
const selection = window.getSelection();
|
||||
const text = selection.toString().trim();
|
||||
// 记得改成你文章容器的类名,我这里是 .post-content
|
||||
const content = document.querySelector('.post-content');
|
||||
|
||||
if (!selectionPopup || !content) return;
|
||||
|
||||
// 没选中文字就隐藏
|
||||
if (!text || selection.rangeCount === 0) {
|
||||
hideSelectionPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// 确保选中的是文章里的内容,别把侧边栏也选进去了
|
||||
if (!content.contains(range.commonAncestorContainer)) {
|
||||
hideSelectionPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 requestAnimationFrame 优化 UI 更新
|
||||
requestAnimationFrame(() => {
|
||||
selectionPopup.style.display = 'flex';
|
||||
|
||||
// 定位逻辑:鼠标抬起时跟随鼠标,键盘选择时跟随选区
|
||||
if (e && e.type === 'mouseup') {
|
||||
// 直接用鼠标坐标,比 getBoundingClientRect 性能好多了
|
||||
selectionPopup.style.top = `${e.clientY - 40}px`;
|
||||
selectionPopup.style.left = `${e.clientX}px`;
|
||||
} else {
|
||||
// 键盘操作没办法,只能计算矩形了
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
hideSelectionPopup();
|
||||
return;
|
||||
}
|
||||
selectionPopup.style.top = `${rect.top}px`;
|
||||
selectionPopup.style.left = `${rect.left + rect.width / 2}px`;
|
||||
}
|
||||
});
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// 跳转评论区并填充内容
|
||||
function scrollToCommentsAndQuote(text) {
|
||||
const comments = document.getElementById('Comments'); // 评论区 ID
|
||||
if (comments) {
|
||||
comments.scrollIntoView({ behavior: 'smooth' });
|
||||
|
||||
// 等滚动完了再填内容
|
||||
setTimeout(() => {
|
||||
const textarea = document.querySelector('.atk-textarea'); // Artalk 输入框 class
|
||||
if (textarea) {
|
||||
const quote = `> ${text}\n\n`;
|
||||
textarea.value = quote;
|
||||
textarea.focus();
|
||||
|
||||
// 触发 input 事件,适配 Vue/React 框架的数据绑定
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
textarea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 简单总结
|
||||
|
||||
代码复制进去,刷新页面(如果有缓存记得清一下),选中这段文字试试?
|
||||
|
||||
这个功能不仅能提高读者的互动率,还能让评论更有针对性。我也就是瞎折腾,如果有更好的实现方式,欢迎大家在评论区指教!
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
layout: post
|
||||
title: Hugo静态博客如何实现搜索功能
|
||||
date: 2026-01-28T02:00:00.000Z
|
||||
updated: 2026-01-28T02:00:00.000Z
|
||||
draft: false
|
||||
author: 小赵同学
|
||||
categories:
|
||||
- 建站
|
||||
tags:
|
||||
- Hugo
|
||||
- Search
|
||||
- PJAX
|
||||
- 折腾
|
||||
gemini_comment: null
|
||||
location: null
|
||||
show_theme: 1
|
||||
top_img: 'https://picsum.photos/800/400'
|
||||
description: 文章慢慢多了起来,有时候自己想找以前写的内容都得翻半天。于是抽空给博客加了个简单的搜索功能,没用什么高大上的第三方服务,纯前端实现,简单好用。
|
||||
ai_comment: >-
|
||||
作为同样被博客搜索问题困扰过的技术小白,看完默默打开了我的hexo配置文件...这种轻量级解决方案简直是我的救星!尤其是把搜索框藏在随笔标题里的小彩蛋,程序员的小幽默总是这么可爱。等我也搞定了就来评论区还愿,希望到时候别卡在json模板那一步(笑)。
|
||||
---
|
||||
|
||||
最近发现博客文章数量慢慢上来了,有时候想找自己以前写的某个知识点,翻半天都翻不到,确实有点不方便。
|
||||
|
||||
其实早就想加个搜索功能了,但看了一圈,像 Algolia 这种第三方服务虽然强大,但配置起来感觉有点繁琐,而且对于咱们这种访问量不大的静态博客来说,是不是有点“杀鸡用牛刀”了?
|
||||
|
||||
想了想,还是决定用最简单的方式来实现:直接生成一个 JSON 索引文件,用 JS 在前端搜一下。虽然比较基础,但胜在轻量,而且完全可控。
|
||||
|
||||
折腾了几个小时,终于搞定了,顺便把交互做得稍微优化了一下,支持了 PJAX 和暗黑模式。这里简单记录一下实现过程,希望能给同样在折腾 Hugo 的朋友一点参考。
|
||||
|
||||
## 效果展示
|
||||
|
||||
为了不破坏页面的简洁感,我把搜索入口藏在了两个地方:
|
||||
|
||||
1. **右下角悬浮工具栏**:点那个放大镜图标,会弹出一个搜索框。
|
||||
2. **首页“随笔”标题**:这个算是个小彩蛋吧。在首页找到“随笔”这两个字,鼠标放上去会有“点击搜索”的提示,点一下就能直接在原地搜索,不用跳转。
|
||||
|
||||
## 实现思路
|
||||
|
||||
原理其实非常简单,主要就三步:
|
||||
|
||||
1. 让 Hugo 构建时多生成一个 `index.json` 文件,里面包含所有文章的标题、链接和摘要。
|
||||
2. 前端 JS 通过 `fetch` 请求这个 JSON 文件。
|
||||
3. 根据用户输入的关键词,在 JSON 数据里进行匹配,然后把结果渲染出来。
|
||||
|
||||
## 代码实现
|
||||
|
||||
### 1. 配置 Hugo 输出 JSON
|
||||
|
||||
首先在 `hugo.toml` (或者 `config.toml`) 里配置一下输出格式,告诉 Hugo 首页除了 HTML 还要输出 JSON。
|
||||
|
||||
```toml
|
||||
[outputs]
|
||||
home = ["HTML", "RSS", "JSON"]
|
||||
```
|
||||
|
||||
### 2. 创建 JSON 模板
|
||||
|
||||
在主题的 `layouts/index.json` 创建一个模板文件,定义 JSON 的数据结构。这里我只取了标题、日期、链接和摘要,尽量让文件小一点。
|
||||
|
||||
```go
|
||||
[
|
||||
{{- range $index, $e := where .Site.RegularPages "Type" "post" -}}
|
||||
{{- if $index -}}, {{- end -}}
|
||||
{
|
||||
"title": {{ .Title | jsonify }},
|
||||
"date": {{ .Date.Format "2006-01-02" | jsonify }},
|
||||
"permalink": {{ .Permalink | jsonify }},
|
||||
"summary": {{ .Summary | plainify | jsonify }}
|
||||
}
|
||||
{{- end -}}
|
||||
]
|
||||
```
|
||||
|
||||
这样每次 `hugo` 构建的时候,网站根目录就会生成一个 `index.json`。
|
||||
|
||||
### 3. 前端 JS 逻辑
|
||||
|
||||
逻辑主要在 `main.js` 里。为了提升体验,我做了一些小优化:
|
||||
|
||||
* **懒加载**:不会一打开网页就下载 JSON,而是等你点击搜索按钮时才去加载,帮大家省点流量。
|
||||
* **防抖处理**:输入时不会每敲一个字都去搜索,而是稍微停顿一下再触发,避免频繁计算。
|
||||
* **PJAX 适配**:因为博客用了 PJAX 做无刷新跳转,一开始遇到个坑,点击搜索结果页面会白屏或者刷新。后来发现需要在渲染结果后,手动调用一下 `window.pjax.refresh()`,让 PJAX 重新接管这些新生成的链接。
|
||||
|
||||
```javascript
|
||||
// 简单的搜索逻辑
|
||||
const results = window.searchIndex.filter(item => {
|
||||
// 简单的关键词匹配
|
||||
return item.title.includes(query) || item.summary.includes(query);
|
||||
});
|
||||
displayResults(results);
|
||||
```
|
||||
|
||||
### 4. 那个“随笔”变搜索框的交互
|
||||
|
||||
这个交互其实主要是 CSS 在控制。
|
||||
|
||||
默认是一个普通的 `<h2>` 标题,点击后通过 JS 隐藏标题,显示输入框。为了过渡自然一点,加了一些简单的 CSS 动画。
|
||||
|
||||
```css
|
||||
/* 默认隐藏提示 */
|
||||
.search-hint {
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 鼠标放上去显示 */
|
||||
.section-header.normal-mode:hover .search-hint {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
```
|
||||
|
||||
为了照顾移动端用户,我在 CSS 里加了媒体查询,手机上直接显示“点击搜索”的提示,不然手机上没有 hover 状态,可能会不知道这里可以点。
|
||||
|
||||
## 遇到的坑
|
||||
|
||||
折腾过程中最大的坑还是 **PJAX**。
|
||||
|
||||
一开始写好的时候,搜索功能本身没问题,但是点搜索结果跳转的时候,要么页面刷新,要么音乐播放器断了。查了半天文档,才发现是因为动态插入的 HTML(搜索结果列表),PJAX 默认是监听不到的。
|
||||
|
||||
解决办法就是在插入 HTML 后,手动通知一下 PJAX:
|
||||
|
||||
```javascript
|
||||
if (window.pjax) window.pjax.refresh(searchResults);
|
||||
```
|
||||
|
||||
另外,为了体验好一点,我还加了个逻辑:点击搜索结果后,自动关闭搜索框,不然跳转完搜索框还挡在那里,确实有点傻。
|
||||
|
||||
## 总结
|
||||
|
||||
虽然只是个小功能,但前前后后也调了好久,特别是细节上的打磨。不过看到最后丝滑的搜索体验,感觉还是挺值得的。
|
||||
|
||||
如果你也在用 Hugo,不想接复杂的第三方搜索,不妨试试这个方案,轻量、简单,完全够用了。
|
||||
|
||||
代码就不全贴了,核心逻辑都在上面,有兴趣的朋友可以自己折腾一下。如果有更好的实现方式,也欢迎留言交流哈!
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
layout: post
|
||||
title: 给Hugo博客添加瀑布流相册功能
|
||||
date: 2026-01-30T13:00:00+08:00
|
||||
updated: 2026-01-30T13:00:00+08:00
|
||||
draft: false
|
||||
author: 小赵同学
|
||||
categories:
|
||||
- 折腾
|
||||
- 建站
|
||||
tags:
|
||||
- Hugo
|
||||
- CSS
|
||||
- 瀑布流
|
||||
- 相册
|
||||
ai_comment: "哇,这个瀑布流相册的效果真的很棒!感觉比普通的方格布局更有生活气息。用 Hugo Data Templates 分离数据的思路也很清晰,以后维护起来应该很方便。我也去折腾一下我的博客试试!😄"
|
||||
gemini_comment:
|
||||
location:
|
||||
show_theme: 1
|
||||
top_img: https://picsum.photos/800/400
|
||||
---
|
||||
|
||||
前两天逛博客,偶然看到了 [immmmm](https://immmmm.com/album/) 大佬的相册页面,觉得那种瀑布流的布局方式展示照片很有感觉,错落有致。
|
||||
|
||||
正好最近也想整理一下自己的照片,于是想着能不能也在自己的 Hugo 博客上实现类似的效果。
|
||||
|
||||
自己也是在摸索中前进,参考了不少前辈的教程,这里简单记录一下实现的思路,希望能给同样有需求的朋友一点参考。
|
||||
|
||||
## 实现思路
|
||||
|
||||
我的想法比较简单,主要是想利用 Hugo 的 `Data Templates` 功能,把照片的数据和页面的展示分离开来,这样管理起来比较方便。
|
||||
|
||||
1. **数据层**:用 YAML 文件存储照片的链接、描述等信息。
|
||||
2. **展示层**:编写一个专门的 Layout 模板来读取并渲染这些数据。
|
||||
3. **样式层**:尝试使用 CSS 的 `column-count` 属性来实现瀑布流布局。
|
||||
|
||||
## 第一步:准备数据
|
||||
|
||||
首先在 `data` 目录下新建了一个 `album.yml` 文件。
|
||||
|
||||
格式如下,每张照片作为一个 item:
|
||||
|
||||
```yaml
|
||||
- title: "随手拍的风景"
|
||||
image: "https://picsum.photos/300/400"
|
||||
date: "2024-01-29"
|
||||
description: "记录生活中的一角"
|
||||
|
||||
- title: "本地图片测试"
|
||||
image: "/album/my-photo.jpg" # 图片放在 static/album/ 目录下
|
||||
date: "2026-01-30"
|
||||
```
|
||||
|
||||
这样以后添加照片,只需要在这个文件里追加内容就可以了。
|
||||
|
||||
## 第二步:编写模板
|
||||
|
||||
接着在 `layouts/_default/` 下新建了 `album.html` 模板文件。
|
||||
|
||||
主要逻辑是遍历 `site.Data.album` 中的数据:
|
||||
|
||||
```html
|
||||
{{ define "main" }}
|
||||
<div id="pjax-container">
|
||||
<div class="post-content">
|
||||
<div class="album-content">
|
||||
{{ range site.Data.album }}
|
||||
<div class="album-item">
|
||||
<div class="album-img-box">
|
||||
<img src="{{ .image }}" loading="lazy">
|
||||
{{ if .date }}<span class="album-date-badge">{{ .date }}</span>{{ end }}
|
||||
<div class="album-info">
|
||||
<span class="album-title">{{ .title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
为了优化加载体验,给 `img` 标签加上了 `loading="lazy"` 属性。
|
||||
|
||||
## 第三步:CSS 样式
|
||||
|
||||
瀑布流的实现方式有很多种,我选择了一种比较简单的 CSS 方案:
|
||||
|
||||
```css
|
||||
.album-content {
|
||||
column-count: 4; /* 设置列数 */
|
||||
column-gap: 10px;
|
||||
}
|
||||
|
||||
.album-item {
|
||||
break-inside: avoid; /* 防止元素被分割 */
|
||||
margin-bottom: 10px;
|
||||
/* 简单的淡入动画 */
|
||||
animation: fadeIn 0.6s ease forwards;
|
||||
}
|
||||
|
||||
/* 简单的移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.album-content {
|
||||
column-count: 2;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
使用 `column-count` 可以快速实现多列布局,配合 `break-inside: avoid` 避免图片在列之间断开。虽然可能不如 JS 库那么完美,但对于简单的相册展示来说,我觉得够用了。
|
||||
|
||||
## 一些小细节
|
||||
|
||||
在实现过程中,也做了一点微小的调整:
|
||||
|
||||
1. **加载动画**:加了一个简单的淡入效果,让图片出现得不那么突兀。
|
||||
2. **本地图片**:在 `static` 目录下建了 `album` 文件夹,方便存放一些本地的照片。
|
||||
3. **导航调整**:考虑到手机屏幕较小,在移动端隐藏了相册的导航入口,保持界面的整洁。
|
||||
|
||||
## 总结
|
||||
|
||||
折腾了一番,总算是把相册页面搭建起来了。虽然代码写得比较简单,样式也还有优化的空间,但看着自己的照片能以这样的方式展示出来,还是挺开心的。
|
||||
|
||||
如果有更好的实现方式,也欢迎大家指教。
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: Artalk评论区接入AI摘要的尝试
|
||||
date: 2026-01-31T13:20:00.000Z
|
||||
draft: false
|
||||
categories:
|
||||
- 尝试
|
||||
- 技术
|
||||
tags:
|
||||
- Artalk
|
||||
- AI
|
||||
- GitHub Actions
|
||||
- SiliconFlow
|
||||
ai_comment: >-
|
||||
哈哈,现在连AI都开始抢我们读者的活了吗?不过这个思路挺巧妙,把摘要伪装成评论既自然又省空间。看到代码里那句“跳过已生成的文章”突然笑出声——AI也怕重复劳动啊!期待哪天我的评论被AI点赞(或者被AI吐槽?)
|
||||
---
|
||||
|
||||
最近看到很多博客都加上了 AI 摘要功能,觉得挺有意思,于是也尝试给自己的博客加了一个。
|
||||
|
||||
我的需求很简单:
|
||||
1. **自动生成**:发布新文章时自动调用 API 生成摘要。
|
||||
2. **融入评论区**:不想额外增加 UI 模块,最好能像一条普通的“置顶评论”一样展示。
|
||||
3. **零成本**:利用现有的免费 API(DeepSeek V3)。
|
||||
|
||||
目前已经实现,效果还算满意。简单记录一下实现过程。
|
||||
|
||||
## 1. 后端:生成 AI 评论
|
||||
|
||||
写了一个简单的 Node.js 脚本,遍历文章目录,提取正文并调用 SiliconFlow 的 API 生成评论,最后回写到 Front Matter 中。
|
||||
|
||||
核心代码 (`scripts/ai_comment.js`):
|
||||
|
||||
```javascript
|
||||
// ... (imports & config)
|
||||
|
||||
async function generateComment(content) {
|
||||
const prompt = `你是一个热情的博客读者。请阅读以下文章内容,并写一段简短的评论(100字以内)。
|
||||
...
|
||||
文章内容:${content.substring(0, 2000)}...`;
|
||||
|
||||
// 调用 SiliconFlow API
|
||||
const response = await axios.post(SILICONFLOW_URL, {
|
||||
model: 'deepseek-ai/DeepSeek-V3',
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
// ...
|
||||
}, { headers: { 'Authorization': `Bearer ${process.env.SILICONFLOW_API_KEY}` } });
|
||||
|
||||
return response.data.choices[0].message.content.trim();
|
||||
}
|
||||
|
||||
// 遍历文件并处理
|
||||
async function processFiles(dir) {
|
||||
// ...
|
||||
if (parsed.data.ai_comment) return; // 跳过已生成的文章
|
||||
|
||||
const comment = await generateComment(cleanContent);
|
||||
if (comment) {
|
||||
parsed.data.ai_comment = comment;
|
||||
// 回写文件
|
||||
await fs.promises.writeFile(filePath, matter.stringify(parsed.content, parsed.data));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 自动化:GitHub Actions
|
||||
|
||||
为了省去手动运行脚本的麻烦,将其集成到了 CI/CD 流程中。
|
||||
|
||||
在 `.github/workflows/upy.yml` 中添加步骤:
|
||||
|
||||
```yaml
|
||||
# 生成 AI 评论
|
||||
- name: Generate AI Comments
|
||||
env:
|
||||
SILICONFLOW_API_KEY: ${{ secrets.SILICONFLOW_API_KEY }}
|
||||
run: node scripts/ai_comment.js
|
||||
|
||||
# 提交变更 (注意需要 permissions: contents: write)
|
||||
- name: Commit AI Comments
|
||||
run: |
|
||||
git config --global user.name "GitHub Actions"
|
||||
git config --global user.email "actions@github.com"
|
||||
git add content/post
|
||||
if ! git diff-index --quiet HEAD; then
|
||||
git commit -m "chore: auto-generate ai comments [skip ci]"
|
||||
git push
|
||||
fi
|
||||
```
|
||||
|
||||
## 3. 前端:Artalk 集成
|
||||
|
||||
最后一步是在前端展示。利用 Artalk 的事件机制,将 AI 评论动态插入到评论列表头部,并加上打字机特效。
|
||||
|
||||
核心代码 (`assets/js/main.js`):
|
||||
|
||||
```javascript
|
||||
window.artalk.on('list-loaded', function() {
|
||||
// 检查是否有 AI 评论数据
|
||||
if (window.artalkConfig.aiComment && !document.querySelector('.atk-ai-comment')) {
|
||||
var listBody = document.querySelector('.atk-list-body') || document.querySelector('.atk-list');
|
||||
|
||||
if (listBody) {
|
||||
// 避免同时出现“此时无声胜有声”
|
||||
var noComment = document.querySelector('.atk-list-no-comment');
|
||||
if (noComment) noComment.style.display = 'none';
|
||||
|
||||
// 创建 DOM 结构
|
||||
var aiDiv = document.createElement('div');
|
||||
aiDiv.className = 'atk-comment atk-ai-comment';
|
||||
aiDiv.innerHTML = `...`; // 填充 HTML
|
||||
|
||||
// 插入到列表头部
|
||||
listBody.prepend(aiDiv);
|
||||
|
||||
// ... (打字机效果)
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 4. 适配 PJAX
|
||||
|
||||
由于博客使用了 PJAX,页面切换时需要重新初始化 Artalk。我们将初始化逻辑封装在 `initArtalk` 函数中,并在 `pjax:complete` 事件中调用。
|
||||
|
||||
```javascript
|
||||
// mypjax.js
|
||||
document.addEventListener("pjax:complete", function () {
|
||||
// ...
|
||||
// 重载评论
|
||||
if (typeof window.initArtalk === 'function') {
|
||||
window.initArtalk();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
这样就完成了一个简单实用的 AI 摘要功能。既丰富了页面内容,又没有过度干扰阅读体验。
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
title: 博客友链实时健康监测方案
|
||||
date: 2026-02-01T02:00:00.000Z
|
||||
draft: false
|
||||
categories:
|
||||
- 技术
|
||||
tags:
|
||||
- GitHub Actions
|
||||
- Node.js
|
||||
- 博客优化
|
||||
- 自动化
|
||||
ai_comment: >-
|
||||
博主这个友链监测功能太机智了!简直是互联网时代的"防火防盗防友链"啊。看到备案那段真的会心一笑,现在做个站长不仅要会写文章,还得学会当网络保安。不过用GitHub
|
||||
Actions自动化解决确实优雅,比我手动检查时疯狂右键刷新体面多了。下次可以考虑加个自动发送哀悼邮件给失效友链的功能?(狗头)
|
||||
---
|
||||
|
||||
最近给博客加上了一个实用的功能:**友链实时健康监测**。
|
||||
|
||||
起因是备案审查越来越严格。对于个人博客来说,如果友链中的某个域名过期被抢注,摇身一变成为博彩或色情网站,而我的博客还挂着它的链接,那么我的域名很有可能因为“导流违规内容”而被注销备案,甚至面临关站风险。
|
||||
|
||||
为了避免这种“人在家中坐,锅从天上来”的情况,手动每天一个个点开检查显然是不现实的。于是,利用 GitHub Actions 和简单的 Node.js 脚本实现自动化监测,就成了最佳解决方案。
|
||||
|
||||
## 实现思路
|
||||
|
||||
整体流程分为三个环节:
|
||||
|
||||
1. **数据源**:读取博客现有的友链配置文件(YAML 格式)。
|
||||
2. **监测脚本**:编写 Node.js 脚本,利用并发和连接复用技术,批量检测 HTTP 状态码。
|
||||
3. **自动化与通知**:通过 GitHub Actions 定时执行,生成状态报告(JSON),并在发现异常时发送邮件通知。
|
||||
4. **前端展示**:在页面上以不打扰的 UI 风格(黑白极简风格)展示友链状态。
|
||||
|
||||
## 1. 编写监测脚本
|
||||
|
||||
在 `scripts/check_links.js` 中,我使用了 `axios` 发送请求,并配合 `https.Agent` 实现了连接复用(Keep-Alive),同时通过并发池控制请求速率。
|
||||
|
||||
这个脚本的核心亮点是:
|
||||
- **并发控制**:开启 10 个 Worker 并行检测,大幅缩短耗时。
|
||||
- **连接复用**:开启 `keepAlive: true`,减少 TCP/TLS 握手开销。
|
||||
- **智能重试**:优先使用 `HEAD` 请求(省流量),失败后自动降级为 `GET` 请求。
|
||||
- **异常汇总**:将失效链接写入 `failed_links_summary.txt`,用于后续邮件通知。
|
||||
|
||||
```javascript
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
const axios = require('axios');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
// ... 路径配置 ...
|
||||
|
||||
// 优化 Axios 实例:开启 Keep-Alive 复用连接
|
||||
const axiosInstance = axios.create({
|
||||
timeout: 10000,
|
||||
maxRedirects: 5,
|
||||
httpAgent: new http.Agent({ keepAlive: true }),
|
||||
httpsAgent: new https.Agent({ keepAlive: true }),
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 ...' // 模拟浏览器 UA
|
||||
},
|
||||
validateStatus: (status) => status >= 200 && status < 400
|
||||
});
|
||||
|
||||
async function checkUrl(url) {
|
||||
try {
|
||||
await axiosInstance.head(url);
|
||||
return true;
|
||||
} catch (error) {
|
||||
try {
|
||||
await axiosInstance.get(url); // HEAD 失败尝试 GET
|
||||
return true;
|
||||
} catch (err2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ... 并发 Worker 逻辑 ...
|
||||
```
|
||||
|
||||
## 2. 配置 GitHub Actions
|
||||
|
||||
为了保证数据的实时性,我在 `.github/workflows/upy.yml` 中配置了定时任务。
|
||||
|
||||
- **定时触发**:每 12 小时自动运行一次 (`cron: '0 */12 * * *'`)。
|
||||
- **邮件通知**:如果发现失效友链,Workflow 会读取脚本生成的摘要文件,并通过邮件推送到我的邮箱。
|
||||
|
||||
```yaml
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */12 * * *' # 每12小时自动检查一次
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
steps:
|
||||
# ... 其他步骤 ...
|
||||
|
||||
- name: Check Link Status
|
||||
run: node scripts/check_links.js
|
||||
|
||||
- name: Read Failed Links Summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f failed_links_summary.txt ]; then
|
||||
echo "LINK_SUMMARY<<EOF" >> $GITHUB_ENV
|
||||
cat failed_links_summary.txt >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# ... 构建步骤 ...
|
||||
|
||||
- name: Send deployment success notification
|
||||
if: success()
|
||||
uses: dawidd6/action-send-mail@v3
|
||||
with:
|
||||
# ... 邮件配置 ...
|
||||
body: |
|
||||
# ... 其他信息 ...
|
||||
|
||||
📊 友链监测报告
|
||||
----------------------------------
|
||||
${{ env.LINK_SUMMARY }}
|
||||
```
|
||||
|
||||
## 3. 前端展示:极简设计
|
||||
|
||||
为了不破坏博客原本的黑白极简风格,我并没有使用传统的“红/绿”大色块,而是设计了一套低调的状态指示器。
|
||||
|
||||
### JS 逻辑 (`main.js`)
|
||||
|
||||
前端脚本会异步加载 `link_status.json`,并智能匹配 URL(自动处理 `https` 和尾部 `/` 的差异)。
|
||||
|
||||
```javascript
|
||||
window.initLinkStatus = function() {
|
||||
fetch('/link_status.json?t=' + new Date().getTime())
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// ... 遍历 DOM 元素 ...
|
||||
// getUrlStatus 逻辑:尝试匹配 url, url/, https/http 变体
|
||||
|
||||
if (status === 'alive') {
|
||||
$item.append('<span class="link-active-badge" title="可访问"></span>');
|
||||
} else if (status === 'dead') {
|
||||
$item.append('<span class="link-dead-badge" title="无法访问"></span>');
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### CSS 样式 (`main.css`)
|
||||
|
||||
这里花了一些心思调整配色。为了避免“红绿灯”配色的突兀感:
|
||||
|
||||
- **正常状态**:使用**实心灰点** (`#9ca3af`),在暗色模式下为浅灰 (`#d1d5db`)。
|
||||
- **失效状态**:使用**空心灰圈**(白色/深色中心 + 灰色边框)。
|
||||
|
||||
这种设计既能传达状态信息,又完美融入了黑白主题。
|
||||
|
||||
```css
|
||||
/* Card View - Active (实心灰点) */
|
||||
.link-active-badge {
|
||||
position: absolute;
|
||||
top: 8px; right: 8px;
|
||||
width: 8px; height: 8px;
|
||||
background-color: #9ca3af;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
/* Card View - Dead (空心灰圈) */
|
||||
.link-dead-badge {
|
||||
position: absolute;
|
||||
top: 8px; right: 8px;
|
||||
width: 8px; height: 8px;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #9ca3af; /* 灰色边框形成空心效果 */
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
这套方案最大的好处是**零成本、自动化且优雅**。
|
||||
|
||||
1. **零成本**:完全利用 GitHub Actions 的计算资源。
|
||||
2. **自动化**:定时检查 + 邮件报警,无需人工干预。
|
||||
3. **优雅**:前端 UI 克制且统一,不喧宾夺主。
|
||||
|
||||
对于注重 SEO 和网站安全的博主来说,这是一个性价比极高的“基建”工作。既对自己负责,也对访客负责。毕竟,谁也不想点击一个链接后发现是 404,或者跳转到奇怪的页面吧。
|
||||
Reference in new issue
Block a user