🚀 Hexo 博客终极搭建与定制指南

本文档记录了基于 Hexo + FlatPaper 主题 + Vercel + Qexo 的完整博客架构,以及如何打造专属的手账风互动系统。

🏗️ 核心架构与工作流

目前的博客采用了双轨制工作流:

⚙️ 后台与云端部署 (Qexo 避坑指南)

关键避坑:

🎨 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>

在侧边栏头像下方,隐藏原本丑陋的空心社交圆圈,替换为带有手账虚线风格的文学书摘。

修改文件: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. 图片命名为 标签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}》的口琴谱。
![${title}](/images/harmonica/${file})
`;
    fs.writeFileSync(path.join(postsDir, `${title}.md`), mdContent, 'utf-8');
    console.log(`✅ 生成:《${title}》`);
});

💡 日常维护提醒

© 2026 专属折腾记录 - 生成于本地环境