返回
返回文章
学习

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

记录 Quilibra 个人主页 2026 年 7 月版本的技术选型、页面架构、内容系统、主题动画、双环境发布、HTTPS、测试体系和维护方法。

#Nuxt#Vue#TypeScript#Nuxt Content#Playwright#Sveltia CMS#静态部署

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

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

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

  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
  • 全局引入 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 定义了六个集合:

  • 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 接管时只剩最终帧。

非主页的全局返回入口固定回到 /,文本只有“返回”,图标和 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 中,因此同一客户端的页面切换不会创建互相冲突的主题实例。

主题计算分为几步:

  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",筛选后的篇数和空状态可以被辅助技术感知。

十七、文章详情与相邻导航

动态路由文件是 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

允许字段只有 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 当前固定写入私人仓库的 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、账号信息、私人对话细节和精确私人资产写入文章;不能用任意编辑器、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 配置或文档改动,会拒绝执行。它是受限本地发布入口,不是远程 CMS 的预发布流程。

npm run deploy

用于已经提交的代码版本。它要求工作区完全干净,不创建 commit,并依次运行:

类型检查
内容与 writer 单元测试
release 单元测试
静态生成
浏览器 E2E
创建 release
原子切换 current
清理过旧 release

npm run sync

scripts/sync.ts 是两个 systemd oneshot 服务共用的自动同步器。环境变量决定它跟踪 master 还是 staging、使用哪一份独立仓库、把 release 写到哪个数据根目录。每轮会:

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

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

  • 只接受 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
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当前生产静态 releasesystemd 长期运行
3100staging 当前静态 releasesystemd 长期运行
3101Playwright 隔离测试服务测试自动启动并结束

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=truePrivateTmp=true:缩小运行时权限;
  • 安装到用户级 default.target,不要求整个服务以 root 运行。

Webhook 服务是 Type=simple 常驻轻量进程,只监听回环地址;两个同步服务仍是 Type=oneshot,仅在收到对应分支的 push 后运行。生产和测试分别拥有独立 sync-repodeployed-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.tsaccentPresets。RGB 面板和非主页调色按钮都复用它。修改后应测试纯黑纯白对比、localStorage 恢复和字标重播。

修改艺术字

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

  1. 打开 https://dev.quilibra.cn/admin/index.html 并保存。
  2. 等待 Webhook 触发 staging 构建,检查测试站主页、列表和具体 URL。
  3. 在干净且最新的 master 工作区运行 npm run promote
  4. 等待 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.servicepersonal-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 图片是否加载失败,以及 visibilitychangefocus 是否触发。一次切回只播一次是正常防抖,不应去掉 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、服务状态同步和多视口不重叠,都来自实际使用中的问题,比为了提高数字而堆大量脆弱测试更有价值。

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

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

  1. content/settings/ 的五份 YAML,知道全站和各页面当前展示什么。
  2. app/pages/index.vuemain.css 的主页部分,理解首屏结构。
  3. useAccentTheme.tsRgbAssembly.vueArtisticWordmark.vue,理解最复杂的交互。
  4. 看两个 writing 页面和 content.config.ts,理解文章查询与渲染。
  5. content.schema.ts、validator 和 writer,理解内容边界。
  6. release.tssync.tspromote.ts 和 systemd unit,理解双环境发布链路。
  7. 看 Nginx 模板与运维文档,理解域名、HTTPS、Basic Auth 和回环上游。
  8. 最后看测试,把“哪些行为不能再坏”快速过一遍。

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

  • 当前页面内容的唯一来源在哪里?
  • 浏览器中的主题色如何从 RGB 状态传到 CSS、字标和 favicon?
  • 为什么 CMS 保存后只改变测试站?
  • publish、deploy、sync 和 promote 的权限、检查与 Git 行为有什么不同?
  • 一个新 release 如何在不重启 3000 的情况下接管生产请求?
  • 3000 与 3100 为什么不会覆盖彼此的仓库和 current?

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

来源与延伸阅读