🚀 Hexo 博客终极搭建与定制指南
本文档记录了基于 Hexo + FlatPaper 主题 + Vercel + Qexo 的完整博客架构,以及如何打造专属的手账风互动系统。
🏗️ 核心架构与工作流
目前的博客采用了双轨制工作流:
- 云端托管 源码存放在 GitHub 的 私有仓库(
hexo-source分支),由 Vercel 监听并自动构建上线。 - 云端后台 Qexo(部署在 Vercel),用于随时随地掏出手机发随笔、改错字。
- 本地开发 使用 VS Code 进行主题魔改、布局调整,以及批量发布口琴谱。
⚙️ 后台与云端部署 (Qexo 避坑指南)
关键避坑:
- 数据库:在 Vercel 创建 Neon Postgres 数据库,并绑定。
- 环境变量:Vercel 中必须配置
DOMAINS白名单,如["myqexo.vercel.app", "admin.jinx1u.top"]。 - 密钥:必须手动配置
DJANGO_SECRET_KEY(随便敲一串50位乱码)。 - Node 版本:Vercel 的 Settings 中把 Node.js Version 改为 18.x,防止构建报错。
🎨 FlatPaper 主题定制
1. 隐藏左侧边栏 & 与导航栏同宽
📄 CSS - 隐藏边栏并居中
/* 隐藏左侧边栏 */
aside#paper-sidebar-drawer, .paper-sidebar { display: none !important; }
/* 与顶部导航栏对齐(假设 1300px) */
.paper-main { width: 100% !important; max-width: 1300px !important; margin: 0 auto !important; }
/* 隐藏顶部的“页面”绿标签 */
.label-pill { display: none !important; }
2. 主页隐藏特定分类的文章
📄 YAML - 注入隐藏文章的脚本
inject:
bottom:
- '<script>document.addEventListener("DOMContentLoaded",function(){ if(window.location.pathname !== "/" && window.location.pathname !== "/index.html") return; document.querySelectorAll(".post-card, .article-card, article").forEach(function(card){if(card.innerHTML.includes("口琴谱")||card.innerHTML.includes("/harmonica/")){card.style.display="none";}});});</script>'
🎵 专属“口琴谱”页面开发
页面文件:source/harmonica/index.md(layout: page)。核心逻辑:通过 fetch('/content.json') 获取全站数据,提取分类为“口琴谱”的文章,生成吸顶的彩色抽拉胶带和卡片网格。
📄 CSS - 吸顶胶带与卡片网格
/* 顶部吸顶胶带 */
.hp-tags-container {
display: flex; align-items: flex-end; height: 130px;
position: sticky; top: 70px; z-index: 99;
background-color: #fcf9f2; border-bottom: 2px dashed #d0c8b8;
}
.hp-tag-btn {
height: 45px; transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: center bottom; /* 悬停向上抽拉 */
}
.hp-tag-btn.active { height: 110px; border-color: #888; } /* 选中拉得更长 */
/* 卡片网格 */
.hp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
💬 Twikoo 评论系统部署 (支持匿名)
使用 MongoDB Atlas 作为免费数据库,Vercel 部署后端。
📄 YAML - 前端连接 Twikoo 后端
comments: twikoo
twikoo:
envId: https://你的twikoo后端域名.vercel.app
# 如果提示版本低,锁定 CDN 版本与后端一致
cdn: https://cdn.jsdelivr.net/npm/twikoo@1.6.42/dist/twikoo.all.min.js
🔐 管理员登录(找暗号)
在评论区昵称输入框,输入暗号后回车,即可召唤出齿轮登录入口。
找暗号方法:按 F12 打开控制台,选 Network 刷新页面,搜索 HIDE_ADMIN_CRYPT,找到 JSON 响应里的值。
✍️ 专属说说系统 (后端鉴权 + 物理隔离)
踩坑经验:千万别把美化 CSS 直接写在全局
inject 里,否则会把所有文章的正常评论框隐藏掉!必须用 JS 判断路径 /shuoshuo/ 后再动态注入。
1. 后端:Vercel 鉴权 API
在 Vercel 的 Twikoo 项目的 api 文件夹下新建 publish.js。
📄 JavaScript - api/publish.js (后端鉴权)
// api/publish.js
export default async function handler(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.status(200).end();
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
const { content, password } = req.body;
if (password !== process.env.SHUOSHUO_PASSWORD) {
return res.status(401).json({ error: '暗号错误,拒绝发布!' });
}
if (!content) {
return res.status(400).json({ error: '内容不能为空' });
}
try {
const twikooRes = await fetch(process.env.TWIKOO_ENV_ID, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'COMMENT_SUBMIT',
url: '/shuoshuo/',
href: 'https://j1nx1u.top/shuoshuo/',
nick: '月禾',
mail: 'shuoshuo@j1nx1u.top', // 🔥 专属马甲邮箱
comment: content,
ua: req.headers['user-agent'] || 'Mozilla/5.0'
})
});
const twikooData = await twikooRes.json();
if (twikooData.code && twikooData.code !== 0) {
throw new Error(twikooData.message || 'Twikoo 写入失败');
}
return res.status(200).json({ success: true });
} catch (error) {
return res.status(500).json({ error: error.message });
}
}
2. 前端:严格的物理隔离注入
在 _config.flatpaper.yml 的 inject -> bottom 中注入。这段代码会自动判断路径,只有在说说页面才会生成 CSS 并执行 DOM 替换。
📄 YAML - 说说页面注入魔法 (严格隔离)
inject:
bottom:
- >-
<script>
document.addEventListener("DOMContentLoaded", function() {
// 🔥 核心拦截:如果当前路径不是说说页面,立刻停止,什么都不做!
if (window.location.pathname.indexOf("/shuoshuo") === -1) return;
// 动态创建并注入 CSS,绝对不污染其他页面
var style = document.createElement("style");
style.innerHTML = `
.tk-submit, .comments-section__head, .tk-comments-title, .tk-row.actions, .tk-footer, .label-pill { display: none !important; }
.article-header, .article-content > p:first-child, .article-tape-wrap { display: none !important; }
article.article-paper { background: transparent !important; border: none !important; box-shadow: none !important; padding: 0 !important; margin-top: 0 !important; }
.article-content { padding-top: 0 !important; margin-top: 0 !important; }
main.paper-shell { margin-top: 10px !important; }
.tk-comment {
background: #fff !important; border: 1px dashed #d0c8b8 !important;
border-radius: 8px !important; padding: 20px !important; margin-bottom: 25px !important;
box-shadow: 3px 5px 15px rgba(0,0,0,0.03) !important; position: relative;
}
.tk-comment::before {
content: ""; position: absolute; top: -8px; left: 20px;
width: 40px; height: 15px; background: rgba(255, 209, 220, 0.85);
transform: rotate(-3deg); z-index: 10;
}
.tk-comment:nth-child(2n)::before { background: rgba(181, 228, 202, 0.85); left: 30px; transform: rotate(2deg); }
.tk-comment:nth-child(3n)::before { background: rgba(253, 245, 184, 0.85); left: 15px; transform: rotate(-1deg); }
.shuoshuo-editor {
background: #fff; border: 1px dashed #d0c8b8; border-radius: 8px;
padding: 15px; margin-bottom: 25px; position: relative; box-shadow: 3px 5px 15px rgba(0,0,0,0.03);
}
.shuoshuo-editor::before {
content: ''; position: absolute; top: -8px; left: 20px;
width: 40px; height: 15px; background: rgba(255, 209, 220, 0.85); transform: rotate(-3deg); z-index: 10;
}
#my-shuoshuo-content { width: 100%; min-height: 80px; border: none; outline: none; resize: vertical; font-family: inherit; font-size: 15px; color: #333; background: transparent; line-height: 1.6; }
.shuoshuo-toolbar { text-align: right; margin-top: 10px; border-top: 1px dashed #f0f0f0; padding-top: 10px; }
#btn-send-shuoshuo { background: #b5e4ca; color: #333; border: none; padding: 6px 18px; border-radius: 20px; font-weight: bold; cursor: pointer; transition: all 0.2s; }
#btn-send-shuoshuo:hover { background: #9dd4b6; transform: scale(1.05); }
`;
document.head.appendChild(style);
setInterval(function() {
var tkComments = document.querySelector(".tk-comments");
if (!tkComments) return;
document.querySelectorAll(".tk-submit, .comments-section__head, .tk-comments-title, .tk-row.actions, .tk-footer").forEach(el => el.remove());
var myEditor = document.getElementById("shuoshuo-editor-box");
if (myEditor && !tkComments.contains(myEditor)) {
tkComments.insertBefore(myEditor, tkComments.firstChild);
}
}, 300);
var btn = document.getElementById("btn-send-shuoshuo");
if(!btn) return;
btn.addEventListener("click", function() {
var content = document.getElementById("my-shuoshuo-content").value.trim();
if(!content) return alert("内容不能为空哦!");
var pwd = prompt("请输入博主暗号才能发布:");
if(!pwd) return;
btn.innerText = "发送中..."; btn.disabled = true;
fetch("https://twikoo-backend-j1nx1u.vercel.app/api/publish", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({content: content, password: pwd})
}).then(res => res.json()).then(data => {
if(data.success){ document.getElementById("my-shuoshuo-content").value = ""; alert("发布成功!"); window.location.reload(); }
else { alert(data.error || "发送失败"); }
}).catch(err => alert("网络错误")).finally(() => { btn.innerText = "🚀 发送动态"; btn.disabled = false; });
});
});
</script>
📚 侧边栏一言书摘 (Hitokoto)
在侧边栏头像下方,隐藏原本丑陋的空心社交圆圈,替换为带有手账虚线风格的文学书摘。
修改文件:
将原本包裹社交图标的
themes/flatpaper/layout/_partial/sidebar-right.ejs将原本包裹社交图标的
<div class="socials">...</div> 替换为以下代码。
📄 HTML/EJS - 书摘结构替换
<!-- 🔥 隐藏原本的社交圆圈 -->
<style>
.side-card .socials { display: none !important; }
</style>
<!-- 🔥 书摘小部件 -->
<div class="sidebar-quote-widget" id="sidebar-quote" style="margin-top: 20px; padding-top: 15px; border-top: 1px dashed #d0c8b8; text-align: center; font-size: 0.9em; color: #666; font-style: italic; line-height: 1.6;">
<span class="quote-text">正在翻阅书页...</span>
</div>
在 sidebar-right.ejs 最底部 </aside> 之前,加入请求 API 的脚本:
📄 JavaScript - 请求 Hitokoto 书摘
<script>
document.addEventListener("DOMContentLoaded", function() {
var quoteWidget = document.getElementById("sidebar-quote");
if (!quoteWidget) return;
// 请求文学书摘 (c=d 代表文学,c=i 代表诗词,c=k 代表哲学)
fetch('https://v1.hitokoto.cn/?c=d')
.then(response => response.json())
.then(data => {
var text = `「${data.hitokoto}」`;
var from = data.from ? `<span style="display:block; margin-top:8px; font-size:0.85em; color:#999; font-style:normal;">—— ${data.from}</span>` : '';
quoteWidget.querySelector('.quote-text').innerHTML = text + from;
})
.catch(error => {
quoteWidget.querySelector('.quote-text').innerText = '书页被风吹乱了...';
});
});
</script>
🤖 本地批量发布口琴谱 (auto_post.js)
操作流程:
1. 图片命名为
2. 丢进根目录的
3. 终端执行
4. 终端执行
1. 图片命名为
标签1_标签2_曲名.jpg。2. 丢进根目录的
待发布琴谱 文件夹。3. 终端执行
node auto_post.js,自动生成 Markdown 文件。4. 终端执行
git push origin hexo-source 一键上线。
📄 Node.js - auto_post.js 批量处理脚本
const fs = require('fs');
const path = require('path');
const uploadDir = path.join(__dirname, '待发布琴谱');
const targetImgDir = path.join(__dirname, 'source', 'images', 'harmonica');
const postsDir = path.join(__dirname, 'source', '_posts');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
if (!fs.existsSync(targetImgDir)) fs.mkdirSync(targetImgDir, { recursive: true });
const files = fs.readdirSync(uploadDir).filter(f => /\.(jpg|jpeg|png)$/i.test(f));
if (files.length === 0) return console.log('⚠️ 待发布琴谱文件夹为空!');
files.forEach(file => {
const baseName = path.basename(file, path.extname(file));
const parts = baseName.split('_');
let title = baseName;
let tags = ['C调'];
if (parts.length > 1) {
title = parts.pop();
tags = parts;
}
fs.copyFileSync(path.join(uploadDir, file), path.join(targetImgDir, file));
const dateStr = new Date().toISOString().replace('T', ' ').substring(0, 19);
const tagsYaml = tags.map(t => ` - ${t}`).join('\n');
const mdContent = `---
title: ${title}
date: ${dateStr}
categories:
- 口琴谱
tags:
${tagsYaml}
cover: /images/harmonica/${file}
---
这里是《${title}》的口琴谱。

`;
fs.writeFileSync(path.join(postsDir, `${title}.md`), mdContent, 'utf-8');
console.log(`✅ 生成:《${title}》`);
});
💡 日常维护提醒
- Git 代理:如果推送时超时,开启 Clash,终端执行
git config --global http.proxy http://127.0.0.1:7890。 - Qexo 缓存:如果发完文章线上没变,去后台清除缓存或手动 Redeploy。
- 图片路径:Markdown 插入图片统一用
/images/路径,不要带source前缀。
© 2026 专属折腾记录 - 生成于本地环境