文章
学习

Quilibra 个人主页完整技术手册:从页面架构到内容发布与生产部署

记录 Quilibra 个人主页的技术选型、页面架构、内容系统、主题动画、master 单分支自动发布、HTTPS、测试体系和维护方法。

NuxtVueTypeScriptNuxt ContentPlaywrightSveltia CMS静态部署

这是一份给自己看的项目说明书。它主要以 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

buildpreview 仍是 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 临时服务与浏览器配置

可以把这些文件分成四层:

  1. content/ 是可频繁修改的数据层。
  2. app/ 是页面表现和交互层。
  3. scripts/ops/ 是发布和运行层。
  4. .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 定义了六个集合:

  • sitehomeSettingswritingSettingsservicesSettingsaboutSettings:五个 data 集合,各自读取同名职责的 YAML;
  • writingpage 类型,读取 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;
  • 服务状态只能是 onlineprivatesoon
  • 图标只能使用代码中已有的枚举;
  • 服务链接只能是 # 或完整的 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 中,因此同一客户端的页面切换不会创建互相冲突的主题实例。

主题计算分为几步:

  1. 把每个通道四舍五入并限制在 0 到 255。
  2. 生成 CSS rgb(r g b) 与大写 #RRGGBB
  3. 把强调色分别与白色、画布色和黑色混合,得到 soft、faint、canvas、grid、strong 等衍生色。
  4. 计算 sRGB 相对亮度,对比黑白文字的对比度,自动选择更清晰的 --accent-ink
  5. 把结果以 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 只改变颜色;
  • pointeruppointercancel 才请求重播一次字标;
  • 键盘调整 range 时由 change 请求重播;
  • 一个零延时抑制标志防止同一次鼠标提交同时触发 pointerupchange,造成双播。

预设与重置属于离散操作,所以点击时立即换色并重播。这个模型可以概括为:连续操作实时预览、松手提交动画;离散操作立即提交。

十三、Quilibra 字标是可编辑的艺术字

当前字标不再依赖固定图片,而是由 content/settings/home.yml 中的 headline.emphasis 生成真实文本。CMS 的“首页 / 艺术字文字”可以直接修改内容,Schema 将长度限制为 20 个字符;组件还会按照中英文字符宽度自动缩小过长文字,避免挤出首屏。

字形使用随构建产物一起发布的 Cormorant Garamond 600 Italic。页面只引入拉丁字符子集,既保留高对比衬线斜体的艺术感,也不依赖 Google Fonts 等站外服务;无法覆盖的字符会回退到本机衬线字体。

动画仍然采用“RGB 套色从偏移到合拢”,没有模拟笔尖逐笔书写。字标由四层相同文本组成:

  • 红层从左下附近偏移进入;
  • 绿层从上方偏移进入;
  • 蓝层从右下附近偏移进入;
  • 最终层使用当前主题色并带极轻的阴影。

前三层使用 mix-blend-mode: multiply、动态模糊、透明度和不同位移。动画前段保留可见错版,中段快速靠近,后段降低彩色层透明度并让最终主题层聚焦。四层都只改变 opacitytransformfilter,播放时临时声明 will-change,结束后不长期占用合成资源。

ArtisticWordmark.vue 的加载控制解决了“首次打开没有动画”和字体中途替换的问题:

  1. @fontsource/cormorant-garamond 把字体文件交给 Vite 打包并生成带哈希的静态资源。
  2. 组件调用 document.fonts.load(),等待艺术字体真正可用于绘制。
  3. 只有字体就绪、组件仍存活、页面可见且请求仍是最新时才开始播放。
  4. 字体加载失败时继续使用衬线回退字体,不阻断内容显示。
  5. 开启减少动态效果时直接展示最终状态。

每次重播都会增加 animationRun,并把它放进四层的 Vue key。这会让浏览器得到新的动画元素,可靠地从第 0 帧开始,而不是依赖移除 class 后强制读取布局。

十四、字标在什么时候重播

全局 useWordmarkAnimation() 只维护一个数字信号。任何组件调用 requestReplay(),数字加一;字标组件 watch 到变化后重播。这个很小的事件通道避免 RGB 组件直接引用字标 DOM。

当前重播场景包括:

  • 首次进入主页并且艺术字体加载完成;
  • 点击主题预设或重置;
  • RGB 滑杆松手或键盘提交;
  • 从文章、服务、关于页面返回主页,主页组件重新挂载;
  • 浏览器标签页从隐藏变为可见;
  • 窗口重新获得焦点;
  • 浏览器的 pageshow,包括可能来自往返缓存的恢复。

visibilitychangefocuspageshow 可能在同一次切回中连续到达。组件使用一个 animation frame 合并激活请求,并设置 200ms 的最小间隔,防止一次切回连续播两三遍。页面变为隐藏时会取消待播放帧、使旧异步请求失效并结束当前播放。

这里还有两个无障碍与容错原则:

  • prefers-reduced-motion: reduce 时不播放套色,直接显示最终字标;
  • 可读文字仍存在于 sr-only 中,视觉 mask 不承担标题语义。

十五、服务矩阵、图标与状态点

每个服务在 YAML 中有六个字段:名称、说明、链接、状态、图标、是否显示在主页。

ServiceIcon.vue 把业务名称映射到 Lucide 图标。NAS 使用独立的 hard-driveHardDrive,不会复用影音播放图标。未知值虽然有 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

允许字段只有 titledescriptiondateupdatedslughiddencategorytagshidden 只能是布尔值,省略时按 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 有两种工作方式:

  1. 本地仓库模式:Chromium 通过 File System Access API 直接编辑本机选择的仓库。
  2. 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、账号信息、私人对话细节和精确私人资产写入文章;不能用任意编辑器、rmgit addgit 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 事件后运行。每轮会:

  1. 从 Sing-box 配置读取带认证的本机 HTTP 代理;
  2. 重试 GitHub fetch,只接受能够快进的远端历史;
  3. 根据 package-lock.json 哈希决定是否运行 npm ci
  4. 比较上次已部署提交与目标提交:只有受管内容变化时执行快速内容部署,否则执行完整 npm run deploy
  5. 只有部署成功才原子写入 deployed-commit
  6. 失败时保留旧 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 再新建,而是:

  1. 创建一个带进程号的临时符号链接;
  2. 让临时链接指向新 release;
  3. 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 自带的 httpfsstreamzlib。监听地址、端口和 current 路径分别由 PERSONAL_SITE_HOSTPERSONAL_SITE_PORTPERSONAL_SITE_ROOT 控制。systemd unit 把 host 设为 127.0.0.1,所以 3000 不直接接受公网连接。它没有框架中间件,职责非常窄:

  • 只接受 GET 和 HEAD,其他方法返回 405;
  • 解码 URL 后用 resolverelative 检查目标仍在 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当前生产静态 releasesystemd 长期运行
3100本地开发约定端口仅手工运行 npm run dev -- --port 3100 时存在
3101Playwright 隔离测试服务测试自动启动并结束
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=truePrivateTmp=true:缩小运行时权限;
  • 安装到用户级 default.target,不要求整个服务以 root 运行。

Webhook 服务是 Type=simple 常驻轻量进程,只监听回环地址;同步服务是 Type=oneshot,开机不自启,完全由 Webhook 事件触发,并拥有独立的 sync-repodeployed-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.tsaccentPresets。RGB 面板和非主页调色按钮都复用它。修改后应测试纯黑纯白对比、localStorage 恢复和字标重播。

修改艺术字

在 CMS 中打开“站点设置 / 首页”,修改“艺术字文字”即可。它最终写入 home.ymlheadline.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 修改内容

  1. 打开 https://quilibra.cn/admin/index.html 并用 GitHub 登录。
  2. 保存后 CMS 直接向 master 提交,Webhook 触发同步:纯内容提交走快速构建,几十秒内上线。
  3. 检查正式站对应的页面和 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.servicepersonal-site-sync.service 日志、~/.local/state/personal-site-sync/deployed-commit 和生产 current。构建失败不会覆盖旧 release;事件文件会保留并延迟重试,也可以修复后手工启动同步服务。

RGB 不能拖、按钮不能点、动画不播放

先看 data-hydrated,再看控制台错误和网络请求。若只发生在临时 Nuxt Dev 的第一次编译,等待完成后重载;若持续发生,停止那个临时开发进程,运行 npx nuxt cleanup 后重启开发端口。常驻 3000 是静态服务器,不使用这份开发缓存。

切回标签页没有动画

检查浏览器是否启用 reduced motion、mask 图片是否加载失败,以及 visibilitychangefocus 是否触发。一次切回只播一次是正常防抖,不应去掉 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、列表与阅读位置恢复、锚点平滑滚动、服务状态同步和多视口不重叠,都来自实际使用中的问题,比为了提高数字而堆大量脆弱测试更有价值。

三十五、重新上手时的最短阅读路径

如果未来很久没有维护这个项目,按下面顺序读,可以最快恢复上下文:

  1. content/settings/ 的五份 YAML,知道全站和各页面当前展示什么。
  2. app/pages/index.vuemain.css 的主页部分,理解首屏结构。
  3. useAccentTheme.tsRgbAssembly.vueArtisticWordmark.vue,理解最复杂的交互。
  4. 看两个 writing 页面和 useWritingListState.tsuseArticleReadingState.ts,理解文章查询、渲染与状态恢复。
  5. content.schema.ts、validator 和 writer,理解内容边界。
  6. release.tssync.tswebhook.ts 和 systemd unit,理解 master 直发的自动部署链路。
  7. 看 Nginx 模板与 docs/ 运维文档,理解域名、HTTPS 和默认拒绝站点。
  8. 最后看测试,把“哪些行为不能再坏”快速过一遍。

只要能重新回答下面六个问题,就已经掌握了这个项目:

  • 当前页面内容的唯一来源在哪里?
  • 浏览器中的主题色如何从 RGB 状态传到 CSS、字标和 favicon?
  • CMS 保存之后,一次生产 release 是如何自动发生的?
  • publish、deploy、deploy:content 和 sync 的权限、检查与 Git 行为有什么不同?
  • 一个新 release 如何在不重启 3000 的情况下接管生产请求?
  • 文章列表状态和阅读位置是如何在会话内恢复的?

这些问题就是 Quilibra 当前技术架构的骨架。

来源与延伸阅读