Quilibra 个人主页完整技术手册:从页面架构到内容发布与生产部署
记录 Quilibra 个人主页 2026 年 7 月版本的技术选型、页面架构、内容系统、主题动画、双环境发布、HTTPS、测试体系和维护方法。
这是一份给自己看的项目说明书。它主要以 2026 年 7 月 27 日的实际代码和 VPS 配置为准,从浏览器里看到的页面一直追到内容文件、Git 分支、构建产物、静态服务器和版本化 release。以后忘了某段代码为什么存在、主页颜色为什么能保存、CMS 保存为什么只出现在测试站,或者 3000、3100、3101 三个端口分别在做什么,可以从这里重新建立完整认识。
本文记录的是一个具体版本的实现,不是一套永远不变的规范。最可靠的事实来源始终是仓库中的代码;本文的作用,是把分散在 Vue、CSS、YAML、脚本和运维文件里的设计意图串起来。
2026 年 8 月 3 日更新:网站已经移除长期
staging分支、远程测试站和promote流程,改为 CMS 直接提交master、代码在本地功能分支验证后推送master。下文关于双环境发布的内容保留为历史架构记录,当前操作以仓库 README 和运维手册为准。
一、先用一句话理解整个网站
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 提供正式站,staging 构建到 ~/.local/share/personal-site-staging/current 并由 3100 提供测试站。两边都使用相同的生成和原子切换逻辑,但仓库、成功状态、release 目录和服务端口完全独立。
这里最重要的边界是: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 test:content 内容校验器与受限 writer 的单元测试
npm run test:release release 工具的单元测试
npm run test:e2e Playwright 浏览器测试
npm run publish 发布未提交的受管内容
npm run deploy 部署已经提交且工作区干净的代码版本
npm run sync 拉取指定 GitHub 分支并自动检查、构建和部署
npm run promote 把测试站确认过的 CMS 内容安全发布到生产
npm run rollback 切换回指定历史 release
build 与 preview 仍是 Nuxt 的常规命令,但当前生产链路以 generate 和自有静态服务器为准。
四、目录结构与所有权边界
理解这个项目,先要知道“改什么就去哪里”。
personal-site/
├── app/
│ ├── app.vue 全站外壳、主题初始化、动态 favicon
│ ├── assets/css/main.css 全站视觉与响应式规则
│ ├── components/
│ │ ├── ArtisticWordmark.vue 可编辑艺术字与重播控制
│ │ ├── RgbAssembly.vue RGB 滑杆、预设与重置
│ │ ├── ServiceIcon.vue 服务图标名称到 Lucide 的映射
│ │ └── ServiceStatusDot.vue 服务状态点
│ ├── composables/
│ │ ├── useAccentTheme.ts 主题色状态、混色、对比色与持久化
│ │ ├── useSiteSettings.ts 并行读取并组合五份站点设置
│ │ └── useWordmarkAnimation.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 测试、生成、提交、发布和回滚
│ ├── promote.ts staging 内容安全合并到 master
│ ├── sync.ts GitHub 拉取、代理、构建与部署
│ ├── webhook.ts GitHub 签名校验、事件队列与部署触发
│ └── static-server.mjs 生产与测试静态文件服务器
├── tests/ 单元、发布和浏览器测试
├── ops/
│ ├── openclaw/.../SKILL.md OpenClaw 文章发布 Skill
│ ├── nginx/ 正式域名、测试域名和默认拒绝站点
│ └── 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; - 全局引入
app/assets/css/main.css; - 关闭开发工具;
- 设置中文页面语言和移动端 viewport;
- 使用
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 接管时只剩最终帧。
非主页的全局返回入口固定回到 /,文本只有“返回”,图标和 aria-label 补充了方向与完整语义。它与文章正文中的“返回文章”属于不同层级:前者返回网站主页,后者返回文章列表。
八、主页是一张单屏工作台
主页不是按多个营销区块向下滚动,而是一张固定在一个视口高度内的工作台。核心区域包括:
- 顶部站点标识、上海时区时钟、指针坐标和主题按钮;
- 左上介绍、普通标题前缀与 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",筛选后的篇数和空状态可以被辅助技术感知。
十七、文章详情与相邻导航
动态路由文件是 app/pages/writing/[...slug].vue。它使用当前 route.path 查询对应文章;找不到时立即抛出带中文说明的 404。
详情查询本身也要求 hidden = false,所以隐藏文章不只是从列表消失,直接访问原 URL 也会得到真正的 404。详情页同时取得按日期倒序排列的全部公开文章,用当前文章的数组位置计算:
- 数组中下一项是时间上更旧的“上一篇”;
- 数组中上一项是时间上更新的“下一篇”。
页面展示分类、发布日期、可选的更新日期、标题、摘要、标签与正文。正文由 Nuxt Content 的 ContentRenderer 生成。SEO 标题使用全站模板变成“文章标题 · Quilibra”,描述来自 frontmatter,Open Graph 类型设为 article。
正文样式集中在 .article-prose:限制阅读宽度、提高行高、分级处理标题、列表、引用、代码、链接和图片。文章页面可以自由使用 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 当前固定写入私人仓库的 staging 分支,完整链路是:
dev.quilibra.cn/admin
|
| GitHub OAuth 保存
v
GitHub staging
|
| GitHub push Webhook,HMAC-SHA256 签名
v
VPS 事件队列 -> staging 同步 -> 快速或完整构建 -> staging release -> 127.0.0.1:3100
|
| 人工确认测试页面后 npm run promote
v
GitHub master
|
| GitHub push Webhook,HMAC-SHA256 签名
v
VPS 事件队列 -> master 同步 -> 快速或完整构建 -> production release -> 127.0.0.1:3000
因此,在 CMS 打开“隐藏文章”并保存,只会先让测试站列表和原 URL 发生变化,正式站保持不动。确认后执行 npm run promote,生产才接收同一个内容状态。这是分支、构建目录、release 和进程都真实隔离的预发布环境,不是给同一份生产内容换了一个域名。
GitHub OAuth 只控制“谁能通过 CMS 读写私人仓库”。本项目不使用 GitHub Actions 部署,也不开放公网 SSH;GitHub 只向现有 HTTPS 精确路径发送带签名的 push 通知,VPS 收到后再通过 Sing-box HTTP 代理主动拉取对应分支。Webhook secret 只在 VPS 和 GitHub 仓库设置中保存。私人仓库存放网站源码、文章、公开图片和不含密钥的配置;OAuth secret、访问令牌、Webhook secret、部署私钥、OpenClaw 会话、cookie 和 .env 不进入仓库。
远程后台统一通过 https://dev.quilibra.cn/admin/index.html 使用。Nginx 在 TLS 之外再加一层 HTTP Basic Auth,并发送 noindex;GitHub OAuth 负责仓库权限,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
|
v
npm run publish
|
v
返回文章 URL、commit、release
直接发布 只跳过人工预览,不跳过 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 配置或文档改动,会拒绝执行。它是受限本地发布入口,不是远程 CMS 的预发布流程。
npm run deploy
用于已经提交的代码版本。它要求工作区完全干净,不创建 commit,并依次运行:
类型检查
内容与 writer 单元测试
release 单元测试
静态生成
浏览器 E2E
创建 release
原子切换 current
清理过旧 release
npm run sync
scripts/sync.ts 是两个 systemd oneshot 服务共用的自动同步器。环境变量决定它跟踪 master 还是 staging、使用哪一份独立仓库、把 release 写到哪个数据根目录。每轮会:
- 从 Sing-box 配置读取带认证的本机 HTTP 代理;
- 重试 GitHub fetch,只接受能够快进的远端历史;
- 根据
package-lock.json哈希决定是否运行npm ci; - 比较上次已部署提交与目标提交:只有受管内容变化时执行快速内容部署,否则执行完整
npm run deploy; - 只有部署成功才原子写入
deployed-commit; - 失败时保留旧 release,由持久化 Webhook 事件延迟重试。
纯文章、站点设置和上传资源变化仍会执行内容校验与静态生成,但跳过未变化代码的类型检查、单元测试和 E2E。生产和测试同步服务使用同一个 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 类型和 staging/master 分支,再把事件以权限 0600 写入用户状态目录并返回 202。
每个 GitHub delivery 都有独立队列文件。若构建期间出现第二次 push,第一轮完成后会再启动一轮同步;进程重启时也会恢复未完成事件。失败使用延迟重试,不需要周期性轮询 GitHub。Webhook 请求只能映射到两个固定 systemd unit,不能携带或执行任意命令。
npm run promote
这是远程 CMS 从测试进入生产的唯一显式关口。它要求本地 master 干净且等于最新远端,确认远端 staging 的精确提交已经成功部署到测试站,并拒绝 staging 中的 Vue、CSS、脚本、文档或 CMS 配置改动。允许晋级的只有:
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 worktree 中把 staging 合并到最新 master,再用一次 git push --atomic 让远端 master 和 staging 同时指向同一个合并提交。若任一引用不能更新,两边都不更新。GitHub 随后发送 master Webhook 并触发正式部署。
因此,修改本文这样的文章有两种合法路径:本地受信流程可用 publish;远程 CMS 则保存到 staging、检查测试站、再运行 promote。修改 Vue/CSS 并已经提交后使用 deploy 或由 master 同步器部署。不要用 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;测试同步服务显式设置为 ~/.local/share/personal-site-staging。因此相同的 release 代码可以复用,但两个环境不会碰到对方的 releases/ 和 current。
脚本默认保留最近五个 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 和 3100 不直接接受公网连接。它没有框架中间件,职责非常窄:
- 只接受 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
dev.quilibra.cn -> HTTPS + Basic Auth -> 127.0.0.1:3100
公网 IP或未知 Host -> 默认站点拒绝
根域名和 www 共用正式站,证书由 Certbot 申请并续期;测试子域名使用自己的证书和密码文件。Nginx 把真实 Host、客户端地址和转发协议传给上游,测试站额外发送 X-Robots-Tag: noindex, nofollow, noarchive。腾讯云安全组只需要开放 80 和 443,3000、3100 不对公网放行。
页脚备案号来自全站设置 content/settings/site.yml,当前 ICP 号链接到 https://beian.miit.gov.cn/。备案展示、DNS、TLS 证书和反向代理是四个不同层次:备案号不会自动配置 HTTPS,证书也不会自动创建 DNS 记录。
二十五、3000、3100、3101 分别是什么
| 端口 | 用途 | 生命周期 |
|---|---|---|
| 3000 | 当前生产静态 release | systemd 长期运行 |
| 3100 | staging 当前静态 release | systemd 长期运行 |
| 3101 | Playwright 隔离测试服务 | 测试自动启动并结束 |
3100 过去曾运行当前工作区的 Nuxt Dev,容易受首次编译、热更新和 Nuxt Content 缓存损坏影响。现在它与 3000 一样只运行静态服务器,从独立 staging release 读取文件,空闲常驻约几十 MB,并由 https://dev.quilibra.cn 提供远程预览。需要真正的热更新时,在开发机或 VPS 的另一个空闲端口临时运行 npm run dev,不要覆盖常驻 3100。
3101 使用独立内容 fixture 和 .nuxt-e2e,不能拿它当日常预览端口。生产 3000、预发布 3100 和测试 3101 可以同时存在,因为 release、构建目录和端口都隔离。
二十六、systemd 用户服务
当前有五个相关 unit:
personal-site.service 3000 生产静态服务
personal-site-dev.service 3100 staging 静态服务
personal-site-webhook.service 3110 签名 Webhook 接收与事件排队
personal-site-sync.service master 单次同步与部署
personal-site-staging-sync.service staging 单次同步与部署
两个静态服务的关键设置包括:
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,仅在收到对应分支的 push 后运行。生产和测试分别拥有独立 sync-repo 与 deployed-commit,但用同一个 /home/user/.local/state/personal-site-deploy.lock 串行构建。仓库没有周期性 GitHub timer;有事件时才 fetch,依赖锁变化时才安装依赖,纯内容提交使用快速构建。
unit 当前把 Node 可执行文件写成 NVM 下的明确路径。升级 Node 或切换 NVM 版本后,shell 中的 node 可能已经变化,但 systemd 仍引用旧路径,这是需要主动检查的运维陷阱。修改 unit 后必须 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 单元测试
test:release 在临时目录中检查:
current能原子切换;- 清理历史版本时保留激活 release;
- release 名称不能路径穿越;
- 受管内容路径不会扩大到应用代码;
- 自动同步失败后不写成功状态,后续重试仍会部署;
- Webhook 拒绝无效签名、其他仓库和其他分支;
- 构建期间到达的第二个 push 会保留并追加同步;
- 纯内容提交走快速构建,代码提交走完整构建;
- staging 未成功部署时不能晋级,晋级后 master 与 staging 原子对齐;
- 子命令失败时错误信息保留退出码。
Playwright 浏览器测试
test:e2e 在 Chromium 中检查:
- 首页成功水合且没有控制台错误;
- 动态 favicon、theme-color 和 RGB 输出同步;
- 自定义主题在纯黑纯白下仍可读;
- 旧黄色缓存会迁移;
- mask 没加载完时字标不偷跑,加载后正常播放;
- 标签页激活、窗口 focus、换色、滑杆松手和返回主页会重播;
- 拖动期间不会反复播放;
- 服务状态、NAS 图标、最近文章、文章详情和更新日期正确;
- 隐藏文章不出现在公开查询中,原 URL 返回 404;
- 文章悬浮只影响当前行,长标题宽度不跳变;
- 手机页面无横向溢出,RGB 在小屏隐藏;
- 多种桌面尺寸下关键模块互不重叠。
Playwright 配置允许一次重试,等待时间 10 秒,临时 Nuxt 服务启动最长等待 120 秒。响应式几何测试启用 reduced motion,避免入场动画中的瞬时位置影响布局判断。
测试不是越多越好。这里保留的是曾经真实出错、或者一旦回归就很难靠类型系统发现的行为;字体文件数量、每个 CSS 颜色和每一段正文不需要分别写测试。
二十八、当前性能画像与做过的优化
在当前版本的一次本机测量中:
- 主 CSS 约 31.1 KB,gzip 后约 6.9 KB;
- 外部或本地字体文件由 98 个减少到 0;
- 首页一次传输约 252 KB;
- 模拟 4G 和 4 倍 CPU 降速时,LCP 约 0.75 秒,完整 load 约 1.37 秒;
- CLS 为 0。
VPS 空闲状态下,生产与测试两个 Node 静态服务合计约 50 MB;Webhook 接收器也是单一轻量 Node 进程,不执行构建。整机仍有约 1.1 GB 可用内存。完整 Nuxt + Playwright 部署的峰值接近 1 GB,但两个同步器通过共享锁串行执行;内容快速构建不启动 Playwright。生产、测试和开发三份仓库合计约 1.5 GB,主要空间来自各自的 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/3100 静态站。
目前通过移除字体、减轻入口资源、预加载 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,保存到测试分支后由 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 是必须复查的回归点。
修改生产或测试服务
静态响应逻辑改 scripts/static-server.mjs;监听端口、release 根目录、Node 路径和重启策略改对应 systemd unit。同步分支、代理、独立仓库和部署锁改两个 sync unit。只有静态服务器或 unit 变化需要重启/重载,普通 release 切换不需要。
三十一、日常发布配方
通过远程 CMS 修改内容
- 打开
https://dev.quilibra.cn/admin/index.html并保存。 - 等待 Webhook 触发 staging 构建,检查测试站主页、列表和具体 URL。
- 在干净且最新的 master 工作区运行
npm run promote。 - 等待 master Webhook 触发生产构建,再检查正式站。
保存、预览、晋级和正式部署是四个明确阶段。隐藏文章也遵循同一流程。
本地受信流程直接发布内容
npm run content:validate
npm run publish
publish 成功后会输出 commit、release 和 current 路径。它用于 OpenClaw 等本地受限流程,会直接切换默认生产 release;是否推送 GitHub 是另一项操作。普通远程 CMS 编辑不要使用这条路径绕过 staging。
发布 Vue、CSS、脚本或配置代码
npm run typecheck
npm run test:content
npm run test:release
npm run test:e2e
npm run generate
git diff --check
# 明确检查并提交本次文件
git push origin master
master Webhook 会触发同步器再次执行完整 npm run deploy,前面的命令适合在提交前尽早发现问题。需要在 VPS 立即部署当前干净提交时也可以手工运行 npm run deploy。代码版本只进入 master 时,测试站可能暂时仍运行上一份代码;下一次内容晋级会把最新 master 与 staging 合并并重新对齐两边。
查看当前生产状态
systemctl --user is-active personal-site.service
systemctl --user is-active personal-site-dev.service
readlink -f ~/.local/share/personal-site/current
readlink -f ~/.local/share/personal-site-staging/current
curl -fsS -o /dev/null -w '%{http_code} %{content_type}\n' http://127.0.0.1:3000/
curl -fsS -o /dev/null -w '%{http_code} %{content_type}\n' http://127.0.0.1:3100/
回滚
先列出确实存在的 release,再执行:
npm run rollback -- <exact-release-id>
回滚后再次检查 current 和 3000 返回值。不要手动删除 current,也不要把它指向仓库的 .output/public。
三十二、故障排查顺序
CMS 保存后测试站看不到
先确认提交是否进入 GitHub staging,并在 GitHub Recent Deliveries 中确认 Webhook 返回 202;再检查 personal-site-webhook.service、personal-site-staging-sync.service 日志、~/.local/state/personal-site-staging-sync/deployed-commit 和测试 current。构建失败不会覆盖旧预览;事件文件会保留并延迟重试,也可以修复后手工启动同步服务。
测试站正确但正式站没变化
确认是否实际运行过 npm run promote、远端 master 是否已更新以及 master Webhook 是否返回 202,再检查 personal-site-sync.service 和生产 deployed-commit。CMS 保存只更新测试站是正常设计,不是同步故障。
RGB 不能拖、按钮不能点、动画不播放
先看 data-hydrated,再看控制台错误和网络请求。若只发生在临时 Nuxt Dev 的第一次编译,等待完成后重载;若持续发生,停止那个临时开发进程,运行 npx nuxt cleanup 后重启开发端口。常驻 3000 和 3100 都是静态服务器,不使用这份开发缓存。
切回标签页没有动画
检查浏览器是否启用 reduced motion、mask 图片是否加载失败,以及 visibilitychange、focus 是否触发。一次切回只播一次是正常防抖,不应去掉 200ms 合并后让三个事件连续触发。
换色先闪回旧颜色
检查静态默认色、Nuxt useState 初始值和 localStorage 恢复顺序。服务端 HTML无法知道客户端保存颜色,完全避免首帧差异需要在页面渲染前执行极小的内联主题恢复脚本;当前实现选择保持静态生成简单,在水合时恢复。
服务状态点不随 CMS 变化
确认 YAML 中 state 已保存、值属于三个枚举,并检查正在看的环境:CMS 保存后测试站应先变化,正式站要等 promote。若 DOM 属性已变而颜色没变,检查 CSS 状态选择器;若属性没变,检查对应环境的 current 是否仍是旧 release。
3000 无法访问
检查用户级 systemd 状态、日志、Node 固定路径、端口占用和 current 链接。服务器进程存在但 current 失效时会返回 503。不要在未确认进程归属前直接杀端口进程。
3100 使用相同排查方法,但服务是 personal-site-dev.service,数据根目录是 ~/.local/share/personal-site-staging。正式站正常而测试站异常时,不要重启 3000。
管理页面提示只允许 HTTPS
这通常来自浏览器安全上下文要求,而不是 Git remote 配错。Git remote 只决定仓库同步地址,不会给 HTTP 页面增加 HTTPS。远程后台应使用已经配置可信证书和 Basic Auth 的 https://dev.quilibra.cn/admin/index.html;本地仓库模式则从 localhost 打开。
三十三、安全与备份边界
至少要区分三类数据:
Git 仓库 源码、文章、站点设置、公开静态资源
OpenClaw 目录 Skill、草稿、会话和本地配置
release 目录 生产与测试当前及历史静态构建结果
Git 提交是版本历史,不是异地备份。私人远程仓库可以备份源码与文章,但 OpenClaw 配置和 release 数据需要单独备份到受控存储。
永远不要提交或写入公开文章的内容包括:API key、OAuth secret、部署私钥、QQ token、cookie、扫码状态、.env、私人服务密码、内部地址清单和不适合公开的资产信息。
CMS 的 /admin/ 路径和 noindex 只能减少搜索引擎收录,不能阻止陌生人访问。当前远程后台同时依靠可信 HTTPS、Nginx Basic Auth 和 GitHub OAuth;生产/测试上游只监听回环地址,公网 IP 与未知 Host 由默认 Nginx 站点拒绝。真正的保护来自这些边界和最小权限凭据,而不是隐藏 URL。
三十四、维护这套代码时应坚持的原则
第一,内容、表现和发布边界分开。能在 YAML 解决的文案不要硬编码进 Vue;能由共享组件表达的状态不要在两个页面复制;生产切换不要混进页面代码。
第二,把浏览器状态当成显式状态。主题色、资源是否解码、页面是否可见、指针是否仍按下、水合是否完成,都应有清楚的变量和生命周期,而不是靠延时猜测。
第三,动画必须有静态最终状态。资源失败、JavaScript 失败或用户减少动态效果时,标题仍要可读,布局仍要成立。
第四,自动化写入必须比人工编辑权限更窄。Skill 负责工作流,writer 负责硬边界,validator 负责内容合约,release 负责上线,四者不能互相冒充。
第五,生产部署应该是“生成一个完整新版本,然后切换”,而不是在访问者正在读取的目录里逐个覆盖文件。原子 release 是这个小网站最值得长期保留的工程设计之一。
第六,测试站和正式站必须由分支、仓库、release 与进程共同隔离。域名不同不等于环境隔离,CMS 保存也不等于生产发布;promote 是有校验的明确边界。
第七,测试真实的关系和历史 bug。主题对比、滑杆松手、标签页重播、长标题 hover、隐藏文章 404、服务状态同步和多视口不重叠,都来自实际使用中的问题,比为了提高数字而堆大量脆弱测试更有价值。
三十五、重新上手时的最短阅读路径
如果未来很久没有维护这个项目,按下面顺序读,可以最快恢复上下文:
- 看
content/settings/的五份 YAML,知道全站和各页面当前展示什么。 - 看
app/pages/index.vue和main.css的主页部分,理解首屏结构。 - 看
useAccentTheme.ts、RgbAssembly.vue、ArtisticWordmark.vue,理解最复杂的交互。 - 看两个 writing 页面和
content.config.ts,理解文章查询与渲染。 - 看
content.schema.ts、validator 和 writer,理解内容边界。 - 看
release.ts、sync.ts、promote.ts和 systemd unit,理解双环境发布链路。 - 看 Nginx 模板与运维文档,理解域名、HTTPS、Basic Auth 和回环上游。
- 最后看测试,把“哪些行为不能再坏”快速过一遍。
只要能重新回答下面五个问题,就已经掌握了这个项目:
- 当前页面内容的唯一来源在哪里?
- 浏览器中的主题色如何从 RGB 状态传到 CSS、字标和 favicon?
- 为什么 CMS 保存后只改变测试站?
- publish、deploy、sync 和 promote 的权限、检查与 Git 行为有什么不同?
- 一个新 release 如何在不重启 3000 的情况下接管生产请求?
- 3000 与 3100 为什么不会覆盖彼此的仓库和 current?
这些问题就是 Quilibra 当前技术架构的骨架。