第一篇文章里记录的是“页面为什么一直卡在加载中”。这篇文章接着记录后面的事情:博客恢复构建之后,我为什么又决定从 Shoka 迁移到 ShokaX,以及一个原本只用来放 Markdown 的 Hexo 项目,最后是怎样逐渐长出一个本地管理台的。
回头看,这次工作并不是简单地更换一个主题目录。旧博客已经积累了文章、分类、标签、图床、评论、搜索、导航和 Live2D 配置,每一项都与主题存在不同程度的耦合。如果只关注首页能不能打开,很容易等到迁移完成后才发现评论对不上文章、图片版本失效、搜索没有新索引,或者关于页和友链只能继续手改 YAML。
因此我把整个过程拆成了四条相互关联的线:主题与配置迁移、LeanCloud 评论迁移、第三方功能适配,以及本地管理台开发。它们不是一次完成的,而是在实际使用中不断暴露问题、再补上工具和保护措施。
# 一、为什么建立一个独立的 ShokaX 测试项目
旧项目位于 now 目录。它虽然已经能够继续使用,但早期添加过不少临时脚本,主题和附加功能之间的边界也不够清楚。另一方面,ShokaX 的配置方式与原来的 Shoka 存在差异,直接在旧目录里覆盖主题,出了问题时很难判断究竟是主题变化、配置字段变化,还是旧文件残留造成的。
所以我保留了 now,再建立一个独立的 shokax-test 作为迁移和验证环境。这个决定看起来只是多占了一份磁盘空间,却带来了一个很实际的好处:每一步都有可对照的旧版本,文章、图片和原配置不会因为一次实验被覆盖。
迁移前我阅读了 jc 文件夹中保存的教程,也对照了 ShokaX 官方文档,重点确认以下内容:
- 站点配置与主题配置分别由哪个文件负责;
- 顶部导航、关于页、友链页以及子菜单的
default结构; - Waline、Algolia 和第三方脚本的接入位置;
- 文章封面、背景图和静态资源的路径规则;
- ShokaX 使用的 Aether Markdown 渲染器及其扩展语法。
实际迁移时,我先复制文章和静态资源,再逐项迁移站点标题、作者信息、分类、标签、图床地址和部署配置。主题配置则按照 ShokaX 的字段重新整理,没有把旧主题的配置文件原封不动覆盖过去。这样做虽然慢一些,但能避免“字段名字相似,实际含义或默认值已经改变”的问题。
我在这一阶段坚持一个原则:先让最小配置能够构建,再逐项恢复功能。每恢复一项就重新执行构建和本地预览,这样一旦出错,排查范围只会落在最近改动的几个文件,而不是整个项目。
# 二、把 LeanCloud 评论完整迁移到 Waline
旧博客的评论和浏览量保存在 LeanCloud。由于原服务已经公布停止对外提供服务的安排,评论迁移不再只是“以后有空再处理”的优化,而是必须在旧服务仍可导出数据时完成的事情。
Waline 服务端在 Vercel 上的搭建过程,我主要参考了 SYS 博客的《Waline 评论系统搭建-Vercel 部署》。下面记录的是我实际迁移时采用的步骤和额外做的数据转换,不把参考教程中的部署流程当成自己的原创内容。
# 1. 先部署一个能够独立工作的 Waline 服务端
数据迁移前要先准备好接收数据的 Waline 服务。按照参考教程,我从 Waline 的 Vercel 模板开始部署:
- 通过 Waline 提供的 Vercel 部署入口创建项目,使用 GitHub 账号登录 Vercel。
- 输入项目名称,等待 Vercel 创建并初始化 Waline 仓库。
- 部署完成后进入项目控制台,而不是立刻开始导入旧评论。
- 在项目的
Storage中选择Create Database,从数据库提供方中选择 Neon。 - 创建 Neon 数据库后进入 Neon 的
SQL Editor,执行 Waline 官方仓库assets/waline.pgsql中的建表语句。 - 回到 Vercel 检查数据库是否已经连接到当前 Waline 项目。如果没有连接,需要在
Storage中点击Connect。 - 数据库和项目连接完成后,到
Deployments中重新部署一次,让新增的数据库环境变量真正进入运行环境。
这里最容易遗漏的是第 6、7 步。数据库“已经创建”不代表 Waline 项目“已经获得数据库连接”。如果 Storage 没有连接,或者连接之后没有重新部署,前端地址可能可以打开,但评论读写仍然会失败。这类问题看起来像 Waline 客户端配置错误,实际上故障发生在服务端与数据库之间。
部署成功后,我访问 Waline 服务地址的 /ui/register 注册第一个账号。Waline 会把第一个注册用户设为管理员,之后就可以在 /ui 中进行评论审核、修改和数据导入。参考教程还列出了几个常用环境变量:
| 环境变量 | 作用 |
|---|---|
SITE_NAME |
显示博客名称 |
SITE_URL |
指定博客正式地址 |
LOGIN |
设置为 force 时要求登录后评论 |
SERVER_URL |
自动识别服务端地址不正确时显式指定 |
Vercel 的环境变量更新后同样需要重新部署。数据库连接和环境变量都不是修改后立刻作用于已有 Deployment,这一点在后续排查中非常重要。
# 2. 从 LeanCloud 导出 Comment 和 Counter
旧数据导出后,我得到两类 JSON Lines 文件:
migration/
├─ leancloud-Comment.jsonl
├─ leancloud-Counter.jsonl
├─ path-map.json
├─ convert-leancloud-to-waline.js
├─ waline-import.json
└─ waline-migration-report.json
Comment 保存评论正文、昵称、邮箱、文章路径、时间、浏览器信息和回复关系;Counter 保存页面路径与访问次数。JSONL 文件每一行是一条独立 JSON 记录,比一个巨大的 JSON 数组更适合逐行读取,也能在某一行损坏时准确报告行号。
我没有直接把 LeanCloud 导出文件交给 Waline。原因有两个:一是两边字段结构不同;二是博客迁移后部分文章永久链接发生了变化。即使数据能够被数据库接收,只要评论中的 url 仍指向旧路径,文章页也会表现为“没有评论”。
# 3. 编写转换脚本,而不是手工改 JSON
转换脚本使用 Node.js 编写,入口是 migration/convert-leancloud-to-waline.js。它首先逐行解析两个导出文件,并检查每条记录是否存在唯一的 objectId。如果 JSON 格式错误、ID 重复,或者回复引用了不存在的父评论,脚本会直接停止,而不是生成一份表面完整、内部关系已经损坏的导入文件。
评论字段的主要映射如下:
| LeanCloud 字段 | Waline 字段 | 处理方式 |
|---|---|---|
comment |
comment |
保留评论正文 |
nick |
nick |
空值使用“匿名” |
mail |
mail |
保留,但不在文章或日志中输出 |
link |
link |
保留评论者网站 |
url |
url |
规范化后再执行旧路径映射 |
pid、rid |
pid、rid |
保留父评论和根评论关系 |
ip、ua |
ip、ua |
仅进入迁移文件,不公开展示 |
createdAt |
insertedAt、createdAt |
保留原评论时间 |
updatedAt |
updatedAt |
无值时回退到创建时间 |
转换后的评论状态统一为 approved,避免历史评论导入后全部进入待审核状态;点赞数没有可靠的旧字段来源,因此初始化为 0。用户表没有从 LeanCloud 评论者信息反向创建账号,最终导入文件中的 Users 保持为空,这样也不会误把普通访客变成 Waline 用户。
输出文件不是简单数组,而是 Waline 导入工具识别的结构:
{
"__version": "1.41.3",
"type": "waline",
"version": 1,
"tables": ["Comment", "Counter", "Users"],
"data": {
"Comment": [],
"Counter": [],
"Users": []
}
}
这里省略了真实评论内容、邮箱、IP 和 User-Agent。迁移脚本及导入文件只保存在本地迁移目录,不应该提交到公开仓库。
# 4. 解决永久链接变化造成的“数据存在但页面看不到”
主题迁移本身不会自动改评论路径,但文章目录或永久链接规则调整后,旧 URL 与新 URL 可能不同。例如旧路径中的 jiaocheng 后来改成了 xuexi,部分随笔和游记也补上了分类目录。为此我单独建立 path-map.json:
{
"/2020/12/06/jiaocheng/MarkDown/": "/2020/12/06/xuexi/MarkDown/",
"/2021/05/29/随笔之十一/": "/2021/05/29/suibi/随笔之十一/"
}
转换时先统一 URL:去掉域名、查询字符串和锚点,把反斜杠改成正斜杠,并保证首尾都有 /;然后再查 path-map.json。这样 https://旧域名/文章/?from=... 和 /文章/ 不会被当成两个页面。
浏览量还多一步聚合。旧地址和新地址如果最终映射到同一路径,不能保留两条 Counter,否则 Waline 查询时可能得到重复记录;也不能只保留其中一条,否则会丢失访问次数。脚本按目标 URL 分组,把 time 相加,同时保留最早创建时间和最晚更新时间。
这也是为什么原始的 45 条 Counter 最终变成 34 条:少掉的不是访问数据,而是被合并的重复路径。
# 5. 用转换报告先做离线校验
运行转换命令:
node migration/convert-leancloud-to-waline.js
脚本会同时生成 waline-import.json 和 waline-migration-report.json。本次转换报告的结果为:
评论:13 -> 13
其中回复:3
浏览量记录:45 -> 34
浏览量总和:804 -> 804
缺失的父评论引用:0
这组数据比“脚本运行成功”更有意义。评论总数相等,说明没有记录在转换中被跳过;回复数和引用检查用于确认楼中楼关系;浏览量总和前后一致,说明路径合并没有吞掉计数。
报告中还按 URL 统计评论数和浏览量,并记录哪些路径映射真正被使用。这样即使导入后某一篇文章没有显示评论,也能先查它的新 URL 是否出现在报告中,而不是反复重导整个数据库。
# 6. 导入 Waline,再接入 ShokaX
离线检查通过后,我登录 Waline 管理端,在数据管理的导入功能中选择 waline-import.json。导入完成后先在管理端检查评论数量和回复关系,再在 ShokaX 的主题配置中启用 Waline:
widgets:
recent_comments: true
waline:
enable: true
serverURL: https://commit-ywg7.vercel.app
lang: zh-CN
meta:
- nick
- mail
- link
这里只需要把公开的服务地址交给浏览器,数据库连接串、管理员信息和其他密钥仍然留在 Vercel 环境变量中。把服务端密钥写进 Hexo 主题配置没有任何必要,因为 Hexo 生成后的文件会被所有访问者下载。
验证时我没有只看 Waline 后台,而是按四层检查:
- 服务地址能否正常访问,数据库是否可读写;
- 管理端能否看到 13 条历史评论以及回复关系;
- 有历史评论的文章、关于页和友链页能否按新路径显示记录;
- 新提交一条测试评论后,文章页和后台是否同时更新。
后来首页“最新评论”出现过把 Markdown 或 HTML 语法直接显示出来的问题。这说明数据库中的评论存在,但首页组件走的渲染链路与文章页不同。最终排查时把“数据是否正确”和“组件如何输出”分开处理,才没有误改已经迁移成功的原始评论。
这一部分给我的最大提醒是:评论迁移的关键不是把若干行 JSON 塞进新数据库,而是同时保住内容、回复关系、时间、页面身份和访问统计。任何一层对不上,用户看到的结果都可能像是“评论丢了”。
原文章中还有配置域名来增加管理页面的部分,但是我的博客使用的是GitHub的服务器好像没办法实现这一功能,所以我就保存了默认的评论管理页面
评论管理
# 三、重新配置 Algolia 搜索
旧博客已经使用 Algolia,新主题也支持它,因此这部分没有改成另一套搜索系统,而是按照 ShokaX 和 hexo-algoliasearch 的字段重新配置。
Algolia 的索引不是浏览器打开博客时自动生成的,而是在部署前主动上传:
hexo algolia
它的正确位置应该是在文章内容和站点配置确认之后、正式部署之前。后来我把这条命令加入管理台的“构建与预览”区域,点击按钮时由管理台启动任务并显示日志。这样做的意义不只是少敲一条命令,更重要的是让索引上传成为发布流程中的一个明确步骤,避免“页面已经上线,但搜索还没有新文章”的情况。
Algolia 的凭据没有写入管理台前端。前端只调用本地 /api/actions/algolia 接口,真正的命令由博客目录中的 Hexo 环境执行。这样浏览器源码里不会出现搜索服务的管理密钥。
# 四、保留原来的 GitHub 图床工作流
图片仍然存放在本地 GitHub 图床仓库中。原来的习惯是为每篇文章建立独立文件夹,再把图片按照 00.png、01.png、02.png 的顺序命名,执行 Git 提交和推送,之后发布新的 GitHub Release,最后使用 jsDelivr 地址插入文章:
文章图片文件夹
↓
按 00、01、02 编号
↓
git add
↓
git commit -m "add files"
↓
git push
↓
发布 GitHub Release
↓
使用 jsDelivr 直链
例如直链中的版本号会随着 Release 改变:
https://cdn.jsdelivr.net/gh/simoxdcs/csgo@2.4/jiaocheng/2/00.png

管理台的图床页围绕这个工作流增加了按文件夹浏览、缩略图、点击放大、自动编号、复制 jsDelivr 直链、提交推送和发布 Release。读取图片时直接使用本地文件,而不是每次请求 GitHub CDN;远端只用于刷新标签和执行推送,从而避免素材较多时页面加载缓慢。
图床版本会写入当前 Git 仓库的本地配置 shokax.image-version。管理台同时读取本地标签和远端 Release 标签,按语义版本排序并选择最新有效版本。这样手工选择过的版本不会在刷新页面后丢失,也不会因为字符串排序把 2.10 和 2.9 排错。
# 五、重新适配 Live2D 看板娘
看板娘使用的是 stevenjoezhang/live2d-widget。原来的方案主要面向其他主题,模型资源、API 路径、脚本加载顺序和页面层级与 ShokaX 并不完全一致。迁移后最明显的问题是模型切换和换装请求在没有后端服务时无法正常工作,移动端的显示位置也需要重新调整。
我先把 Live2D 拆成几个独立部分检查:
- widget 脚本是否只加载一次;
- 模型和贴图路径是否能从当前站点根路径访问;
- 本地模型列表是否返回正确 JSON;
- 看板娘容器的
z-index是否被主题遮挡; - 移动端尺寸、位置和台词框是否超出屏幕;
- 切换模型、切换服装和工具栏按钮是否仍然可用。
在确认资源路径后,再把模型分组、换装参数和位置配置迁移到 ShokaX 能识别的结构中。管理台里也增加了看板娘调整页,可以修改位置、宽高、缩放、台词、模型和皮肤,并使用博客中真实的模型资源实时预览,而不是用一张静态占位图模拟效果。

保存配置时,后端会校验数值范围、CSS 值和资源 URL,再通过原子写入保存 JSON。主题没有暴露的选项仍可以在原始 JSON 中编辑,但不会允许把任意脚本或越界路径当成配置写进去。
这部分的经验是:第三方脚本最好先与主题解耦。先确认模型加载、换装接口和工具栏能够独立运行,再决定把哪些入口放进主题。否则主题动画、全局变量和脚本加载顺序混在一起,很难判断故障属于哪一层。
# 六、为什么开始做本地管理台
迁移完成后,我发现日常维护需要记住的动作越来越多:
编辑 Markdown
上传并编号图片
提交图床仓库
发布 GitHub Release
hexo generate
hexo server
hexo algolia
hexo deploy
这些动作单独看都不复杂,但它们之间存在顺序和状态。例如新图片已经提交却没有发布 Release,生成的 jsDelivr 链接就会引用一个并不存在的版本;文章已经部署却忘记上传 Algolia,搜索结果就仍是旧的;预览服务重复启动还会产生端口冲突。
最初我只是想给几个常用命令加按钮,真正开始使用后才发现,管理台更重要的职责是把“文件、命令、状态和错误”连成一个可检查的工作流。因此它后来逐步加入文章管理、素材管理、页面导航、Markdown 预览、图床、Live2D 和配置编辑,最终才形成现在的样子。

# 七、技术选型:为什么是 Node 原生服务加原生前端
这个管理台只在我自己的电脑上运行,数据源就是 Hexo 项目中的 Markdown、YAML、JSON 和图片文件。它不需要多用户权限、云端数据库或服务端渲染,因此我没有引入 React、Vue、Express 和独立数据库,而是采用下面的组合:
| 部分 | 实现 |
|---|---|
| 本地服务 | Node.js 原生 http 模块 |
| 前端 | 原生 HTML、CSS、JavaScript |
| 图标 | Lucide |
| 配置解析 | yaml 包 |
| Markdown 预览 | 复用博客的 hexo-renderer-aether |
| Hexo 任务 | child_process.spawn 启动本地 Hexo CLI |
| 数据来源 | Markdown、YAML、JSON 与本地资源目录 |
这样选择不是因为框架没有价值,而是管理台的边界很明确:它是博客仓库的本地操作层,不是另一个需要长期部署的网站。少一层数据库同步,也就少一类“管理台显示的数据与 Git 仓库不一致”的问题。
管理台的启动命令写进博客根目录的 package.json:
{
"scripts": {
"manager": "node manager/server.js",
"manager:test": "node --test manager/tests/*.test.js"
}
}
使用时在博客目录执行:
npm run manager
服务只监听 127.0.0.1:4179,而不是 0.0.0.0。这意味着它默认只接受本机访问。管理台拥有写文章、改配置和执行 Git/Hexo 命令的能力,如果为了“方便”直接暴露到局域网甚至公网,风险远大于收益。
# 八、管理台的整体结构与请求流程
目录按照功能拆分,核心结构大致如下:
manager/
├─ server.js # HTTP 服务、路由、Hexo 子进程
├─ lib.js # 配置、文章、素材与安全写入
├─ job-progress.js # 统一任务进度模型
├─ image-host/image-host-service.js # 图床与 GitHub Release
├─ pages/page-navigation-service.js # 页面、友链和导航
├─ markdown/markdown-effect-service.js
├─ live2d/live2d-service.js
├─ public/
│ ├─ index.html
│ ├─ app.js
│ └─ app.css
└─ tests/
浏览器中的 app.js 使用 fetch 请求 /api/*。server.js 根据请求方法和路径分发到各个 service,service 再读写博客文件或启动子进程,最后统一返回 JSON:
点击“保存文章”
↓
PUT /api/post
↓
校验路径、Front Matter 和正文大小
↓
备份旧文件并原子写入 Markdown
↓
返回保存后的文章数据
↓
前端刷新列表和状态提示
我没有把所有逻辑都堆在 server.js 中。路由层只负责读取请求、调用 service 和返回状态码;路径校验、YAML 解析、Git 命令和 Live2D 配置校验分别由自己的模块负责。这样遇到故障时可以先判断是前端交互、API 路由,还是具体服务内部的问题。
# 1. 文件就是事实来源
文章列表来自 source/_posts 和 source/_drafts,关于页和友链页来自各自的 Markdown/YAML,主题选项来自 _config.shokax.yml。管理台没有复制一份数据到数据库,而是每次读取真实文件。
文章解析时先拆分 Front Matter 和正文。保存时以原属性为基础合并用户修改,因此管理台当前没有显示的自定义字段也不会被随手删除。分类和标签候选则遍历已有文章后去重排序,不需要再维护一份选项表。
# 2. 写入前备份,并尽量做到原子替换
直接 writeFile 覆盖主题配置有一个隐患:进程意外结束时可能只写入半个文件。管理台采用的写入顺序是:
- 先把新内容写入同目录临时文件;
- 如果原文件存在,将它复制到
.manager-backups; - 把原文件临时改名;
- 将新临时文件重命名为目标文件;
- 成功后删除中间文件,失败则恢复原文件。
配置保存前还会先用 YAML 解析器验证语法。也就是说,原始配置编辑器中少一个缩进或冒号时,API 会返回错误,原配置不会被无效文本覆盖。
# 3. 路径和请求大小必须受限
管理台允许浏览器传入文章 ID、素材路径和图床文件夹,因此不能直接执行 path.join(root, userInput) 后就读写。ensureInside 会解析绝对路径并确认结果仍然位于允许的根目录;resolveRelative 还会拒绝绝对路径和空字节,从而阻止 ../../ 一类目录穿越。
不同请求也设置了大小上限:主题原始配置不超过 1 MB,Markdown 正文预览不超过 2 MB,文章导入不超过 3 MB,本地素材和图床图片各自有独立限制。即使服务只在本机运行,这些边界仍能避免误选超大文件后拖垮进程。
# 九、构建、预览、索引和部署是怎样执行的
“在网页里执行 Hexo”并不是把 hexo d 拼成字符串交给 Shell。后端通过 child_process.spawn 调用当前 Node 可执行文件和博客本地的 Hexo CLI:
spawn(process.execPath, [HEXO_BIN, ...args], {
cwd: BLOG_ROOT,
windowsHide: true,
env: { ...process.env, FORCE_COLOR: '0' }
});
这样使用的是当前项目 node_modules 中的 Hexo,不依赖系统是否全局安装,也避免把浏览器输入直接拼接成可执行命令。管理台只暴露预先定义好的 generate、server、algolia 和 deploy 动作。
# 1. 任务状态和日志
每个任务都有统一状态:idle、running、success、failed 或 stopping,同时记录开始时间、结束时间、退出码和最近日志。标准输出与错误输出都会被按行收集,ANSI 颜色控制符会先被移除,前端轮询 /api/actions/status 后显示结果。
构建、Algolia 和部署被设置为互斥任务,避免它们同时读写 public 或部署缓存。预览进程则单独管理,停止时先发送 SIGTERM,超时后再强制结束。
# 2. 进度条不是伪造一个固定计时器
Hexo 和 Git 并不总是提供统一百分比,因此进度条采用“阶段日志 + 保守时间估计”的方式。构建日志出现 Validating config、Start processing、Generated: 等内容时会推进到对应阶段;Git 输出 Writing objects: 60% 时则可以直接换算真实上传进度。
任务成功才会到 100%;失败时停在当前阶段,并提示查看日志。这样进度值不是精确测速,但至少能区分“还在加载文章”“正在生成文件”“已经开始上传远端”,比只显示一个不断旋转的加载图标更有用。
# 3. 预览按钮最初为什么没有效果
最初的“本地博客预览”只是一个链接,并没有先启动 hexo server,所以点击后访问 localhost:4000 当然得不到页面。后来改成 /api/actions/preview/start 启动 Hexo 子进程,预览地址仍使用标准的 http://localhost:4000/,并增加停止预览和停止管理台按钮。
停止管理台前会检查构建、Algolia、部署和图床任务是否还在运行,避免用户误点后把正在推送的数据进程一起终止。
# 十、文章管理从“改字段”发展成写作界面

文章管理最初只有标题、日期、分类、标签、封面和正文几个输入框,适合修改 Front Matter,却不适合真正写长文。后续的功能都是在实际写作时发现缺口后补上的。
# 1. 导入 Markdown 与保留 Front Matter
导入 .md 文件时,后端只解析内容并返回给编辑器,不会立即写入博客。用户仍可以选择草稿或正式文章、调整分类标签和路径后再保存。这样导入一个文件不会因为同名而悄悄覆盖现有文章。
保存已有文章时,会合并管理台没有显示的 Front Matter 字段。封面、摘要之外的主题扩展字段不会因为一次普通编辑而丢失;从草稿切换到正式文章时,则在安全写入新位置后再删除旧文件。
# 2. 分类、标签、日期和筛选
分类与标签候选来自所有现有文章的聚合结果,既可以选择已有项,也可以直接输入新项。文章列表支持关键词、分类和草稿状态组合筛选,解决文章增多后只能靠文件名查找的问题。
日期输入改成浏览器的 datetime-local 控件,并提供“现在”按钮。前端负责把日期转换为 Hexo 使用的 YYYY-MM-DD HH:mm:ss,不再要求手工输入完整格式。
# 3. Markdown 工具栏、图片选择和特效插入
正文上方增加了标题、粗体、斜体、引用、代码、列表、链接和图片等常用按钮。它们不是插入固定文本,而是读取 textarea 的选区,把当前文字包裹为对应 Markdown,并在操作后恢复光标位置。
插入图片时可以从博客本地素材、GitHub 图床或外部 URL 中选择。选中素材并填写替代文本后,前端自动生成:

ShokaX 的文字特效也接入同一个编辑器。用户选中文字、选择效果后,管理台调用后端生成与 Aether 语法一致的 Markdown,再插入当前光标位置。这样不用每次回文档查标签块、下划线、着重、荧光或 Ruby 注音的具体写法。
# 4. 使用真实渲染器做实时预览
如果用另一个通用 Markdown 库预览,普通标题可能看起来正确,但 ShokaX 特效和标签块会与最终页面不一致。因此后端直接加载项目中的 hexo-renderer-aether,把正文渲染成 HTML;前端再通过带 sandbox 的 iframe 加载博客正文 CSS。
输入停止约 320 毫秒后才发起渲染请求,避免每敲一个字符就请求一次。预览前还会检查生成的 HTML,拒绝脚本标签、事件属性和 javascript: URL,降低把文章中的意外 HTML 带入管理页面的风险。

后来又加入“沉浸写作”模式,让 Markdown 编辑器和预览占据主要空间。编辑与预览可以分别滚动,也可以开启同步滚动。同步方式不是强行让两边使用相同像素值,而是计算各自的滚动比例:
当前滚动比例 = scrollTop / (scrollHeight - clientHeight)
再把这个比例应用到另一侧。由于 Markdown 源码和渲染后的文章高度不同,按比例同步比直接复制 scrollTop 更稳定。同步开关关闭后,两侧仍可独立滚动。
# 十一、页面、友链、导航和主题配置怎样保存
ShokaX 的菜单不仅是简单的“名称 + URL”。有子菜单时,父项本身保存在 default 中,其他键才是子项。管理台读取配置时先把这种 YAML 结构转换成便于前端排序的数组,保存时再还原:
menu:
文章:
default: / || list
分类: /categories/ || th
标签: /tags/ || tags
前端可以新增、删除、排序一级菜单和子菜单;后端会校验名称、URL 与图标字段,再只更新 _config.shokax.yml 中的 menu。使用 yaml 的 Document API 而不是把整个文件转成普通对象后重写,是为了尽量保留无关配置与注释。
关于页和友链页仍然是 Markdown,友链条目保存在 source/friends/_data.yml。保存页面时会保留未知 Front Matter 字段;保存友链时会校验网址、头像 URL 和条目顺序。

主题基础设置采用两种模式:常用字段通过开关、下拉框和输入框修改,完整配置则保留原始 YAML 编辑器。前者限制可修改字段和数据类型,后者提供灵活性,但仍需通过 YAML 解析和备份后才会写入。
# 十二、图床自动化中真正需要处理的边界
图床功能并不只是依次运行三条 Git 命令。它还要处理工作区里可能存在非图片改动、推送失败后的重试、Release 版本持久化和 Windows 的 Git 安全目录检查。
# 1. 只暂存图片,不把无关文件带进提交
后端先读取 git status --porcelain=v1 -z,解析新增、修改、删除和重命名记录,再筛选图片扩展名。暂存时使用 --literal-pathspecs 和明确的图片路径列表,而不是无条件执行 git add *。这样图床仓库中的说明文件或临时文件不会跟着一次图片上传进入提交。
# 2. 退出码 128 与 safe.directory
管理台曾经在推送时得到 Git 退出码 128。此类错误只有退出码还不够,必须保留标准错误日志。当前所有 Git 命令都会显式带上:
-c safe.directory=图床仓库绝对路径
它解决了 Git 判断仓库所有者与当前用户不一致时拒绝操作的问题,同时只对本次命令生效,没有粗暴修改全局配置。
# 3. 提交成功但 push 失败时允许直接重试
第一次推送可能已经完成 git commit,只是在网络或认证阶段失败。如果再次点击按钮仍然强行 commit,就会得到“没有可提交内容”。现在后端同时检查未提交图片和 @{upstream}..HEAD 的领先提交数:有图片则暂存、提交、推送;没有图片但本地领先远端,则跳过 commit,直接重试 push。
# 4. Release 与直链版本
如果本机安装并登录了 GitHub CLI,管理台通过 gh release create 创建 Release,随后拉取标签并保存版本;如果没有可用权限,则打开 GitHub Release 页面手动发布。手工输入的版本必须能在本地或远端标签中找到,不能只保存一个尚不存在的版本号,否则生成的 jsDelivr 地址必然返回 404。

# 十三、封面焦点、素材与 Markdown 特效
固定比例的首页封面通常会裁掉图片上下部分。如果主体不在中央,同一张图在原图中很好看,到了首页却只剩背景。为此文章 Front Matter 增加 cover_position:
cover_position: 50% 15%
管理台的封面焦点选择器允许直接点击或拖动图片,前端把位置换算为 0 到 100 的横纵百分比,后端再次检查范围。主题生成时将它应用为 object-position,只影响封面裁切位置,不修改原图,也不改变文章内图片样式。

本地素材页支持上传、删除、缩略图、放大查看、复制相对路径和设置为封面。删除不是直接永久移除:后端先把文件复制到 .manager-backups/assets,再删除原文件,并且只允许操作预先定义的素材目录。
独立的 Markdown 特效页则提供效果目录、语法生成和主题正文预览。它与文章编辑器共用同一套后端渲染服务,避免“工具页看起来一种效果,文章预览又是另一种效果”。
# 十四、测试并不是最后才补的一步
管理台会直接修改博客文件和执行远端操作,因此我更关心的是边界条件,而不只是按钮能不能点击。测试使用 Node 内置的 node:test,不再引入额外测试框架,目前覆盖的重点包括:
- 结构化修改主题配置时保留注释和无关字段;
- 无效 YAML 不会覆盖原配置;
- 文章导入、Front Matter 保留、草稿发布和同名保护;
../路径穿越被拒绝;- 素材删除前创建备份;
- ShokaX 子菜单的读取与回写;
- 友链 URL 校验和顺序保留;
- Live2D 数值、CSS 和资源路径校验;
- Aether 特效语法与完整正文预览;
- Git 状态中只提取真实图片变更;
- 图片重命名时同时处理旧路径和新路径;
- Release 版本按语义版本排序;
- 推送失败后识别领先提交并重试;
- 构建、Algolia、部署与 Git 推送的进度阶段。
运行测试:
npm run manager:test
测试不能证明界面布局一定好用,因此每次涉及编辑器、封面或 Live2D 预览时仍需要在浏览器中实际操作。但它能保证改一个功能时没有破坏路径限制、配置保留和 Git 状态解析这些不容易靠肉眼发现的部分。
# 十五、迁移和开发中遇到的几个典型问题
# 1. 评论已经导入,文章页却显示为零
优先比较当前页面 pathname 与迁移报告中的 URL,而不是重复导入。评论系统把 URL 当作页面身份,少一个分类目录或尾部斜杠都可能查询不到。
# 2. 首页最新评论显示 Markdown/HTML 语法
后台数据正常不等于主题组件渲染正常。文章评论区与首页最新评论可能使用不同接口和模板,应检查返回字段在组件中是作为纯文本、Markdown 还是 HTML 输出,不能为修首页而直接批量修改数据库正文。
# 3. 构建时报 Shiki 语言错误
一次静态构建遇到:
ShikiError: Language `Python` is not included in this bundle
原因是代码块语言写成了大写 Python,而当前高亮器识别小写标识。修改为下面的写法后恢复构建:
```python
print("hello")
```
Markdown 看起来只是文本,但进入主题渲染器后仍然存在严格的语言标识和扩展语法。构建错误应先回到报错文章和代码块,而不是先怀疑整个主题。
# 4. hexo d 能成功,远端却提示非法主页
部署命令成功只说明 Git 推送完成,不代表站点配置正确。远端地址、Hexo 的 url/root、GitHub Pages 仓库类型和主题中的主页校验必须一致。这个问题最终也是通过区分“部署链路”和“浏览器运行链路”处理的。
# 5. 图片在本地存在,jsDelivr 直链却打不开
本地文件、Git 远端和 Release 标签是三个状态。图片只完成 commit/push,但直链使用了一个尚未发布的版本号时,CDN 无法读取。管理台后来增加版本刷新、版本持久化和 Release 存在性校验,就是为了把这三个状态显式展示出来。
# 十六、最后固定下来的发布流程
创建或导入文章
↓
设置分类、标签、日期与封面焦点
↓
上传图片并确认图床版本
↓
使用 Aether 预览正文和特效
↓
保存文章并执行静态构建
↓
启动本地预览
↓
检查首页、文章页、图片和评论
↓
上传 Algolia 索引
↓
执行 hexo deploy
↓
检查远端站点
如果只是修改文章文字,可以跳过图床步骤;如果修改导航、关于页或主题配置,也应该至少重新构建并预览。流程不是越复杂越好,而是让每一次发布都能回答“我改了什么、验证了什么、失败时应该去哪里看日志”。
# 十七、这次迁移和开发带给我的启发
第一,主题迁移不是复制一个主题目录。真正需要迁移的是内容模型、永久链接、资源路径、第三方服务和自己的使用习惯。主题页面能打开只代表最表层成功。
第二,数据迁移必须能够验证。LeanCloud 到 Waline 的转换报告、路径映射和浏览量守恒,让迁移从“看起来导进去了”变成有数字可核对的过程。数据越重要,越不能只依赖后台弹出的“成功”提示。
第三,本地工具也需要安全边界。只监听回环地址、限制路径和请求大小、写入前备份、使用原子替换、固定可执行命令,这些措施不会让界面多一个功能,却决定了我是否敢长期用它修改博客。
第四,自动化不应该掩盖真实状态。进度条要说明当前阶段,Git 推送失败要保留日志,Release 不存在就不能生成一个看似正确的直链。按钮的价值不是少输入几个字符,而是把原来散落在终端、文件和网页中的状态组织起来。
最后,这次工作让我重新认识了 Hexo 各层之间的边界:Markdown 是内容,Front Matter 是内容元数据,主题模板负责结构,Aether 负责渲染,Hexo 负责生成,Waline 和 Algolia 是独立服务,管理台则负责把这些动作串成工作流。只有把每一层的职责分清楚,遇到问题时才不会再次陷入“改一处、坏三处”的循环。
现在的管理台并没有让博客变成一个完全不需要维护的系统,但它已经把最容易忘记、最容易误操作的步骤变成了可见的流程。更重要的是,经过这次迁移,我终于能够说清楚每一份数据在哪里、每一个按钮实际执行什么,以及失败后应该从哪一层开始检查。
