Quilibra 个人主页完整技术手册:从页面架构到内容发布与生产部署
完整记录 Quilibra 个人主页当前版本的技术选型、页面架构、内容系统、主题动画、测试体系、发布流程和日常维护方法。
这是一份面向未来自己的项目说明书。它不再复述网站是怎样一轮轮改成现在这个样子的,而是以 2026 年 7 月 26 日的实际代码为准,从浏览器里看到的页面一直追到内容文件、构建产物、静态服务器和生产 release。以后忘了某段代码为什么存在、主页颜色为什么能保存、文章为什么保存后还没有上线,或者 3000、3100、3101 三个端口分别在做什么,可以从这里重新建立完整认识。
本文记录的是一个具体版本的实现,不是一套永远不变的规范。最可靠的事实来源始终是仓库中的代码;本文的作用,是把分散在 Vue、CSS、YAML、脚本和运维文件里的设计意图串起来。
一、先用一句话理解整个网站
Quilibra 是一个由 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
浏览器 / 反向代理
这里最重要的边界是:仓库中的源码是长期事实,.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 | 内容工具、release 脚本和生产静态服务器 |
没有引入 Tailwind、组件库、动画框架、运行时数据库或外部字体。页面使用系统无衬线字体和系统等宽字体回退,这减少了字体请求,也避免了字体加载完成时重新排版。
package.json 中最常用的入口是:
npm run dev 开发服务,默认对外监听 0.0.0.0
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 rollback 切换回指定历史 release
build 与 preview 仍是 Nuxt 的常规命令,但当前生产链路以 generate 和自有静态服务器为准。
四、目录结构与所有权边界
理解这个项目,先要知道“改什么就去哪里”。
personal-site/
├── app/
│ ├── app.vue 全站外壳、主题初始化、动态 favicon
│ ├── assets/css/main.css 全站视觉与响应式规则
│ ├── components/
│ │ ├── HandwrittenWordmark.vue Quilibra 字标加载与重播控制
│ │ ├── RgbAssembly.vue RGB 滑杆、预设与重置
│ │ ├── ServiceIcon.vue 服务图标名称到 Lucide 的映射
│ │ └── ServiceStatusDot.vue 服务状态点
│ ├── composables/
│ │ ├── useAccentTheme.ts 主题色状态、混色、对比色与持久化
│ │ ├── useSiteSettings.ts 读取唯一一份站点设置
│ │ └── useWordmarkAnimation.ts 跨组件字标重播信号
│ ├── config/ 文章分类、服务状态和图标枚举
│ ├── pages/ 首页、文章、服务、关于页面
│ └── error.vue 错误页面
├── content/
│ ├── settings/site.yml 主页与栏目内容的唯一数据源
│ └── writing/*.md 正式文章
├── public/
│ ├── admin/ Sveltia CMS 入口与字段配置
│ ├── brand/ 字标位图资源
│ ├── uploads/ 文章上传资源
│ └── favicon.svg 静态 favicon 回退
├── scripts/
│ ├── content/validate.ts 内容结构校验
│ ├── content/write.ts 受限文章写入、更新与删除
│ ├── release.ts 测试、生成、提交、发布和回滚
│ └── static-server.mjs 生产静态文件服务器
├── tests/ 单元、发布和浏览器测试
├── ops/
│ ├── openclaw/.../SKILL.md OpenClaw 文章发布 Skill
│ └── systemd/personal-site.service 用户级生产服务定义
├── 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/site.yml、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:data类型,只读取settings/site.yml;writing:page类型,读取writing/**/*.md,因此每篇文章会拥有可路由的path和可供ContentRenderer使用的正文。
E2E 测试通过 NUXT_CONTENT_CWD=tests/fixtures/content 把文章集合切换到固定 fixture,测试不会依赖真实文章的标题和数量,也不会改动生产内容。
六、站点设置:一份 YAML 如何驱动多个页面
content/settings/site.yml 是主页和栏目文本的中心配置。它包含:
- 站点名称、浏览器标题、描述、位置;
- 首页介绍、当前状态、关注主题;
- 首页标题前缀与强调字标文字;
- 默认主题色;
- 最新文章、服务矩阵、RGB 模块和底部入口的开关与文案;
- 服务名称、说明、URL、状态、图标和是否显示在主页;
- 文章、服务、关于三个页面的介绍与 SEO 文案;
- ICP、公安备案和署名。
useSiteSettings() 使用固定的异步数据键 site-settings 查询 site 集合的第一条记录。固定键让多个页面共享同一份 Nuxt 数据;如果设置缺失,它立即抛出 500,而不是让各组件在大量 undefined 判断中带病运行。
配置不是任意 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 在内存中生成一个含背景色和 Q 图形的 SVG,再编码为 data:image/svg+xml。因此标签页图标和 meta[name=theme-color] 会与 RGB 面板同步;public/favicon.svg 只负责 JavaScript 接管前或不支持动态图标时的回退。
十二、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 字标并不是逐笔书写动画
当前字标资源是 public/brand/quilibra-handwritten.png,尺寸比例为 1127:293,文件约 30 KB。页面不会直接显示这张 PNG,而是把它作为 CSS mask:图片只定义哪些像素可见,真正填充的颜色由 CSS 决定。因此同一份手写轮廓可以跟随任意主题色。
最终采用的是“RGB 套色从偏移到合拢”的动画,而不是模拟笔尖沿路径逐笔书写。原因是位图没有天然的笔画顺序;从位图自动推断真实书写路径成本高、结果也容易机械。套色动画更符合现有 RGB 工作台语言,同时实现稳定。
字标由四层相同 mask 组成:
- 红层从左下附近偏移进入;
- 绿层从上方偏移进入;
- 蓝层从右下附近偏移进入;
- 最终层使用当前主题色并带极轻的阴影。
前三层使用 mix-blend-mode: multiply、动态模糊、透明度和不同位移。动画前段保留可见错版,中段快速靠近,后段降低彩色层透明度并让最终主题层聚焦。四层都只改变 opacity、transform 和 filter,播放时临时声明 will-change,结束后不长期占用合成资源。
HandwrittenWordmark.vue 的加载控制解决了“首次打开没有动画”的问题:
- 首页用
<link rel="preload" as="image">提前请求 mask。 - 组件创建一次共享的
wordmarkReadyPromise。 Image加载后调用decode(),等待图片真正可用于绘制。- 只有资源就绪、组件仍存活、页面可见且请求仍是最新时才开始播放。
- 图片失败时显示文字 fallback;开启减少动态效果时直接展示最终状态。
每次重播都会增加 animationRun,并把它放进四层的 Vue key。这会让浏览器得到新的动画元素,可靠地从第 0 帧开始,而不是依赖移除 class 后强制读取布局。
十四、字标在什么时候重播
全局 useWordmarkAnimation() 只维护一个数字信号。任何组件调用 requestReplay(),数字加一;字标组件 watch 到变化后重播。这个很小的事件通道避免 RGB 组件直接引用字标 DOM。
当前重播场景包括:
- 首次进入主页并且 mask 解码完成;
- 点击主题预设或重置;
- 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 在构建阶段查询全部文章,并按日期倒序排列。浏览器端只对已经加载的数组做筛选,不需要为每个关键词请求服务器。
分类来自共享常量:随笔、学习、交易、观察。列表额外添加“全部”。搜索会把标题、摘要和标签拼成小写字符串,再做简单的 includes 匹配。这适合当前文章规模,特点是实现透明、中文可用、没有索引服务;但它不是分词搜索,也不会搜索正文。
文章行显示日期、标题、摘要、分类和箭头。悬浮一篇时:
- 只给当前行增加浅主题色背景;
- 日期与正文整体平移 8px;
- 箭头向右上轻移;
- 其他文章保持完全不变。
标题容器的 grid 列不会在 hover 时改变,所以长标题不会因为悬浮突然获得更小宽度而换行。主页“最近写下”的标题使用 minmax(0,1fr)、overflow:hidden、省略号和 white-space:nowrap,确保固定高度面板不被长标题撑开。
搜索结果区使用 aria-live="polite",筛选后的篇数和空状态可以被辅助技术感知。
十七、文章详情与相邻导航
动态路由文件是 app/pages/writing/[...slug].vue。它使用当前 route.path 查询对应文章;找不到时立即抛出带中文说明的 404。
详情页同时取得按日期倒序排列的全部文章,用当前文章的数组位置计算:
- 数组中下一项是时间上更旧的“上一篇”;
- 数组中上一项是时间上更新的“下一篇”。
页面展示分类、发布日期、可选的更新日期、标题、摘要、标签与正文。正文由 Nuxt Content 的 ContentRenderer 生成。SEO 标题使用全站模板变成“文章标题 · Quilibra”,描述来自 frontmatter,Open Graph 类型设为 article。
正文样式集中在 .article-prose:限制阅读宽度、提高行高、分级处理标题、列表、引用、代码、链接和图片。文章页面可以自由使用 Markdown,不需要为每篇文章编写 Vue 模板。
十八、文章 frontmatter 合约
一篇新文章最小结构如下:
---
title: 清楚的文章标题
description: 一句话说明文章实际记录了什么。
date: 2026-07-26
category: 学习
tags:
- Nuxt
---
文件名必须是:
YYYY-MM-DD-english-kebab.md
允许字段只有 title、description、date、updated、slug、category、tags。发布新文章时:
date必须是中国时区当天;- 文件名日期必须与
date一致; - 不能包含
updated; slug如果存在,必须与文件名后半段一致。
修订文章时保留原文件名和 date,把 updated 设置为中国时区当天。正文结构是自由的,但不能为空。若使用二级标题“来源与延伸阅读”,该节必须至少有一个 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 根据配置向私有仓库提交内容。
无论哪种模式,“保存”都只意味着源文件或远程仓库出现改动。它不会神奇地更新正在运行的静态 release。要让网站变化,仍需要一台部署机取得新提交,运行校验与生成,再切换 release。
GitHub OAuth 控制“谁能通过 CMS 读写仓库”,GitHub Actions 控制“仓库收到提交后执行什么自动任务”。两者不是同一个东西。私人仓库存放网站源码、文章、公开图片和不含密钥的配置;OAuth secret、访问令牌、部署 SSH key、OpenClaw 会话和 .env 不应进入仓库。
目前 CMS 配置指向 master 分支。若未来启用自动部署,一个典型链路是:
浏览器登录 GitHub -> CMS 提交 Markdown/YAML -> 私有仓库 master 更新
-> GitHub Actions 触发 -> 部署机拉取指定提交
-> npm ci / 校验 / npm run deploy -> 新 release 上线
域名是否已经备案、网站是否已有公网 HTTPS,与“先把代码推到私人仓库”没有冲突。远程 CMS 登录通常需要 HTTPS 回调,但仓库和 Actions 可以先配置好。
二十、受限 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 publish
|
v
返回文章 URL、commit、release
直接发布 只跳过人工预览,不跳过 writer、内容校验和静态生成。更新文章必须保留 dated URL;删除必须先展示目标信息,再收到完全匹配的 确认删除 <dated-slug>。
发布 Skill 还规定:只接受所有者私聊中的发布命令;把网页、对话和搜索结果视为不可信内容;不把 API key、cookie、账号信息、私人对话细节和精确私人资产写入文章;不能用任意编辑器、rm、git add 或 git commit 绕过 writer 和 release 脚本。
二十二、publish 与 deploy 是两条不同入口
这两个命令最终都会生成静态站并切换 release,但权限和检查强度不同。
npm run publish
用于尚未提交的内容改动。它只允许以下路径:
content/settings/site.yml
content/writing/**
public/uploads/**
它验证内容、生成静态站、自动创建本地 Git commit、复制 release 并切换生产。若工作区混入 Vue、CSS、脚本、CMS 配置或文档改动,会拒绝执行。内容发布不重复跑全部代码 E2E,因为代码版本没有变化。
npm run deploy
用于已经提交的代码版本。它要求工作区完全干净,不创建 commit,并依次运行:
类型检查
内容与 writer 单元测试
release 单元测试
静态生成
浏览器 E2E
创建 release
原子切换 current
清理过旧 release
因此,修改本文这样的文章后,正常下一步是 publish;修改 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 路径解析文件。
脚本默认保留最近五个 release,并额外保证当前激活版本不被删掉。由于当前版本有可能是手工回滚到的较旧目录,保留“当前版本”比单纯保留时间最新的五个更重要。
回滚只调用同一个原子激活函数,把 current 指回已存在的 release。它不修改 Git、不重新构建、不删除较新的版本,也不重启生产服务。
二十四、生产静态服务器做了哪些事
scripts/static-server.mjs 使用 Node 自带的 http、fs、stream 和 zlib,默认监听 0.0.0.0: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 或反向代理。公网 HTTPS 一般由 Nginx、Caddy、访问代理或隧道层终止,再转发到本机 3000。把服务监听改成 0.0.0.0 只代表网络接口可达,不会自动获得 HTTPS,也不会自动建立访问控制。
二十五、3000、3100、3101 分别是什么
| 端口 | 用途 | 生命周期 |
|---|---|---|
| 3000 | 当前生产静态 release | systemd 长期运行 |
| 3100 | 远程开发或稳定预览 | 人工启动,需要时运行 |
| 3101 | Playwright 隔离测试服务 | 测试自动启动并结束 |
开发命令使用 --host 0.0.0.0 后,可以从远程电脑访问 VPS 的 3100,但还必须满足防火墙、Tailscale/内网路由和浏览器安全策略。Sveltia 的本地仓库模式依赖 Chromium File System Access API;通过普通 HTTP 远程访问时,浏览器可能因为不是安全上下文而拒绝文件系统权限。解决方向是通过 HTTPS 访问后台,或在实际拥有仓库文件的电脑上用 localhost 打开,而不是继续扩大 3100 的公网暴露。
3101 使用独立内容 fixture 和 .nuxt-e2e,不能拿它当日常预览端口。生产 3000、开发 3100 和测试 3101 可以同时存在,因为缓存目录和端口都隔离。
二十六、systemd 用户服务
生产服务由 ops/systemd/personal-site.service 描述。它的关键设置包括:
Type=simple:Node 进程本身就是主进程;After/Wants=network-online.target:网络就绪后启动;Restart=on-failure与 5 秒延迟:异常退出后自动恢复;KillSignal=SIGINT:与静态服务器的优雅关闭逻辑对应;NoNewPrivileges=true、PrivateTmp=true:缩小运行时权限;- 安装到用户级
default.target,不要求整个服务以 root 运行。
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 必须与文件名一致;
- 站点 YAML 满足 Schema;
- writer 拒绝覆盖、穿越、符号链接和错误修订日期;
- 更新保留发布日期;
- 删除只接受精确存在的 dated slug。
release 单元测试
test:release 在临时目录中检查:
current能原子切换;- 清理历史版本时保留激活 release;
- release 名称不能路径穿越;
- 受管内容路径不会扩大到应用代码;
- 子命令失败时错误信息保留退出码。
Playwright 浏览器测试
test:e2e 在 Chromium 中检查:
- 首页成功水合且没有控制台错误;
- 动态 favicon、theme-color 和 RGB 输出同步;
- 自定义主题在纯黑纯白下仍可读;
- 旧黄色缓存会迁移;
- mask 没加载完时字标不偷跑,加载后正常播放;
- 标签页激活、窗口 focus、换色、滑杆松手和返回主页会重播;
- 拖动期间不会反复播放;
- 服务状态、NAS 图标、最近文章、文章详情和更新日期正确;
- 文章悬浮只影响当前行,长标题宽度不跳变;
- 手机页面无横向溢出,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。
这些数字是环境相关快照,不是永久承诺。浏览器缓存、网络、CPU、文章数量和依赖升级都会改变结果。
当前主要性能措施包括:
- 静态预渲染,HTML 首次响应包含页面内容;
- 移除大批字体资源,改用系统字体;
- 只预加载首屏真正依赖的 30 KB 字标 mask;
- 字标等待
decode(),避免资源未准备好时动画丢失; requestAnimationFrame合并高频指针更新;- 缓存元素边界,避免每次 pointermove 读取布局;
- 连续调色只改颜色,松手才重播动画;
- 用 transform/opacity 处理大多数动效;
- 生产服务器流式响应并支持 Brotli、gzip、ETag 和长期哈希缓存;
- 固定组件尺寸,避免动态文本造成布局偏移;
- 分离开发、release 和 E2E 的 Nuxt Content 数据库。
.output/public 整体约 5.4 MB,其中包括 Nuxt Content 的客户端 SQLite/WASM 相关资源;这不等于每个访客首屏都会下载整个目录。分析性能时应看浏览器实际请求和传输体积,不应把产物目录大小直接当作首页流量。
二十九、为什么首次交互偶尔会“过一会儿才正常”
这个问题曾同时表现为字标不播放和 RGB 滑杆暂时失灵,根因方向不是滑杆 CSS 本身,而是客户端水合尚未完成或开发服务器仍在编译。静态 HTML 可以先显示,但 Vue 事件只有在 JavaScript 加载、执行并成功水合后才生效。
判断方法是观察 .site-shell[data-hydrated="true"]。如果页面已经显示但该属性迟迟没有变成 true,应检查:
- 浏览器控制台是否有 JavaScript 错误;
- Nuxt 开发服务器是否还在首次编译;
- 内容 SQLite 缓存是否损坏;
- 大型依赖或资源是否阻塞客户端入口;
- 当前访问的是 3100 开发服务,还是已经生成的生产静态站。
目前通过移除字体、减轻入口资源、预加载 mask、分离内容数据库和明确动画水合闸门改善了这个过程。生产静态站通常比第一次启动的开发服务器稳定,因为它不需要边访问边编译。
三十、常见修改应该改哪里
修改个人介绍、栏目文案或服务
优先通过 CMS 或编辑 content/settings/site.yml。若新增服务状态或图标类型,还要同步修改 Schema、配置枚举、CMS 选项、组件映射、CSS 和测试。
修改默认颜色
修改 theme.defaultAccent。它可以是任意 #RRGGBB。已有访客若保存过自定义颜色,会继续看到自己的选择;要做全体迁移,需要像旧黄色迁移一样明确识别并处理旧值,不能无条件清空 localStorage。
修改红绿蓝预设
修改 useAccentTheme.ts 的 accentPresets。RGB 面板和非主页调色按钮都复用它。修改后应测试纯黑纯白对比、localStorage 恢复和字标重播。
修改字标图
替换 public/brand/quilibra-handwritten.png,尽量保留透明背景和接近 1127:293 的宽高比;若比例改变,同步修改 CSS aspect-ratio。检查 preload 路径、CSS mask 路径和组件资源路径三者一致。
修改字标动画
播放条件在 HandwrittenWordmark.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;启动用户、Node 路径、重启策略改 systemd unit。只有这两类变化需要重启服务本身,普通 release 切换不需要。
三十一、日常发布配方
只发布文章或 YAML 内容
npm run content:validate
npm run publish
publish 成功后会输出 commit、release 和 current 路径。它创建的是本地提交;是否推送 GitHub 是另一项操作。
发布 Vue、CSS、脚本或配置代码
npm run typecheck
npm run test:content
npm run test:release
npm run test:e2e
npm run generate
git diff --check
# 明确检查并提交本次文件
npm run deploy
deploy 自身也会执行完整检查,前面的命令适合在提交前尽早发现问题。工作区不干净时它会拒绝上线。
查看当前生产状态
systemctl --user is-active personal-site.service
readlink -f "$SITE_DATA/current"
curl -fsS -o /dev/null -w '%{http_code} %{content_type}\n' http://127.0.0.1:3000/
$SITE_DATA 应指向个人站点的 release 数据目录,不要把示例变量直接用于删除命令。
回滚
先列出确实存在的 release,再执行:
npm run rollback -- <exact-release-id>
回滚后再次检查 current 和 3000 返回值。不要手动删除 current,也不要把它指向仓库的 .output/public。
三十二、故障排查顺序
文章已保存但网站看不到
依次确认:文章是否在 content/writing/、校验是否通过、是否执行 publish/deploy、current 是否指向新 release、3000 返回的 HTML 是否包含新标题。CMS 保存和 Git push 本身都不等于部署完成。
RGB 不能拖、按钮不能点、动画不播放
先看 data-hydrated,再看控制台错误和网络请求。若只发生在 Nuxt 第一次开发编译,等待编译完成后重载;若持续发生,停止开发服务,运行 npx nuxt cleanup,再启动 3100。不要为此停止生产 3000。
切回标签页没有动画
检查浏览器是否启用 reduced motion、mask 图片是否加载失败,以及 visibilitychange、focus 是否触发。一次切回只播一次是正常防抖,不应去掉 200ms 合并后让三个事件连续触发。
换色先闪回旧颜色
检查静态默认色、Nuxt useState 初始值和 localStorage 恢复顺序。服务端 HTML无法知道客户端保存颜色,完全避免首帧差异需要在页面渲染前执行极小的内联主题恢复脚本;当前实现选择保持静态生成简单,在水合时恢复。
服务状态点不随 CMS 变化
确认 YAML 中 state 已保存、值属于三个枚举、已经重新生成 release,并检查 DOM 的 data-state。如果属性已变而颜色没变,检查 CSS 状态选择器;如果属性没变,检查当前访问的 release 是否仍是旧版本。
3000 无法访问
检查用户级 systemd 状态、日志、Node 固定路径、端口占用和 current 链接。服务器进程存在但 current 失效时会返回 503。不要在未确认进程归属前直接杀端口进程。
管理页面提示只允许 HTTPS
这通常来自浏览器安全上下文要求,而不是 Git remote 配错。Git remote 只决定仓库同步地址,不会给 HTTP 页面增加 HTTPS。使用 localhost、本机仓库模式,或为远程后台配置可信 HTTPS 和 Git OAuth;同时把后台放在 Tailscale或访问控制后面,因为后台路径本身不是权限系统。
三十三、安全与备份边界
至少要区分三类数据:
Git 仓库 源码、文章、站点设置、公开静态资源
OpenClaw 目录 Skill、草稿、会话和本地配置
release 目录 当前及历史静态构建结果
Git 提交是版本历史,不是异地备份。私人远程仓库可以备份源码与文章,但 OpenClaw 配置和 release 数据需要单独备份到受控存储。
永远不要提交或写入公开文章的内容包括:API key、OAuth secret、部署私钥、QQ token、cookie、扫码状态、.env、私人服务密码、内部地址清单和不适合公开的资产信息。
CMS 的 /admin/ 路径和 noindex 只能减少搜索引擎收录,不能阻止陌生人访问。真正的保护来自 Git 登录、HTTPS、网络访问范围和最小权限部署凭据。
三十四、维护这套代码时应坚持的原则
第一,内容、表现和发布边界分开。能在 YAML 解决的文案不要硬编码进 Vue;能由共享组件表达的状态不要在两个页面复制;生产切换不要混进页面代码。
第二,把浏览器状态当成显式状态。主题色、资源是否解码、页面是否可见、指针是否仍按下、水合是否完成,都应有清楚的变量和生命周期,而不是靠延时猜测。
第三,动画必须有静态最终状态。资源失败、JavaScript 失败或用户减少动态效果时,标题仍要可读,布局仍要成立。
第四,自动化写入必须比人工编辑权限更窄。Skill 负责工作流,writer 负责硬边界,validator 负责内容合约,release 负责上线,四者不能互相冒充。
第五,生产部署应该是“生成一个完整新版本,然后切换”,而不是在访问者正在读取的目录里逐个覆盖文件。原子 release 是这个小网站最值得长期保留的工程设计之一。
第六,测试真实的关系和历史 bug。主题对比、滑杆松手、标签页重播、长标题 hover、服务状态同步和多视口不重叠,都来自实际使用中的问题,比为了提高数字而堆大量脆弱测试更有价值。
三十五、重新上手时的最短阅读路径
如果未来很久没有维护这个项目,按下面顺序读,可以最快恢复上下文:
- 看
content/settings/site.yml,知道网站当前展示什么。 - 看
app/pages/index.vue和main.css的主页部分,理解首屏结构。 - 看
useAccentTheme.ts、RgbAssembly.vue、HandwrittenWordmark.vue,理解最复杂的交互。 - 看两个 writing 页面和
content.config.ts,理解文章查询与渲染。 - 看
content.schema.ts、validator 和 writer,理解内容边界。 - 看
release.ts、static-server.mjs和 systemd unit,理解生产链路。 - 最后看测试,把“哪些行为不能再坏”快速过一遍。
只要能重新回答下面五个问题,就已经掌握了这个项目:
- 当前页面内容的唯一来源在哪里?
- 浏览器中的主题色如何从 RGB 状态传到 CSS、字标和 favicon?
- 为什么 CMS 保存后线上不会立刻变化?
- publish 和 deploy 的权限、检查与 Git 行为有什么不同?
- 一个新 release 如何在不重启 3000 的情况下接管生产请求?
这五条就是 Quilibra 当前技术架构的骨架。