Quilibra 个人主页完整技术手册:从页面架构到内容发布与生产部署
记录 Quilibra 个人主页的技术选型、页面架构、内容系统、主题动画、master 单分支自动发布、HTTPS、测试体系和维护方法。
这是一份给自己看的项目说明书。它主要以 2026 年 9 月 4 日的实际代码和 VPS 配置为准,从浏览器里看到的页面一直追到内容文件、Git 分支、构建产物、静态服务器和版本化 release。以后忘了某段代码为什么存在、主页颜色为什么能保存、一次 CMS 保存如何自动变成线上新版本,或者 3000、3100、3110 几个端口分别在做什么,可以从这里重新建立完整认识。
本文记录的是一个具体版本的实现,不是一套永远不变的规范。最可靠的事实来源始终是仓库中的代码;本文的作用,是把分散在 Vue、CSS、YAML、脚本和运维文件里的设计意图串起来。
2026 年 9 月 4 日更新:8 月 3 日起网站移除长期 staging 分支、远程测试站和
promote流程,全站改为master单分支:CMS 直接提交master,Webhook 自动构建部署,纯内容提交走快速构建。此后新增了文章列表状态与阅读进度恢复、移动端返回与回顶按钮、平滑标题锚点和文章表格样式;8 月 26 日曾试验文章 TOC 与自托管统计,当天整体回退;9 月 2 日仓库迁移至XXXCH-siu/personal-site。
一、先用一句话理解整个网站
Quilibra(名字也是AI起的捏) 是一个由 Nuxt 生成的静态个人网站:页面结构写在 Vue 中,主页设置写在 YAML 中,文章写在 Markdown 中;发布时把这些源文件编译成普通 HTML、CSS、JavaScript 和图片,再由一个很小的 Node 静态服务器提供给浏览器。
它的静态生成核心是:
Vue / CSS / YAML / Markdown
|
| Nuxt generate
v
.output/public
|
| 复制到带时间戳的 release
v
releases/<release-id>
|
| 原子切换 current 符号链接
v
current -> 当前 release
|
| Node 静态服务器 :3000
v
浏览器 / 反向代理
现在这套链路只有一个生产实例:master 构建到 ~/.local/share/personal-site/current,由 3000 提供正式站。本地开发和 Playwright 测试会临时占用 3100、3101,但它们不常驻,也没有独立的 release。2026 年 8 月 3 日之前,这里曾有第二个 staging 实例和独立的测试域名,双环境简化后彻底删除。
这里最重要的边界是:Git 分支中的源码是长期事实,.output/public 是可重新生成的临时产物,current 指向的 release 才是线上真正提供的版本。
因此,修改 Markdown 或 YAML 只改变源码;只有重新生成并切换 release,线上页面才会改变。网站没有生产数据库,也没有在访问文章时临时渲染 Markdown。
二、为什么选择静态优先
个人主页的读取远多于写入,而且文章、简介和服务入口都不需要按访客实时计算。静态生成正好适合这种负载:
- 访问时不查询数据库,也不运行 Vue 服务端渲染逻辑;
- HTML 已经在发布阶段生成,首屏可以直接返回完整内容;
- 生产服务只需要读取文件,故障面比常驻 Nuxt 服务小;
- 每个 release 都是一份独立、完整、可回滚的目录;
- Markdown 和 YAML 都在 Git 中,内容、配置和代码可以一起审查与恢复;
- CMS 只是源码编辑器,不会成为网站运行时依赖。
代价也很明确:每次内容变化都要重新生成站点;文章越多,生成时间越长;需要登录、评论、实时状态或用户数据时,必须接入独立服务,而不能假装静态文件可以承担动态业务。
当前项目主动接受这个取舍,因为稳定、透明和容易备份比“保存后数据库立刻生效”更重要。
三、技术栈与各自职责
当前主要依赖如下:
| 技术 | 当前职责 |
|---|---|
| Nuxt 4 | 应用框架、路由、预渲染、页面头信息和构建产物 |
| Vue 3 | 组件、响应式状态、生命周期和浏览器交互 |
| TypeScript | 页面、组件、配置、内容脚本和测试的静态类型检查 |
| Nuxt Content 3 | 读取 YAML/Markdown、建立集合、查询文章并渲染正文 |
| Zod | 约束站点设置与文章字段 |
| Lucide Vue | 返回、搜索、服务、调色等界面图标 |
| 原生 CSS | 全部视觉、响应式布局、悬浮反馈和字标动画 |
| Sveltia CMS | 在浏览器中编辑仓库里的 YAML、Markdown 和上传资源 |
| Playwright | 真实浏览器中的交互、布局、响应式和回归测试 |
| Node.js | 内容工具、同步与发布脚本、生产和测试静态服务器 |
没有引入 Tailwind、组件库、动画框架、运行时数据库或外部字体。页面使用系统无衬线字体和系统等宽字体回退,这减少了字体请求,也避免了字体加载完成时重新排版。
package.json 中最常用的入口是:
npm run dev 本地临时 Nuxt 开发服务
npm run generate 生成静态站点
npm run typecheck Nuxt/Vue TypeScript 检查
npm run content:validate 校验全部文章与站点设置
npm run content:write 受限 writer:新建、修订和删除文章
npm run test:content 内容校验器与受限 writer 的单元测试
npm run test:release release、同步与 Webhook 的单元测试
npm run test:e2e Playwright 浏览器测试
npm run verify 提交代码前的完整本地检查
npm run publish 发布未提交的受管内容
npm run deploy 部署已经提交且工作区干净的代码版本
npm run deploy:content 纯内容快速部署,由自动同步器调用
npm run sync 检查 GitHub master 并自动部署新提交
npm run rollback 切换回指定历史 release
build 与 preview 仍是 Nuxt 的常规命令,但当前生产链路以 generate 和自有静态服务器为准。2026 年 8 月 3 日移除双环境时,promote 命令随 staging 一起删除。
四、目录结构与所有权边界
理解这个项目,先要知道“改什么就去哪里”。
personal-site/
├── app/
│ ├── app.vue 全站外壳、主题初始化、动态 favicon、返回与回顶控制
│ ├── assets/css/main.css 全站视觉与响应式规则
│ ├── components/
│ │ ├── ArtisticWordmark.vue 可编辑艺术字与重播控制
│ │ ├── RgbAssembly.vue RGB 滑杆、预设与重置
│ │ ├── ServiceIcon.vue 服务图标名称到 Lucide 的映射
│ │ └── ServiceStatusDot.vue 服务状态点
│ ├── composables/
│ │ ├── useAccentTheme.ts 主题色状态、混色、对比色与持久化
│ │ ├── useArticleReadingState.ts 文章阅读进度的会话级记忆
│ │ ├── useSiteSettings.ts 并行读取并组合五份站点设置
│ │ ├── useWordmarkAnimation.ts 跨组件字标重播信号
│ │ └── useWritingListState.ts 文章列表筛选与滚动位置的会话级记忆
│ ├── config/ 文章分类、服务状态和图标枚举
│ ├── pages/ 首页、文章、服务、关于页面
│ └── error.vue 错误页面
├── content/
│ ├── settings/ 全站与四个页面的独立 YAML 配置
│ └── writing/*.md 正式文章
├── public/
│ ├── admin/ Sveltia CMS 入口与字段配置
│ ├── brand/ 字标位图资源
│ ├── uploads/ 文章上传资源
│ └── favicon.svg 静态 favicon 回退
├── scripts/
│ ├── content/validate.ts 内容结构校验
│ ├── content/write.ts 受限文章写入、更新与删除
│ ├── release.ts 测试、生成、提交、发布和回滚
│ ├── sync.ts GitHub 拉取、代理、构建与部署
│ ├── webhook.ts GitHub 签名校验、事件队列与部署触发
│ └── static-server.mjs 生产静态文件服务器
├── tests/ 单元、发布和浏览器测试
├── docs/ 发布合约、内容管理、运维与 OpenClaw 说明
├── ops/
│ ├── openclaw/.../SKILL.md OpenClaw 文章发布 Skill
│ ├── nginx/ 正式域名、Webhook 位置和默认拒绝站点
│ └── systemd/ 静态、Webhook 与同步服务
├── content.config.ts Nuxt Content 集合
├── content.schema.ts 站点设置 Schema
├── nuxt.config.ts Nuxt 与静态预渲染配置
└── playwright.config.ts E2E 临时服务与浏览器配置
可以把这些文件分成四层:
content/是可频繁修改的数据层。app/是页面表现和交互层。scripts/与ops/是发布和运行层。.output/、.nuxt*是构建缓存或产物,不是手工维护的源码。
CMS 默认只管理 content/settings/ 中五份固定 YAML、content/writing/ 和 public/uploads/。Vue、CSS、CMS 配置、脚本或测试发生变化时,应当视为代码变更,走完整代码部署流程;设置目录中的未知文件也不会自动进入内容发布白名单。
五、Nuxt 配置与构建数据库
nuxt.config.ts 做了几件关键的事:
- 加载
@nuxt/content; - 引入 Cormorant Garamond 字标字体和
app/assets/css/main.css; - 关闭开发工具;
- 设置中文页面语言、移动端 viewport 和浅色
color-scheme; - 使用
out-in页面切换过渡; - 允许 Nitro 爬取内部链接并预渲染,遇到错误时直接让构建失败;
- 把 Nuxt Content 的本地数据库放到与构建目录对应的位置。
Nuxt Content 3 在构建过程中会建立 SQLite 内容索引。开发环境默认使用 .nuxt 和 .data/content/contents.sqlite;release 构建传入 NUXT_BUILD_DIR=.nuxt-release,E2E 使用 .nuxt-e2e。把三种构建目录分开,是为了避免开发服务器、正式生成和 Playwright 同时读写同一个内容缓存。
这里的 SQLite 是构建时索引,不是生产文章数据库。它帮助 Nuxt Content 查询 Markdown;静态页面生成完成后,生产服务器只负责输出构建结果。遇到 no such table: _content_writing 时,通常是本地 Nuxt Content 缓存被并发构建或中断破坏,不代表文章源文件丢失。
content.config.ts 定义了六个集合:
site、homeSettings、writingSettings、servicesSettings、aboutSettings:五个data集合,各自读取同名职责的 YAML;writing:page类型,读取writing/**/*.md,因此每篇文章会拥有可路由的path和可供ContentRenderer使用的正文。
E2E 测试通过 NUXT_CONTENT_CWD=tests/fixtures/content 把文章集合切换到固定 fixture,测试不会依赖真实文章的标题和数量,也不会改动生产内容。
六、站点设置:五份 YAML 如何保持页面边界
站点设置按后台入口拆成五份文件:
| 文件 | 管理内容 |
|---|---|
site.yml | 站点名称、浏览器标题、SEO、位置、默认主题色、备案和署名 |
home.yml | 首页介绍、标题、最近文章、服务矩阵、RGB 模块和底部入口 |
writing.yml | 文章列表页介绍、SEO 和空状态 |
services.yml | 服务列表、状态、图标、主页显示和访问说明 |
about.yml | 关于页介绍、当前状态、关注主题和联系文案 |
拆分不只是 CMS 菜单分组。如果多个表单只声明同一 YAML 的部分字段,保存其中一个表单可能覆盖另一个表单未声明的字段;因此每个入口必须拥有独立源文件。useSiteSettings() 使用固定异步数据键 site-settings,通过 Promise.all 并行查询五个 data collection,再返回结构化的 site/home/writing/services/about 对象。任何一份缺失都会立即抛出 500,而不是让页面带着部分设置运行。
这些配置不是任意 YAML。content.schema.ts 为每个文件提供独立严格 Schema,例如:
- 默认强调色必须是
#RRGGBB; - 最新文章数量只能是 1 到 3;
- 服务数量只能是 1 到 12;
- 服务状态只能是
online、private、soon; - 图标只能使用代码中已有的枚举;
- 服务链接只能是
#或完整的 HTTPS URL; - 对象使用严格模式,拼错字段不会被静默忽略。
public/admin/config.yml 的表单选项、content.schema.ts 的运行时约束、app/config/site.ts 的 TypeScript 枚举应该同步修改。只改 CMS 下拉框,不改 Schema,保存后会校验失败;只改 Schema,不改组件映射,界面可能没有正确图标。
七、全站外壳与水合边界
app/app.vue 是每个路由外面的共同外壳。它负责:
- 在页面加载时取得站点设置;
- 判断当前是否为主页或文章详情页;
- 初始化和恢复主题色;
- 把主题 CSS 变量挂到
.site-shell; - 为非主页显示上下文相关的“返回”按钮与主题预设切换按钮;
- 为非主页在滚动超过阈值时显示“回到顶部”按钮;
- 设置标题模板、站点描述、Open Graph 信息和
theme-color; - 根据当前强调色实时生成 data URL favicon;
- 显示 Nuxt 页面加载进度条。
hydrated 初始为 false,在 onMounted 完成主题恢复和本地监听注册后变成 true,最终反映为:
<div class="site-shell" data-hydrated="true">
这个属性既是测试可观察的“客户端已经接管页面”标志,也是字标动画的播放闸门。CSS 动画先建立,但在页面完成水合前保持暂停,防止服务端 HTML 刚显示就消耗掉一段动画,等 JavaScript 接管时只剩最终帧。
非主页的全局返回入口是上下文相关的:在文章详情页显示“文章”并返回 /writing,在其他页面显示“返回”并回到 /,图标和 aria-label 补充方向与完整语义。它与文章内部的标题锚点属于不同层级:前者是全局导航,后者是页面内跳转。
滚动超过 360 像素(视口宽度不超过 720 像素时是 200 像素)后,非主页会出现一个“回到顶部”按钮,点击后平滑滚回顶部,并在系统开启减少动态效果时退回瞬间跳转;路由切换时它立即隐藏,避免新页面继承旧滚动状态。
八、主页是一张单屏工作台
主页不是按多个营销区块向下滚动,而是一张固定在一个视口高度内的工作台。核心区域包括:
- 顶部站点标识、上海时区时钟、指针坐标和主题按钮;
- 左上介绍、普通标题前缀与 Quilibra 字标;
- 右上服务矩阵;
- 左下最近三篇文章;
- 右下 RGB 调色器;
- 底部中央文章、服务、关于三个入口;
- 两侧备案与署名。
首页在 setup 阶段并行取得站点设置和最近文章。文章查询按 date DESC 排序,再按 YAML 中的 limit 截取。服务矩阵先筛选 showOnHome: true,按 YAML 顺序最多取四个;服务页仍然展示完整列表。
顶部时钟通过 Intl.DateTimeFormat 固定使用 Asia/Shanghai,每 30 秒更新一次。指针坐标不是每次 pointermove 都直接改 DOM:事件只保存最新坐标,然后在下一帧 requestAnimationFrame 中统一计算。元素边界在挂载与窗口缩放时缓存,触摸指针被忽略。这样可以减少高频事件中的布局读取和响应式更新。
工作台没有为了装饰引入 canvas 或 WebGL。网格、面板、条纹、阴影和入场效果全部由 CSS 完成,浏览器可以直接合成绝大多数变换。
九、响应式布局为何不只是“按比例缩小”
主页有大量绝对定位元素,因此响应式的目标不是把桌面页面机械缩成手机版,而是维护清晰的空间约束:
- 桌面端通过
clamp()、视口高度和稳定的面板高度控制上下关系; - 最近文章与 RGB 面板在宽桌面上对齐顶部和底部;
- 小屏笔记本缩小间距和模块尺寸,仍避免重叠;
- 768 像素附近的紧凑布局隐藏无法舒适操作的 RGB 面板;
- 手机端重新排列主页元素,并使用
overflow: clip防止装饰或过渡产生横向滚动; - 固定格式组件使用明确的 grid 列、最小宽度和高度,标题悬浮时不会重新计算可用宽度。
Playwright 会在 768×1024、1024×768、1440×900 和 3440×1440 上读取真实元素几何信息,验证介绍不压住服务矩阵、上方面板不压住底部导航、RGB 与最近文章对齐、页面没有横向溢出。这里测试的是关系,而不是容易因一个像素改动就失效的整页截图。
十、主题色从一个 RGB 值扩展成整套界面
主题逻辑集中在 useAccentTheme.ts。唯一的核心状态是:
interface RgbColor {
r: number
g: number
b: number
}
三个内置预设是红、绿、蓝。站点设置里的 theme.defaultAccent 可以是任意合法十六进制颜色,并不要求与预设一致。当前颜色存放在 Nuxt useState 中,因此同一客户端的页面切换不会创建互相冲突的主题实例。
主题计算分为几步:
- 把每个通道四舍五入并限制在 0 到 255。
- 生成 CSS
rgb(r g b)与大写#RRGGBB。 - 把强调色分别与白色、画布色和黑色混合,得到 soft、faint、canvas、grid、strong 等衍生色。
- 计算 sRGB 相对亮度,对比黑白文字的对比度,自动选择更清晰的
--accent-ink。 - 把结果以 CSS 变量挂到站点外壳,按钮、悬浮底色、网格、字标和 favicon 共同消费这些变量。
主题变量的意义大致如下:
--accent 原始强调色
--accent-ink 强调色背景上的黑或白文字
--accent-soft 大面积悬浮反馈
--accent-faint 更浅的面板背景
--accent-canvas 轻微带主题倾向的页面画布
--accent-grid 网格与线条色
--accent-strong 向黑色混合后的强调色
这样做比在 CSS 中到处硬编码“红色按钮、浅红背景、深红边框”更可靠。换成极亮或极暗的自定义颜色时,文字对比度仍会自动调整。E2E 会直接把颜色调到纯白和纯黑,验证 --accent-ink 分别变成深色和白色。
十一、主题持久化与旧黄色迁移
客户端挂载时,restoreSavedAccent() 从 localStorage 读取:
site-accent-rgb 完整 RGB 对象
site-accent red / green / blue / custom 等名称
恢复后,深度监听 accentRgb,后续每次变化都会重新保存。由于服务端无法读取浏览器存储,静态 HTML 首先使用 YAML 中的默认颜色;客户端挂载后再恢复访客上次的选择。
旧版本曾使用黄色默认主题。为了避免已经访问过网站的浏览器永远被旧缓存锁在黄色,恢复逻辑专门识别 {255,255,2} 与 yellow 的组合,删除这两个旧值并回到当前 YAML 默认色。这是一次小型数据迁移:不是清空所有人的自定义配色,只清除能够明确识别的旧默认值。
动态 favicon 也跟随主题和站点名称。app.vue 在内存中生成一个包含主题背景色与 site.name 首字符的 SVG,再编码为 data:image/svg+xml。因此 CMS 修改站点名称并重新构建后,标签页字母会同步改变;图标底色、字色和 meta[name=theme-color] 则继续与 RGB 面板同步。public/favicon.svg 只作为静态回退。
十二、RGB 调色器的交互细节
RgbAssembly.vue 不是独立维护另一份颜色,它调用同一个 useAccentTheme(),因此滑杆、预设按钮、页面按钮和 favicon 始终共享状态。
三个 range input 分别控制 R、G、B:
input事件实时更新颜色,拖动时页面连续渐变;- 每条轨道通过 CSS 变量计算已填充百分比;
- 输出区显示十六进制、十进制通道值,并采用自动对比文字;
- 点击 RED、GREEN、BLUE 立即加载预设;
- 重置按钮恢复 YAML 中的默认颜色,而不是写死某个预设。
字标动画不能在滑杆的每一个像素变化时重播,否则会不断重建动画并显得卡顿。组件因此区分“预览变化”和“提交变化”:
- 指针按下后标记
pointerActive,并捕获当前 pointer; - 拖动中的
input只改变颜色; pointerup或pointercancel才请求重播一次字标;- 键盘调整 range 时由
change请求重播; - 一个零延时抑制标志防止同一次鼠标提交同时触发
pointerup和change,造成双播。
预设与重置属于离散操作,所以点击时立即换色并重播。这个模型可以概括为:连续操作实时预览、松手提交动画;离散操作立即提交。
十三、Quilibra 字标是可编辑的艺术字
当前字标不再依赖固定图片,而是由 content/settings/home.yml 中的 headline.emphasis 生成真实文本。CMS 的“首页 / 艺术字文字”可以直接修改内容,Schema 将长度限制为 20 个字符;组件还会按照中英文字符宽度自动缩小过长文字,避免挤出首屏。
字形使用随构建产物一起发布的 Cormorant Garamond 600 Italic。页面只引入拉丁字符子集,既保留高对比衬线斜体的艺术感,也不依赖 Google Fonts 等站外服务;无法覆盖的字符会回退到本机衬线字体。
动画仍然采用“RGB 套色从偏移到合拢”,没有模拟笔尖逐笔书写。字标由四层相同文本组成:
- 红层从左下附近偏移进入;
- 绿层从上方偏移进入;
- 蓝层从右下附近偏移进入;
- 最终层使用当前主题色并带极轻的阴影。
前三层使用 mix-blend-mode: multiply、动态模糊、透明度和不同位移。动画前段保留可见错版,中段快速靠近,后段降低彩色层透明度并让最终主题层聚焦。四层都只改变 opacity、transform 和 filter,播放时临时声明 will-change,结束后不长期占用合成资源。
ArtisticWordmark.vue 的加载控制解决了“首次打开没有动画”和字体中途替换的问题:
@fontsource/cormorant-garamond把字体文件交给 Vite 打包并生成带哈希的静态资源。- 组件调用
document.fonts.load(),等待艺术字体真正可用于绘制。 - 只有字体就绪、组件仍存活、页面可见且请求仍是最新时才开始播放。
- 字体加载失败时继续使用衬线回退字体,不阻断内容显示。
- 开启减少动态效果时直接展示最终状态。
每次重播都会增加 animationRun,并把它放进四层的 Vue key。这会让浏览器得到新的动画元素,可靠地从第 0 帧开始,而不是依赖移除 class 后强制读取布局。
十四、字标在什么时候重播
全局 useWordmarkAnimation() 只维护一个数字信号。任何组件调用 requestReplay(),数字加一;字标组件 watch 到变化后重播。这个很小的事件通道避免 RGB 组件直接引用字标 DOM。
当前重播场景包括:
- 首次进入主页并且艺术字体加载完成;
- 点击主题预设或重置;
- RGB 滑杆松手或键盘提交;
- 从文章、服务、关于页面返回主页,主页组件重新挂载;
- 浏览器标签页从隐藏变为可见;
- 窗口重新获得焦点;
- 浏览器的
pageshow,包括可能来自往返缓存的恢复。
visibilitychange、focus 和 pageshow 可能在同一次切回中连续到达。组件使用一个 animation frame 合并激活请求,并设置 200ms 的最小间隔,防止一次切回连续播两三遍。页面变为隐藏时会取消待播放帧、使旧异步请求失效并结束当前播放。
这里还有两个无障碍与容错原则:
prefers-reduced-motion: reduce时不播放套色,直接显示最终字标;- 可读文字仍存在于
sr-only中,视觉 mask 不承担标题语义。
十五、服务矩阵、图标与状态点
每个服务在 YAML 中有六个字段:名称、说明、链接、状态、图标、是否显示在主页。
ServiceIcon.vue 把业务名称映射到 Lucide 图标。NAS 使用独立的 hard-drive 与 HardDrive,不会复用影音播放图标。未知值虽然有 Server 回退,但正常内容会在 Schema 阶段被拒绝,因此回退只是组件级防御。
状态共有三种:
online 在线
private 仅限本人
soon 准备中
ServiceStatusDot.vue 把状态写入 data-state,CSS 用属性选择器决定颜色,title 提供文字说明。主页和服务页都复用这个组件,因此 CMS 修改 YAML 状态后,重新构建的页面会同步变化,不需要分别维护配色。
状态点表达的是人工配置的展示状态,不是实时健康检查。它不会主动请求 Code、Photos、NAS 或 Status,也不会自动从监控系统同步。如果未来需要实时状态,应由独立状态 API 提供可信结果,再决定是在客户端请求还是构建前抓取;不能仅把绿色小点误解为自动监控。
链接为 # 时,主页服务节点转到 /services,服务页显示为不可点击行;完整 HTTPS 链接则在新标签页打开,并带 noopener noreferrer。Schema 禁止 javascript: 和非 HTTPS 外部地址,避免 CMS 字段直接变成危险链接。
十六、文章列表、筛选与搜索
/writing 在构建阶段只查询 hidden = false 的文章,并按日期倒序排列。首页最近文章使用同样条件,浏览器端再对已经加载的公开数组做筛选,不需要为每个关键词请求服务器。
分类来自共享常量:随笔、学习、交易、观察。列表额外添加“全部”。搜索会把标题、摘要和标签拼成小写字符串,再做简单的 includes 匹配。这适合当前文章规模,特点是实现透明、中文可用、没有索引服务;但它不是分词搜索,也不会搜索正文。
文章行显示日期、标题、摘要、分类和箭头。悬浮一篇时:
- 只给当前行增加浅主题色背景;
- 日期与正文整体平移 8px;
- 箭头向右上轻移;
- 其他文章保持完全不变。
标题容器的 grid 列不会在 hover 时改变,所以长标题不会因为悬浮突然获得更小宽度而换行。主页“最近写下”的标题使用 minmax(0,1fr)、overflow:hidden、省略号和 white-space:nowrap,确保固定高度面板不被长标题撑开。
搜索结果区使用 aria-live="polite",筛选后的篇数和空状态可以被辅助技术感知。
从文章详情返回列表时,列表状态会被恢复。useWritingListState() 用一个小的 Nuxt useState 保存当前分类、搜索词和滚动位置:进入文章时 capture() 记录这三项并标记 restorePending,返回列表时读取并消费这份状态,恢复筛选结果和滚动位置;浏览器后退同样恢复。直接打开一篇文章(例如从外部链接进入)没有可恢复的上下文,列表从顶部开始。恢复只发生在同一会话内存中,不写入 localStorage。
十七、文章详情与相邻导航
动态路由文件是 app/pages/writing/[...slug].vue。它使用当前 route.path 查询对应文章;找不到时立即抛出带中文说明的 404。
详情查询本身也要求 hidden = false,所以隐藏文章不只是从列表消失,直接访问原 URL 也会得到真正的 404。详情页同时取得按日期倒序排列的全部公开文章,用当前文章的数组位置计算:
- 数组中下一项是时间上更旧的“上一篇”;
- 数组中上一项是时间上更新的“下一篇”。
页面展示分类、发布日期、可选的更新日期、标题、摘要、标签与正文。正文由 Nuxt Content 的 ContentRenderer 生成。SEO 标题使用全站模板变成“文章标题 · Quilibra”,描述来自 frontmatter,Open Graph 类型设为 article。
详情页会记住每篇文章的阅读位置。useArticleReadingState() 把“路径到滚动位置”的映射存在 sessionStorage 中,离开文章时捕获当前位置,回到文章时用 behavior: 'instant' 一步恢复,不经过平滑滚动,避免看到一次从顶部滑下来的动画;恢复值会被限制在当前可滚动的最大范围内,内容变化后也不会定位到不存在的区域。同一会话内在多篇文章间切换,各自的位置互不覆盖。
文章内的标题锚点在允许动画的系统中平滑滚动:CSS 在 prefers-reduced-motion: no-preference 时给 html:has(.article-prose) 设置 scroll-behavior: smooth,标题统一留出 16 像素的 scroll-margin-top;减少动态效果时保持瞬间跳转。
正文样式集中在 .article-prose:限制阅读宽度、提高行高、分级处理标题、列表、引用、代码、链接、图片和带边框的表格(表格在 2026 年 8 月底加上了完整的边框、内边距和表头底色,长表格在窄屏下可横向阅读)。文章页面可以自由使用 Markdown,不需要为每篇文章编写 Vue 模板。
十八、文章 frontmatter 合约
一篇新文章最小结构如下:
---
title: 清楚的文章标题
description: 一句话说明文章实际记录了什么。
date: 2026-07-26
hidden: false
category: 学习
tags:
- Nuxt
---
文件名必须是:
YYYY-MM-DD-english-kebab.md
允许字段只有 title、description、date、updated、slug、hidden、category、tags。hidden 只能是布尔值,省略时按 false 处理。发布新文章时:
date必须是中国时区当天;- 文件名日期必须与
date一致; - 不能包含
updated; slug如果存在,必须与文件名后半段一致。
修订文章时保留原文件名和 date,把 updated 设置为中国时区当天。writer 默认保留现有隐藏状态,只有修订内容明确给出 hidden: false 才会恢复。正文结构是自由的,但不能为空。若使用二级标题“来源与延伸阅读”,该节必须至少有一个 HTTP 或 HTTPS 链接。
scripts/content/validate.ts 使用 gray-matter 和 YAML 解析 frontmatter,再用 Markdown AST 检查正文与来源节。使用结构化解析而不是正则扫描全文,可以区分标题、链接与普通文本,并给出确定的错误。
十九、Sveltia CMS 到底是什么
/admin/index.html 只加载 Sveltia CMS 的前端脚本,public/admin/config.yml 描述后台字段。它不是另一个网站后端,也不存文章副本。
后台能维护:
- 站点、首页与栏目文案;
- 默认强调色和主页模块开关;
- 服务的增删、顺序、URL、状态、图标和主页显示;
- 文章的新建、搜索、筛选、修改和删除;
- 文章的隐藏和恢复;
public/uploads/中的文章资源。
CMS 有两种工作方式:
- 本地仓库模式:Chromium 通过 File System Access API 直接编辑本机选择的仓库。
- GitHub 模式:用户经 GitHub 登录后,CMS 根据配置向私人仓库提交内容。
无论哪种模式,“保存”都只意味着源文件或远程仓库出现改动。它不会直接覆盖某个静态目录。远程 CMS 当前固定提交私人仓库的 master 分支,完整链路是:
quilibra.cn/admin
|
| GitHub OAuth 保存
v
GitHub master
|
| GitHub push Webhook,HMAC-SHA256 签名
v
VPS 事件队列 -> master 同步 -> 快速或完整构建 -> production release -> 127.0.0.1:3000
因此,在 CMS 打开“隐藏文章”并保存,正式站会在同步器构建完成后自动切换到新 release;不再有 staging 预览和 promote 晋级。2026 年 8 月 3 日之前,这条链路中间还有 staging 分支、测试域名和人工晋级,CMS 保存只先出现在测试站;简化后保存即上线,代价是发布前的内容自查变得更加重要。
GitHub OAuth 只控制“谁能通过 CMS 读写私人仓库”。本项目不使用 GitHub Actions 部署,也不开放公网 SSH;GitHub 只向现有 HTTPS 精确路径发送带签名的 push 通知,VPS 收到后再通过 Sing-box HTTP 代理主动拉取 master。Webhook secret 只在 VPS 和 GitHub 仓库设置中保存。私人仓库存放网站源码、文章、公开图片和不含密钥的配置;OAuth secret、访问令牌、Webhook secret、部署私钥、OpenClaw 会话、cookie 和 .env 不进入仓库。
远程后台统一通过 https://quilibra.cn/admin/index.html 使用。这个路径随静态站点公开发布,页面本身带 noindex 且不含任何秘密;能不能登录、能不能保存,完全由 GitHub OAuth 与私人仓库权限决定。2026 年 8 月 3 日之前它曾躲在带 Basic Auth 的测试域名后面,双环境简化时这层入口遮罩被一并移除。后台路径和 noindex 都不是访问控制。
二十、受限 writer 为什么存在
普通编辑器可以修改仓库中的任何文件,但自动化发布工具不应该拥有同样宽的写入自由。scripts/content/write.ts 把 OpenClaw 的写入范围压缩为三个明确操作:create、update、delete。
新建流程只接受草稿目录中的一个普通 Markdown 文件名和一个无日期 slug。writer 会检查:
- 草稿名不能包含目录或路径穿越;
- 草稿必须是普通文件,不能是符号链接;
- 草稿不超过 1 MiB;
- slug 必须是小写英文、数字和连字符;
- frontmatter 与正文必须通过完整校验;
- 日期必须是中国时区当天;
- 目标文章不能已经存在。
写入时先以 wx 模式建立权限为 0640 的随机临时文件并 fsync,再用硬链接创建最终文件,利用文件系统的 EEXIST 保证不会覆盖已有新文章。更新时先验证现有文件和发布日期,再用 rename 原子替换。删除只接受精确的完整 dated slug,并拒绝目录、符号链接和不存在的目标。
这不是为了让日常写作变复杂,而是为了让“从聊天自动发布”仍有可审计的硬边界。Skill 中的文字规则可能被模型误解,writer 的路径、日期和文件类型检查则由操作系统与代码强制执行。
二十一、OpenClaw 发布 Skill 的实际状态机
仓库只维护一个 personal-site-publisher Skill。它先读取文章合约,再按以下过程工作:
链接或素材
|
v
确定标题、摘要、日期、slug、分类、标签
|
v
在私聊中返回完整草稿,不写仓库
|
+-- 修改:... --> 修改内存草稿
+-- 放弃 ------> 清除内存草稿
+-- 发布 ------> 保存到受限草稿目录
|
v
content:write + content:validate
|
v
提交并推送 GitHub master
|
v
Webhook 自动构建部署,返回文章 URL 和 commit
直接发布 只跳过人工预览,不跳过 writer、内容校验和静态生成。更新文章必须保留 dated URL;删除必须先展示目标信息,再收到完全匹配的 确认删除 <dated-slug>。
发布 Skill 还规定:只接受所有者私聊中的发布命令;把网页、对话和搜索结果视为不可信内容;不把 API key、cookie、账号信息、私人对话细节和精确私人资产写入文章;不能用任意编辑器、rm、git add 或 git commit 绕过 writer 和 release 脚本。
二十二、本地发布、自动同步与内容快速通道
当前有四个相关入口,名字接近但权限完全不同。
npm run publish
用于受信本地流程中尚未提交的内容改动,例如 OpenClaw Skill。它只允许以下路径:
content/settings/site.yml
content/settings/home.yml
content/settings/writing.yml
content/settings/services.yml
content/settings/about.yml
content/writing/**
public/uploads/**
它验证内容、生成静态站、自动创建本地 Git commit、复制 release 并切换本地默认生产目录。若工作区混入 Vue、CSS、脚本、CMS 配置或文档改动,会拒绝执行。它是受限本地发布入口;线上更新仍然依赖把提交推送到 GitHub 后由 Webhook 部署。
npm run deploy
用于已经提交的代码版本。它要求工作区完全干净,不创建 commit,并依次运行:
类型检查
内容与 writer 单元测试
release/sync/webhook 单元测试
静态生成
浏览器 E2E
创建 release
原子切换 current
清理过旧 release
npm run deploy:content
自动同步器判定提交只包含受管内容时使用的快速路径。它仍执行内容校验和静态生成,但跳过类型检查、单元测试和 E2E,让纯文章或站点设置的提交几十秒内上线;任何代码文件变化都会让同步器退回完整 deploy。
npm run sync
scripts/sync.ts 是 master 自动同步器的唯一实现,由 systemd oneshot 服务在收到 push 事件后运行。每轮会:
- 从 Sing-box 配置读取带认证的本机 HTTP 代理;
- 重试 GitHub fetch,只接受能够快进的远端历史;
- 根据
package-lock.json哈希决定是否运行npm ci; - 比较上次已部署提交与目标提交:只有受管内容变化时执行快速内容部署,否则执行完整
npm run deploy; - 只有部署成功才原子写入
deployed-commit; - 失败时保留旧 release,由持久化 Webhook 事件延迟重试。
它不再区分 staging:仓库、deployed-commit 和 release 目录都只有一份。构建通过共享的 flock 文件串行执行,防止 2 GB 内存的 VPS 同时跑多份 Nuxt 构建和 Playwright。
npm run webhook
scripts/webhook.ts 只监听 127.0.0.1:3110,Nginx 把 https://quilibra.cn/_hooks/github 的精确 POST 路由转给它。接收器先验证 HMAC-SHA256 签名、仓库名、push 类型和 master 分支,再把事件以权限 0600 写入用户状态目录并返回 202。
每个 GitHub delivery 都有独立队列文件。若构建期间出现第二次 push,第一轮完成后会再启动一轮同步;进程重启时也会恢复未完成事件。失败使用延迟重试,不需要周期性轮询 GitHub。Webhook 请求只能映射到固定的同步 unit,不能携带或执行任意命令。
因此,修改文章现在只有两条合法路径:远程 CMS 保存到 master,或本地受信流程用 writer/publish 提交;两者最终都由同一个 Webhook 部署。修改 Vue、CSS 或脚本并提交后,同步器自动执行完整 deploy。不要用 publish 偷渡代码,也不要让 deploy 带着未提交文件上线。
二十三、静态 release 如何做到切换时不中断
scripts/release.ts 用 UTC 时间生成 release 名称,例如:
20260726T144914-134Z
生成结束后,把 .output/public 递归复制到 releases/<id>。激活时不是先删 current 再新建,而是:
- 创建一个带进程号的临时符号链接;
- 让临时链接指向新 release;
- 用
rename把临时链接原子替换成current。
对访问者来说,某个请求要么读取旧 release,要么读取新 release,不会遇到 current 暂时不存在的中间状态。静态 Node 进程也不需要重启,因为它每次请求都从 current 路径解析文件。
PERSONAL_SITE_DATA_ROOT 决定 release 根目录。生产不设置时使用 ~/.local/share/personal-site;双环境简化后,staging 数据根目录已经删除,线上只有这一份 release 历史。
脚本默认保留最近五个 release,并额外保证当前激活版本不被删掉。由于当前版本有可能是手工回滚到的较旧目录,保留“当前版本”比单纯保留时间最新的五个更重要。
回滚只调用同一个原子激活函数,把 current 指回已存在的 release。它不修改 Git、不重新构建、不删除较新的版本,也不重启生产服务。
二十四、静态服务器、Nginx 与 HTTPS
scripts/static-server.mjs 使用 Node 自带的 http、fs、stream 和 zlib。监听地址、端口和 current 路径分别由 PERSONAL_SITE_HOST、PERSONAL_SITE_PORT、PERSONAL_SITE_ROOT 控制。systemd unit 把 host 设为 127.0.0.1,所以 3000 不直接接受公网连接。它没有框架中间件,职责非常窄:
- 只接受 GET 和 HEAD,其他方法返回 405;
- 解码 URL 后用
resolve与relative检查目标仍在current内,阻止目录穿越; - 请求目录时返回该目录的
index.html; - 找不到文件时返回预生成的
404.html和 404 状态; - 根据扩展名发送正确的 Content-Type;
- 对大于等于 1 KiB 的文本资源协商 Brotli 或 gzip;
- Brotli 使用质量 4,gzip 使用级别 6,在 CPU 与体积间取中间值;
- 使用读取流和 pipeline,不把大文件一次性读进内存;
- 根据文件大小和修改时间生成弱 ETag;
- 命中
If-None-Match时返回 304; _nuxt/哈希资源缓存一年并标记 immutable;- HTML、图片等非哈希路径使用 no-cache,确保 release 切换后会重新验证;
- 收到 SIGINT 或 SIGTERM 时停止接受新连接并正常退出。
如果 current 不可用,服务器返回 503,而不是把文件系统错误暴露给访问者。若响应流已经开始后出错,则销毁连接,避免发送一半文件后再拼接错误文本。
它不会处理 TLS、OAuth 或反向代理。公网入口由 Nginx 负责:
quilibra.cn / www.quilibra.cn -> HTTPS -> 127.0.0.1:3000
quilibra.cn/_hooks/github -> 精确 location -> 127.0.0.1:3110
公网 IP或未知 Host -> 默认站点拒绝
根域名和 www 共用正式站,证书由 Certbot 申请并续期;HTTP 到 HTTPS 的跳转和证书续期都由 Certbot 管理。GitHub Webhook 是 Nginx 上的一个精确 location,只放行 POST,client_max_body_size 限制为 1m。同一台 Nginx 还反代了个别与网站无关的自建子域,它们不属于这个仓库的发布链路,也不使用网站域名证书之外的资源。腾讯云安全组只需要开放 80 和 443,3000、3110、8081 不对公网放行。
页脚备案号来自全站设置 content/settings/site.yml,当前 ICP 号链接到 https://beian.miit.gov.cn/。备案展示、DNS、TLS 证书和反向代理是四个不同层次:备案号不会自动配置 HTTPS,证书也不会自动创建 DNS 记录。
二十五、3000、3100、3101、3110 分别是什么
| 端口 | 用途 | 生命周期 |
|---|---|---|
| 3000 | 当前生产静态 release | systemd 长期运行 |
| 3100 | 本地开发约定端口 | 仅手工运行 npm run dev -- --port 3100 时存在 |
| 3101 | Playwright 隔离测试服务 | 测试自动启动并结束 |
| 3110 | 签名 Webhook 接收与事件排队 | systemd 长期运行 |
3100 曾经运行一个常驻的 staging 静态服务,双环境简化后它退化为纯粹的本地开发端口;需要热更新时在开发机或 VPS 临时运行 npm run dev -- --port 3100,不要让它占据生产心智。3101 使用独立内容 fixture 和 .nuxt-e2e,不能拿它当日常预览端口。3110 只监听回环地址,由 Nginx 的精确 location 转发 GitHub 的 POST。
生产 3000 和事件驱动的同步流程可以共存,因为每次构建都通过共享锁串行执行,不会互相踩踏。
二十六、systemd 用户服务
当前与网站相关的 unit 有四个:
personal-site.service 3000 生产静态服务
personal-site-webhook.service 3110 签名 Webhook 接收与事件排队
personal-site-sync.service master 单次同步与部署
personal-site-goatcounter.service 8081 自托管统计服务(实验回退后仍在运行)
前三个来自仓库的 ops/systemd/;第四个是服务器上独立部署的 GoatCounter 进程,不属于网站发布链路。2026 年 8 月 26 日的统计实验回退后,站点不再加载任何统计脚本,这个服务目前只是遗留基础设施。
静态服务的关键设置包括:
Type=simple:Node 进程本身就是主进程;After/Wants=network-online.target:网络就绪后启动;Restart=on-failure与 5 秒延迟:异常退出后自动恢复;KillSignal=SIGINT:与静态服务器的优雅关闭逻辑对应;NoNewPrivileges=true、PrivateTmp=true:缩小运行时权限;- 安装到用户级
default.target,不要求整个服务以 root 运行。
Webhook 服务是 Type=simple 常驻轻量进程,只监听回环地址;同步服务是 Type=oneshot,开机不自启,完全由 Webhook 事件触发,并拥有独立的 sync-repo 与 deployed-commit。仓库没有周期性 GitHub timer;有事件时才 fetch,依赖锁变化时才安装依赖,纯内容提交使用快速构建。Webhook 与同步服务的环境覆盖(例如校验的仓库名)通过 systemd drop-in 目录维护。
unit 当前把 Node 可执行文件写成 NVM 下的明确路径。升级 Node 或切换 NVM 版本后,shell 中的 node 可能已经变化,但 systemd 仍引用旧路径,这是需要主动检查的运维陷阱。修改 unit 或 drop-in 后必须 daemon-reload,而普通文章或页面 release 不需要重启静态服务。
用户级服务要在 VPS 重启且用户未登录时自动启动,需要为运行网站的 Linux 用户启用 linger。管理它时应使用相同用户的 systemctl --user,不应使用 sudo systemctl --user 去连接 root 的用户总线。
二十七、测试体系究竟覆盖什么
测试按风险边界分成三组。
内容和 writer 单元测试
test:content 检查:
- 合法文章和四种分类可以通过;
- 非法日期、分类、文件名、重复标签和空正文会失败;
- 来源节必须包含公共 HTTP(S) 链接;
- CMS 可选 slug 必须与文件名一致;
hidden只能是布尔值,writer 更新时默认保留已有隐藏状态;- 站点 YAML 满足 Schema;
- writer 拒绝覆盖、穿越、符号链接和错误修订日期;
- 更新保留发布日期;
- 删除只接受精确存在的 dated slug。
release、同步与 Webhook 单元测试
test:release 在临时目录中检查:
current能原子切换;- 清理历史版本时保留激活 release;
- release 名称不能路径穿越;
- 受管内容路径不会扩大到应用代码;
- 自动同步失败后不写成功状态,后续重试仍会部署;
- Webhook 拒绝无效签名、其他仓库和
master之外的分支; - 构建期间到达的第二个 push 会保留并追加同步;
- 纯内容提交走快速构建,代码提交走完整构建;
- 子命令失败时错误信息保留退出码。
Playwright 浏览器测试
test:e2e 在 Chromium 中检查:
- 首页成功水合且没有控制台错误;
- 动态 favicon、theme-color 和 RGB 输出同步;
- 自定义主题在纯黑纯白下仍可读;
- 旧黄色缓存会迁移;
- mask 没加载完时字标不偷跑,加载后正常播放;
- 标签页激活、窗口 focus、换色、滑杆松手和返回主页会重播;
- 拖动期间不会反复播放;
- 服务状态、NAS 图标、最近文章、文章详情和更新日期正确;
- 隐藏文章不出现在公开查询中,原 URL 返回 404;
- 文章悬浮只影响当前行,长标题宽度不跳变;
- 从文章返回恢复列表的分类、搜索词和滚动位置,浏览器后退同样恢复,直接进入文章时列表从顶部开始;
- 文章阅读位置按路径保存并瞬时恢复;
- 文章标题锚点在允许动画的系统中平滑滚动;
- 触屏设备有按压反馈且不残留 hover 状态,触摸屏笔记本能正确区分触控与触控板 hover;
- 移动端返回按钮指向正确目标,“回到顶部”按钮按滚动阈值出现;
- 手机页面无横向溢出,RGB 在小屏隐藏;
- 多种桌面尺寸下关键模块互不重叠。
Playwright 配置允许一次重试,等待时间 10 秒,临时 Nuxt 服务启动最长等待 120 秒。响应式几何测试启用 reduced motion,避免入场动画中的瞬时位置影响布局判断。
测试不是越多越好。这里保留的是曾经真实出错、或者一旦回归就很难靠类型系统发现的行为;字体文件数量、每个 CSS 颜色和每一段正文不需要分别写测试。
二十八、当前性能画像与做过的优化
以下数字来自 2026 年 7 月版本的一次本机测量:
- 主 CSS 约 31.1 KB,gzip 后约 6.9 KB;
- 外部或本地字体文件由 98 个减少到 0;
- 首页一次传输约 252 KB;
- 模拟 4G 和 4 倍 CPU 降速时,LCP 约 0.75 秒,完整 load 约 1.37 秒;
- CLS 为 0。
VPS 空闲状态下,生产静态服务与 Webhook 接收器等常驻 Node 进程合计约几十 MB;统计实验遗留的 GoatCounter 进程也很轻。完整 Nuxt + Playwright 部署的峰值接近 1 GB,但同步构建通过共享锁串行执行;内容快速构建不启动 Playwright。磁盘主要消耗在工作仓库、同步仓库和各自的 node_modules 上;这是用磁盘换取工作区、缓存和发布故障互不污染。
这些数字是环境相关快照,不是永久承诺。浏览器缓存、网络、CPU、文章数量和依赖升级都会改变结果。
当前主要性能措施包括:
- 静态预渲染,HTML 首次响应包含页面内容;
- 移除大批字体资源,改用系统字体;
- 只预加载首屏真正依赖的 30 KB 字标 mask;
- 字标等待
decode(),避免资源未准备好时动画丢失; requestAnimationFrame合并高频指针更新;- 缓存元素边界,避免每次 pointermove 读取布局;
- 连续调色只改颜色,松手才重播动画;
- 用 transform/opacity 处理大多数动效;
- 生产服务器流式响应并支持 Brotli、gzip、ETag 和长期哈希缓存;
- 固定组件尺寸,避免动态文本造成布局偏移;
- 分离本地开发、生产同步和 E2E 的工作目录与 Nuxt Content 数据库。
.output/public 整体约 5.4 MB,其中包括 Nuxt Content 的客户端 SQLite/WASM 相关资源;这不等于每个访客首屏都会下载整个目录。分析性能时应看浏览器实际请求和传输体积,不应把产物目录大小直接当作首页流量。
二十九、为什么首次交互偶尔会“过一会儿才正常”
这个问题曾同时表现为字标不播放和 RGB 滑杆暂时失灵,根因方向不是滑杆 CSS 本身,而是客户端水合尚未完成或 Nuxt Dev 仍在首次编译。静态 HTML 可以先显示,但 Vue 事件只有在 JavaScript 加载、执行并成功水合后才生效。
判断方法是观察 .site-shell[data-hydrated="true"]。如果页面已经显示但该属性迟迟没有变成 true,应检查:
- 浏览器控制台是否有 JavaScript 错误;
- 本地 Nuxt Dev 是否还在首次编译;
- 本地内容 SQLite 缓存是否损坏;
- 大型依赖或资源是否阻塞客户端入口;
- 当前访问的是临时 Nuxt Dev,还是已经生成的 3000 静态站。
目前通过移除字体、减轻入口资源、预加载 mask、分离内容数据库和明确动画水合闸门改善了这个过程。所有常驻端口提供的都是静态 release,不存在边访问边编译的线上环境。
三十、常见修改应该改哪里
修改个人介绍、栏目文案或服务
优先通过 CMS 中对应的页面入口,或编辑 content/settings/ 下对应 YAML;服务列表位于 services.yml。若新增服务状态或图标类型,还要同步修改 Schema、配置枚举、CMS 选项、组件映射、CSS 和测试。
修改默认颜色
修改 theme.defaultAccent。它可以是任意 #RRGGBB。已有访客若保存过自定义颜色,会继续看到自己的选择;要做全体迁移,需要像旧黄色迁移一样明确识别并处理旧值,不能无条件清空 localStorage。
修改红绿蓝预设
修改 useAccentTheme.ts 的 accentPresets。RGB 面板和非主页调色按钮都复用它。修改后应测试纯黑纯白对比、localStorage 恢复和字标重播。
修改艺术字
在 CMS 中打开“站点设置 / 首页”,修改“艺术字文字”即可。它最终写入 home.yml 的 headline.emphasis,保存到 master 后由 Webhook 自动重新构建上线。字形由 nuxt.config.ts 引入,字体栈、字号和四层颜色位于 main.css。
修改字标动画
播放条件在 ArtisticWordmark.vue,跨组件触发在 useWordmarkAnimation.ts,视觉关键帧在 main.css。不要把三者混成一个大组件:字体状态、事件状态和视觉时间线是不同责任。
修改文章分类
至少同步 app/config/articles.ts、Nuxt Content Schema、CMS 选项、校验器测试与界面测试。文章中的旧分类是否迁移,需要单独决定。
修改文章样式
文章列表看 .article-row,正文看 .article-prose(表格边框也在这里),详情头部看 .article-hero。长标题、手机宽度、hover 前后几何和其他文章 opacity 是必须复查的回归点。
修改全局返回与回顶按钮
返回目标的判断和阈值在 app.vue,视觉与过渡在 main.css。修改后要同时覆盖文章页、普通内页和移动端三类场景。
修改生产服务
静态响应逻辑改 scripts/static-server.mjs;监听端口、release 根目录、Node 路径和重启策略改对应 systemd unit。同步仓库、代理和部署锁改 sync 脚本与 drop-in 环境变量。只有静态服务器或 unit 变化需要重启/重载,普通 release 切换不需要。
三十一、日常发布配方
通过远程 CMS 修改内容
- 打开
https://quilibra.cn/admin/index.html并用 GitHub 登录。 - 保存后 CMS 直接向
master提交,Webhook 触发同步:纯内容提交走快速构建,几十秒内上线。 - 检查正式站对应的页面和 URL。
保存和上线之间没有人工晋级步骤,提交前的内容自查是唯一的安全垫。隐藏文章也遵循同一流程。
本地受信流程直接发布内容
npm run content:validate
npm run publish
publish 成功后会输出 commit、release 和 current 路径。它用于 OpenClaw 等本地受限流程,会验证内容、生成静态站、提交并切换本地默认生产目录;把提交推送到 GitHub 后,Webhook 负责线上部署。若工作区混入 Vue、CSS、脚本、CMS 配置或文档改动,它会拒绝执行。
发布 Vue、CSS、脚本或配置代码
npm run verify
git diff --check
# 明确检查并提交本次文件
git push origin master
verify 会依次运行内容校验、类型检查、单元测试、静态生成和 E2E;master Webhook 之后触发同步器执行完整 npm run deploy。需要在 VPS 立即部署当前干净提交时,也可以手工运行 npm run deploy。
查看当前生产状态
systemctl --user is-active personal-site.service
systemctl --user is-active personal-site-webhook.service
readlink -f ~/.local/share/personal-site/current
cat ~/.local/state/personal-site-sync/deployed-commit
curl -fsS -o /dev/null -w '%{http_code} %{content_type}\n' http://127.0.0.1:3000/
回滚
先列出确实存在的 release,再执行:
npm run rollback -- <exact-release-id>
回滚后再次检查 current 和 3000 返回值。不要手动删除 current,也不要把它指向仓库的 .output/public。
三十二、故障排查顺序
CMS 保存后正式站没变化
先确认提交是否进入 GitHub master,并在 GitHub Recent Deliveries 中确认 Webhook 返回 202;再检查 personal-site-webhook.service、personal-site-sync.service 日志、~/.local/state/personal-site-sync/deployed-commit 和生产 current。构建失败不会覆盖旧 release;事件文件会保留并延迟重试,也可以修复后手工启动同步服务。
RGB 不能拖、按钮不能点、动画不播放
先看 data-hydrated,再看控制台错误和网络请求。若只发生在临时 Nuxt Dev 的第一次编译,等待完成后重载;若持续发生,停止那个临时开发进程,运行 npx nuxt cleanup 后重启开发端口。常驻 3000 是静态服务器,不使用这份开发缓存。
切回标签页没有动画
检查浏览器是否启用 reduced motion、mask 图片是否加载失败,以及 visibilitychange、focus 是否触发。一次切回只播一次是正常防抖,不应去掉 200ms 合并后让三个事件连续触发。
换色先闪回旧颜色
检查静态默认色、Nuxt useState 初始值和 localStorage 恢复顺序。服务端 HTML 无法知道客户端保存颜色,完全避免首帧差异需要在页面渲染前执行极小的内联主题恢复脚本;当前实现选择保持静态生成简单,在水合时恢复。
服务状态点不随 CMS 变化
确认 YAML 中 state 已保存、值属于三个枚举。CMS 保存后需要等同步构建完成才会看到变化;若 DOM 属性已变而颜色没变,检查 CSS 状态选择器;若属性没变,检查 current 是否仍是旧 release。
3000 无法访问
检查用户级 systemd 状态、日志、Node 固定路径、端口占用和 current 链接。服务器进程存在但 current 失效时会返回 503。不要在未确认进程归属前直接杀端口进程。真正常驻的只有 3000 和 3110;3100、3101 都按需临时启动,不为它们单独排障。
管理页面提示只允许 HTTPS
这通常来自浏览器安全上下文要求,而不是 Git remote 配错。Git remote 只决定仓库同步地址,不会给 HTTP 页面增加 HTTPS。远程后台应使用已经配置可信证书的 https://quilibra.cn/admin/index.html;本地仓库模式则从 localhost 打开。
三十三、安全与备份边界
至少要区分三类数据:
Git 仓库 源码、文章、站点设置、公开静态资源
OpenClaw 目录 Skill、草稿、会话和本地配置
release 目录 生产当前及历史静态构建结果
Git 提交是版本历史,不是异地备份。私人远程仓库可以备份源码与文章,但 OpenClaw 配置和 release 数据需要单独备份到受控存储。
永远不要提交或写入公开文章的内容包括:API key、OAuth secret、部署私钥、QQ token、cookie、扫码状态、.env、私人服务密码、内部地址清单和不适合公开的资产信息。
CMS 的 /admin/ 路径和 noindex 只能减少搜索引擎收录,不能阻止陌生人访问。当前后台没有 Basic Auth,保护来自可信 HTTPS 下的 GitHub OAuth 登录与私人仓库权限;生产上游只监听回环地址,公网 IP 与未知 Host 由默认 Nginx 站点拒绝(HTTP 返回 444,HTTPS 直接拒绝握手)。真正的保护来自这些边界和最小权限凭据,而不是隐藏 URL。
三十四、维护这套代码时应坚持的原则
第一,内容、表现和发布边界分开。能在 YAML 解决的文案不要硬编码进 Vue;能由共享组件表达的状态不要在两个页面复制;生产切换不要混进页面代码。
第二,把浏览器状态当成显式状态。主题色、资源是否解码、页面是否可见、指针是否仍按下、水合是否完成、列表与阅读位置是否待恢复,都应有清楚的变量和生命周期,而不是靠延时猜测。
第三,动画必须有静态最终状态。资源失败、JavaScript 失败或用户减少动态效果时,标题仍要可读,布局仍要成立。
第四,自动化写入必须比人工编辑权限更窄。Skill 负责工作流,writer 负责硬边界,validator 负责内容合约,release 负责上线,四者不能互相冒充。
第五,生产部署应该是“生成一个完整新版本,然后切换”,而不是在访问者正在读取的目录里逐个覆盖文件。原子 release 是这个小网站最值得长期保留的工程设计之一。
第六,发布路径必须单一且有校验。CMS 和本地流程都汇入 master,由同一个签名 Webhook 部署;内容快速构建与代码完整构建的差别由脚本判定,而不是由人临场决定。环境越简单,越要靠校验和原子切换守住质量。
第七,测试真实的关系和历史 bug。主题对比、滑杆松手、标签页重播、长标题 hover、隐藏文章 404、列表与阅读位置恢复、锚点平滑滚动、服务状态同步和多视口不重叠,都来自实际使用中的问题,比为了提高数字而堆大量脆弱测试更有价值。
三十五、重新上手时的最短阅读路径
如果未来很久没有维护这个项目,按下面顺序读,可以最快恢复上下文:
- 看
content/settings/的五份 YAML,知道全站和各页面当前展示什么。 - 看
app/pages/index.vue和main.css的主页部分,理解首屏结构。 - 看
useAccentTheme.ts、RgbAssembly.vue、ArtisticWordmark.vue,理解最复杂的交互。 - 看两个 writing 页面和
useWritingListState.ts、useArticleReadingState.ts,理解文章查询、渲染与状态恢复。 - 看
content.schema.ts、validator 和 writer,理解内容边界。 - 看
release.ts、sync.ts、webhook.ts和 systemd unit,理解 master 直发的自动部署链路。 - 看 Nginx 模板与
docs/运维文档,理解域名、HTTPS 和默认拒绝站点。 - 最后看测试,把“哪些行为不能再坏”快速过一遍。
只要能重新回答下面六个问题,就已经掌握了这个项目:
- 当前页面内容的唯一来源在哪里?
- 浏览器中的主题色如何从 RGB 状态传到 CSS、字标和 favicon?
- CMS 保存之后,一次生产 release 是如何自动发生的?
- publish、deploy、deploy:content 和 sync 的权限、检查与 Git 行为有什么不同?
- 一个新 release 如何在不重启 3000 的情况下接管生产请求?
- 文章列表状态和阅读位置是如何在会话内恢复的?
这些问题就是 Quilibra 当前技术架构的骨架。