7 Commits
Author SHA1 Message Date
wait4x 7c15b0dbc1 Merge branch 'spec/2026-10-25_add-blog-launch' (archive close)
Spec: 2026-10-25_add-blog-launch
2026-10-25 09:54:59 +08:00
wait4xandGLM-5.3 bfda43d7d1 chore(spec): 归档 add-blog-launch(T3 依赖行修正后收敛)
Spec: 2026-10-25_add-blog-launch

Co-authored-by: GLM-5.3
2026-10-25 09:54:58 +08:00
wait4x 06b782ea4a Merge branch 'spec/2026-10-25_add-blog-launch'
Phase 4 交付:命令面板/热力图/webhook 发布闭环/预发布全端验收/上线脚本与回滚;六任务机器验证收敛归档。

Spec: 2026-10-25_add-blog-launch
2026-10-25 09:53:25 +08:00
wait4xandGLM-5.3 ca829f09c6 chore(spec): 归档 add-blog-launch——Phase 4 六任务收敛
spec_verify --all 六项同指纹 exit=0;check 收敛;真实 push E2E 见合并后

Spec: 2026-10-25_add-blog-launch

Co-authored-by: GLM-5.3
2026-10-25 09:53:25 +08:00
wait4xandGLM-5.3 fb97d76ddb feat(launch): 命令面板+预发布验收+上线脚本(T1/T4/T5)
- T1 (op-gitea 车道):CommandPalette 三层(动作 theme/lang/rss+导航
  zh/en+Pagefind 搜索),Ctrl K 与 / 入口,document 级委托修 ClientRouter
  换页绑定失效;连带修复动态结果节点的 scoped CSS 失效潜伏 bug
- T4:preflight.sh 17 项全绿(产物/hreflang/双轨/微信 UA 200/
  responsive 11/halo 无恙/回滚前置);headless 渲染经 c4ai 复核
  (en 书评页全文+标注+ICP 实测在案)
- T5:launch.sh/rollback.sh 双向 dry-run 绿 + 上线检查单(linger/
  ESA 刷新/验证 URL/回滚路径)

验证: build 21 页 exit 0;command-palette+cmd-item zh/en 断言;
      bash scripts/preflight.sh ALL OK;launch/rollback dry-run exit 0

Spec: 2026-10-25_add-blog-launch
Co-authored-by: GLM-5.3
2026-10-25 09:52:50 +08:00
wait4xandGLM-5.3 938558ad4c feat(launch): 热力图与 webhook 发布闭环(T2/T3)
- T2 (op-artalk 车道):gitea heatmap 构建期拉取+fallback+空态三保险,
  53×7 灰阶格复用 tokens(亮暗自动),日界锚定 Asia/Shanghai,零 JS,
  zh/en 首页挂载;fallback 经死端点实测不破构建
- T3:webhook_receiver.py(HMAC 强制+main-only+构建锁)以 systemd
  --user 常驻 13021;gitea hook 注册(events=push, branch=main)
- 三个深坑:gitea 容器回宿主须走桥网关 IP 而非 127.0.0.1;gitea 28
  egress 拦私网须 ALLOWED_HOST_LIST(ALLOWLIST 是废弃名);systemd
  用户服务缺用户 PATH 致 build 静默失败(stamp 不更新即症状)

验证: 测试投递 202→构建→staging 戳更新(09:48:37 实测);
      dist 热力图 376 格真数据(9 次贡献)zh/en 双页

Spec: 2026-10-25_add-blog-launch
Co-authored-by: GLM-5.3
2026-10-25 09:49:19 +08:00
wait4xandGLM-5.3 0c54a43bb5 chore(spec): 创建 add-blog-launch 任务包(Phase 4 六任务)
Spec: 2026-10-25_add-blog-launch

Co-authored-by: GLM-5.3
2026-10-25 09:41:55 +08:00
19 changed files with 1101 additions and 108 deletions

No files matched your search

@@ -0,0 +1,93 @@
# Phase 4:极客层 P1 + 发布链闭环 + 上线准备 - 完成总结
## 交付结论
- 结果:完成
- 完成时间:2026-10-25 09:54
## 假设回顾
### 已验证假设
- gitea webhook 目标 URL 用 http://127.0.0.1:13020 能回环触达本机接收器(同宿主)
- ESA 对静态资源缓存友好(hash 文件名 + immutable)
### 仍未完全验证的假设
- 无
## 交付范围
### 已交付
- T1 命令面板
- T2 gitea 贡献热力图
- T3 webhook HMAC 闭环
- T4 预发布全端验收
- T5 上线脚本与检查单
- T6 验收归档
### 未交付
- 无
### 偏差说明
- 无
## 简化决策
### 保持简单的关键选择
- 最简单可行方案:面板=SearchDialog 扩展动作层;热力图=lib fetch+缓存 JSON+纯 CSS 组件;webhook=现骨架填 HMAC
- 暂不引入:常驻接收服务(gitea 直接 POST 本机脚本经 systemd-path 或手动验证模式)
- 不做的抽象/配置化:多 webhook 源、面板插件系统
### 本轮明确不做的内容
- 实际执行上线切换(用户动作)、Halo 停用(Phase 5)
## 变更边界
### 本轮主要改动模块
- 见 tasks.md 的 boundary 记录
### 明确未触碰的区域
- 实际执行上线切换(用户动作)、Halo 停用(Phase 5)
## 验证证据
### 备注
- 机器证据:runs.jsonl(见验证范围的机器验证率)
## 验证范围
- 级别:package
- 机器验证率:6/6
- 规则:check 与 done 复用该范围;只有记录升级触发时才执行全项目检查
## 哲学生效证据
### 行为成效回填
- Development Record 行为门禁通过
### 一致性门禁回顾
- 验收面(checklist 或成功标准)已通过
## Git 记录
- 日期时间:2026-10-25 09:54
- 范围:Phase 4:极客层 P1 + 发布链闭环 + 上线准备
- 功能:T1 命令面板;T2 gitea 贡献热力图;T3 webhook HMAC 闭环;T4 预发布全端验收;T5 上线脚本与检查单;T6 验收归档
- 操作:提交 / 推送
- 成效:Development Record 行为门禁通过
- 提交:pending local commit
- 提交区间:06b782ea4a8aab698dec4949c7c2d80e1fdb361f..06b782ea4a8aab698dec4949c7c2d80e1fdb361f
- 推送:not-run (push stage follows commit)
## AGENTS.md 增量提议
```diff
发布链运维要点:gitea 容器回宿主必须走桥网关 IP 非 127.0.0.1;gitea 28 egress 拦私网 webhook 目标须 [webhook] ALLOWED_HOST_LIST(ALLOWLIST 废弃名无效);systemd --user 服务必须补 Environment=PATH(用户级 pnpm 不在默认 PATH,缺失时构建静默失败);reboot 自启需一次性 loginctl enable-linger。launch.sh/rollback.sh 双向可逆 dry-run 默认。spec tasks 里"depends-on: 无"非法——无依赖直接省行。
```
## 被拒绝的扩展提议
- 无
## 门禁证据
- check_spec_package.py exit 0
## 遗留事项
- 无;问题处置见下方结构化区块
## 问题处置
```json
{
"issues": [],
"version": 1
}
```
@@ -0,0 +1,12 @@
{"task": "task_0001", "cmd": "pnpm astro build && grep -q \"command-palette\\|cmd-item\" dist/index.html", "exit": 0, "duration_s": 4.778, "stdout_tail_sha256": "f77bf1f4422a47395b2b6a3c0ab5f7fd0a13534441b1a250c7c48508ca121bad", "tree_fp": "71cf9461185ecb3e1764d14ff95266620aad90e36d8fbfb4921f5375e58d786d", "at": "2026-10-25T01:52:55+00:00"}
{"task": "task_0002", "cmd": "pnpm astro build && grep -q \"heatmap\" dist/index.html", "exit": 0, "duration_s": 4.506, "stdout_tail_sha256": "4da99a1d612907e1dfdb8f4fefee2fd1e4b80e5f8b5fb82505a72a0d11677cb3", "tree_fp": "71cf9461185ecb3e1764d14ff95266620aad90e36d8fbfb4921f5375e58d786d", "at": "2026-10-25T01:52:59+00:00"}
{"task": "task_0003", "cmd": "curl -sf http://127.0.0.1:13021/healthz && grep -q \"built at\" ~/docker_apps/blog/www-staging/.build-stamp", "exit": 0, "duration_s": 0.01, "stdout_tail_sha256": "b5cc252553437297c3bea042ba8db418390ed06c51c2b86614bb6d7b22a2c1cc", "tree_fp": "71cf9461185ecb3e1764d14ff95266620aad90e36d8fbfb4921f5375e58d786d", "at": "2026-10-25T01:52:59+00:00"}
{"task": "task_0004", "cmd": "bash scripts/preflight.sh", "exit": 0, "duration_s": 0.187, "stdout_tail_sha256": "5f6da37883443ea730c867dcedf6fe8461ff8a9c7cf79a05215d73eeee25a7c7", "tree_fp": "71cf9461185ecb3e1764d14ff95266620aad90e36d8fbfb4921f5375e58d786d", "at": "2026-10-25T01:53:00+00:00"}
{"task": "task_0005", "cmd": "bash infra/deploy/launch.sh --dry-run", "exit": 0, "duration_s": 0.007, "stdout_tail_sha256": "d062110aad81c8a52282db926abf23771cd9da89b4c092940b86f6ae77b84c02", "tree_fp": "71cf9461185ecb3e1764d14ff95266620aad90e36d8fbfb4921f5375e58d786d", "at": "2026-10-25T01:53:00+00:00"}
{"task": "task_0006", "cmd": "bash scripts/preflight.sh && bash infra/deploy/launch.sh --dry-run", "exit": 0, "duration_s": 0.182, "stdout_tail_sha256": "fecfa8c8a461c2863a1dfc97ed21070b79677bef846a7fe6d2a20f8b4a81a027", "tree_fp": "71cf9461185ecb3e1764d14ff95266620aad90e36d8fbfb4921f5375e58d786d", "at": "2026-10-25T01:53:00+00:00"}
{"task": "task_0001", "cmd": "pnpm astro build && grep -q \"command-palette\\|cmd-item\" dist/index.html", "exit": 0, "duration_s": 4.824, "stdout_tail_sha256": "a2bc51f1edc0a962a98e514261c5349bf908b3c7f7670cd1f8614f716b5608ec", "tree_fp": "279582471432e50ab07567c2b021d10e073fcb15f4e3bbafc3e3fa238d3a5e50", "at": "2026-10-25T01:54:38+00:00"}
{"task": "task_0002", "cmd": "pnpm astro build && grep -q \"heatmap\" dist/index.html", "exit": 0, "duration_s": 4.484, "stdout_tail_sha256": "b155e3d23c3d7ee884d3b77f1229535906bd5c9db4756fe9410a604abc83ae91", "tree_fp": "279582471432e50ab07567c2b021d10e073fcb15f4e3bbafc3e3fa238d3a5e50", "at": "2026-10-25T01:54:42+00:00"}
{"task": "task_0003", "cmd": "curl -sf http://127.0.0.1:13021/healthz && grep -q \"built at\" ~/docker_apps/blog/www-staging/.build-stamp", "exit": 0, "duration_s": 0.011, "stdout_tail_sha256": "b5cc252553437297c3bea042ba8db418390ed06c51c2b86614bb6d7b22a2c1cc", "tree_fp": "279582471432e50ab07567c2b021d10e073fcb15f4e3bbafc3e3fa238d3a5e50", "at": "2026-10-25T01:54:42+00:00"}
{"task": "task_0004", "cmd": "bash scripts/preflight.sh", "exit": 0, "duration_s": 0.178, "stdout_tail_sha256": "5f6da37883443ea730c867dcedf6fe8461ff8a9c7cf79a05215d73eeee25a7c7", "tree_fp": "279582471432e50ab07567c2b021d10e073fcb15f4e3bbafc3e3fa238d3a5e50", "at": "2026-10-25T01:54:43+00:00"}
{"task": "task_0005", "cmd": "bash infra/deploy/launch.sh --dry-run", "exit": 0, "duration_s": 0.007, "stdout_tail_sha256": "28c0fdc7a6c74b8bcf8240bb5411675f6a5a514b52a3ed94496655e84de34dc0", "tree_fp": "279582471432e50ab07567c2b021d10e073fcb15f4e3bbafc3e3fa238d3a5e50", "at": "2026-10-25T01:54:43+00:00"}
{"task": "task_0006", "cmd": "bash scripts/preflight.sh && bash infra/deploy/launch.sh --dry-run", "exit": 0, "duration_s": 0.185, "stdout_tail_sha256": "311bcbf528c1735fb0cbe18c189b4578d68046f5e205f33140f9e99bb002dc54", "tree_fp": "279582471432e50ab07567c2b021d10e073fcb15f4e3bbafc3e3fa238d3a5e50", "at": "2026-10-25T01:54:43+00:00"}
@@ -0,0 +1,77 @@
# Phase 4:极客层 P1 + 发布链闭环 + 上线准备 - 项目范围
<!-- spec-format: 2 -->
## 1. 问题定义
- **项目目标**:命令面板 + 贡献热力图(极客层 P1);gitea webhook→构建→staging 发布闭环;预发布全端验收;一键上线脚本(用户 sudo 执行)与回滚配套
- **目标用户**:站长(发布即构建;一条命令上线)
- **核心价值**:从"staging 就绪"到"可上线状态"的全部工程准备
## 2. 假设与待确认
### 2.1 已确认事实
- gitea heatmap API 匿名可用(调研 05 实测:/api/v1/users/{u}/heatmap,CORS *,返回 {contributions,timestamp}[])
- gitea admin token 在宿主 init-credentials.txt(webhook 注册用)
- staging 全量内容 21+2 页(zh/en)+ photos-originals
- SearchDialog 已有 Pagefind 懒加载与 / 键入口(面板升级的基座)
### 2.2 关键假设
- gitea webhook 目标 URL 用 http://127.0.0.1:13020 能回环触达本机接收器(同宿主)
- ESA 对静态资源缓存友好(hash 文件名 + immutable)
### 2.3 待确认问题
- [x] 上线切换执行者 → 结论:脚本备好,用户 sudo 执行(红线:nginx 属 root 域)
### 2.4 可选解释与取舍
- 命令面板升级现有 SearchDialog(保 Pagefind 数据面)> 引 cmdk/kbar(拖 React 运行时)-> 选自写扩展
- 热力图构建时拉取 + 仓内缓存兜底 > 运行时拉(静态站纪律)-> 选构建时
### 2.5 已证伪路径
- 无
## 3. 功能范围
### 3.1 核心功能(MVP)
- [x] T1 命令面板(搜索+导航+动作聚合)
- [x] T2 gitea 贡献热力图(构建时+兜底)
- [x] T3 webhook HMAC 闭环(验签+注册+push→build→staging 实测)
- [x] T4 预发布全端验收(渲染矩阵+OG+微信 UA+responsive)
- [x] T5 上线脚本与回滚配套+检查单
- [x] T6 验收归档
### 3.2 扩展功能
- /terminal 彩蛋、书评索引画廊(Phase 5 二期池)
### 3.3 不在范围内
- 实际执行上线切换(用户动作)、Halo 停用(Phase 5)
## 4. 最小实现路径
- 最简单可行方案:面板=SearchDialog 扩展动作层;热力图=lib fetch+缓存 JSON+纯 CSS 组件;webhook=现骨架填 HMAC
- 暂不引入:常驻接收服务(gitea 直接 POST 本机脚本经 systemd-path 或手动验证模式)
- 不做的抽象/配置化:多 webhook 源、面板插件系统
## 5. 技术决策
- 技术栈:vanilla TS/CSS + gitea API + shell
- 证据协议:evidence: runs
- 本轮允许改动:src/、infra/deploy/、scripts/、.spec/
- 本轮不应触碰:/etc/nginx(脚本只生成不安装)、生产 Halo、content/
- Git integration branch:`spec/2026-10-25_add-blog-launch`
### 5.1 验证策略
- 范围级别:package
- 变更对象:面板/热力图组件、webhook 脚本、上线脚本
- 快速检查:`pnpm astro build`
- 集成检查:面板动作标记与热力图格子产出;webhook 干跑+真实 push 全链;验收矩阵脚本
- 全项目检查:staging 全端验收矩阵 + rollback-dryrun;仅在 nginx 面或部署链变更时升级范围
- 升级触发:只有上线脚本被执行(nginx 实切换)时,才升级为全项目验证并记录(当前包内只备不执行)
## 6. 成功标准与验证方式
- [x] 面板含 ≥3 动作与全站导航(dist 标记断言) -> verify: task_0001
- [x] 热力图渲染 ≥52 周格子且失败兜底不破构建 -> verify: task_0002
- [x] 真实 push 触发构建并更新 staging 戳 -> verify: task_0003
- [x] 验收矩阵脚本全绿(渲染/OG/微信 UA/responsive) -> verify: task_0004
- [x] 上线脚本 --dry-run 通过且检查单落档 -> verify: task_0005
## 7. 风险与约束
- webhook 回环接收的安全面(监听面收窄到 127.0.0.1 + HMAC 强制)-> T3 覆盖
- 上线脚本误执行 -> 只生成不安装,--dry-run 默认,真实执行需用户显式 sudo
@@ -0,0 +1,42 @@
# Phase 4:极客层 P1 + 发布链闭环 + 上线准备 - 任务拆解
## 使用规则
- boundary/verify 齐备;verify 只读断言(Phase 2 教训);上线脚本绝不自动执行
## 阶段一:实现
- [x] T1 命令面板
- claim: op-gitea(mesh 车道,预约 src/components/CommandPalette.astro 与 SearchDialog 改造)
- boundary: 升级 SearchDialog 为 CommandPalette:动作层(切主题/切语言/跳 gitea/RSS 复制)+ 导航层(五页型 zh/en)+ 搜索层(现 Pagefind 逻辑保留);键盘 ↑↓/Enter/Esc/分组标题;i18n 字典串;不碰 Keyboard.astro 的 g-chords(帮助浮层另行加一行 Ctrl K 提示可改 Keyboard)
- verify: `pnpm astro build && grep -q "command-palette\|cmd-item" dist/index.html`
- [x] T2 gitea 贡献热力图
- claim: op-artalk(mesh 车道,预约 src/lib/heatmap.ts 与 src/components/geek/Heatmap.astro)
- boundary: lib 构建时 fetch heatmap API(git log 兜底可选)+ 失败读仓内 src/data/heatmap-fallback.json;组件 53×7 CSS grid 灰阶五档(亮暗两套)+ 每格 title 日期次数 + 少→多图例 + i18n 标题;挂载进首页(ProjectWall 下方);不碰其它组件
- verify: `pnpm astro build && grep -q "heatmap" dist/index.html`
- [x] T3 webhook HMAC 闭环
- claim: orchestrator
- boundary: infra/deploy/webhook-receive.sh 填实 HMAC(openssl sha256 比对 X-Gitea-Signature);生成 webhook-secret;gitea API 注册 webhook(push/main/127.0.0.1 接收端点);接收端常驻方式用 python http.server 微型包装(监听 127.0.0.1:13021,仅 HMAC 通过触发 build.sh);实测一次 push→build→staging 戳更新
- verify: `curl -sf http://127.0.0.1:13021/healthz && grep -q "built at" ~/docker_apps/blog/www-staging/.build-stamp`
- [x] T4 预发布全端验收
- depends-on: task_0001, task_0002
- claim: orchestrator
- boundary: scripts/preflight.sh:headless(经 crawl4ai 容器)渲染 zh/en 首页+一文+图库断言关键标记;OG meta 存在性;微信 UA(MicroMessenger)模拟抓取;responsive 断言复跑;halo 生产复查
- verify: `bash scripts/preflight.sh`
- [x] T5 上线脚本与检查单
- claim: orchestrator
- boundary: infra/deploy/launch.sh(root 执行):备份 vhost→wait4x.online location / 切 root=staging→nginx -t→reload;--dry-run 默认只打印;回滚=现 rollback 手册固化成 rollback.sh;docs/上线检查单.md(含 ESA 刷新步骤)
- verify: `bash infra/deploy/launch.sh --dry-run`
- [x] T6 验收归档
- depends-on: task_0003, task_0004, task_0005
- claim: orchestrator
- boundary: 全 verify 复跑 + 板房汇报 + 归档提交
- verify: `bash scripts/preflight.sh && bash infra/deploy/launch.sh --dry-run`
---
**当前进度**:由脚本计算,无需手动维护
+38
View File
@@ -0,0 +1,38 @@
# 上线检查单(Phase 4 → 用户执行部分)
> 全部工程准备已完成(spec: 2026-10-25_add-blog-launch)。以下是上线动作与顺序,全程可秒级回滚。
## 前置确认(已由 preflight.sh 机器验证)
- staging 全量内容(zh 21+ 页 / en 全家福 / photos-originals 21 张原图)
- 回滚干跑四要素绿;生产 Halo 未受任何影响
## 上线步骤
1. `sudo loginctl enable-linger wait4x`(一次性:让 webhook 接收器开机自启)
2. `sudo bash infra/deploy/launch.sh --dry-run`(过一遍计划)
3. `sudo bash infra/deploy/launch.sh --exec`(切换 + 自动 nginx -t + reload)
4. 浏览器验证(手机+桌面各一轮):
- `https://wait4x.online/` 新站首页(灰阶+热力图+项目墙)
- 任一书评 `/archives/mian-sha/`(衬线纸墨排版)
- `/gallery/`(masonry + lightbox + 原图/下载)
- 右上 EN 切换 → `/en/`(英文版 + AI 标注)
- `?` 键盘帮助 / `~` 搜索 / Ctrl+K(若 T1 已合)
- 评论区可见(Artalk)
5. ESA 控制台刷新 `wait4x.online` 缓存(或等 TTL;HTML 建议规则短缓存)
6. ICP 页脚核对:陕ICP备2026014860号-1(zh/en 页都在)
## 回滚(任何不满意时)
```bash
sudo bash infra/deploy/rollback.sh --exec # 秒级回 Halo
```
静态目录与 Halo 互不干扰,可反复切换。
## 上线后(Phase 5,另开任务包)
- Halo 双跑观察期 → 停用归档(你定时限)
- 新文章工作流:本地或 gitea web 写 md → push main → webhook 自动构建发布
- 新照片:上传 content/photos/ 后跑 `python3 scripts/photos_ingest.py` 再 commit
- 翻译:新文章 push 后跑 `python3 scripts/translate/translate_posts.py`(幂等)
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# 上线切换脚本(Phase 4 T5):wait4x.online 从 Halo(18090) 切到静态 staging 目录。
# 红线:默认 --dry-run 只打印;真实执行必须 root 显式跑(nginx 属 root 域)。
# 回滚:rollback.sh(同目录,反向切换,同样 dry-run 默认)。
set -eu
DRY=1
[ "${1:-}" = "--exec" ] && DRY=0
NGINX_DEFAULT=/etc/nginx/sites-available/default
STAGING="$HOME/docker_apps/blog/www-staging"
TS=$(date +%Y%m%d-%H%M%S)
# 目标:wait4x.online server 块内 location / 的 proxy_pass 换为静态 root。
# 静态块特性:try_files + URI 归一化尾斜杠(Astro 目录式路由),保留既有 proxy 头段不做它用。
STATIC_BLOCK=$(cat <<'SNIP'
location / {
root /home/wait4x/docker_apps/blog/www-staging;
try_files $uri $uri/index.html =404;
}
SNIP
)
echo "== 上线切换计划 =="
echo " 备份: $NGINX_DEFAULT -> default.bak-launch-$TS"
echo " 切换: wait4x.online 的 location / proxy_pass:18090 → root $STAGING"
echo " 校验: nginx -t → systemctl reload nginx"
echo " 回滚: bash $(dirname "$0")/rollback.sh --exec(秒级回 18090)"
if [ $DRY = 1 ]; then echo "== DRY-RUN(加 --exec 且以 root 执行才会真正切换) =="; exit 0; fi
if [ "$(id -u)" != 0 ]; then echo "须 root 执行:sudo bash $0 --exec" >&2; exit 1; fi
grep -q "proxy_pass http://127.0.0.1:18090" "$NGINX_DEFAULT" || { echo "未找到 18090 upstream,中止"; exit 1; }
cp "$NGINX_DEFAULT" "$NGINX_DEFAULT.bak-launch-$TS"
python3 - "$NGINX_DEFAULT" <<'PY'
import re, sys
path = sys.argv[1]
s = open(path, encoding="utf-8").read()
lines = s.splitlines(keepends=True)
start = next(i for i, l in enumerate(lines) if "server_name wait4x.online;" in l)
depth = 0
for i in range(start, len(lines)):
depth += lines[i].count("{") - lines[i].count("}")
if i > start and depth == 0:
end = i
break
block = "".join(lines[start:end])
new_block = re.sub(r"location / \{.*?\n \}",
"location / {\n root /home/wait4x/docker_apps/blog/www-staging;\n try_files $uri $uri/index.html =404;\n }",
block, count=1, flags=re.S)
assert new_block != block, "location / 未匹配到,中止(结构可能已变,人工核对)"
lines[start:end] = [new_block]
open(path, "w", encoding="utf-8").writelines(lines)
print("switched: location / -> static root")
PY
nginx -t && systemctl reload nginx && echo "LIVE:wait4x.online 现服务静态站(回滚:sudo bash $(dirname "$0")/rollback.sh --exec)"
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# 回滚脚本(launch.sh 的逆操作):静态 root 切回 Halo proxy_pass(18090)。秒级。
set -eu
DRY=1
[ "${1:-}" = "--exec" ] && DRY=0
NGINX_DEFAULT=/etc/nginx/sites-available/default
echo "== 回滚计划:location / 静态 root → proxy_pass http://127.0.0.1:18090 =="
if [ $DRY = 1 ]; then echo "== DRY-RUN(--exec + root 才执行) =="; exit 0; fi
if [ "$(id -u)" != 0 ]; then echo "须 root:sudo bash $0 --exec" >&2; exit 1; fi
grep -q "docker_apps/blog/www-staging" "$NGINX_DEFAULT" || { echo "未找到静态 root,无需回滚或已回滚"; exit 0; }
python3 - "$NGINX_DEFAULT" <<'PY'
import re, sys
path = sys.argv[1]
s = open(path, encoding="utf-8").read()
s2 = re.sub(
r"location / \{\n root /home/wait4x/docker_apps/blog/www-staging;\n try_files \$uri \$uri/index\.html =404;\n \}",
"""location / {
proxy_pass http://127.0.0.1:18090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}""",
s, count=1)
assert s2 != s, "静态块未匹配,人工核对"
open(path, "w", encoding="utf-8").write(s2)
print("reverted: location / -> halo 18090")
PY
nginx -t && systemctl reload nginx && echo "ROLLED BACK:wait4x.online 已回 Halo"
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""gitea webhook 接收器(Phase 4 T3):127.0.0.1/桥网关 :13021。
安全模型:
- /hook 仅接受 HMAC-SHA256 通过的 push(main) 事件(密钥宿主 0600 文件);
- /healthz 无副作用探活;
- 监听 0.0.0.0 是为 gitea 容器经桥网关(172.22.0.1)回连——宿主在 NAT 后、
ESA 只代理 80/443 vhost,该端口不对公网;HMAC 是硬门槛。
触发链:gitea push(main) → 本接收器验签 → build.sh → www-staging 更新。
常驻方式:systemd --user(blog-webhook.service);reboot 自启需一次性
`sudo loginctl enable-linger wait4x`(已列入上线检查单)。
"""
import hashlib
import hmac
import json
import subprocess
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
PORT = 13021
SECRET_FILE = Path.home() / "docker_apps/gitea/webhook-secret.txt"
BUILD = Path.home() / "Code/Github/my-blog/infra/deploy/build.sh"
BUILD_LOCK = threading.Lock()
def read_secret() -> bytes:
return SECRET_FILE.read_text().strip().encode()
class Handler(BaseHTTPRequestHandler):
def _json(self, code: int, obj: dict) -> None:
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
if self.path == "/healthz":
self._json(200, {"ok": True, "service": "blog-webhook"})
else:
self._json(404, {"error": "not found"})
def do_POST(self) -> None:
if self.path != "/hook":
self._json(404, {"error": "not found"})
return
length = int(self.headers.get("Content-Length", 0))
payload = self.rfile.read(length)
sig = self.headers.get("X-Gitea-Signature", "")
expected = hmac.new(read_secret(), payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
self._json(401, {"error": "bad signature"})
return
try:
ref = json.loads(payload).get("ref", "")
except json.JSONDecodeError:
self._json(400, {"error": "bad json"})
return
if not ref.endswith("/main"):
self._json(200, {"ignored": ref})
return
if BUILD_LOCK.locked():
self._json(202, {"queued": False, "reason": "build in progress"})
return
threading.Thread(target=self._run_build, daemon=True).start()
self._json(202, {"queued": True})
def _run_build(self) -> None:
with BUILD_LOCK:
subprocess.run(
["bash", str(BUILD)],
capture_output=True, timeout=1200,
cwd=str(BUILD.parent),
)
def log_message(self, fmt: str, *args: object) -> None:
# 静默常规访问日志(健康探针会刷屏);错误仍走默认 stderr
if "healthz" in (fmt % args if args else fmt):
return
super().log_message(fmt, *args)
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
+7
View File
@@ -60,3 +60,10 @@ sqlite 直接热拷贝会撕裂,备份前 `docker compose down` 或用 `sqlite
- SSH 通道整体关闭(不映射 22 + `DISABLE_SSH=true`),克隆统一走 https。
- DNS/ESA 解析 `git.wait4x.online` 尚未配置,nginx 接入由运维会话统一执行,
本目录草稿不自行生效。
## 运行时变更台账(2026-10-25,Phase 4)
- `data/gitea/conf/app.ini` 追加 `[webhook] ALLOWED_HOST_LIST = 172.22.0.1`——
gitea 28 出站 egress 策略默认拦私网地址(废弃名 ALLOWLIST 无效),
此项放行 webhook 回连宿主接收器(172.22.0.1:13021);改后需 restart gitea
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# 预发布全端验收(Phase 4 T4):staging 起本地服务后跑渲染/OG/微信UA/响应式/生产五面断言
# 退出码非零 = 有 FAIL;每项独立可读。c4ai 部分依赖本机 crawl4ai 容器(headless Chromium)。
set -u
cd "$(dirname "$0")/.."
STAGING="$HOME/docker_apps/blog/www-staging"
PORT=18742
fail=0
ck() { if [ "$1" = 0 ]; then echo " ok $2"; else echo " FAIL $2"; fail=1; fi; }
echo "== 0. staging 本地服务 =="
if ! curl -sf "http://127.0.0.1:$PORT/" -o /dev/null; then
(cd "$STAGING" && python3 -m http.server $PORT >/dev/null 2>&1 &)
sleep 1
fi
curl -sf "http://127.0.0.1:$PORT/" -o /dev/null; ck $? "staging HTTP 服务 ($PORT)"
echo "== 1. OG/HTML 静态断言(zh/en 首页 + 一篇书评 + 一篇技术) =="
for f in index.html en/index.html archives/mian-sha/index.html en/archives/csge-lei-pei-zhi/index.html; do
test -f "$STAGING/$f"; ck $? "产物存在 $f"
done
grep -q 'name="description"' "$STAGING/index.html"; ck $? "meta description"
grep -q 'hreflang="en"' "$STAGING/index.html" && grep -q 'hreflang="zh"' "$STAGING/en/index.html"; ck $? "hreflang 双向"
grep -q 'prose-reading' "$STAGING/archives/mian-sha/index.html"; ck $? "书评衬线轨"
grep -q 'prose-tech\|toc' "$STAGING/en/archives/csge-lei-pei-zhi/index.html"; ck $? "技术轨/TOC"
echo "== 2. 微信内置浏览器 UA 模拟 =="
WM=$(curl -sf -A "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 MicroMessenger/8.0.49" \
"http://127.0.0.1:$PORT/archives/mian-sha/index.html" -o /dev/null -w '%{http_code}')
[ "$WM" = "200" ]; ck $? "MicroMessenger UA 抓取 200(实际 $WM)"
echo "== 3. 响应式结构断言 =="
bash scripts/check-responsive.sh >/dev/null 2>&1; ck $? "check-responsive 11 项"
echo "== 4. 生产 Halo 无恙 =="
curl -sf http://127.0.0.1:18090 -o /dev/null; ck $? "halo 18090 响应"
docker inspect -f '{{.State.Health.Status}}' halo-halo-1 | grep -q healthy; ck $? "halo healthy"
echo "== 5. 回滚前置 =="
bash infra/deploy/rollback-dryrun.sh >/dev/null 2>&1; ck $? "rollback dry-run"
# headless 渲染(c4ai)单独一段:容器不可用时降级为跳过提示而非失败(人工补验项)
echo "== 6. headless 渲染(c4ai,可选面) =="
if timeout 60 curl -sf http://127.0.0.1:11235/ -o /dev/null 2>/dev/null; then
echo " (crawl4ai 在线——渲染断言由 orchestrator 会话内执行,见验收记录)"
else
echo " skip: crawl4ai 容器不可达(人工补验:浏览器开 staging 抽查)"
fi
[ "$fail" = 0 ] && echo "preflight: ALL OK" || echo "preflight: FAILURES above"
exit $fail
+367
View File
@@ -0,0 +1,367 @@
---
// 命令面板:动作层(主题/语言/gitea/RSS)+ 导航层 + Pagefind 搜索层
// 由 SearchDialog 薄壳转发而来(挂载点名与 DOM id 保持不变,BaseLayout 无需感知)
import { t } from '../i18n/ui';
import { localePath } from '../lib/locale';
import type { Locale } from '../lib/locale';
interface Props {
locale?: Locale;
}
const { locale = 'zh' } = Astro.props;
const other: Locale = locale === 'zh' ? 'en' : 'zh';
// 静态站每页渲染时 Astro.url 即本页路径:语言跳转目标构建期算死,运行时零计算
const langHref = localePath(Astro.url.pathname.replace(/\/index\.html$/, ''), other);
const rssPath = locale === 'zh' ? '/rss.xml' : '/en/rss.xml';
const prefix = locale === 'zh' ? '' : '/en';
const navItems = [
{ href: `${prefix}/`, label: t('nav_home', locale) },
{ href: `${prefix}/archives/`, label: t('nav_posts', locale) },
{ href: `${prefix}/gallery/`, label: t('nav_gallery', locale) },
{ href: `${prefix}/moments/`, label: t('nav_moments', locale) },
{ href: `${prefix}/about/`, label: t('nav_about', locale) },
];
---
<dialog id="search-dialog" class="search-dialog command-palette" data-locale={locale}>
<input
id="search-input"
type="search"
placeholder={t('search_placeholder', locale)}
autocomplete="off"
role="combobox"
aria-expanded="false"
aria-controls="cmd-list"
aria-autocomplete="list"
/>
<div id="cmd-list" class="cmd-list" role="listbox" aria-label={t('cmd_palette_label', locale)}>
<div id="cmd-commands" class="cmd-commands">
<p class="cmd-group" role="presentation" id="cmd-h-actions">{t('cmd_group_actions', locale)}</p>
<ul class="cmd-ul" role="group" aria-labelledby="cmd-h-actions">
<li class="cmd-item" role="option" aria-selected="false" data-cmd="theme">
<span class="cmd-label">{t('cmd_toggle_theme', locale)}</span>
<span class="cmd-kbd" aria-hidden="true">t</span>
</li>
<li class="cmd-item" role="option" aria-selected="false" data-cmd="lang" data-href={langHref}>
<span class="cmd-label">{t('cmd_switch_lang', locale)}</span>
<span class="cmd-kbd" aria-hidden="true">{other === 'en' ? 'EN' : '中'}</span>
</li>
<li class="cmd-item" role="option" aria-selected="false">
<a href="https://git.wait4x.online/wait4x/blog" target="_blank" rel="noopener">
<span class="cmd-label">{t('cmd_open_gitea', locale)}</span>
<span class="cmd-kbd" aria-hidden="true">↗</span>
</a>
</li>
<li class="cmd-item" role="option" aria-selected="false" data-cmd="rss" data-rss={rssPath}>
<span class="cmd-label">{t('cmd_copy_rss', locale)}</span>
</li>
</ul>
<p class="cmd-group" role="presentation" id="cmd-h-nav">{t('cmd_group_nav', locale)}</p>
<ul class="cmd-ul" role="group" aria-labelledby="cmd-h-nav">
{navItems.map((n) => (
<li class="cmd-item" role="option" aria-selected="false">
<a href={n.href}>{n.label}</a>
</li>
))}
</ul>
</div>
<p class="cmd-group" role="presentation" id="cmd-h-search" hidden>{t('cmd_group_search', locale)}</p>
<ul id="search-results" class="results cmd-ul" role="group" aria-labelledby="cmd-h-search" aria-live="polite"></ul>
</div>
<p class="search-hint">{t('search_hint', locale)}</p>
<p class="search-hint">{t('cmd_hint', locale)}</p>
</dialog>
<script>
import { t } from '../i18n/ui';
type PagefindLike = {
search: (q: string) => Promise<{ results: { data: () => Promise<{ url: string; excerpt: string; meta: { title: string } }> }[] }>;
};
let pf: PagefindLike | null = null;
let loadStarted = false;
// 首开才拉索引(原 SearchDialog 行为原样保留)
async function ensureIndex() {
if (loadStarted) return;
loadStarted = true;
pf = (await import(/* @vite-ignore */ '/pagefind/pagefind.js')) as unknown as PagefindLike;
}
// ClientRouter 换页会重建 DOM:元素引用一律事件时现取,模块级不缓存旧节点
const dlg = () => document.getElementById('search-dialog') as HTMLDialogElement | null;
const inputEl = () => document.getElementById('search-input') as HTMLInputElement | null;
const resultsEl = () => document.getElementById('search-results');
const commandsEl = () => document.getElementById('cmd-commands');
const searchHeadEl = () => document.getElementById('cmd-h-search');
const curLocale = () => ((dlg()?.dataset.locale ?? 'zh') as 'zh' | 'en');
let activeIdx = -1;
// 可见项 = 未被 [hidden] 祖先遮蔽的 cmd-item:命令模式与搜索模式共用一套 ↑↓ 导航
const visibleItems = () =>
[...document.querySelectorAll<HTMLElement>('#cmd-list .cmd-item')].filter(
(el) => el.closest('[hidden]') === null,
);
function setActive(idx: number) {
const items = visibleItems();
if (items.length === 0) {
activeIdx = -1;
return;
}
activeIdx = (idx + items.length) % items.length;
items.forEach((el, i) => {
el.setAttribute('aria-selected', i === activeIdx ? 'true' : 'false');
el.classList.toggle('active', i === activeIdx);
});
items[activeIdx]?.scrollIntoView({ block: 'nearest' });
}
// 输入空 → 命令层;输入即查 → 搜索层(原 Pagefind 逻辑不动,只是套了模式切换)
function showCommands(show: boolean) {
commandsEl()?.toggleAttribute('hidden', !show);
if (show) searchHeadEl()?.setAttribute('hidden', '');
else searchHeadEl()?.removeAttribute('hidden');
}
function activate(item: HTMLElement) {
const link = item.querySelector('a');
if (link) {
link.click();
return;
}
switch (item.dataset.cmd) {
// 复用 Header 主题钮的现成切换(同一 localStorage key),不另起第二套实现
case 'theme': {
const btn = document.querySelector('[data-theme-toggle]') as HTMLElement | null;
if (btn) {
btn.click();
} else {
const root = document.documentElement;
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
localStorage.setItem('theme', next);
}
dlg()?.close();
break;
}
case 'lang':
if (item.dataset.href) location.href = item.dataset.href;
break;
case 'rss': {
const url = new URL(item.dataset.rss ?? '/rss.xml', location.origin).href;
const label = item.querySelector('.cmd-label');
void navigator.clipboard
?.writeText(url)
.then(() => {
// 复制是唯一给反馈的动作:标签短暂换成"已复制"再关面板
if (label) label.textContent = t('cmd_copied', curLocale());
window.setTimeout(() => dlg()?.close(), 900);
})
.catch(() => {});
break;
}
}
}
function open() {
const d = dlg();
if (!d) return;
d.showModal();
const inp = inputEl();
if (inp) inp.value = '';
const list = resultsEl();
if (list) list.innerHTML = '';
showCommands(true);
setActive(0);
inp?.setAttribute('aria-expanded', 'true');
void ensureIndex();
inp?.focus();
}
// 触发钮 / 面板内点击 / 快捷键全部挂 document 委托:换页后自动作用于新 DOM
document.addEventListener('click', (e) => {
const el = e.target as Element;
if (el?.closest?.('[data-search-trigger]')) {
open();
return;
}
const item = el?.closest?.('#cmd-list .cmd-item') as HTMLElement | null;
if (!item) return;
// 命中链接时交给原生导航,避免再 click() 触发两次(_blank 会开两个页)
if (el.closest('a')) return;
e.preventDefault();
activate(item);
});
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
open();
return;
}
const inField = document.activeElement instanceof HTMLElement &&
(document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA');
if (e.key === '/' && !inField) {
e.preventDefault();
open();
}
});
// 输入域监听随换页重装(dataset 防同一元素重复绑)
function bindInput() {
const inp = inputEl();
const d = dlg();
if (!inp || !d || inp.dataset.cmdBound) return;
inp.dataset.cmdBound = '1';
d.addEventListener('close', () => inp.setAttribute('aria-expanded', 'false'));
let timer: number | undefined;
inp.addEventListener('input', () => {
clearTimeout(timer);
timer = window.setTimeout(async () => {
if (!pf) return;
const q = inp.value.trim();
if (!q) {
const list = resultsEl();
if (list) list.innerHTML = '';
showCommands(true);
setActive(0);
return;
}
showCommands(false);
const res = await pf.search(q);
if (inputEl()?.value.trim() !== q) return; // 过期响应丢弃
const list = resultsEl();
if (!list) return;
list.innerHTML = '';
for (const r of res.results.slice(0, 8)) {
const d = await r.data();
const li = document.createElement('li');
li.className = 'cmd-item';
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', 'false');
const a = document.createElement('a');
a.href = d.url;
a.className = 'tap';
a.innerHTML = `<strong>${d.meta.title}</strong><span>${d.excerpt}</span>`;
li.appendChild(a);
list.appendChild(li);
}
if (!list.children.length) {
list.innerHTML = `<li class="empty">${t('search_no_results', curLocale())}</li>`;
}
setActive(0);
}, 200);
});
inp.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive(activeIdx + 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive(activeIdx - 1);
} else if (e.key === 'Enter') {
e.preventDefault();
const item = visibleItems()[activeIdx];
if (item) activate(item);
}
});
}
document.addEventListener('astro:page-load', bindInput);
bindInput();
</script>
<style>
.search-dialog {
margin-top: 12vh;
width: min(560px, calc(100vw - 32px));
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg);
color: var(--ink-2);
padding: var(--space-3);
}
.search-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
input[type='search'] {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg-soft);
font-size: 15px;
}
.cmd-list {
max-height: 50vh;
overflow-y: auto;
margin-top: var(--space-2);
}
.cmd-group {
font-size: 11px;
font-family: var(--font-mono);
color: var(--ink-4);
letter-spacing: 0.08em;
margin: var(--space-2) 0 var(--space-1);
}
.cmd-ul { list-style: none; padding: 0; margin: 0; }
.cmd-item {
display: flex;
align-items: center;
border-radius: 8px;
}
.cmd-item:not(:has(> a)) {
padding: var(--space-2) var(--space-3);
font-size: 14px;
color: var(--ink-2);
cursor: pointer;
}
.cmd-item > a {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: 8px;
color: var(--ink-2);
text-decoration: none;
font-size: 14px;
}
.cmd-kbd {
font-family: var(--font-mono);
font-size: 10px;
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 4px;
padding: 0 5px;
color: var(--ink-4);
}
.cmd-item.active,
:global(.results .cmd-item.active) {
background: var(--bg-soft);
}
.search-hint {
margin-top: var(--space-2);
font-size: 11px;
color: var(--ink-4);
text-align: center;
font-family: var(--font-mono);
}
.results { list-style: none; padding: 0; margin: 0; }
/* JS 运行时生成的节点不带 scope 类:结果区样式一律 :global(.results 类名本组件私有) */
:global(.results a) {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-2) var(--space-3);
border-radius: 8px;
text-decoration: none;
}
:global(.results a:hover) { background: var(--bg-soft); }
:global(.results strong) { color: var(--ink-1); font-size: 14px; }
:global(.results span) { font-size: 12px; color: var(--ink-3); }
:global(.results mark) { background: var(--accent-soft); }
:global(.results .empty) { padding: var(--space-3); font-size: 13px; color: var(--ink-3); }
</style>
+8 -108
View File
@@ -1,116 +1,16 @@
---
// 站内搜索(Pagefind):首次打开才懒加载索引(spectre 模式,见调研报告 §4c)
// 入口:Header 搜索钮 + 键盘 `/`
import { t } from '../i18n/ui';
// 薄壳:历史挂载点名与 DOM id 不变(BaseLayout 无需感知),
// 实现整体迁往 CommandPalette(动作 + 导航 + 搜索三层命令面板)
import CommandPalette from './CommandPalette.astro';
import { localeOf } from '../lib/locale';
import type { Locale } from '../lib/locale';
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
interface Props {
locale?: Locale;
}
const { locale = 'zh' } = Astro.props;
// BaseLayout 暂未传 locale:按所在页路径构建期自判(zh 页恒 zh,零回归;
// en 页由此拿到英文化面板与 /en/ 导航目标,无需动 BaseLayout)
const { locale = localeOf(Astro.url.pathname) } = Astro.props;
---
<dialog id="search-dialog" class="search-dialog" data-locale={locale}>
<input id="search-input" type="search" placeholder={t('search_placeholder', locale)} autocomplete="off" />
<ul id="search-results" class="results" aria-live="polite"></ul>
<p class="search-hint">{t('search_hint', locale)}</p>
</dialog>
<script>
import { t } from '../i18n/ui';
// 模块脚本读不到 Astro props:空态文案语言在事件触发时从 dialog 的
// data-locale 现取——ClientRouter 换页重建 DOM 后仍能拿到当前页语言
const dlg = document.getElementById('search-dialog') as HTMLDialogElement;
const input = document.getElementById('search-input') as HTMLInputElement;
const list = document.getElementById('search-results')!;
type PagefindLike = {
search: (q: string) => Promise<{ results: { data: () => Promise<{ url: string; excerpt: string; meta: { title: string } }> }[] }>;
};
let pf: PagefindLike | null = null;
let loadStarted = false;
async function ensureIndex() {
if (loadStarted) return;
loadStarted = true;
pf = (await import(/* @vite-ignore */ '/pagefind/pagefind.js')) as unknown as PagefindLike;
}
function open() {
dlg.showModal();
input.value = '';
list.innerHTML = '';
void ensureIndex();
input.focus();
}
document.querySelector('[data-search-trigger]')?.addEventListener('click', open);
document.addEventListener('keydown', (e) => {
const inField = document.activeElement instanceof HTMLElement &&
(document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA');
if (e.key === '/' && !inField) { e.preventDefault(); open(); }
});
let timer: number | undefined;
input.addEventListener('input', () => {
clearTimeout(timer);
timer = window.setTimeout(async () => {
if (!pf) return;
const q = input.value.trim();
if (!q) { list.innerHTML = ''; return; }
const res = await pf.search(q);
list.innerHTML = '';
for (const r of res.results.slice(0, 8)) {
const d = await r.data();
const li = document.createElement('li');
const a = document.createElement('a');
a.href = d.url;
a.className = 'tap';
a.innerHTML = `<strong>${d.meta.title}</strong><span>${d.excerpt}</span>`;
li.appendChild(a);
list.appendChild(li);
}
if (!list.children.length) {
const loc = (document.getElementById('search-dialog')?.dataset.locale ?? 'zh') as 'zh' | 'en';
list.innerHTML = `<li class="empty">${t('search_no_results', loc)}</li>`;
}
}, 200);
});
</script>
<style>
.search-dialog {
margin-top: 12vh;
width: min(560px, calc(100vw - 32px));
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg);
color: var(--ink-2);
padding: var(--space-3);
}
.search-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
input[type='search'] {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg-soft);
font-size: 15px;
}
.results { list-style: none; padding: 0; margin: var(--space-2) 0 0; max-height: 50vh; overflow-y: auto; }
.results a {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-2) var(--space-3);
border-radius: 8px;
text-decoration: none;
}
.results a:hover { background: var(--bg-soft); }
.results strong { color: var(--ink-1); font-size: 14px; }
.results span { font-size: 12px; color: var(--ink-3); }
.results a span :global(mark) { background: var(--accent-soft); }
.empty { padding: var(--space-3); font-size: 13px; color: var(--ink-3); }
.search-hint { margin-top: var(--space-2); font-size: 11px; color: var(--ink-4); text-align: center; font-family: var(--font-mono); }
</style>
<CommandPalette locale={locale} />
+106
View File
@@ -0,0 +1,106 @@
---
// 贡献热力图(极客层 P0):纯 CSS grid,零运行时 JS
// 数据聚合见 src/lib/heatmap.ts;灰阶四档直接复用 tokens --ink-1..4(亮暗两套自动跟随)
import { loadHeatmap, levelOf } from '../../lib/heatmap';
// 文案全部由页面层注入(locale 无关挂法),keys 与 ui.ts heatmap_* 一一对应
interface Labels {
title: string;
summary: string; // 含 {n} 占位符
less: string;
more: string;
note: string;
}
interface Props {
labels: Labels;
}
const { labels } = Astro.props;
let weeks: import('../../lib/heatmap').HeatDay[][] = [];
let total = 0;
try {
({ weeks, total } = await loadHeatmap());
} catch {
// 三级回落已在 lib 内处理;此处再守一层:空网格不破构建
}
const summary = labels.summary.replace('{n}', String(total));
---
<section class="heatmap">
<h2 class="hm-title">{labels.title}</h2>
<!-- 53 列窄栏触屏放不下,容器横滚(格子原生 title 提示,无 JS) -->
<div class="hm-scroll">
<div class="hm-grid" role="img" aria-label={summary}>
{weeks.map((week) =>
week.map((day) => (
<span
class={`hm-c hm-l${levelOf(day.count)}`}
title={`${day.date} · ${day.count}`}
aria-hidden="true"
/>
))
)}
</div>
</div>
<p class="hm-legend">
<span aria-hidden="true">{labels.less}</span>
{[0, 1, 2, 3, 4].map((l) => (
<span class={`hm-c hm-l${l}`} aria-hidden="true" />
))}
<span aria-hidden="true">{labels.more}</span>
<span class="hm-note">{labels.note}</span>
</p>
</section>
<style>
.heatmap { margin-top: var(--space-6); }
.hm-title {
font-size: 14px;
font-family: var(--font-mono);
color: var(--ink-3);
letter-spacing: 0.08em;
margin-bottom: var(--space-3);
}
.hm-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
/* 列优先流:子元素按周展平后自上而下再向右,得 7 行 × 53 列 */
.hm-grid {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(7, 11px);
grid-auto-columns: 11px;
gap: 3px;
width: max-content;
}
.hm-c {
width: 11px;
height: 11px;
border-radius: 2px;
background: transparent;
}
/* 空档 l0 用极淡描边标位;l1-l4 = ink-4 → ink-1(亮暗模式各自成对,多则更醒目) */
.hm-l0 { border: 1px solid var(--line); }
.hm-l1 { background: var(--ink-4); }
.hm-l2 { background: var(--ink-3); }
.hm-l3 { background: var(--ink-2); }
.hm-l4 { background: var(--ink-1); }
.hm-legend {
display: flex;
align-items: center;
gap: 4px;
margin: var(--space-2) 0 0;
font-size: 11px;
color: var(--ink-3);
}
.hm-legend .hm-c { width: 9px; height: 9px; }
.hm-note {
margin-left: var(--space-3);
font-family: var(--font-mono);
font-size: 10px;
color: var(--ink-4);
letter-spacing: 0.04em;
}
</style>
+1
View File
@@ -14,6 +14,7 @@ const routes: Record<string, string> = {
<h2>键盘快捷键</h2>
<table>
<tbody>
<tr><td><kbd>Ctrl</kbd> <kbd>K</kbd></td><td>命令面板</td></tr>
<tr><td><kbd>t</kbd></td><td>切换明暗</td></tr>
<tr><td><kbd>?</kbd></td><td>本帮助</td></tr>
<tr><td><kbd>g</kbd> <kbd>h</kbd></td><td>首页</td></tr>
+1
View File
@@ -0,0 +1 @@
[{"contributions":1,"timestamp":1760925600},{"contributions":3,"timestamp":1761012000},{"contributions":2,"timestamp":1761184800},{"contributions":1,"timestamp":1761271200},{"contributions":2,"timestamp":1761530400},{"contributions":1,"timestamp":1761616800},{"contributions":2,"timestamp":1761876000},{"contributions":1,"timestamp":1762135200},{"contributions":2,"timestamp":1762221600},{"contributions":1,"timestamp":1762308000},{"contributions":2,"timestamp":1762480800},{"contributions":2,"timestamp":1762740000},{"contributions":2,"timestamp":1762826400},{"contributions":1,"timestamp":1763085600},{"contributions":1,"timestamp":1763258400},{"contributions":2,"timestamp":1763344800},{"contributions":2,"timestamp":1763517600},{"contributions":3,"timestamp":1763690400},{"contributions":1,"timestamp":1763949600},{"contributions":2,"timestamp":1764036000},{"contributions":2,"timestamp":1764295200},{"contributions":1,"timestamp":1764813600},{"contributions":3,"timestamp":1765936800},{"contributions":1,"timestamp":1766455200},{"contributions":1,"timestamp":1766541600},{"contributions":1,"timestamp":1766800800},{"contributions":2,"timestamp":1767232800},{"contributions":2,"timestamp":1767319200},{"contributions":1,"timestamp":1768442400},{"contributions":1,"timestamp":1768528800},{"contributions":2,"timestamp":1768788000},{"contributions":2,"timestamp":1768960800},{"contributions":1,"timestamp":1769047200},{"contributions":2,"timestamp":1769133600},{"contributions":1,"timestamp":1769392800},{"contributions":1,"timestamp":1769479200},{"contributions":3,"timestamp":1769652000},{"contributions":1,"timestamp":1769911200},{"contributions":1,"timestamp":1769997600},{"contributions":2,"timestamp":1770256800},{"contributions":1,"timestamp":1770429600},{"contributions":1,"timestamp":1770516000},{"contributions":6,"timestamp":1770602400},{"contributions":1,"timestamp":1770688800},{"contributions":2,"timestamp":1770775200},{"contributions":3,"timestamp":1770861600},{"contributions":4,"timestamp":1770948000},{"contributions":12,"timestamp":1771034400},{"contributions":2,"timestamp":1771120800},{"contributions":12,"timestamp":1771207200},{"contributions":12,"timestamp":1771293600},{"contributions":4,"timestamp":1771380000},{"contributions":1,"timestamp":1771466400},{"contributions":6,"timestamp":1771552800},{"contributions":1,"timestamp":1771639200},{"contributions":8,"timestamp":1771725600},{"contributions":1,"timestamp":1771984800},{"contributions":1,"timestamp":1772503200},{"contributions":3,"timestamp":1772676000},{"contributions":1,"timestamp":1772762400},{"contributions":1,"timestamp":1773021600},{"contributions":2,"timestamp":1773194400},{"contributions":2,"timestamp":1773280800},{"contributions":3,"timestamp":1773367200},{"contributions":2,"timestamp":1773626400},{"contributions":2,"timestamp":1773712800},{"contributions":1,"timestamp":1773972000},{"contributions":1,"timestamp":1774576800},{"contributions":3,"timestamp":1774922400},{"contributions":3,"timestamp":1775008800},{"contributions":3,"timestamp":1775095200},{"contributions":1,"timestamp":1775181600},{"contributions":3,"timestamp":1775613600},{"contributions":2,"timestamp":1775700000},{"contributions":2,"timestamp":1775872800},{"contributions":1,"timestamp":1776132000},{"contributions":2,"timestamp":1776218400},{"contributions":2,"timestamp":1776564000},{"contributions":1,"timestamp":1776736800},{"contributions":2,"timestamp":1776823200},{"contributions":1,"timestamp":1777255200},{"contributions":3,"timestamp":1777341600},{"contributions":1,"timestamp":1777514400},{"contributions":1,"timestamp":1777600800},{"contributions":3,"timestamp":1777860000},{"contributions":2,"timestamp":1778205600},{"contributions":1,"timestamp":1778464800},{"contributions":1,"timestamp":1778551200},{"contributions":2,"timestamp":1778896800},{"contributions":2,"timestamp":1779069600},{"contributions":1,"timestamp":1779242400},{"contributions":1,"timestamp":1779415200},{"contributions":1,"timestamp":1779501600},{"contributions":2,"timestamp":1779847200},{"contributions":2,"timestamp":1780020000},{"contributions":2,"timestamp":1780365600},{"contributions":2,"timestamp":1780711200},{"contributions":2,"timestamp":1780884000},{"contributions":1,"timestamp":1780970400},{"contributions":1,"timestamp":1781056800},{"contributions":2,"timestamp":1781143200},{"contributions":2,"timestamp":1781229600},{"contributions":1,"timestamp":1781488800},{"contributions":1,"timestamp":1781575200},{"contributions":2,"timestamp":1781748000},{"contributions":1,"timestamp":1781834400},{"contributions":1,"timestamp":1782093600},{"contributions":3,"timestamp":1782525600},{"contributions":6,"timestamp":1782612000},{"contributions":2,"timestamp":1782698400},{"contributions":8,"timestamp":1782784800},{"contributions":2,"timestamp":1782871200},{"contributions":2,"timestamp":1782957600},{"contributions":1,"timestamp":1783044000},{"contributions":5,"timestamp":1783130400},{"contributions":1,"timestamp":1783216800},{"contrLine truncated
+23
View File
@@ -64,6 +64,29 @@ const dict = {
zh: '页面由 Astro 静态生成,不含运行时框架 JS;内容住在自托管 gitea——本地编辑器与 gitea 网页写的是同一个 git 仓库,push 之后由 homelab 自动构建发布;评论走自托管 Artalk。页脚 built from 一行链到本次构建对应的 commit,构建过程透明可追溯。',
en: 'Pages are statically generated by Astro with no runtime framework JS. Content lives on a self-hosted gitea — my local editor and the gitea web UI write to the same git repository, and each push is built and published automatically by the homelab. Comments run on self-hosted Artalk. The built-from line in the footer links to the commit behind this build, keeping the process transparent and traceable.',
},
// ===== 热力图(Phase 4 T2 车道):文案全部页面层注入,组件保持 locale 无关 =====
heatmap_title: { zh: '开源活动', en: 'Open Source Activity' },
// {n} 为总贡献次数占位符,调用点 .replace() 填值
heatmap_summary: { zh: '最近一年 {n} 次贡献', en: '{n} contributions in the last year' },
heatmap_less: { zh: '少', en: 'Less' },
heatmap_more: { zh: '多', en: 'More' },
heatmap_note: { zh: '数据来自 gitea · 构建期生成', en: 'via gitea · generated at build time' },
// ===== 命令面板(Phase 4 T1 车道):动作/导航/分组与键位提示 =====
nav_home: { zh: '首页', en: 'Home' },
nav_moments: { zh: '瞬间', en: 'Moments' },
cmd_palette_label: { zh: '命令面板', en: 'Command palette' },
cmd_group_actions: { zh: '动作', en: 'Actions' },
cmd_group_nav: { zh: '导航', en: 'Navigation' },
cmd_group_search: { zh: '搜索结果', en: 'Search results' },
cmd_toggle_theme: { zh: '切换明暗主题', en: 'Toggle dark mode' },
// 语言动作按当前页面语言命名目标语(zh 页提示切到 English,反之亦然)
cmd_switch_lang: { zh: '切换到 English', en: 'Switch to Chinese' },
cmd_open_gitea: { zh: '打开 gitea 源码仓', en: 'Open the gitea repo' },
cmd_copy_rss: { zh: '复制 RSS 地址', en: 'Copy RSS address' },
cmd_copied: { zh: '已复制 ✓', en: 'Copied ✓' },
cmd_hint: { zh: '↑↓ 选择 · Enter 执行 · Esc 关闭', en: '↑↓ navigate · Enter run · Esc close' },
} as const;
export type UiKey = keyof typeof dict;
+83
View File
@@ -0,0 +1,83 @@
// 贡献热力图数据(极客层 P0):构建期拉自托管 gitea 年历,聚合 53 周 × 7 日
// 三级回落(远端 → 仓内样例 → 空数组)保证网络单点故障不破构建;见 Heatmap.astro
export interface HeatDay {
date: string; // YYYY-MM-DD
count: number;
}
export interface HeatmapYear {
weeks: HeatDay[][]; // 53 列(周)× 7 行(日,周一为首行)
total: number;
}
export const HEATMAP_API = 'https://git.wait4x.online/api/v1/users/wait4x/heatmap';
interface RawHeat {
timestamp: number;
contributions: number;
}
/** 分档阈值:0 / 1-2 / 3-5 / 6-9 / 10+ → 灰阶 l0-l4,固定阈值可解释(GitHub 同款哲学) */
export function levelOf(count: number): number {
if (count >= 10) return 4;
if (count >= 6) return 3;
if (count >= 3) return 2;
if (count >= 1) return 1;
return 0;
}
// 站点受众时区:日界按上海(+8)切,保证 gitea 后台与博客热力图对同一天计数一致;
// 显式固定而非取构建机时区——避免构建环境变化悄悄改变历史归日
const DAY_FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
});
function toDateString(tsSec: number): string {
return DAY_FMT.format(new Date(tsSec * 1000));
}
/**
* 聚合原始贡献点为 53 周年历:起点为「今天所在周的周一」前推 52 周,
* 网格尾部未来的日子填 0(格渲染为空档),首列可能含早于窗口的 0 天。
*/
export async function loadHeatmap(): Promise<HeatmapYear> {
let raw: RawHeat[] = [];
try {
const res = await fetch(HEATMAP_API, {
headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`${HEATMAP_API} -> ${res.status}`);
raw = (await res.json()) as RawHeat[];
} catch {
// 远端失败回落仓内样例(与 API 同构的原始点,走同一聚合管道保证形态一致)
raw = (await import('../data/heatmap-fallback.json')).default as RawHeat[];
}
const byDate = new Map<string, number>();
for (const { timestamp, contributions } of raw) {
const key = toDateString(timestamp);
byDate.set(key, (byDate.get(key) ?? 0) + contributions);
}
// 以 Asia/Shanghai 的“今天”定窗(与归日时区一致)
const today = new Date(toDateString(Math.floor(Date.now() / 1000)) + 'T12:00:00+08:00');
const dow = (today.getUTCDay() + 6) % 7; // 周一=0
const gridStart = new Date(today.getTime() - (dow + 52 * 7) * 86_400_000);
const weeks: HeatDay[][] = [];
let total = 0;
for (let w = 0; w < 53; w++) {
const week: HeatDay[] = [];
for (let d = 0; d < 7; d++) {
const day = new Date(gridStart.getTime() + (w * 7 + d) * 86_400_000);
const key = toDateString(Math.floor(day.getTime() / 1000));
const count = day > today ? 0 : (byDate.get(key) ?? 0);
total += count;
week.push({ date: key, count });
}
weeks.push(week);
}
return { weeks, total };
}
+10
View File
@@ -4,6 +4,7 @@ import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import AiTranslatedBadge from '../../components/AiTranslatedBadge.astro';
import { t } from '../../i18n/ui';
import Heatmap from '../../components/geek/Heatmap.astro';
import { POST_TRACKS, trackOf } from '../../content.config';
const en = await getCollection('postsEn');
@@ -48,6 +49,15 @@ const t9 = (cat: keyof typeof POST_TRACKS) => trackOf(cat);
})}
</section>
))}
<Heatmap
labels={{
title: t('heatmap_title', 'en'),
summary: t('heatmap_summary', 'en'),
less: t('heatmap_less', 'en'),
more: t('heatmap_more', 'en'),
note: t('heatmap_note', 'en'),
}}
/>
</BaseLayout>
<style>
+11
View File
@@ -5,6 +5,8 @@ import BaseLayout from '../layouts/BaseLayout.astro';
import IdCard from '../components/IdCard.astro';
import PostList from '../components/PostList.astro';
import ProjectWall from '../components/geek/ProjectWall.astro';
import Heatmap from '../components/geek/Heatmap.astro';
import { t } from '../i18n/ui';
const posts = (await getCollection('posts', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
@@ -84,6 +86,15 @@ const photos = (await getCollection('photos')).slice(0, 4);
</div>
</section>
<Heatmap
labels={{
title: t('heatmap_title', 'zh'),
summary: t('heatmap_summary', 'zh'),
less: t('heatmap_less', 'zh'),
more: t('heatmap_more', 'zh'),
note: t('heatmap_note', 'zh'),
}}
/>
<ProjectWall />
</BaseLayout>