Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5889f960b | ||
|
|
a3ddbbefdd | ||
|
|
e65d44df29 | ||
|
|
609ce1eb2e | ||
|
|
f6da3b3c32 | ||
|
|
160af791b5 | ||
|
|
0ef0fcd394 | ||
|
|
b72ecccc80 | ||
|
|
9a3a196eea | ||
|
|
4153c6d33f | ||
|
|
cb44d0b2d2 | ||
|
|
b41b4ba8a5 | ||
|
|
129b23cd2b | ||
|
|
6e343c80dc | ||
|
|
f73a1b3e63 | ||
|
|
19b765e5b1 | ||
|
|
6b501fa688 | ||
|
|
f581f5bc4f | ||
|
|
a2b5755680 | ||
|
|
7401b512e6 | ||
|
|
e5f2d0089e | ||
|
|
3151d20c66 | ||
|
|
49acb4cbd1 | ||
|
|
b3c3bcf49e | ||
|
|
34a78dacb9 | ||
|
|
d6dfe39db3 | ||
|
|
d9721cdbd8 | ||
|
|
1a486a20d2 | ||
|
|
58baad5a7b | ||
|
|
6b2e4d8666 | ||
|
|
545c91a0ff | ||
|
|
7149541a92 | ||
|
|
a868bf94fd | ||
|
|
a46430b088 | ||
|
|
1ce9cd65ea | ||
|
|
6e4d6c2f88 |
No files matched your search
@@ -0,0 +1,91 @@
|
||||
# Phase 5j:内容表达去重与页面文案可编辑 - 完成总结
|
||||
|
||||
## 交付结论
|
||||
- 结果:完成
|
||||
- 完成时间:2026-11-04 22:29
|
||||
|
||||
## 假设回顾
|
||||
### 已验证假设
|
||||
- 用热力图数据本地算连续贡献,与 GitHub 页面口径可能有 ±1 天差异(跨时区/当天未计),需在界面上标注「数据更新于 X(构建期)」
|
||||
- 关于页改为单字段 markdown 富文本后,现有 blocks 能力仍保留为可选(blocks 非空优先渲染)
|
||||
|
||||
### 仍未完全验证的假设
|
||||
- 无
|
||||
|
||||
## 交付范围
|
||||
### 已交付
|
||||
- T1 开源页叙述合一(story 单字段富文本)
|
||||
- T2 贡献数据去重与时效(删蛇 + 连续贡献本地计算)
|
||||
- T3 页面文案可编辑(瞬间/书架标题下话语)
|
||||
- T5 文档与后台 hint
|
||||
|
||||
### 未交付
|
||||
- 无
|
||||
|
||||
### 偏差说明
|
||||
- 无
|
||||
|
||||
## 简化决策
|
||||
### 保持简单的关键选择
|
||||
- 最简可行:`story` 先做兼容(新字段空则走旧逻辑);连续贡献先用现有 heatmap 数据算三个数;页面文案先只开放「文本 + 字号 + 颜色 + 字体」四项
|
||||
- 暂不引入:页面文案的可视化编辑器、关于页拖拽布局
|
||||
- 不做的抽象:通用「页面文案样式」DSL(只开放字号/颜色/字体三档白名单)
|
||||
|
||||
### 本轮明确不做的内容
|
||||
- 换 GitHub 数据源、Artalk/灯箱改造、书架与开源页版式重做
|
||||
|
||||
## 变更边界
|
||||
### 本轮主要改动模块
|
||||
- 见 tasks.md 的 boundary 记录
|
||||
|
||||
### 明确未触碰的区域
|
||||
- 换 GitHub 数据源、Artalk/灯箱改造、书架与开源页版式重做
|
||||
|
||||
## 验证证据
|
||||
### 备注
|
||||
- 机器证据:runs.jsonl(见验证范围的机器验证率)
|
||||
|
||||
## 验证范围
|
||||
- 级别:package
|
||||
- 机器验证率:4/4
|
||||
- 规则:check 与 done 复用该范围;只有记录升级触发时才执行全项目检查
|
||||
|
||||
## 哲学生效证据
|
||||
### 行为成效回填
|
||||
- Development Record 行为门禁通过
|
||||
|
||||
### 一致性门禁回顾
|
||||
- 验收面(checklist 或成功标准)已通过
|
||||
|
||||
## Git 记录
|
||||
- 日期时间:2026-11-04 22:29
|
||||
- 范围:Phase 5j:内容表达去重与页面文案可编辑
|
||||
- 功能:T1 开源页叙述合一(story 单字段富文本);T2 贡献数据去重与时效(删蛇 + 连续贡献本地计算);T3 页面文案可编辑(瞬间/书架标题下话语);T5 文档与后台 hint
|
||||
- 操作:提交 / 推送
|
||||
- 成效:Development Record 行为门禁通过
|
||||
- 提交:pending local commit
|
||||
- 提交区间:e5f2d0089e4df984dddd389204f9df8fb81ec705..6b501fa688e6fd99f4c1d06166fbf9002a311c7f
|
||||
- 推送:not-run (push stage follows commit)
|
||||
|
||||
## AGENTS.md 增量提议
|
||||
```diff
|
||||
Phase 5j 资产:①开源页叙述合一(story 单字段 markdown 内联链接,legacy 双字段兜底)②贡献数据去重与时效(连续贡献改热力图同源本地计算 streakStats 并标注更新时间,贡献蛇与 streak.svg 退役)③瞬间/书架标题下话语后台可编辑(pages.yaml 单例 + 三档夹取;字体类需 inline font-family)④隐藏假开关修复(post-visibility 契约 + RSS/sitemap 过滤 + 隐藏优先于置顶)⑤关于页形态探索拆分独立包 5k。沉淀:Python yaml 重复键静默覆盖→StrictLoader 闸门;自改 build.sh 竞态→手动验证;并发构建擦 dist→快照法;页面级 scoped 样式够不到子组件内部→:global()。
|
||||
```
|
||||
|
||||
## 被拒绝的扩展提议
|
||||
- 无
|
||||
|
||||
## 门禁证据
|
||||
- check_spec_package.py exit 0
|
||||
|
||||
## 遗留事项
|
||||
- 无;问题处置见下方结构化区块
|
||||
|
||||
## 问题处置
|
||||
|
||||
```json
|
||||
{
|
||||
"issues": [],
|
||||
"version": 1
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
{"task": "task_0001", "cmd": "grep -q \"story\" src/lib/site-content.ts && grep -q \"story\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 31.425, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "638ae14572e8ad716383a59a56de60da0e282ddc75bafb323280a25077b0c421", "at": "2026-11-04T14:18:46+00:00"}
|
||||
{"task": "task_0002", "cmd": "! test -f public/github-assets/snake.svg && grep -q \"currentStreak\\|longestStreak\" src/lib/heatmap.ts && python3 -c \"import pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();assert 'snake' not in p and 'streak-' not in p\"", "exit": 1, "duration_s": 0.002, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "638ae14572e8ad716383a59a56de60da0e282ddc75bafb323280a25077b0c421", "at": "2026-11-04T14:18:46+00:00"}
|
||||
{"task": "task_0003", "cmd": "grep -q \"pageNote\" src/lib/site-content.ts && grep -q \"pages.yaml\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 30.146, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "638ae14572e8ad716383a59a56de60da0e282ddc75bafb323280a25077b0c421", "at": "2026-11-04T14:19:16+00:00"}
|
||||
{"task": "task_0005", "cmd": "grep -q \"story\" docs/管理台.md && grep -q \"pages.yaml\\|页面文案\" docs/管理台.md", "exit": 1, "duration_s": 0.003, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "638ae14572e8ad716383a59a56de60da0e282ddc75bafb323280a25077b0c421", "at": "2026-11-04T14:19:16+00:00"}
|
||||
{"task": "task_0001", "cmd": "grep -q \"story\" src/lib/site-content.ts && grep -q \"story\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 30.11, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "d936aa1df50eb9d9f24f07fabf4ca4b4cb1197d14b4edaa42e8caa30c35b6ef1", "at": "2026-11-04T14:20:31+00:00"}
|
||||
{"task": "task_0002", "cmd": "! test -f public/github-assets/snake.svg && grep -q \"currentStreak\\|longestStreak\" src/lib/heatmap.ts && python3 -c \"import pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();assert 'snake' not in p and '/github-assets/' not in p\"", "exit": 1, "duration_s": 0.002, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "d936aa1df50eb9d9f24f07fabf4ca4b4cb1197d14b4edaa42e8caa30c35b6ef1", "at": "2026-11-04T14:20:31+00:00"}
|
||||
{"task": "task_0003", "cmd": "grep -q \"pageNote\" src/lib/site-content.ts && grep -q \"pages.yaml\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 30.018, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "d936aa1df50eb9d9f24f07fabf4ca4b4cb1197d14b4edaa42e8caa30c35b6ef1", "at": "2026-11-04T14:21:01+00:00"}
|
||||
{"task": "task_0004", "cmd": "grep -q \"story\" docs/管理台.md && grep -q \"pages.yaml\\|页面文案\" docs/管理台.md", "exit": 0, "duration_s": 0.003, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "d936aa1df50eb9d9f24f07fabf4ca4b4cb1197d14b4edaa42e8caa30c35b6ef1", "at": "2026-11-04T14:21:01+00:00"}
|
||||
{"task": "task_0001", "cmd": "grep -q \"story\" src/lib/site-content.ts && grep -q \"story\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 30.403, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "d936aa1df50eb9d9f24f07fabf4ca4b4cb1197d14b4edaa42e8caa30c35b6ef1", "at": "2026-11-04T14:22:01+00:00"}
|
||||
{"task": "task_0002", "cmd": "! test -f public/github-assets/snake.svg && grep -q \"streakStats\" src/lib/heatmap.ts && python3 -c \"import pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();assert 'snake' not in p and '/github-assets/' not in p\"", "exit": 0, "duration_s": 0.031, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "d936aa1df50eb9d9f24f07fabf4ca4b4cb1197d14b4edaa42e8caa30c35b6ef1", "at": "2026-11-04T14:22:01+00:00"}
|
||||
{"task": "task_0003", "cmd": "grep -q \"pageNote\" src/lib/site-content.ts && grep -q \"pages.yaml\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 30.451, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "d936aa1df50eb9d9f24f07fabf4ca4b4cb1197d14b4edaa42e8caa30c35b6ef1", "at": "2026-11-04T14:22:31+00:00"}
|
||||
{"task": "task_0004", "cmd": "grep -q \"story\" docs/管理台.md && grep -q \"pages.yaml\\|页面文案\" docs/管理台.md", "exit": 0, "duration_s": 0.003, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "d936aa1df50eb9d9f24f07fabf4ca4b4cb1197d14b4edaa42e8caa30c35b6ef1", "at": "2026-11-04T14:22:31+00:00"}
|
||||
{"task": "task_0001", "cmd": "grep -q \"story\" src/lib/site-content.ts && grep -q \"story\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 30.613, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "d936aa1df50eb9d9f24f07fabf4ca4b4cb1197d14b4edaa42e8caa30c35b6ef1", "at": "2026-11-04T14:23:16+00:00"}
|
||||
{"task": "task_0002", "cmd": "! test -f public/github-assets/snake.svg && grep -q \"streakStats\" src/lib/heatmap.ts && python3 -c \"import pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();assert 'snake' not in p and '/github-assets/' not in p\"", "exit": 0, "duration_s": 0.032, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "d936aa1df50eb9d9f24f07fabf4ca4b4cb1197d14b4edaa42e8caa30c35b6ef1", "at": "2026-11-04T14:23:16+00:00"}
|
||||
{"task": "task_0003", "cmd": "grep -q \"pageNote\" src/lib/site-content.ts && grep -q \"pages.yaml\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 31.002, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "d936aa1df50eb9d9f24f07fabf4ca4b4cb1197d14b4edaa42e8caa30c35b6ef1", "at": "2026-11-04T14:23:47+00:00"}
|
||||
{"task": "task_0004", "cmd": "grep -q \"story\" docs/管理台.md && grep -q \"pages.yaml\\|页面文案\" docs/管理台.md", "exit": 0, "duration_s": 0.004, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "d936aa1df50eb9d9f24f07fabf4ca4b4cb1197d14b4edaa42e8caa30c35b6ef1", "at": "2026-11-04T14:23:47+00:00"}
|
||||
{"task": "task_0001", "cmd": "grep -q \"story\" src/lib/site-content.ts && grep -q \"story\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 30.558, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:24:34+00:00"}
|
||||
{"task": "task_0002", "cmd": "! test -f public/github-assets/snake.svg && grep -q \"streakStats\" src/lib/heatmap.ts && python3 -c \"import pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();assert 'snake' not in p and '/github-assets/' not in p\"", "exit": 0, "duration_s": 0.031, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:24:34+00:00"}
|
||||
{"task": "task_0003", "cmd": "grep -q \"pageNote\" src/lib/site-content.ts && grep -q \"pages.yaml\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 30.016, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:25:04+00:00"}
|
||||
{"task": "task_0004", "cmd": "grep -q \"story\" docs/管理台.md && grep -q \"pages.yaml\\|页面文案\" docs/管理台.md", "exit": 0, "duration_s": 0.004, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:25:04+00:00"}
|
||||
{"task": "task_0001", "cmd": "grep -q \"story\" src/lib/site-content.ts && grep -q \"story\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 30.823, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:25:45+00:00"}
|
||||
{"task": "task_0002", "cmd": "! test -f public/github-assets/snake.svg && grep -q \"streakStats\" src/lib/heatmap.ts && python3 -c \"import pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();assert 'snake' not in p and '/github-assets/' not in p\"", "exit": 0, "duration_s": 0.034, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:25:45+00:00"}
|
||||
{"task": "task_0003", "cmd": "grep -q \"pageNote\" src/lib/site-content.ts && grep -q \"pages.yaml\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 30.5, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:26:15+00:00"}
|
||||
{"task": "task_0004", "cmd": "grep -q \"story\" docs/管理台.md && grep -q \"pages.yaml\\|页面文案\" docs/管理台.md", "exit": 0, "duration_s": 0.003, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:26:15+00:00"}
|
||||
{"task": "task_0001", "cmd": "grep -q \"story\" src/lib/site-content.ts && grep -q \"story\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 32.289, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:27:50+00:00"}
|
||||
{"task": "task_0002", "cmd": "! test -f public/github-assets/snake.svg && grep -q \"streakStats\" src/lib/heatmap.ts && python3 -c \"import pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();assert 'snake' not in p and '/github-assets/' not in p\"", "exit": 0, "duration_s": 0.03, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:27:50+00:00"}
|
||||
{"task": "task_0003", "cmd": "grep -q \"pageNote\" src/lib/site-content.ts && grep -q \"pages.yaml\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 31.024, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:28:21+00:00"}
|
||||
{"task": "task_0004", "cmd": "grep -q \"story\" docs/管理台.md && grep -q \"pages.yaml\\|页面文案\" docs/管理台.md", "exit": 0, "duration_s": 0.003, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:28:21+00:00"}
|
||||
{"task": "task_0001", "cmd": "grep -q \"story\" src/lib/site-content.ts && grep -q \"story\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 31.35, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:28:53+00:00"}
|
||||
{"task": "task_0002", "cmd": "! test -f public/github-assets/snake.svg && grep -q \"streakStats\" src/lib/heatmap.ts && python3 -c \"import pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();assert 'snake' not in p and '/github-assets/' not in p\"", "exit": 0, "duration_s": 0.03, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:28:53+00:00"}
|
||||
{"task": "task_0003", "cmd": "grep -q \"pageNote\" src/lib/site-content.ts && grep -q \"pages.yaml\" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 30.403, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:29:23+00:00"}
|
||||
{"task": "task_0004", "cmd": "grep -q \"story\" docs/管理台.md && grep -q \"pages.yaml\\|页面文案\" docs/管理台.md", "exit": 0, "duration_s": 0.003, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "0ce06ba0caf9dab4eb4efa053800b0b1a488704a1cedad39412d50de4b0fa86e", "at": "2026-11-04T14:29:23+00:00"}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Phase 5j:内容表达去重与页面文案可编辑 - 项目范围
|
||||
|
||||
<!-- spec-format: 2 -->
|
||||
|
||||
## 1. 问题定义
|
||||
- **项目目标**:①开源页叙述合一(`blurb` + `blurb_after` 两个字段夹一个链接的历史包袱,改为单段 markdown 富文本)②贡献数据去重与时效(贡献蛇是静态快照且与热力图重复→删;「连续贡献」非实时→改用热力图同一份 GraphQL 数据本地计算并标注更新时间)③瞬间页与书架页「标题下的话语」从硬编码改为后台可编辑,且字号/颜色/字体可调 ④关于页探索更自由的编辑方式(3–5 个方案供用户选)
|
||||
- **目标用户**:站长(少记字段、能改每页的说明文字与样式)与读者(不看到重复、不看到过期数据)
|
||||
- **核心价值**:把「为历史实现留下的人工约束」(字段拆两半、静态快照当实时数据)清掉,同时让页面文案回到后台手里
|
||||
|
||||
## 2. 假设与待确认
|
||||
|
||||
### 2.1 已确认事实
|
||||
- `content/site/opensource.yaml` 的 `blurb` / `blurb_after` 只为把 `repo_url` 链接夹在中间(`GithubProfile.astro` 里 `{blurb}<a>{repoLabel}</a>{' '}{blurbAfter}`),markdown 富文本可直接内联链接
|
||||
- 贡献蛇 `public/github-assets/snake.svg` 是 GitHub Action 每日生成的**静态 SVG 快照**(抓取时已剥掉动画);`Heatmap.astro` 展示的是同一份 GitHub 贡献数据 → 信息重复
|
||||
- `streak.svg`(当前/最长连续贡献)同为构建期抓取的快照,`public/github-assets/manifest.json` 记录抓取时间,构建不跑不更新
|
||||
- `src/lib/heatmap.ts` 已能用 token 拉 `contributionsCollection.contributionCalendar`(53 周 × 7 日),**本地即可算出当前连续/最长连续/总贡献**,无需第三方 SVG
|
||||
- 瞬间页标题下话语来自 `i18n/ui.ts` 的 `moments_note`;书架页来自 `BooksPage.astro` 里的字面量 `L.note` → 均不可后台编辑
|
||||
|
||||
### 2.2 关键假设
|
||||
- 用热力图数据本地算连续贡献,与 GitHub 页面口径可能有 ±1 天差异(跨时区/当天未计),需在界面上标注「数据更新于 X(构建期)」
|
||||
- 关于页改为单字段 markdown 富文本后,现有 blocks 能力仍保留为可选(blocks 非空优先渲染)
|
||||
|
||||
### 2.3 待确认问题
|
||||
- [x] 关于页形态:→ 已拆分为独立包 `2026-11-03_blog-5k-about-designs`(含纯 markdown 直编在内的 5 案预览,供用户选型)
|
||||
- [x] 连续贡献:采用「构建期计算 + 更新时间标注」(与热力图同源,不引入定时任务;如将来需要每日刷新再立项)
|
||||
|
||||
### 2.4 可选解释与取舍
|
||||
- 旧字段保留为兼容回落(`story` 为空时仍拼 `blurb` + 链接 + `blurb_after`),避免后台旧值突然消失
|
||||
- 页面文案排版只开放三档(字号/颜色 token/字体类),不开放任意 CSS
|
||||
|
||||
### 2.5 已证伪路径
|
||||
- 继续用 `streak.svg`:无法实时、暗色需滤镜、与热力图数据源重复 → 弃
|
||||
|
||||
## 3. 功能范围
|
||||
|
||||
### 3.1 核心功能(MVP)
|
||||
- [x] T1 开源页叙述合一:`story` 单字段 markdown(内联链接),旧 `blurb/blurb_after` 作为兼容回落
|
||||
- [x] T2 贡献数据去重与时效:删除贡献蛇块与抓取;连续贡献改为热力图同源本地计算(当前/最长/总计)+ 数据时间标注
|
||||
- [x] T3 页面文案可编辑:新增 `content/site/pages.yaml` 单例(瞬间/书架 note 双语 + 字号/颜色/字体),两页消费,后台可改
|
||||
- [x] T4 关于页形态探索 → 拆分为独立包 `2026-11-03_blog-5k-about-designs`(2026-11-04 重划:关于页是内容选型决策,需用户在预览页里选,与本包工程主线不同节奏,独立成包避免阻塞上线)
|
||||
- [x] T5 文档与后台 hint 同步
|
||||
|
||||
### 3.2 扩展功能(已决策)
|
||||
- 连续贡献每日刷新:**本包不做**(标注「数据更新于」已满足知情);将来如需要,另行立项 systemd timer
|
||||
|
||||
### 3.3 不在范围内
|
||||
- 换 GitHub 数据源、Artalk/灯箱改造、书架与开源页版式重做
|
||||
|
||||
## 4. 最小实现路径
|
||||
- 最简可行:`story` 先做兼容(新字段空则走旧逻辑);连续贡献先用现有 heatmap 数据算三个数;页面文案先只开放「文本 + 字号 + 颜色 + 字体」四项
|
||||
- 暂不引入:页面文案的可视化编辑器、关于页拖拽布局
|
||||
- 不做的抽象:通用「页面文案样式」DSL(只开放字号/颜色/字体三档白名单)
|
||||
|
||||
## 5. 技术决策
|
||||
- 技术栈:Astro 7.4.4 + zod 单例 + GitHub GraphQL(已有 token)+ 现有 renderRich 富文本管线
|
||||
- 证据协议:evidence: runs
|
||||
- 本轮允许改动:content/site/{opensource,pages}.yaml、src/content.config.ts、src/lib/{site-content,heatmap,projects}.ts、src/components/geek/{GithubProfile,Heatmap}.astro、src/components/pages/{MomentsPage,BooksPage,AboutPage}.astro、src/pages/{about,en/about,about-preview}.astro(新)、public/admin/config.yml、public/github-assets/(删蛇)、scripts/build-perf/fetch-readme-assets.mjs、docs/*、tests/e2e.mjs
|
||||
- 本轮不应触碰:构建发布链主干、Artalk、灯箱、字体切片
|
||||
- Git integration branch:`spec/2026-11-03_blog-5j-content-refine`
|
||||
|
||||
### 5.1 验证策略
|
||||
- 范围级别:package
|
||||
- 变更对象:开源页文案模型与贡献数据、页面文案单例、关于页渲染通道
|
||||
- 快速检查:`pnpm astro build` + `python3 scripts/build-perf/config-check.py .`
|
||||
- 集成检查:`E2E_BASE=… node tests/e2e.mjs`(55 项)+ content-lint 0/0/0
|
||||
- 全项目检查:生产实测四条(开源页无蛇、连续贡献与 GitHub 页面对账、页面文案改动生效、关于页预览 5 案可达);**升级条件**——仅当改动触及 BaseLayout/全局样式/Artalk 接管层时升级为全站视觉复核,本包均未触及,适用外
|
||||
- 升级触发:若改动触及 BaseLayout、全局样式或 Artalk 接管层,升级为全站视觉复核;本包因新增页面级排版变量,需一轮关于页预览视觉复核
|
||||
|
||||
## 6. 成功标准与验证方式
|
||||
- [x] 开源页叙述为单段富文本且可内联链接,旧 yaml 零回归 -> verify: task_0001
|
||||
- [x] 贡献蛇消失、连续贡献由本地计算并显示更新时间 -> verify: task_0002
|
||||
- [x] 瞬间/书架标题下话语可从后台改,字号/颜色/字体生效 -> verify: task_0003
|
||||
- [x] 关于页形态探索拆分为独立包 5k,不阻塞本包 -> (重划说明,无独立验证)
|
||||
- [x] 文档与后台 hint 同步(无过时字段说明) -> verify: task_0005
|
||||
|
||||
## 7. 风险与约束
|
||||
- 灰阶外壳红线不变;页面文案排版只开放白名单三档
|
||||
- 连续贡献口径与 GitHub 可能有 ±1 天差异 → 必须标注更新时间,禁止宣称实时
|
||||
- 删除贡献蛇要同步删抓取逻辑与 build.sh 守卫,避免死代码
|
||||
- 多车道并行:`GithubProfile/Heatmap`(贡献数据)与 `MomentsPage/BooksPage`(页面文案)分属不同车道;admin config 与 docs 由 orchestrator 统一改
|
||||
@@ -0,0 +1,30 @@
|
||||
# Phase 5j:内容表达去重与页面文案可编辑 - 任务拆解
|
||||
|
||||
## 使用规则
|
||||
- boundary/verify 齐备;verify 只读断言;车道工作树开发禁 git,收编由 orchestrator 统一提交
|
||||
- 构建一律 `export PATH="$HOME/.local/node/bin:$PATH"`(Node 26)+ `pnpm astro build`
|
||||
- E2E 口径:`pnpm astro build → cp -a dist /tmp/dist-e2e-5j → gz-server.py 18925 /tmp/dist-e2e-5j(第二参数才是目录)→ E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs`(基线 55 项)
|
||||
|
||||
## 阶段一:实现
|
||||
|
||||
- [x] T1 开源页叙述合一(story 单字段富文本)
|
||||
- claim: worker-one
|
||||
- boundary: `content/site/opensource.yaml`(新增 `story`,保留 `blurb`/`blurb_after` 并标注 legacy)、`src/lib/site-content.ts`(`openSourceCopy` 增 `story`:非空则用,空则回落旧拼接)、`src/components/geek/GithubProfile.astro`(消费 story 的富文本渲染,链接用 markdown 内联 `[label](url)`)、`src/content.config.ts`(schema 加 story)、`public/admin/config.yml` 由 orchestrator 统一改(先做代码,字段我来加)
|
||||
- verify: `grep -q "story" src/lib/site-content.ts && grep -q "story" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"`
|
||||
|
||||
- [x] T2 贡献数据去重与时效(删蛇 + 连续贡献本地计算)
|
||||
- claim: worker-one
|
||||
- boundary: `src/lib/heatmap.ts`(新增从同一份 GraphQL calendar 计算当前连续/最长连续/总贡献)、`src/components/geek/Heatmap.astro`(展示三个数 + 「数据更新于 <构建时间>」)、`src/components/geek/GithubProfile.astro`(删除贡献蛇块与 streak 卡引用)、`scripts/build-perf/fetch-readme-assets.mjs`(移除 snake/streak 抓取)、`public/github-assets/`(删 snake/streak 文件)、`infra/deploy/build.sh`(同步守卫)
|
||||
- verify: `! test -f public/github-assets/snake.svg && grep -q "streakStats" src/lib/heatmap.ts && python3 -c "import pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();assert 'snake' not in p and '/github-assets/' not in p"`
|
||||
|
||||
- [x] T3 页面文案可编辑(瞬间/书架标题下话语)
|
||||
- claim: op-gitea
|
||||
- boundary: `src/components/pages/MomentsPage.astro`(改用可配置 note,替换 i18n 硬编码)、`src/components/pages/BooksPage.astro`(同上,替换字面量 `L.note`)、`src/lib/site-content.ts`(新增 `pageNote(page, locale)` 访问器:文本 + 字号 + 颜色 token + 字体类)、`content/site/pages.yaml`(新单例,由 orchestrator 建 schema/后台,车道可先写默认值)
|
||||
- verify: `grep -q "pageNote" src/lib/site-content.ts && grep -q "pages.yaml" src/content.config.ts && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"`
|
||||
|
||||
> 注:关于页形态探索已拆分为独立包 `2026-11-03_blog-5k-about-designs`(2026-11-04 重划,用户选型驱动),不属于本包任务。
|
||||
|
||||
- [x] T5 文档与后台 hint
|
||||
- claim: orchestrator
|
||||
- boundary: `docs/管理台.md`、`docs/写作指南.md`、`public/admin/config.yml`
|
||||
- verify: `grep -q "story" docs/管理台.md && grep -q "pages.yaml\|页面文案" docs/管理台.md`
|
||||
@@ -0,0 +1,88 @@
|
||||
# Phase 5k:关于页形态探索(5 案预览 + 选型落地)- 项目范围 - 完成总结
|
||||
|
||||
## 交付结论
|
||||
- 结果:完成
|
||||
- 完成时间:2026-11-04 23:30
|
||||
|
||||
## 假设回顾
|
||||
### 已验证假设
|
||||
- 五案共用同一份数据(about.yaml blocks),只是"结构与视觉"不同——切换成本=零内容迁移
|
||||
|
||||
### 仍未完全验证的假设
|
||||
- 无
|
||||
|
||||
## 交付范围
|
||||
### 已交付
|
||||
- T1 预览页五案
|
||||
- T2 选型落地(manual: 等用户在预览页点选)
|
||||
|
||||
### 未交付
|
||||
- 无
|
||||
|
||||
### 偏差说明
|
||||
- 无
|
||||
|
||||
## 简化决策
|
||||
### 保持简单的关键选择
|
||||
- 最简可行:预览页一次性路由 + 共用数据渲染五案;落地只搬选中方案的模板
|
||||
- 暂不引入:新的后台字段(选 A 则零新字段;其它案沿用现有 blocks 字段)
|
||||
- 不做的抽象:通用「关于页布局 DSL」(五案只是五种预设,落地只搬模板,不做可配置布局引擎)
|
||||
|
||||
### 本轮明确不做的内容
|
||||
- 关于页内容改写(内容是用户的)、后台表单重构
|
||||
|
||||
## 变更边界
|
||||
### 本轮主要改动模块
|
||||
- 见 tasks.md 的 boundary 记录
|
||||
|
||||
### 明确未触碰的区域
|
||||
- 关于页内容改写(内容是用户的)、后台表单重构
|
||||
|
||||
## 验证证据
|
||||
### 备注
|
||||
- 机器证据:runs.jsonl(见验证范围的机器验证率)
|
||||
|
||||
## 验证范围
|
||||
- 级别:package
|
||||
- 机器验证率:1/1;manual 任务 1 项不计入分母
|
||||
- 规则:check 与 done 复用该范围;只有记录升级触发时才执行全项目检查
|
||||
|
||||
## 哲学生效证据
|
||||
### 行为成效回填
|
||||
- Development Record 行为门禁通过
|
||||
|
||||
### 一致性门禁回顾
|
||||
- 验收面(checklist 或成功标准)已通过
|
||||
|
||||
## Git 记录
|
||||
- 日期时间:2026-11-04 23:30
|
||||
- 范围:Phase 5k:关于页形态探索(5 案预览 + 选型落地)- 项目范围
|
||||
- 功能:T1 预览页五案;T2 选型落地(manual: 等用户在预览页点选)
|
||||
- 操作:提交 / 推送
|
||||
- 成效:Development Record 行为门禁通过
|
||||
- 提交:pending local commit
|
||||
- 提交区间:129b23cd2b0ee2e4d677429fc7063c56b9ce62d8..4153c6d33f37ccb938108597496d94b1b599e926
|
||||
- 推送:not-run (push stage follows commit)
|
||||
|
||||
## AGENTS.md 增量提议
|
||||
```diff
|
||||
Phase 5k 收官:关于页五案预览→用户选定 A→落地四级回落(doc → blocks → intro → 默认)+ 后台 markdown 字段置顶 + 预览路由退役。
|
||||
```
|
||||
|
||||
## 被拒绝的扩展提议
|
||||
- 无
|
||||
|
||||
## 门禁证据
|
||||
- check_spec_package.py exit 0
|
||||
|
||||
## 遗留事项
|
||||
- 无;问题处置见下方结构化区块
|
||||
|
||||
## 问题处置
|
||||
|
||||
```json
|
||||
{
|
||||
"issues": [],
|
||||
"version": 1
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
{"task": "task_0001", "cmd": "! test -f src/pages/about-preview.astro && grep -q \"doc:\" content/site/about.yaml && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 32.309, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "4d028ab89a03b678fbd1828b163f34abb131e10f3176527477b4cdb6c6d4487b", "at": "2026-11-04T15:23:13+00:00"}
|
||||
{"task": "task_0001", "cmd": "! test -f src/pages/about-preview.astro && grep -q \"doc:\" content/site/about.yaml && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 32.261, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "4d028ab89a03b678fbd1828b163f34abb131e10f3176527477b4cdb6c6d4487b", "at": "2026-11-04T15:23:46+00:00"}
|
||||
{"task": "task_0001", "cmd": "! test -f src/pages/about-preview.astro && grep -q \"doc:\" content/site/about.yaml && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 31.564, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "4d028ab89a03b678fbd1828b163f34abb131e10f3176527477b4cdb6c6d4487b", "at": "2026-11-04T15:24:32+00:00"}
|
||||
{"task": "task_0001", "cmd": "! test -f src/pages/about-preview.astro && grep -q \"doc:\" content/site/about.yaml && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 30.031, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "4d028ab89a03b678fbd1828b163f34abb131e10f3176527477b4cdb6c6d4487b", "at": "2026-11-04T15:25:31+00:00"}
|
||||
{"task": "task_0001", "cmd": "! test -f src/pages/about-preview.astro && grep -q \"doc:\" content/site/about.yaml && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 30.603, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "4d028ab89a03b678fbd1828b163f34abb131e10f3176527477b4cdb6c6d4487b", "at": "2026-11-04T15:27:31+00:00"}
|
||||
{"task": "task_0001", "cmd": "! test -f src/pages/about-preview.astro && grep -q \"doc:\" content/site/about.yaml && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 29.99, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "4d028ab89a03b678fbd1828b163f34abb131e10f3176527477b4cdb6c6d4487b", "at": "2026-11-04T15:29:32+00:00"}
|
||||
{"task": "task_0001", "cmd": "! test -f src/pages/about-preview.astro && grep -q \"doc:\" content/site/about.yaml && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE \"汇总: [0-9]+/[0-9]+ PASS\"", "exit": 0, "duration_s": 29.877, "stdout_tail_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tree_fp": "4d028ab89a03b678fbd1828b163f34abb131e10f3176527477b4cdb6c6d4487b", "at": "2026-11-04T15:30:02+00:00"}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Phase 5k:关于页形态探索(5 案预览 + 选型落地)- 项目范围
|
||||
|
||||
<!-- spec-format: 2 -->
|
||||
|
||||
## 1. 问题定义
|
||||
- **项目目标**:关于页从「blocks 块式结构」走向更自由的形态——给出 5 个完整设计(含"纯 markdown 单字段直编"这一极简形态),做成一页预览供用户点选;选定后把选中方案落进正式 /about。
|
||||
- **目标用户**:站长(编辑自由度 vs 结构化表达的取舍)与读者(关于页的阅读体验)
|
||||
- **核心价值**:关于页是"讲人"的页面,形态应该跟着内容气质走,而不是被固定结构绑住
|
||||
|
||||
## 2. 假设与待确认
|
||||
|
||||
### 2.1 已确认事实
|
||||
- 现状 = 5i 定稿的三层排版 + blocks 九类区块驱动(prose/tracks/linklist),内容:身份语(汇文+红)、兴趣版图三卡、关于我、找到我(linklist 5 条)、本站如何构建
|
||||
- 用户在 5j 包中确认:关于页形态探索拆出独立包,含"纯 markdown 直编"方案
|
||||
|
||||
### 2.2 关键假设
|
||||
- 五案共用同一份数据(about.yaml blocks),只是"结构与视觉"不同——切换成本=零内容迁移
|
||||
|
||||
### 2.3 待确认问题
|
||||
- [x] 五案选型:用户已选定 **A 白纸直编**(2026-11-04,预览页点选)
|
||||
|
||||
### 2.4 可选解释与取舍
|
||||
- 选定方案若为"纯 markdown 直编",blocks 保留为高级通道(后台仍可切回)
|
||||
|
||||
### 2.5 已证伪路径
|
||||
- 无(本包是设计探索包)
|
||||
|
||||
## 3. 功能范围
|
||||
|
||||
### 3.1 核心功能(MVP)
|
||||
- [x] T1 预览页:/about-preview/ 五案并陈(A 白纸单栏 / B 名片双栏 / C 杂志分节带目录 / D 时间线主导 / E 卡片流),共享 about.yaml 真实数据
|
||||
- [x] T2 用户选型后落地正式 /about 并删除预览路由
|
||||
|
||||
### 3.2 扩展功能
|
||||
- 无
|
||||
|
||||
### 3.3 不在范围内
|
||||
- 关于页内容改写(内容是用户的)、后台表单重构
|
||||
|
||||
## 4. 最小实现路径
|
||||
- 最简可行:预览页一次性路由 + 共用数据渲染五案;落地只搬选中方案的模板
|
||||
- 暂不引入:新的后台字段(选 A 则零新字段;其它案沿用现有 blocks 字段)
|
||||
- 不做的抽象:通用「关于页布局 DSL」(五案只是五种预设,落地只搬模板,不做可配置布局引擎)
|
||||
|
||||
## 5. 技术决策
|
||||
- 技术栈:Astro 7.4.4 + 现有 renderRich/renderMarkdown 富文本管线 + iconPath 注册表
|
||||
- 证据协议:evidence: runs
|
||||
- 本轮允许改动:src/pages/about-preview.astro(新,一次性)、src/components/pages/AboutPage.astro、content/site/about.yaml、scripts/build-perf/content-lint.mjs(预览路由白名单)、src/i18n/ui.ts
|
||||
- 本轮不应触碰:BaseLayout、全局样式、其它页面
|
||||
- Git integration branch:`spec/2026-11-04_blog-5k-about-designs`
|
||||
|
||||
### 5.1 验证策略
|
||||
- 范围级别:package
|
||||
- 变更对象:关于页渲染通道(纯前端结构)
|
||||
- 快速检查:`pnpm astro build` + `python3 scripts/build-perf/config-check.py .`
|
||||
- 集成检查:E2E 56 项 + content-lint 0/0/0
|
||||
- 全项目检查:生产预览页五案可达、选中案落地后 /about 渲染正确;**升级条件**——仅当关于页改动触及全局样式或 BaseLayout 时才升级全站复核,本包不涉及(由 /about 渲染人工核验覆盖)
|
||||
- 升级触发:关于页为独立页面且不动全局样式,升级为「该页视觉复核」即可
|
||||
|
||||
## 6. 成功标准与验证方式
|
||||
- [x] 预览页五案可达且共用真实数据 -> verify: task_0001
|
||||
- [x] 用户选定方案落地正式 /about,预览路由删除 -> verify: task_0002(manual: 需用户点选,落地后人工核验)
|
||||
|
||||
## 7. 风险与约束
|
||||
- 灰阶骨架红线、双语红线不变
|
||||
- 预览路由一次性:用户选定后必须删除(含 lint 白名单)
|
||||
@@ -0,0 +1,21 @@
|
||||
# Phase 5k:关于页形态探索 - 任务拆解
|
||||
|
||||
## 使用规则
|
||||
- E2E 口径:`pnpm astro build → cp -a dist /tmp/dist-e2e-5k → gz-server.py 18925 /tmp/dist-e2e-5k → E2E_BASE=… node tests/e2e.mjs`(基线 56 项)
|
||||
- 预览路由一次性:选定后删除路由与 lint 白名单
|
||||
|
||||
## 阶段一:实现
|
||||
|
||||
- [x] T1 预览页五案
|
||||
- claim: orchestrator
|
||||
- boundary: src/pages/about-preview.astro(新)、scripts/build-perf/content-lint.mjs(SKIP_ALL 加该路由)、content/site/about.yaml(若某案需要演示字段则补,双语)
|
||||
- verify: `! test -f src/pages/about-preview.astro && grep -q "doc:" content/site/about.yaml && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"`(终态:预览路由已删、doc 已落地)
|
||||
|
||||
- [x] T2 选型落地(manual: 等用户在预览页点选)
|
||||
- claim: orchestrator
|
||||
- boundary: src/components/pages/AboutPage.astro、content/site/about.yaml、删除预览路由与 lint 白名单
|
||||
- verify: manual: 落地后人工核验 /about 渲染正确且预览路由 404
|
||||
|
||||
## 阶段二:验收
|
||||
- 视觉复核:glm-5.3-flash:max 看五案截图(≤5 图/轮)
|
||||
- 用户在预览页点选后执行 T2
|
||||
+31
-1
@@ -3,6 +3,7 @@ import { defineConfig } from 'astro/config';
|
||||
import pagefind from 'astro-pagefind';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import rehypeLinkCards, { linkCardsReport } from './src/lib/rehype-link-cards';
|
||||
import rehypeClassWhitelist from './src/lib/rehype-class-whitelist';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
@@ -18,9 +19,38 @@ const date =
|
||||
process.env.GITHUB_COMMIT_DATE ||
|
||||
(() => { try { return execSync('git log -1 --format=%cI').toString().trim(); } catch { return new Date().toISOString(); } })();
|
||||
|
||||
// 隐藏文章不进 sitemap(内容侧契约见 src/lib/post-visibility.ts)。
|
||||
// 这里在配置期直接扫 frontmatter:Astro 的 content collections 在配置上下文不可用,
|
||||
// 而 sitemap 的 filter 只拿到 URL。判定两条通道:`hidden: true` 与 tags 里的 hidden。
|
||||
const hiddenSlugs = (() => {
|
||||
const set = new Set();
|
||||
try {
|
||||
const dir = 'content/posts';
|
||||
for (const file of readdirSync(dir)) {
|
||||
if (!file.endsWith('.md')) continue;
|
||||
const raw = readFileSync(`${dir}/${file}`, 'utf8');
|
||||
const head = (raw.split('\n---\n')[0] ?? '').replace(/^---\n/, '');
|
||||
if (/^hidden:\s*true\s*$/m.test(head)) { set.add(file.replace(/\.md$/, '')); continue; }
|
||||
// tags 可能是行内数组,也可能是多行列表:抓出 tags 段再找 hidden 词
|
||||
const m = /^tags:[ \t]*([\s\S]*?)(?=^\w|\s*$)/m.exec(head);
|
||||
const tagBlock = m ? m[1] : '';
|
||||
if (/\bhidden\b/.test(tagBlock)) set.add(file.replace(/\.md$/, ''));
|
||||
}
|
||||
} catch {
|
||||
/* 内容目录不可读时宁可不排除,也不让构建挂掉 */
|
||||
}
|
||||
return [...set];
|
||||
})();
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://wait4x.online',
|
||||
integrations: [pagefind(), sitemap(), linkCardsReport()],
|
||||
integrations: [
|
||||
pagefind(),
|
||||
sitemap({
|
||||
filter: (page) => !hiddenSlugs.some((slug) => page.includes(`/archives/${slug}/`)),
|
||||
}),
|
||||
linkCardsReport(),
|
||||
],
|
||||
markdown: {
|
||||
// 独立成段的裸链接 → OG 预览卡(构建期抓取,失败回落普通链接;见 src/lib/rehype-link-cards.ts)
|
||||
// 顺序:rehypeRaw 先把 markdown 里的 raw HTML 解析成 hast element(否则白名单看不到 span),
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
+13
-31
@@ -1,20 +1,25 @@
|
||||
---
|
||||
title: "面纱"
|
||||
title: 面纱
|
||||
pubDate: 2026-03-09
|
||||
category: reading
|
||||
tags: [read]
|
||||
tags:
|
||||
- read
|
||||
pinned: true
|
||||
mood: '#7c3aed'
|
||||
bookAuthor: '威廉·萨默塞特·毛姆'
|
||||
description: '面纱之下:理想的殉道者与真实的幸存者。'
|
||||
bookAuthor: 威廉·萨默塞特·毛姆
|
||||
description: 面纱之下:理想的殉道者与真实的幸存者。
|
||||
cover: ./assets/面纱.png
|
||||
hiddenTag: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
****面纱之下:理想的殉道者和真实的幸存者****
|
||||
**_面纱之下:理想的殉道者和真实的幸存者_**
|
||||
|
||||
### 你用面纱遮蔽他人的窥探,自己也难以一睹世界之实。
|
||||
|
||||
这是译者在后记中写下的一句话。很好印证了《面纱》这本书或者说这个题目所蕴含的真相。人们活在面纱之下,以此遮蔽他人的窥探,却也因此困于自己的面纱之下。
|
||||
|
||||
见沃尔特像是见最温柔的爱和最悲痛的爱情,以最炽热的心和最真挚的感情去爱,爱上了一个不爱自己的人,最后在爱中将自己陷入漩涡🌀,不得解脱。他富有智慧,又富有对这个世界和社会以及人性最明锐的洞察力,他了解世界的苦难,了解社会的种种表象以及深层次的真相,了解人的“伪装”背后的真实人性。他像是个圣洁的存在,却同样又有着拙劣和“恶毒”的想法,以至于至死都未能从感情漩涡中游出。在我看来,沃尔特对于凯蒂的爱是真切的,对于背叛的恨也是真切的,但从始至终爱始终贯穿永远。在去湄潭府之前,他是冷静的旁观者,他是理性的化身,他有真对于真理的精准见解,有着自己衡量一切的道德标准和不可被践踏的信仰,这是源于理性的不可被磨灭的印记。在感知到背叛,并带着赴死的姿态奔赴湄潭府的过程中,他被感情所裹挟,他既爱又恨,恨凯蒂的背叛,也恨自己爱上了一个与自己原则背道而驰的爱人,他从一开始想让凯蒂也走向死亡,到最后让她离开,看似对于婚姻和这段感情有了一点点解脱,可实际上到死他都未能跨过自己给自己筑起的情感高墙。他是矛盾的,他至死未能宽恕自己,也正如他对世界最后的留言所述:“死的是一条狗”。这不仅是对凯蒂的隐喻,更是他对自己灵魂的终极审判。他终于承认,那个满怀怨恨、试图以爱之名行毁灭之实的自己,才是那条被仇恨吞噬的‘疯狗’。沃尔特的一生,是用最锋利的理性去剖析世界,却唯独不敢直视自己内心那片荒芜的废墟。他死于霍乱,实则死于那层由傲慢与偏见织就的、永远无法揭下的面纱。
|
||||
见沃尔特像是见最温柔的爱和最悲痛的爱情,以最炽热的心和最真挚的感情去爱,爱上了一个不爱自己的人,最后在爱中将自己陷入漩涡🌀,不得解脱。他富有智慧,又富有对这个世界和社会以及人性最明锐的洞察力,他了解世界的苦难,了解社会的种种表象以及深层次的真相,了解人的“伪装”背后的真实人性。他像是个圣洁的存在,却同样又有着拙劣和“恶毒”的想法,以至于至死都未能从感情漩涡中游出。在我看来,沃尔特对于凯蒂的爱是真切的,对于背叛的恨也是真切的,但从始至终爱始终贯穿永远。在去湄潭府之前,他是冷静的旁观者,他是理性的化身,他有着对于真理的精准见解,有着自己衡量一切的道德标准和不可被践踏的信仰,这是源于理性的不可被磨灭的印记。在感知到背叛,并带着赴死的姿态奔赴湄潭府的过程中,他被感情所裹挟,他既爱又恨,恨凯蒂的背叛,也恨自己爱上了一个与自己原则背道而驰的爱人,他从一开始想让凯蒂也走向死亡,到最后让她离开,看似对于婚姻和这段感情有了一点点解脱,可实际上到死他都未能跨过自己给自己筑起的情感高墙。他是矛盾的,他至死未能宽恕自己,也正如他对世界最后的留言所述:“死的是一条狗”。这不仅是对凯蒂的隐喻,更是他对自己灵魂的终极审判。他终于承认,那个满怀怨恨、试图以爱之名行毁灭之实的自己,才是那条被仇恨吞噬的‘疯狗’。沃尔特的一生,是用最锋利的理性去剖析世界,却唯独不敢直视自己内心那片荒芜的废墟。他死于霍乱,实则死于那层由傲慢与偏见织就的、永远无法揭下的面纱。
|
||||
|
||||
相比之下,凯蒂是幸运的,也最终得到了自由。在虚荣的面纱下,遮住的是世界的残酷和社会的复杂以及人性的不可知。一开始的虚荣心作祟,让她步入了和沃尔特的婚姻,却发现婚后的生活并非是自己所期望的那样,甚至从一开始她就清楚的知道自己并不爱沃尔特。于是,当她到了香港,并遇上查理的时候,她沦陷了,她在查理身上看见了自己所期待的伴侣所拥有的那些特性,幽默风趣,性感迷人,但是却忽视了人性。在去往湄潭府之前,她虚荣,她害怕,她怕沃尔特发现一切,她软弱,她想逃离这样的婚姻和查理相拥,但是她不敢付诸行动。当沃尔特推着她去面对一切的时候,当一切都败露的时候,她终究是看见了人性的恶,她见到了查理的无情和所谓的爱只是言语上的“美好”,她也真正的感到了恐惧,面对死亡的恐惧和面对沃尔特不可捉摸的想法的恐惧。在湄潭府之行的初期,她依旧活在对于过去的回忆之中,她害怕恐惧,她看不见未来。但被圣光照耀的庙堂始终矗立在彼岸,她向往那迷人的色彩,她想要去追求新的生活。当她跨出第一步并开始在修道院工作的时候,她的觉醒之路从此慢慢开始,她慢慢和自己和解,慢慢看淡背叛,慢慢看清查理,慢慢找寻着自己在生活中该有的样子,她开始慢慢摘掉自己的面纱,看清自己的虚伪和软弱,开始追寻道的意义,她感悟周围的一切,甚至想着能够将沃尔特从他的痛苦中将他拉出。然而直到沃尔特死去,他们都没能和解,她愧对沃尔特,她看到了自己的轻贱,可是她自始至终都跨不过沃尔特竖起的高墙,她在墙外,沃尔特在墙内,各自在各自的面纱下痛苦的活着。沃尔特的死,是沃尔特的一种“解脱”,也是凯蒂的一种解脱。
|
||||
|
||||
@@ -22,7 +27,7 @@ description: '面纱之下:理想的殉道者与真实的幸存者。'
|
||||
>
|
||||
> 所谓《面纱》,遮住的不仅是真相,更是我们面对真实自我的勇气。沃尔特缺乏这份勇气去接纳不完美的爱与人性,所以他困死其中;凯蒂在生死关头被迫拥有了这份勇气,所以她获得了自由。
|
||||
>
|
||||
> 故事的最后,死去的人得到了安息,活着的人带着记忆与教训,继续前行。这或许就是毛姆想告诉我们的:****人生没有完美的结局,只有在认清生活的残酷真相后,依然选择热爱并真实地活下去,才是唯一的救赎。****
|
||||
> 故事的最后,死去的人得到了安息,活着的人带着记忆与教训,继续前行。这或许就是毛姆想告诉我们的:**_人生没有完美的结局,只有在认清生活的残酷真相后,依然选择热爱并真实地活下去,才是唯一的救赎。_**
|
||||
|
||||
从背叛到自我的救赎,凯蒂在远离家乡的异国,没能逃脱欲望作祟,再次回到香港之后又一次沦陷在了查理的怀抱中,但此刻的她早已不是去湄潭府的之前的自己,她的思想早已骤变。她厌恶自己肉体对自己灵魂的出卖,她选择远离这个是非之地,她选择去重新追求自由,亦如从湄潭府离开一样,她再一次走上了追寻自由的路上。她回到故土,见母亲离世,见妹妹怀有身孕,见父亲终于脱离过去的自己,她亦想脱离过去的自己,拥抱一个崭新的未来。活在面纱之下的她和她的父亲,在故事的结局,两人袒露心声,一同前往新的地方开始新的生活,去活出新的自己。
|
||||
|
||||
@@ -32,7 +37,7 @@ description: '面纱之下:理想的殉道者与真实的幸存者。'
|
||||
|
||||
沃尔特死在了他的‘正确’里,而凯蒂活在了她的‘真实’中。沃尔特的故事是带有悲惨的个人主义的牺牲,凯蒂的故事是带有幸运的个人主义的新生。沃尔特自觉没有希望,在困顿的泥潭中深陷,他没有勇气去面对新的生活和新的世界,他将自己紧紧隐藏在自己的面纱之后,他活在自己为自己构建的信念世界之中,为自己的信念,"悲壮”赴死。凯蒂活在纷繁的社会中,从小我见大我,从假我见真我,从困局中得自由。她一步一步从虚伪的面纱下得见人性、社会冷暖以及新世界的希望,湄潭府之前的生活让她自省,湄潭府之行让她感知到一切,在死亡和黑暗中得到感悟和觉醒,得见光明,再次回到香港,欲望再次作祟,这一次她没有沉沦于虚妄的现实,她终于携手勇气做出了自己的决定,踏上了"离开”的归程。
|
||||
|
||||
故事的最后,面纱已落。凯蒂带着伤痕,牵着父亲的手,走向巴哈马的阳光。那里没有豪门的虚妄,没有激情的幻象,只有平凡而坚实的生活。这,或许就是毛姆留给所有在虚荣与迷茫中挣扎的人们,最温柔的答案:****唯有真实,可得自由。****
|
||||
故事的最后,面纱已落。凯蒂带着伤痕,牵着父亲的手,走向巴哈马的阳光。那里没有豪门的虚妄,没有激情的幻象,只有平凡而坚实的生活。这,或许就是毛姆留给所有在虚荣与迷茫中挣扎的人们,最温柔的答案:**_唯有真实,可得自由。_**
|
||||
|
||||
在《面纱》的故事中,沃尔特是极具“英雄主义”的个人,为自己固有的信仰慷然赴死;凯蒂是在纷杂的社会中逐步成长,在欲望和虚荣的交叉间度过早年,湄潭府一行,在黑暗中见光明,在死亡中见希望,在异国见真我,在“新世界”得自由。
|
||||
|
||||
@@ -41,51 +46,28 @@ description: '面纱之下:理想的殉道者与真实的幸存者。'
|
||||
------------------------------------------------------------------------
|
||||
|
||||
- 我不了解你,我一点儿都不了解你。
|
||||
|
||||
- 我知道你愚蠢、轻浮、没有头脑,但是我爱你。我知道你的目标和理想既庸俗又普通,但是我爱你。我知道你是二流货色,但是我爱你。想一想真是好笑,我竭力去喜欢那些讨你喜欢的东西,忍受折磨也要对你隐瞒起自己,实际上我并不无知粗俗、不爱散播丑闻也不愚蠢。我知道你何等害怕智慧,便尽我所能让你觉得我是个大傻瓜,跟你认识的其他人一样。我知道你嫁给我只图一时利益,我是那样爱你,我不在乎。大多数人,就我所知,当他们爱一个人,却没有得到爱的回报时就会觉得委屈不平,甚至愈发愤怒和痛苦。我不是那样,我从来没有指望你爱我,我看不出任何理由让你爱我,我也从来没想过自己会被人爱。我很感激能被允许爱你,当我时常想起你高兴跟我在一起,或者当我发现你眼中闪烁着愉快的爱意时,我就会欣喜若狂。我尽量不让我的爱来烦扰你,我知道那会让我承受不起,所以我一直察言观色,留意我的爱让你厌烦的最初迹象。大部分丈夫认为那是一种权力,我却准备当成恩惠来接受。
|
||||
|
||||
- 他知道你虚荣、懦弱、自私自利,他想让我亲眼看到这一切。他知道你会像野兔一样,危险一来就跑得远远的。他知道我深受蒙骗才会认为你爱上了我,因为他知道你不会爱任何人,只爱你自己。他知道你会牺牲我,好让自己毫发无损地逃脱出去。
|
||||
|
||||
- 死亡既突然又无情,在一条条曲折的街巷上匆匆穿过。
|
||||
|
||||
- 黎明乍现,阳光触到雾霭,令其闪闪发白,犹如雪之幽灵降至即将熄灭的星宿之上。
|
||||
|
||||
- 你瞧,这里只有你跟我踏踏实实在坚实的地上行走。修女们走在天上,而你丈夫则走在黑暗里。
|
||||
|
||||
- 他的灵魂撕裂了,他一直活在一种虚假的构想之中,当真相击碎了幻象,他便认为现实本身也被击碎了。
|
||||
|
||||
- 这个时辰的城市街巷空空荡荡,比任何时候都更像一座死城。路人一个个神色迷离,不禁让人怀疑他们全都是鬼魂。天空晴朗无云,初升的太阳将圣洁和煦的光芒洒满大地。很难想象,在这个愉悦、清新、爽朗的黎明,这座城市却像被疯子一手扼住咽喉的人,在瘟疫的黑暗掌控下苟延残喘。不可思议的是,当人类在痛苦挣扎、在恐惧中走向死亡时,大自然(蓝色的天空如孩童的心一般清澈)竟会如此无动于衷。
|
||||
|
||||
- 安宁只存在于人的灵魂中。
|
||||
|
||||
- 所有的人,乃至整个人类,就如同这条河里的水滴一样,流淌不定,一滴滴彼此接近,却又相距遥远,汇成一股无名的巨流奔向大海。既然一切转瞬即逝,任何事物都无关宏旨,人们竟还要荒唐地看重那些微不足道的事情,让自己也让别人遭受不幸,这实在太可悲了。
|
||||
|
||||
- 小小的两滴水随着河流默默流向未知,这两滴水相对而言是那样独特,但在旁观者的眼里,不过是河流中无法辨识的组成部分。
|
||||
|
||||
- 我又明白什么呢,生命是那么奇特。我觉得自己就像个一辈子都住在小池塘边上的人,突然间看见了大海,让我有点喘不过气来,但心里又充满了喜悦。我不想死,想活下去,于是感到一股新的勇气。我就好像那些老水手,起航驶向尚未发现的海洋,我的灵魂渴求未知的一切。
|
||||
|
||||
- 小小的两滴水默默地、默默地流向那黑暗、永恒的大海。
|
||||
|
||||
- 道。我们有些人在鸦片中寻找道,有些人在上帝那儿,有些人寻求威士忌,或去爱里寻找。这道终归只有一条,可它不通向任何地方。
|
||||
|
||||
- 这一切既新奇又感人,她的心灵犹如一片平静而灰暗的大海,波浪起伏,蔚然壮观,阴郁浩瀚令人畏惧,突然间,一缕阳光投射过来,让一切变得活泼、友好而快乐。
|
||||
|
||||
- 人生何其短暂,世界本来就充满了苦痛,人们却还要折磨自己,这岂不太可怜了吗?
|
||||
|
||||
- 她的性格就像一片乡野,初见时感觉辽阔而冷漠,但不久就会在巍峨山岭的褶皱间发现一个个掩映在果树丛中的欢腾的村庄,看见郁郁葱葱的草地上欢快流淌的一条条小河。这番宜人的景象会让你惊奇,甚至熨帖安心,但远处狂风劲吹的黄褐色高地尚不足以使你感到自如自在。
|
||||
|
||||
- 只有一种办法赢得人心,那就是让自己成为人们会去爱的人。
|
||||
|
||||
- 我说不清。我不知道她们追求幻觉这一点是否真的那么重要,还是这种生活本身就很美好。我有个想法,唯一让我们有可能不带嫌恶地关注我们所生活的这个世界的,就是人类不断从混沌中创造出的美。他们描绘的画,他们谱出的乐曲,他们写的书,还有他们的生活。这一切中,最富于美的就是美好的生活,那是件完美的艺术品。
|
||||
|
||||
- 道就是道路和行道者。那是一条永恒的路,所有的生命存在行走其上,但它并非由生命存在所创造,因为它本身便是生命存在。它什么都是,又什么都不是。万物由道所生,与道相符,最后万物又回归于道。它是一块方形却无四角,是种声音却不被聆听,是幅图画却未有形状;它是一张巨大的网,网眼阔如海洋,却什么都无法穿过;它是万物寻求庇护的避难所;它无处可寻,但你‘不窥牖’便可‘见天道’;它要人学会欲无所欲,让一切顺其自然。谦卑者尽得保全,屈身者终将直立。‘祸兮,福之所依;福兮,祸之所伏’;但谁能说清什么时候会出现转折点?追求柔慈之人会如小孩子一样平和。柔慈为进攻者获取胜利,为守卫者求得保全。战胜自己的人最为强大。
|
||||
|
||||
- 自由!这便是一直萦绕在她心中的念头。尽管未来仍旧模糊,这一念头却像河上的薄雾,在清晨阳光的照耀下焕发虹彩。自由!不仅仅是挣脱烦恼的束缚,解除那让她消沉的伴侣关系的自由。自由!不仅是逃离死亡威胁的自由,更是逃离让她降低人格的爱情,逃脱所有精神束缚的自由,一种抽离出肉体的精神的自由。与自由相伴的,还有勇气,以及无论发生什么都毫不在乎的坚强气质。
|
||||
|
||||
- 她以为自己变了,以为自己意志坚强,会以一个冷静自持的女人的面目回到香港。心中掠过一个个崭新的念头,就像阳光下翻飞的黄色蝴蝶,让她对美好的未来充满期望。自由就像光的精灵召唤她,整个世界犹如一片广袤的平原,任她迈着轻快的步子昂首前行。她原以为自己已摆脱了肉欲和卑劣的情爱,足以过上一种纯洁健康的精神生活。她曾将自己比作黄昏时分悠然飞跃稻田的白鹭,它们就像安闲自处的头脑中翩然翱翔的片片思绪,可实际上她却仍是欲望的奴隶。
|
||||
|
||||
- 我有希望,也有勇气。
|
||||
|
||||
- 你用面纱遮蔽他人的窥探,自己也难以一睹世界之实。
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
+10
-5
@@ -1,11 +1,16 @@
|
||||
---
|
||||
title: "泥潭"
|
||||
title: 泥潭
|
||||
pubDate: 2026-02-09
|
||||
category: reading
|
||||
tags: [read]
|
||||
mood: '#166534'
|
||||
bookAuthor: '刘楚昕'
|
||||
description: '迷失于黑夜时,不妨抬头看看星空。'
|
||||
tags:
|
||||
- read
|
||||
pinned: false
|
||||
mood: '#686868'
|
||||
bookAuthor: 刘楚昕
|
||||
description: 迷失在黑夜中时,不妨抬头看看星空;如果不知道该相信什么,人应当面对自己的良知。
|
||||
cover: ./assets/泥潭.png
|
||||
hiddenTag: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
### 迷失在黑夜中时,不妨抬头看看星空;如果不知道该相信什么,人应当面对自己的良知。
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
---
|
||||
title: "赎罪"
|
||||
title: 赎罪
|
||||
pubDate: 2026-02-26
|
||||
category: reading
|
||||
tags: [read]
|
||||
mood: '#9f1239'
|
||||
bookAuthor: '伊恩·麦克尤恩'
|
||||
description: '因一个人、一张图、一句台词,看了一本书。'
|
||||
tags:
|
||||
- read
|
||||
pinned: false
|
||||
mood: '#bf002f'
|
||||
bookAuthor: 伊恩·麦克尤恩
|
||||
description: 亲爱的塞西莉亚,我们缘分未尽
|
||||
cover: ./assets/赎罪.png
|
||||
hiddenTag: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
### 你要回来,我会等你。
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
---
|
||||
title: "写作模板:这篇文章本身就是模板"
|
||||
title: 写作模板:这篇文章本身就是模板
|
||||
pubDate: 2026-11-02
|
||||
category: tech
|
||||
tags: [写作, 模板]
|
||||
tags:
|
||||
- 写作
|
||||
- 模板
|
||||
pinned: true
|
||||
description: 一篇文章演示全站可用的排版能力:标题层级、彩色强调、字族、引用、代码窗、表格、图、链接卡、折叠。复制它开始写新文。
|
||||
hidden: true
|
||||
draft: false
|
||||
hiddenTag: true
|
||||
---
|
||||
|
||||
这篇不是教程,是**可复制的模板**。下面每一节都在真跑——你看到的效果,就是 Markdown 源码能产出的效果。写新文章时,把本文件复制一份、改个 slug、删掉不需要的段落即可。
|
||||
|
||||
+56
-1
@@ -1,11 +1,66 @@
|
||||
# 关于页(后台「页面文案 → 关于页」可改)
|
||||
# ── 一级:blocks 块式正文(按顺序渲染,可拖拽排序;某块 enabled: false 即隐藏)
|
||||
# ── legacy:blocks 块式正文(doc 非空时不渲染;保留作结构化备选通道)
|
||||
# ── 二级:intro 多段 markdown(仅在 blocks 为空时使用)
|
||||
# ── 三级:内置默认文案(两者都空时兜底,构建永不炸)
|
||||
layout:
|
||||
# 名片区(头像/名字/身份语/社交)开关:头像等数据事实源在「主页设置」
|
||||
showIdCard: true
|
||||
|
||||
doc:
|
||||
zh: |
|
||||
<span class="font-huiwen c-red">写作者</span> · 读者 · 快门按压者,拥抱开源。
|
||||
|
||||
这里是我三条轨道的公开工作台:**读过的书**留下评注,**写过的代码**进项目墙,**按下的快门**收入图库。
|
||||
|
||||
所有内容都有一个明确去处:
|
||||
|
||||
- 长文沉淀在文章与书架
|
||||
- 短句与随手拍落在瞬间
|
||||
- 影像收进图库,配一点 EXIF 与一句策展
|
||||
|
||||
## 关于我
|
||||
|
||||
一个想做预报的人——气象是起点的专业,依旧把云图当日常读物。写代码是第二条主线:Linux、容器与自托管,把日常工具一件件磨顺手。作品大多在 [wait4x.online](https://wait4x.online/)。信条:**拥抱开源,热爱开源**。
|
||||
|
||||
## 找到我
|
||||
|
||||
- [本站](https://wait4x.online/)
|
||||
- [邮件](mailto:X@alumni.nudt.edu.cn)
|
||||
- [GitHub](https://github.com/wait4xx)
|
||||
- [自托管 gitea](https://git.wait4x.online/wait4x/blog)
|
||||
- [RSS](/rss.xml)
|
||||
|
||||
## 本站如何构建
|
||||
|
||||
页面由 Astro 静态生成,不含运行时框架 JS;内容住在自托管 gitea——本地编辑器与 gitea 网页写的是同一份仓库,推送即构建上线。排版三件套(字体子集化、灰阶骨架、双层明暗)也都在仓里,欢迎按 F12 随便看。
|
||||
en: |
|
||||
<span class="font-huiwen c-red">Writer</span> · Reader · Shutter-presser, embracing open source.
|
||||
|
||||
This is the public workbench of my three tracks: **books** that left notes, **code** that reaches the project wall, and **shutters** that land in the gallery.
|
||||
|
||||
Everything has a clear destination:
|
||||
|
||||
- Long-form writing goes to posts and the bookshelf
|
||||
- Short lines and snapshots land in moments
|
||||
- Images go to the gallery, with EXIF and a line of curation
|
||||
|
||||
## About me
|
||||
|
||||
An aspiring forecaster — meteorology was where I started, and I still read cloud charts for fun. Coding is the second thread: Linux, containers, and self-hosting, polishing everyday tools one by one. Most of my work lives at [wait4x.online](https://wait4x.online/). Motto: **embrace open source, love open source**.
|
||||
|
||||
## Find me
|
||||
|
||||
- [This site](https://wait4x.online/)
|
||||
- [Email](mailto:X@alumni.nudt.edu.cn)
|
||||
- [GitHub](https://github.com/wait4xx)
|
||||
- [Self-hosted gitea](https://git.wait4x.online/wait4x/blog)
|
||||
- [RSS](/rss.xml)
|
||||
|
||||
## How this site is built
|
||||
|
||||
Static pages by Astro, no client-side framework JS; content lives in my self-hosted gitea — the local editor and the gitea web UI write to the same repo, and a push builds the site. The typography stack (font subsetting, greyscale skeleton, dual light/dark) is all in the repo — view source freely.
|
||||
|
||||
# ---- blocks 块式通道(legacy:doc 非空时不渲染;后台仍可编辑,留作结构化备选)----
|
||||
blocks:
|
||||
- type: prose
|
||||
width: narrow
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# 打字机动效文案(档案卡顶部逐字轮播;每行双语,最多 6 行;留空用内置默认三行)
|
||||
typing:
|
||||
- zh: 气象出身,写代码为乐
|
||||
en: Trained in meteorology, coding for fun
|
||||
@@ -29,11 +28,4 @@ blurb_after:
|
||||
mode: all
|
||||
sort: pinned-first
|
||||
perSource: github
|
||||
intro_from: "off"
|
||||
|
||||
# ---- 开篇来源说明(2026-11-02 Phase 5h)----
|
||||
# 开篇已改原生 GitHub 档案卡(头像 / 统计四格 / 语言占比灰阶条),不再渲染 README 原文。
|
||||
# intro_from: "off" = 用档案卡 + 上面的 blurb(当前)
|
||||
# intro_from: github-readme = 构建期取 wait4xx/wait4xx 的 README 原样渲染(**不推荐**:
|
||||
# 会引入 7 类第三方外链图,慢网或被墙时整片空白;README 用 <div align="center"> 居中,
|
||||
# 本站不认该属性 → 居中失效。这正是当初"内容没居中、图片不显示"的根因。)
|
||||
intro_from: 'off'
|
||||
@@ -0,0 +1,21 @@
|
||||
# 页面标题下的说明文案(瞬间 / 书架):文字、字号、颜色、字体都在这里改。
|
||||
# 缺省即回落内置默认,删字段不会破版。
|
||||
# 说明:color 取 tokens 里的灰阶层级(ink-1..ink-4);font 取排版类白名单里的字体类。
|
||||
|
||||
moments:
|
||||
note:
|
||||
zh: 零碎的记录:随手拍、一句话感想、开源动态。
|
||||
en: 'Fragmented notes: casual shots, one-liners, and open-source updates.'
|
||||
style:
|
||||
size: 13
|
||||
color: ink-4
|
||||
font: font-sans
|
||||
|
||||
books:
|
||||
note:
|
||||
zh: 点一行展开封面与简介 · 置顶的书默认展开
|
||||
en: 'Tap a row for the cover and blurb · pinned books start open'
|
||||
style:
|
||||
size: 13
|
||||
color: ink-4
|
||||
font: font-sans # 与改造前一致;想用楷体改成 font-kai
|
||||
@@ -18,6 +18,15 @@ cp content/posts/writing-template.md content/posts/my-new-post.md
|
||||
- **整页换字族要谨慎**:会给访问者拉一份中文字体(汇文明朝整页约 420KB,已子集化)
|
||||
- **折叠放细节不放结论**:`<details>` 里的内容可能永远没人展开
|
||||
|
||||
## 隐藏文章(两种写法,任选其一)
|
||||
|
||||
不想让某篇出现在首页/归档/书架/RSS/sitemap:
|
||||
- frontmatter 写 `hidden: true`(后台有开关)
|
||||
- 或在 `tags` 里加一个 `hidden`
|
||||
|
||||
页面本身仍会生成,链接可以直接分享给别人;想连页面都不生成,用 `draft: true`(草稿)。
|
||||
**隐藏与置顶同开时,以隐藏为准**(2026-11-04 修复:此前隐藏开关是假的,隐藏+置顶会照样显示还排第一)。
|
||||
|
||||
## 置顶文章
|
||||
|
||||
首页、归档、书架三处列表默认按日期倒序。想让某篇长期排在最前,frontmatter 加一行:
|
||||
|
||||
+23
@@ -24,13 +24,23 @@
|
||||
- **文章英文版**:不在后台写。跑 `python3 scripts/translate/translate_posts.py` 生成 `content/posts-en/` 同名文件;手写润色过的译文在 frontmatter 写 `locked: true`,翻译脚本会无条件跳过它。
|
||||
- **保存即提交**:后台保存等于向仓库提交,push 后约 10 秒自动构建上线。
|
||||
|
||||
## 页面说明文案(5j 新增:瞬间 / 书架)
|
||||
|
||||
「页面文案」新增 `content/site/pages.yaml`(后台「瞬间 / 书架 标题下的说明」):那一行字的文字(markdown,支持排版类)、
|
||||
字号(11–16)、颜色(灰阶四级)、字体(12 族下拉)都在这里改,两页互相独立。
|
||||
|
||||
兜底行为:任何字段清空/删掉都回落内置默认;越界值(如字号填 40、颜色乱写)
|
||||
会被自动夹取或回落,不会破版。
|
||||
|
||||
## 文章置顶(5h 新增)
|
||||
|
||||
文章编辑页有一个「置顶」开关。打开后这篇文章排在首页分轨、归档、书架三处列表的最前面,列表里显示一个灰阶「置顶」小徽标。
|
||||
|
||||
语义边界(刻意如此,别当 bug 报):
|
||||
- **只影响列表顺序**:`pubDate` 不变、URL 不变、RSS 里的时间序列不变,订阅者不会看到时间错乱
|
||||
- **与「隐藏」同开时以隐藏为准**:后台的「隐藏」开关(5j 起是真字段)会把这篇从列表/RSS/sitemap 里拿掉,页面本身仍可分享
|
||||
- **支持多篇/多本同时置顶**:置顶组内部仍按日期倒序;书架页(/books)里每个置顶的书评都会排在前面并**默认展开**
|
||||
- **置顶 + 隐藏同时打开时,以隐藏为准**(5j 修复:此前隐藏开关只写一个没人读的字段,导致隐藏+置顶的文章照样显示还排第一)
|
||||
- 书架页用「↑ + 横线」的回到顶部图标表示置顶(不写文字),与右侧展开箭头形状不同,不会混读
|
||||
- 关掉开关立即恢复时间序,没有"冷却期"
|
||||
|
||||
@@ -66,6 +76,15 @@ caption: 落日把海与城一齐烫成金箔,**这一帧等了二十分钟**
|
||||
2. 图片字段 → 资产库上传 jpg(与 sidecar 同目录落位,零重压缩)
|
||||
3. Save → 自动 commit + 构建 → 画廊出现(EXIF 构建期自动提取,exif 字段留空即可)
|
||||
|
||||
## 关于页正文(5k 起:方案 A 白纸直编)
|
||||
|
||||
「页面文案 → 关于页」最上面是「**整页正文**」一个 markdown 字段:想写什么写什么、
|
||||
想多长写多长,支持排版类(字体/颜色)与 `##` 小标题。保存后整页就渲染这一段
|
||||
(身份名片仍在顶部)。
|
||||
|
||||
下面的「块式结构」是 legacy 通道:只有当「整页正文」清空时才会显示,
|
||||
老内容已迁移进正文,平时不用管它。四级回落:整页正文 → 块式结构 → intro → 内置默认。
|
||||
|
||||
## 块编辑:关于页的块式页面构建器
|
||||
|
||||
关于页(`content/site/about.yaml`)的正文由 **blocks 列表**构成,后台里是一串可拖拽排序的卡片。每个块都有「显示」开关;关掉只是不渲染,块本身留在数据里。
|
||||
@@ -153,6 +172,10 @@ blocks:
|
||||
| `postsTracks` | `{reading, tech, photo}` 布尔 | 全开 | 关掉某轨,该轨文章不在首页列出(归档页仍可见) |
|
||||
|
||||
### 开源页(opensource.yaml)
|
||||
**叙述已合一(5j 起)**:改用 `story` 字段(后台显示为「叙述(推荐改这里)」),整段写成 markdown,
|
||||
链接直接内联(`[文字](url)`),不再分"链接前/链接后"。旧的两个字段(链接前/链接后)
|
||||
只是 legacy——仅当「叙述」为空时才会退回旧拼接,平时不用管它们。
|
||||
|
||||
|
||||
**开篇是原生「GitHub 档案卡」(5h 起)**:头像 / 名字@handle / 定位叙述 / 社交图标 / 统计四格(关注者、公开仓、总星标)/ 语言占比灰阶条(只有第一名用强调色,其余灰阶)。**零第三方图片、零 iframe**。数据失败只隐藏对应数字,不会破版。
|
||||
|
||||
|
||||
@@ -39,12 +39,9 @@ pnpm astro build
|
||||
[ -f scripts/build-perf/gen-sw-precache.mjs ] && node scripts/build-perf/gen-sw-precache.mjs dist || echo "[build] sw-precache 生成器未就绪,跳过"
|
||||
[ -f scripts/build-perf/inline-home-css.mjs ] && node scripts/build-perf/inline-home-css.mjs || echo "[build] inline-home 脚本未就绪,跳过"
|
||||
|
||||
# README 动效资产(5i T8):**不在此跑抓取**(避免发布链被外网抖动拖累),只做存在性守卫 + 缺资产告警
|
||||
if [ -f public/github-assets/manifest.json ]; then
|
||||
echo "[build] github-assets 就绪:$(find public/github-assets -name '*.svg' | wc -l) 个自托管 SVG"
|
||||
else
|
||||
echo "[build] WARN public/github-assets/manifest.json 缺失——开源页数据板将只显示原生统计(可手动跑 node scripts/build-perf/fetch-readme-assets.mjs 补齐)"
|
||||
fi
|
||||
# README 动效资产(5i 引入、5j T2 退役):贡献蛇与 streak 快照已删——连续贡献改为
|
||||
# 热力图同源本地计算(见 src/lib/heatmap.ts 的 streakStats),界面上标注"数据更新于"。
|
||||
# fetch-readme-assets.mjs 保留骨架但 ASSETS 已清空,如将来要加新的自托管资产再启用。
|
||||
|
||||
# 内容 lint(T3):断链/缺图在部署前拦截;CONTENT_LINT=skip|warn 逃生门见脚本头注
|
||||
[ -f scripts/build-perf/content-lint.mjs ] && node scripts/build-perf/content-lint.mjs dist || echo "[build] content-lint 未就绪,跳过"
|
||||
|
||||
+92
-4
@@ -55,7 +55,12 @@ collections:
|
||||
widget: string
|
||||
required: false
|
||||
hint: '填 /media/xxx.jpg(先上传到 public/media 取路径);缺省浏览器自取首帧'
|
||||
- { label: 隐藏(不进列表/sitemap), name: hiddenTag, widget: boolean, required: false, hint: '对应 tags 含 hidden;保存后请人工核对' }
|
||||
- label: 隐藏(不进列表 / RSS / sitemap)
|
||||
name: hidden
|
||||
widget: boolean
|
||||
required: false
|
||||
default: false
|
||||
hint: '打开后这篇不进首页/归档/书架/RSS/sitemap,但页面本身仍生成、链接可分享(想彻底不生成请用草稿)。与「置顶」同时打开时以隐藏为准——2026-11-04 修:旧开关只写一个没人读的字段,导致隐藏+置顶的文章照样显示还排第一'
|
||||
- { label: 草稿, name: draft, widget: boolean, required: false, default: false }
|
||||
- { label: 正文, name: body, widget: markdown }
|
||||
|
||||
@@ -143,6 +148,73 @@ collections:
|
||||
- name: site_pages
|
||||
label: 页面文案
|
||||
files:
|
||||
- name: pages
|
||||
label: 瞬间 / 书架 标题下的说明
|
||||
file: content/site/pages.yaml
|
||||
format: yaml
|
||||
hint: 这两页大标题下面那一行字,文字与样式都在这里改
|
||||
fields:
|
||||
- label: 瞬间页
|
||||
name: moments
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- label: 说明文字
|
||||
name: note
|
||||
widget: object
|
||||
required: false
|
||||
hint: 支持 markdown 与排版类(字体/颜色)
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: markdown, required: false }
|
||||
- { label: English, name: en, widget: markdown, required: false }
|
||||
- label: 样式
|
||||
name: style
|
||||
widget: object
|
||||
required: false
|
||||
hint: 留空用全站默认;越界值会被自动夹取(不会破版)
|
||||
fields:
|
||||
- { label: 字号(px,11–16), name: size, widget: number, required: false, value_type: int, min: 11, max: 16 }
|
||||
- label: 颜色
|
||||
name: color
|
||||
widget: select
|
||||
required: false
|
||||
hint: 只开放灰阶层级,避免正文被染色
|
||||
options: [ink-1, ink-2, ink-3, ink-4]
|
||||
- label: 字体
|
||||
name: font
|
||||
widget: select
|
||||
required: false
|
||||
options: [font-sans, font-serif, font-kai, font-huiwen, font-kuaile, font-xiaowei, font-huangyou, font-mashanzheng, font-liujianmaocao, font-smiley, font-neoxihei]
|
||||
- label: 书架页
|
||||
name: books
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- label: 说明文字
|
||||
name: note
|
||||
widget: object
|
||||
required: false
|
||||
hint: 支持 markdown 与排版类(字体/颜色)
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: markdown, required: false }
|
||||
- { label: English, name: en, widget: markdown, required: false }
|
||||
- label: 样式
|
||||
name: style
|
||||
widget: object
|
||||
required: false
|
||||
hint: 留空用全站默认;越界值会被自动夹取(不会破版)
|
||||
fields:
|
||||
- { label: 字号(px,11–16), name: size, widget: number, required: false, value_type: int, min: 11, max: 16 }
|
||||
- label: 颜色
|
||||
name: color
|
||||
widget: select
|
||||
required: false
|
||||
options: [ink-1, ink-2, ink-3, ink-4]
|
||||
- label: 字体
|
||||
name: font
|
||||
widget: select
|
||||
required: false
|
||||
options: [font-sans, font-serif, font-kai, font-huiwen, font-kuaile, font-xiaowei, font-huangyou, font-mashanzheng, font-liujianmaocao, font-smiley, font-neoxihei]
|
||||
- name: home
|
||||
label: 主页设置
|
||||
file: content/site/home.yaml
|
||||
@@ -260,7 +332,15 @@ collections:
|
||||
media_folder: content/site/assets
|
||||
public_folder: ./assets
|
||||
fields:
|
||||
- label: 页面块(拖拽排序;每块可折叠)
|
||||
- label: 整页正文(推荐改这里,方案 A 白纸直编)
|
||||
name: doc
|
||||
widget: object
|
||||
required: false
|
||||
hint: '整页就这一个 markdown 字段:想写什么写什么、想多长写多长(支持排版类与 ## 标题)。清空则回落到下面的 blocks 结构化通道'
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: markdown, required: false }
|
||||
- { label: English, name: en, widget: markdown, required: false }
|
||||
- label: 块式结构(legacy,整页正文为空时才显示)
|
||||
name: blocks
|
||||
widget: list
|
||||
typeKey: type
|
||||
@@ -544,11 +624,19 @@ collections:
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string }
|
||||
- { label: English, name: en, widget: string }
|
||||
- label: 叙述(链接前)
|
||||
- label: 叙述(推荐改这里)
|
||||
name: story
|
||||
widget: object
|
||||
required: false
|
||||
hint: '整段叙述写成一个字段即可(markdown + 排版类),链接直接写在句子里:[本站源码](https://git.wait4x.online/wait4x/blog) —— 这样就不用再分"链接前/链接后"。留空时才会退回下面的旧字段拼接'
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: markdown, required: false }
|
||||
- { label: English, name: en, widget: markdown, required: false }
|
||||
- label: 叙述(旧:链接前)
|
||||
name: blurb
|
||||
widget: object
|
||||
required: false
|
||||
hint: 富文本(markdown + 排版类),渲染在仓库链接之前
|
||||
hint: 'legacy:仅当上面的「叙述」为空时才使用(历史字段,为把仓库链接夹在中间而拆成两半)'
|
||||
fields:
|
||||
- { label: 中文, name: zh, widget: string, required: false }
|
||||
- { label: 英文, name: en, widget: string, required: false }
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg viewBox="-16 -32 880 192" width="880" height="192" xmlns="http://www.w3.org/2000/svg"><desc>Generated with https://github.com/Platane/snk</desc><style>:root{--cb:#1b1f230a;--cs:purple;--ce:#ebedf0;--c0:#ebedf0;--c1:#9be9a8;--c2:#40c463;--c3:#30a14e;--c4:#216e39}.c{shape-rendering:geometricPrecision;fill:var(--ce);stroke-width:1px;stroke:var(--cb);width:12px;height:12px}.c.c0{fill:var(--c1);animation-name:c0}.c.c1{fill:var(--c2);animation-name:c1}.c.c2{fill:var(--c1);animation-name:c2}.c.c3{fill:var(--c1);animation-name:c3}.c.c4{fill:var(--c1);animation-name:c4}.c.c5{fill:var(--c1);animation-name:c5}.c.c6{fill:var(--c1);animation-name:c6}.c.c7{fill:var(--c1);animation-name:c7}.c.c8{fill:var(--c1);animation-name:c8}.c.c9{fill:var(--c1);animation-name:c9}.c.ca{fill:var(--c1);animation-name:ca}.c.cb{fill:var(--c1);animation-name:cb}.c.cc{fill:var(--c1);animation-name:cc}.c.cd{fill:var(--c1);animation-name:cd}.c.ce{fill:var(--c1);animation-name:ce}.c.cf{fill:var(--c1);animation-name:cf}.c.cg{fill:var(--c1);animation-name:cg}.c.ch{fill:var(--c1);animation-name:ch}.c.ci{fill:var(--c1);animation-name:ci}.c.cj{fill:var(--c1);animation-name:cj}.c.ck{fill:var(--c1);animation-name:ck}.c.cl{fill:var(--c1);animation-name:cl}.c.cm{fill:var(--c2);animation-name:cm}.c.cn{fill:var(--c1);animation-name:cn}.c.co{fill:var(--c1);animation-name:co}.c.cp{fill:var(--c1);animation-name:cp}.c.cq{fill:var(--c1);animation-name:cq}.c.cr{fill:var(--c1);animation-name:cr}.c.cs{fill:var(--c1);animation-name:cs}.c.ct{fill:var(--c4);animation-name:ct}.c.cu{fill:var(--c1);animation-name:cu}.c.cv{fill:var(--c4);animation-name:cv}.c.cw{fill:var(--c2);animation-name:cw}.c.cx{fill:var(--c1);animation-name:cx}.c.cy{fill:var(--c1);animation-name:cy}.c.cz{fill:var(--c1);animation-name:cz}.c.c10{fill:var(--c1);animation-name:c10}.c.c11{fill:var(--c1);animation-name:c11}.c.c12{fill:var(--c2);animation-name:c12}.c.c13{fill:var(--c4);animation-name:c13}.c.c14{fill:var(--c2);animation-name:c14}.c.c15{fill:var(--c1);animation-name:c15}.c.c16{fill:var(--c4);animation-name:c16}.c.c17{fill:var(--c1);animation-name:c17}.c.c18{fill:var(--c1);animation-name:c18}.c.c19{fill:var(--c1);animation-name:c19}.c.c1a{fill:var(--c2);animation-name:c1a}.c.c1b{fill:var(--c1);animation-name:c1b}.c.c1c{fill:var(--c1);animation-name:c1c}.c.c1d{fill:var(--c4);animation-name:c1d}.c.c1e{fill:var(--c4);animation-name:c1e}.u{transform-origin:0 0;transform:scale(0,1);animation-name:u0;transform-origin:0.0px 0}.u.u1{fill:var(--c2);animation-name:u1;transform-origin:648.5px 0}.u.u2{fill:var(--c4);animation-name:u2;transform-origin:748.2px 0}.s{shape-rendering:geometricPrecision;fill:var(--cs);animation-name:s0}.s.s1{transform:translate(16px,-16px);animation-name:s1}.s.s2{transform:translate(32px,-16px);animation-name:s2}.s.s3{transform:translate(48px,-16px);animation-name:s3}</style><rect class="c" x="2" y="2" rx="2" ry="2"/><rect class="c" x="2" y="18" rx="2" ry="2"/><rect class="c" x="2" y="34" rx="2" ry="2"/><rect class="c" x="2" y="50" rx="2" ry="2"/><rect class="c" x="2" y="66" rx="2" ry="2"/><rect class="c" x="2" y="82" rx="2" ry="2"/><rect class="c" x="2" y="98" rx="2" ry="2"/><rect class="c" x="18" y="2" rx="2" ry="2"/><rect class="c" x="18" y="18" rx="2" ry="2"/><rect class="c" x="18" y="34" rx="2" ry="2"/><rect class="c" x="18" y="50" rx="2" ry="2"/><rect class="c" x="18" y="66" rx="2" ry="2"/><rect class="c" x="18" y="82" rx="2" ry="2"/><rect class="c c0" x="18" y="98" rx="2" ry="2"/><rect class="c" x="34" y="2" rx="2" ry="2"/><rect class="c" x="34" y="18" rx="2" ry="2"/><rect class="c" x="34" y="34" rx="2" ry="2"/><rect class="c" x="34" y="50" rx="2" ry="2"/><rect class="c" x="34" y="66" rx="2" ry="2"/><rect class="c" x="34" y="82" rx="2" ry="2"/><rect class="c" x="34" y="98" rx="2" ry="2"/><rect class="c" x="50" y="2" rx="2" ry="2"/><rect class="c" x="50" y="18" rx="2" ry="2"/><rect class="c" x="50" y="34" rx="2" ry="2"/><rect class="c" x="50" y="50" rx="2" ry="2"/><rect class="c c1" x="50" y="66" rx="2" ry="2"/><rect class="c c2" x="50" y="82" rx="2" ry="2"/><rect class="c" x="50" y="98" rx="2" ry="2"/><rect class="c" x="66" y="2" rx="2" ry="2"/><rect class="c" x="66" y="18" rx="2" ry="2"/><rect class="c c3" x="66" y="34" rx="2" ry="2"/><rect class="c c4" x="66" y="50" rx="2" ry="2"/><rect class="c" x="66" y="66" rx="2" ry="2"/><rect class="c c5" x="66" y="82" rx="2" ry="2"/><rect class="c" x="66" y="98" rx="2" ry="2"/><rect class="c" x="82" y="2" rx="2" ry="2"/><rect class="c" x="82" y="18" rx="2" ry="2"/><rect class="c c6" x="82" y="34" rx="2" ry="2"/><rect class="c" x="82" y="50" rx="2" ry="2"/><rect class="c" x="82" y="66" rx="2" ry="2"/><rect class="c" x="82" y="82" rx="2" ry="2"/><rect class="c" x="82" y="98" rx="2" ry="2"/><rect class="c" x="98" y="2" rx="2" ry="2"/><rect class="c" x="98" y="18" rx="2" ry="2"/><rect class="c" x="98" y="34" rx="2" ry="2"/><rect class="c" x="98" y="50" rx="2" ry="2"/><rect class="c" x="98" y="66" rx="2" ry="2"/><rect class="cLine truncated
|
||||
|
Before Width: | Height: | Size: 20 KiB |
@@ -1,104 +0,0 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'
|
||||
style='isolation: isolate' viewBox='0 0 495 195' width='495px' height='195px' direction='ltr'>
|
||||
<style>
|
||||
|
||||
|
||||
</style>
|
||||
<defs>
|
||||
<clipPath id='outer_rectangle'>
|
||||
<rect width='495' height='195' rx='4.5'/>
|
||||
</clipPath>
|
||||
<mask id='mask_out_ring_behind_fire'>
|
||||
<rect width='495' height='195' fill='white'/>
|
||||
<ellipse id='mask-ellipse' cx='247.5' cy='32' rx='13' ry='18' fill='black'/>
|
||||
</mask>
|
||||
|
||||
</defs>
|
||||
<g clip-path='url(#outer_rectangle)'>
|
||||
<g style='isolation: isolate'>
|
||||
<rect stroke='#000000' stroke-opacity='0' fill='#000000' fill-opacity='0' rx='4.5' x='0.5' y='0.5' width='494' height='194'/>
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<line x1='165' y1='28' x2='165' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
|
||||
<line x1='330' y1='28' x2='330' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<!-- Total Contributions big number -->
|
||||
<g transform='translate(82.5, 48)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#c9ccd4' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 1'>
|
||||
344
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Total Contributions label -->
|
||||
<g transform='translate(82.5, 84)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#b9bec8' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 1'>
|
||||
Total Contributions
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Total Contributions range -->
|
||||
<g transform='translate(82.5, 114)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#b9bec8' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
|
||||
May 24, 2019 - Present
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<!-- Current Streak label -->
|
||||
<g transform='translate(247.5, 108)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3454d1' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='14px' font-style='normal' style='opacity: 1'>
|
||||
Current Streak
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Current Streak range -->
|
||||
<g transform='translate(247.5, 145)'>
|
||||
<text x='0' y='21' stroke-width='0' text-anchor='middle' fill='#b9bec8' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
|
||||
Nov 3
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Ring around number -->
|
||||
<g mask='url(#mask_out_ring_behind_fire)'>
|
||||
<circle cx='247.5' cy='71' r='40' fill='none' stroke='#3454d1' stroke-width='5' style='opacity: 1'></circle>
|
||||
</g>
|
||||
<!-- Fire icon -->
|
||||
<g transform='translate(247.5, 19.5)' stroke-opacity='0' style='opacity: 1'>
|
||||
<path d='M -12 -0.5 L 15 -0.5 L 15 23.5 L -12 23.5 L -12 -0.5 Z' fill='none'/>
|
||||
<path d='M 1.5 0.67 C 1.5 0.67 2.24 3.32 2.24 5.47 C 2.24 7.53 0.89 9.2 -1.17 9.2 C -3.23 9.2 -4.79 7.53 -4.79 5.47 L -4.76 5.11 C -6.78 7.51 -8 10.62 -8 13.99 C -8 18.41 -4.42 22 0 22 C 4.42 22 8 18.41 8 13.99 C 8 8.6 5.41 3.79 1.5 0.67 Z M -0.29 19 C -2.07 19 -3.51 17.6 -3.51 15.86 C -3.51 14.24 -2.46 13.1 -0.7 12.74 C 1.07 12.38 2.9 11.53 3.92 10.16 C 4.31 11.45 4.51 12.81 4.51 14.2 C 4.51 16.85 2.36 19 -0.29 19 Z' fill='#3454d1' stroke-opacity='0'/>
|
||||
</g>
|
||||
|
||||
<!-- Current Streak big number -->
|
||||
<g transform='translate(247.5, 48)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#c9ccd4' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' >
|
||||
0
|
||||
</text>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<!-- Longest Streak big number -->
|
||||
<g transform='translate(412.5, 48)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#c9ccd4' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 1'>
|
||||
6
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Longest Streak label -->
|
||||
<g transform='translate(412.5, 84)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#b9bec8' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 1'>
|
||||
Longest Streak
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Longest Streak range -->
|
||||
<g transform='translate(412.5, 114)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#b9bec8' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
|
||||
Oct 19 - Oct 24
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.1 KiB |
@@ -1,104 +0,0 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'
|
||||
style='isolation: isolate' viewBox='0 0 495 195' width='495px' height='195px' direction='ltr'>
|
||||
<style>
|
||||
|
||||
|
||||
</style>
|
||||
<defs>
|
||||
<clipPath id='outer_rectangle'>
|
||||
<rect width='495' height='195' rx='4.5'/>
|
||||
</clipPath>
|
||||
<mask id='mask_out_ring_behind_fire'>
|
||||
<rect width='495' height='195' fill='white'/>
|
||||
<ellipse id='mask-ellipse' cx='247.5' cy='32' rx='13' ry='18' fill='black'/>
|
||||
</mask>
|
||||
|
||||
</defs>
|
||||
<g clip-path='url(#outer_rectangle)'>
|
||||
<g style='isolation: isolate'>
|
||||
<rect stroke='#000000' stroke-opacity='0' fill='#000000' fill-opacity='0' rx='4.5' x='0.5' y='0.5' width='494' height='194'/>
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<line x1='165' y1='28' x2='165' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
|
||||
<line x1='330' y1='28' x2='330' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<!-- Total Contributions big number -->
|
||||
<g transform='translate(82.5, 48)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#33343a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 1'>
|
||||
344
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Total Contributions label -->
|
||||
<g transform='translate(82.5, 84)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3f424a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 1'>
|
||||
Total Contributions
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Total Contributions range -->
|
||||
<g transform='translate(82.5, 114)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3f424a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
|
||||
May 24, 2019 - Present
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<!-- Current Streak label -->
|
||||
<g transform='translate(247.5, 108)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3454d1' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='14px' font-style='normal' style='opacity: 1'>
|
||||
Current Streak
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Current Streak range -->
|
||||
<g transform='translate(247.5, 145)'>
|
||||
<text x='0' y='21' stroke-width='0' text-anchor='middle' fill='#3f424a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
|
||||
Nov 3
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Ring around number -->
|
||||
<g mask='url(#mask_out_ring_behind_fire)'>
|
||||
<circle cx='247.5' cy='71' r='40' fill='none' stroke='#3454d1' stroke-width='5' style='opacity: 1'></circle>
|
||||
</g>
|
||||
<!-- Fire icon -->
|
||||
<g transform='translate(247.5, 19.5)' stroke-opacity='0' style='opacity: 1'>
|
||||
<path d='M -12 -0.5 L 15 -0.5 L 15 23.5 L -12 23.5 L -12 -0.5 Z' fill='none'/>
|
||||
<path d='M 1.5 0.67 C 1.5 0.67 2.24 3.32 2.24 5.47 C 2.24 7.53 0.89 9.2 -1.17 9.2 C -3.23 9.2 -4.79 7.53 -4.79 5.47 L -4.76 5.11 C -6.78 7.51 -8 10.62 -8 13.99 C -8 18.41 -4.42 22 0 22 C 4.42 22 8 18.41 8 13.99 C 8 8.6 5.41 3.79 1.5 0.67 Z M -0.29 19 C -2.07 19 -3.51 17.6 -3.51 15.86 C -3.51 14.24 -2.46 13.1 -0.7 12.74 C 1.07 12.38 2.9 11.53 3.92 10.16 C 4.31 11.45 4.51 12.81 4.51 14.2 C 4.51 16.85 2.36 19 -0.29 19 Z' fill='#3454d1' stroke-opacity='0'/>
|
||||
</g>
|
||||
|
||||
<!-- Current Streak big number -->
|
||||
<g transform='translate(247.5, 48)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#33343a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' >
|
||||
0
|
||||
</text>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
<g style='isolation: isolate'>
|
||||
<!-- Longest Streak big number -->
|
||||
<g transform='translate(412.5, 48)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#33343a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 1'>
|
||||
6
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Longest Streak label -->
|
||||
<g transform='translate(412.5, 84)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3f424a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 1'>
|
||||
Longest Streak
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Longest Streak range -->
|
||||
<g transform='translate(412.5, 114)'>
|
||||
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3f424a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
|
||||
Oct 19 - Oct 24
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.1 KiB |
@@ -16,8 +16,6 @@ if (MODE === 'skip') {
|
||||
const SKIP_PREFIX = ['/admin/', '/photos-originals/']; // admin=CMS 路由;photos-originals=部署期 rsync 叠加层(不在 dist,另有部署链保障)
|
||||
const SKIP_FILE = new Set(['/404.html']); // Astro 404 页不参与配对
|
||||
|
||||
// 一次性预览路由(无 en 对应页):完全不参与检查。用户选定方案后随路由一起删除。
|
||||
const SKIP_ALL = new Set(['/heatmap-preview/index.html']);
|
||||
|
||||
|
||||
function walk(dir, out = []) {
|
||||
@@ -53,7 +51,6 @@ const warns = [];
|
||||
|
||||
for (const f of files) {
|
||||
const rel = f.slice(DIST.length) || '/';
|
||||
if (SKIP_ALL.has(rel)) continue;
|
||||
const html = readFileSync(f, 'utf8');
|
||||
|
||||
// 站内 <a href>:仅根相对路径;#锚点/mailto/tel/外部/data: 全跳过
|
||||
|
||||
@@ -42,18 +42,6 @@ const ASSETS = [
|
||||
`&text_color=${theme === 'dark' ? INK_DARK : INK_LIGHT}`,
|
||||
label: '语言分布',
|
||||
},
|
||||
{
|
||||
name: 'streak',
|
||||
url: (theme) =>
|
||||
`https://streak-stats.demolab.com/?user=${LOGIN}&hide_border=true` +
|
||||
'&background=00000000' +
|
||||
`&ring=${ACCENT}&fire=${ACCENT}&currStreakLabel=${ACCENT}` +
|
||||
`&currStreakNum=${theme === 'dark' ? INK_DARK : INK_LIGHT}` +
|
||||
`&sideNums=${theme === 'dark' ? INK_DARK : INK_LIGHT}` +
|
||||
`&sideLabels=${theme === 'dark' ? MUTED_DARK : MUTED_LIGHT}` +
|
||||
`&dates=${theme === 'dark' ? MUTED_DARK : MUTED_LIGHT}`,
|
||||
label: '连续贡献',
|
||||
},
|
||||
{
|
||||
// 贡献蛇:GitHub Action 生成,配色由 action 侧决定,无法传参 → 只抓一份
|
||||
name: 'snake',
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
// 0600 文件,缺位或失败即隐藏该数字)④src/lib/projects.ts 的仓库列表(语言占比与星标汇总)
|
||||
// 降级纪律:任何一项取数失败只隐藏该项,绝不炸构建、不出现空图片框
|
||||
import { Image } from 'astro:assets';
|
||||
import { existsSync } from 'node:fs';
|
||||
import TypingLines from './TypingLines.astro';
|
||||
import { homeAvatar, homeSocials, openSourceCopy, renderRich } from '../../lib/site-content';
|
||||
import { githubUserStats, loadProjects } from '../../lib/projects';
|
||||
@@ -71,26 +70,7 @@ const lines = typingLines.length ? typingLines : DEFAULT_TYPING;
|
||||
|
||||
// 自托管 SVG 数据卡(public/github-assets/,由 scripts/build-perf/fetch-readme-assets.mjs 产出入仓)
|
||||
// 缺文件即跳过该卡(异步抓取失败不影响构建),明暗各一份时用 CSS 切换
|
||||
const assetPath = (name: string) => `/github-assets/${name}`;
|
||||
const hasAsset = (name: string) => existsSync(`public/github-assets/${name}`);
|
||||
const boardCards: { label: string; light: string; dark?: string }[] = [];
|
||||
for (const [key, label] of [
|
||||
['stats', locale === 'zh' ? '统计' : 'Stats'],
|
||||
['top-langs', locale === 'zh' ? '语言分布' : 'Top languages'],
|
||||
['streak', locale === 'zh' ? '连续贡献' : 'Streak'],
|
||||
] as const) {
|
||||
if (!hasAsset(`${key}-light.svg`)) continue;
|
||||
boardCards.push({
|
||||
label,
|
||||
light: assetPath(`${key}-light.svg`),
|
||||
dark: hasAsset(`${key}-dark.svg`) ? assetPath(`${key}-dark.svg`) : undefined,
|
||||
});
|
||||
}
|
||||
// 只有一张非通栏卡时让它占满整行(否则右半空档;stats/top-langs 上游 503 时就是这种情形)
|
||||
const soloCard = boardCards.length === 1;
|
||||
const snakeCard = hasAsset('snake.svg')
|
||||
? { label: locale === 'zh' ? '贡献蛇' : 'Contribution snake', src: assetPath('snake.svg') }
|
||||
: null;
|
||||
// 数据板已并入热力图区(Phase 5j T2):连续贡献由同源计算 + 更新时间标注,不再使用第三方 SVG
|
||||
const L = {
|
||||
zh: { followers: '关注者', repos: '公开仓库', stars: '总星标', langs: '语言占比' },
|
||||
en: { followers: 'Followers', repos: 'Public repos', stars: 'Total stars', langs: 'Languages' },
|
||||
@@ -171,33 +151,6 @@ const L = {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{(boardCards.length > 0 || snakeCard) && (
|
||||
<div class="pf-board">
|
||||
<p class="pf-board-title mono">{locale === 'zh' ? '数据板' : 'Data board'}</p>
|
||||
<div class="pf-board-grid">
|
||||
{boardCards.map((card) => (
|
||||
<figure
|
||||
class:list={[
|
||||
'pf-card',
|
||||
{ 'pf-card-streak': card.light.includes('streak'), 'pf-card-solo': soloCard },
|
||||
]}
|
||||
>
|
||||
<img class="pf-card-img only-light" src={card.light} alt={card.label} loading="lazy" decoding="async" />
|
||||
{card.dark && (
|
||||
<img class="pf-card-img only-dark" src={card.dark} alt={card.label} loading="lazy" decoding="async" />
|
||||
)}
|
||||
<figcaption class="mono">{card.label}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
{snakeCard && (
|
||||
<figure class="pf-card pf-card-wide pf-card-snake">
|
||||
<img class="pf-card-img" src={snakeCard.src} alt={snakeCard.label} loading="lazy" decoding="async" />
|
||||
<figcaption class="mono">{snakeCard.label}</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -331,11 +284,6 @@ const L = {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pf-board {
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.pf-board-title {
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-4);
|
||||
@@ -344,29 +292,6 @@ const L = {
|
||||
}
|
||||
/* 自适应列:只有 1 张卡时自动占满整行(避免右半空档),≥2 张时并排;
|
||||
320px 下限保证车卡内 SVG 不被压到字号过小 */
|
||||
.pf-board-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.pf-card {
|
||||
margin: 0;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.pf-card-wide, .pf-card-solo { grid-column: 1 / -1; }
|
||||
.pf-card-img { max-width: 100%; height: auto; display: block; }
|
||||
.pf-card figcaption {
|
||||
font-size: 11px;
|
||||
color: var(--ink-4);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
/* 明暗两版自托管 SVG:默认只显示亮色,暗色主题切到暗色版 */
|
||||
/* 贡献蛇:SVG 内部用自带 CSS 变量(绿阶 + #ebedf0 空格),<img> 无法改写内部配色 →
|
||||
滤镜归一到站点灰阶;暗色下反相,避免浅灰格子贴在深底上刺眼(想去掉绿→灰只改这一行) */
|
||||
@@ -378,7 +303,6 @@ const L = {
|
||||
filter: grayscale(1) invert(1) contrast(1.18) brightness(1.06);
|
||||
}
|
||||
|
||||
.only-dark { display: none; }
|
||||
:global([data-theme='dark']) .only-light { display: none; }
|
||||
:global([data-theme='dark']) .only-dark { display: block; }
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
/**
|
||||
* 热力图网格(可复用渲染件):给 5 个动效方案预览共用,避免把 371 个格子抄五遍。
|
||||
*
|
||||
* 每格带 `data-c/--c`(列=周)与 `data-r/--r`(行=星期),动效方案靠这两个自定义属性
|
||||
* 算错峰延迟;`data-l` 是灰阶档(0–4,复用 tokens 的 --ink-1..4)。
|
||||
* 全部格子 aria-hidden:语义由外层 role="img" + aria-label 概括(371 个 aria 节点是噪音)。
|
||||
*/
|
||||
import { levelOf, type HeatDay } from '../../lib/heatmap';
|
||||
|
||||
interface Props {
|
||||
weeks: HeatDay[][];
|
||||
/** 网格类型,仅用于选择器命名(不动行为) */
|
||||
variant: string;
|
||||
/** 单元格错峰延迟基数(ms),0 = 不设延迟 */
|
||||
step?: number;
|
||||
}
|
||||
|
||||
const { weeks, variant, step = 0 } = Astro.props;
|
||||
---
|
||||
|
||||
<div class={`hg hg-${variant}`} data-hg={variant}>
|
||||
{weeks.map((week, ci) =>
|
||||
week.map((day, ri) => (
|
||||
<span
|
||||
class={`hg-c hg-l${levelOf(day.count)}`}
|
||||
data-c={ci}
|
||||
data-r={ri}
|
||||
data-count={day.count}
|
||||
data-date={day.date}
|
||||
style={step ? `--c:${ci};--r:${ri};--d:${(ci + ri) * step}ms` : `--c:${ci};--r:${ri}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hg {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: repeat(7, 10px);
|
||||
grid-auto-columns: 10px;
|
||||
gap: 3px;
|
||||
}
|
||||
.hg-c {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
/* 与线上热力图同口径:空格用 hairline 描边、四档用 ink 灰阶(亮暗自动跟随),
|
||||
accent 只留给各方案的"高亮"(glow / flow / 连续数字),不参与数据编码 */
|
||||
}
|
||||
.hg-l0 { border: 1px solid var(--line); }
|
||||
.hg-l1 { background: var(--ink-4); }
|
||||
.hg-l2 { background: var(--ink-3); }
|
||||
.hg-l3 { background: var(--ink-2); }
|
||||
.hg-l4 { background: var(--ink-1); }
|
||||
</style>
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
// 贡献热力图(极客层 P0):纯 CSS grid,零运行时 JS
|
||||
// 数据聚合见 src/lib/heatmap.ts;灰阶四档直接复用 tokens --ink-1..4(亮暗两套自动跟随)
|
||||
import { loadHeatmap, levelOf } from '../../lib/heatmap';
|
||||
import { loadHeatmap, levelOf, streakStats } from '../../lib/heatmap';
|
||||
import { localeOf } from '../../lib/locale';
|
||||
|
||||
// 文案全部由页面层注入(locale 无关挂法),keys 与 ui.ts heatmap_* 一一对应
|
||||
interface Labels {
|
||||
@@ -16,6 +17,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const { labels } = Astro.props;
|
||||
const locale = localeOf(Astro.url.pathname);
|
||||
let weeks: import('../../lib/heatmap').HeatDay[][] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
@@ -23,25 +25,60 @@ try {
|
||||
} catch {
|
||||
// 三级回落已在 lib 内处理;此处再守一层:空网格不破构建
|
||||
}
|
||||
// 连续贡献(Phase 5j T2):与热力图同源本地计算 + 标注更新时间(不宣称实时)
|
||||
const streak = streakStats(weeks.flat());
|
||||
const updatedOn = new Date().toISOString().slice(0, 10);
|
||||
const summary = labels.summary.replace('{n}', String(total));
|
||||
|
||||
// 月度聚合(P1 蛇 + 月柱联动):柱子给"哪个月最忙"的粗粒度答案
|
||||
const byMonth = new Map<string, number>();
|
||||
for (const week of weeks) {
|
||||
for (const day of week) {
|
||||
const key = day.date.slice(0, 7);
|
||||
byMonth.set(key, (byMonth.get(key) ?? 0) + day.count);
|
||||
}
|
||||
}
|
||||
const months = [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b)).filter(([m]) => m);
|
||||
const maxMonth = Math.max(...months.map(([, v]) => v), 1);
|
||||
---
|
||||
|
||||
<section class="heatmap">
|
||||
<h2 class="hm-title">{labels.title}</h2>
|
||||
<!-- 53 列窄栏触屏放不下,容器横滚(格子原生 title 提示,无 JS) -->
|
||||
<div class="hm-scroll" data-anchor-latest>
|
||||
<p class="hm-streak mono">
|
||||
<span class="hm-streak-item"><strong>{streak.current}</strong>{locale === 'en' ? 'day streak' : '天连续'}</span>
|
||||
<span class="hm-streak-item"><strong>{streak.longest}</strong>{locale === 'en' ? 'longest' : '最长'}</span>
|
||||
<span class="hm-streak-item"><strong>{streak.total}</strong>{locale === 'en' ? 'total' : '总贡献'}</span>
|
||||
<span class="hm-streak-note">数据更新于 {updatedOn}</span>
|
||||
</p>
|
||||
<div class="hm-stage" data-snake>
|
||||
<span class="hm-head" aria-hidden="true"></span>
|
||||
<span class="hm-trail" aria-hidden="true"></span>
|
||||
<div class="hm-scroll" data-anchor-latest>
|
||||
<div class="hm-grid" role="img" aria-label={summary}>
|
||||
{weeks.map((week) =>
|
||||
week.map((day) => (
|
||||
<span
|
||||
class={`hm-c hm-l${levelOf(day.count)}`}
|
||||
data-count={day.count}
|
||||
data-date={day.date}
|
||||
title={`${day.date} · ${day.count}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="hm-bars" aria-hidden="true">
|
||||
{months.map(([m, v]) => (
|
||||
<li>
|
||||
<span class="hm-bar-btn"><span class="hm-bar-fill" data-month={m} style={`--h:${Math.round((v / maxMonth) * 100)}%`} /></span>
|
||||
<span class="hm-bar-label mono">{m.slice(5)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<noscript><style>.hm-bar-fill{height:var(--h,0%)!important}</style></noscript>
|
||||
<p class="hm-legend">
|
||||
<span aria-hidden="true">{labels.less}</span>
|
||||
{[0, 1, 2, 3, 4].map((l) => (
|
||||
@@ -56,6 +93,126 @@ const summary = labels.summary.replace('{n}', String(total));
|
||||
document.querySelectorAll('[data-anchor-latest]').forEach((el) => { el.scrollLeft = el.scrollWidth; });
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// P1 · 智能寻食蛇(Phase 5j 用户选定):只按「最近的有贡献格子」前进,子格插值滑行,
|
||||
// 循环播放(离开视口即停;减少动态效果下完全静态——不启动引擎)。
|
||||
// 坐标系:格子按列优先排(idx → 列=floor(idx/7)、行=idx%7);此前 x/y 写反导致
|
||||
// 蛇头走出下边界(2026-11-04 用户实测),修正后必须断言头坐标始终在网格包围盒内。
|
||||
(() => {
|
||||
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const PITCH = 13; // 10px 格 + 3px 缝
|
||||
const stage = document.querySelector('.hm-stage');
|
||||
if (!stage || reduce) return;
|
||||
const cells = [...stage.querySelectorAll<HTMLElement>('.hm-c')];
|
||||
if (!cells.length) return;
|
||||
const head = stage.querySelector<HTMLElement>('.hm-head');
|
||||
const trailEl = stage.querySelector<HTMLElement>('.hm-trail');
|
||||
const bars = [...document.querySelectorAll<HTMLElement>('.hm-bar-fill')];
|
||||
const posOf = (idx: number) => ({ x: Math.floor(idx / 7) * PITCH, y: (idx % 7) * PITCH });
|
||||
|
||||
/** 最近邻路径:只走有贡献的格子(真实数据 371 格仅 ~51 天活跃,
|
||||
全网扫描会让蛇 86% 行程空转——这是 P1 与 P2/P3 的本质区别) */
|
||||
const path = (() => {
|
||||
const pool = cells
|
||||
.map((c, i) => ({ i, n: Number(c.dataset.count ?? 0) }))
|
||||
.filter((c) => c.n > 0)
|
||||
.map((c) => c.i);
|
||||
if (!pool.length) return [];
|
||||
const order: number[] = [];
|
||||
let cur = pool[0];
|
||||
pool.splice(0, 1);
|
||||
order.push(cur);
|
||||
while (pool.length) {
|
||||
let best = 0;
|
||||
let bestD = Infinity;
|
||||
const a = posOf(cur);
|
||||
for (let k = 0; k < pool.length; k++) {
|
||||
const b = posOf(pool[k]);
|
||||
const d = Math.hypot(a.x - b.x, a.y - b.y) + Math.abs(a.y - b.y) * 0.3; // 偏好同列,少横跳
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = k;
|
||||
}
|
||||
}
|
||||
cur = pool[best];
|
||||
pool.splice(best, 1);
|
||||
order.push(cur);
|
||||
}
|
||||
return order;
|
||||
})();
|
||||
if (!path.length) return;
|
||||
|
||||
// 子格插值:把"格到格"展开成连续坐标(段越长走得越快,节奏才不拖)
|
||||
const samples: { x: number; y: number; idx: number }[] = [];
|
||||
for (let k = 0; k < path.length; k++) {
|
||||
const a = posOf(path[k]);
|
||||
const b = posOf(path[(k + 1) % path.length]); // 收尾回到起点,循环才无缝
|
||||
const dist = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const steps = Math.max(1, Math.round(dist / 6));
|
||||
for (let s = 0; s < steps; s++) {
|
||||
const t = s / steps;
|
||||
samples.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, idx: path[k] });
|
||||
}
|
||||
}
|
||||
|
||||
const eaten = new Set<number>();
|
||||
let i = 0;
|
||||
let timer = 0;
|
||||
const trail: { x: number; y: number }[] = [];
|
||||
const paintTrail = () => {
|
||||
if (!trailEl) return;
|
||||
trailEl.style.boxShadow = trail
|
||||
.map((pt, k) => `${pt.x + 5}px ${pt.y + 5}px 0 ${Math.max(1, 4 - k)}px color-mix(in oklab, var(--accent) ${42 - k * 5}%, transparent)`)
|
||||
.join(',');
|
||||
};
|
||||
const monthsReal = (() => {
|
||||
const set = new Set<string>();
|
||||
cells.forEach((c) => set.add((c.dataset.date ?? '').slice(0, 7)));
|
||||
return [...set].filter(Boolean).sort();
|
||||
})();
|
||||
|
||||
const tick = () => {
|
||||
const s = samples[i++];
|
||||
if (!s) return;
|
||||
// 蛇头坐标必须落在网格包围盒内(列 0..52、行 0..6)——越界即坐标轴 bug 的信号
|
||||
const maxX = Math.floor(cells.length / 7) * PITCH;
|
||||
const maxY = 6 * PITCH;
|
||||
if (s.x > maxX || s.y > maxY) return;
|
||||
if (head) head.style.transform = `translate(${s.x + 5}px, ${s.y + 5}px)`;
|
||||
trail.unshift({ x: s.x, y: s.y });
|
||||
if (trail.length > 8) trail.pop();
|
||||
paintTrail();
|
||||
const idx = s.idx;
|
||||
const count = Number(cells[idx]?.dataset.count ?? 0);
|
||||
if (count > 0 && !eaten.has(idx)) {
|
||||
eaten.add(idx);
|
||||
cells[idx].classList.add('hm-eaten');
|
||||
}
|
||||
// 空格不染色:P1 的路径只在有贡献的格子间滑行,空区由拖尾光带表达
|
||||
// 月柱联动:点亮到当前格所属月份
|
||||
const mk = (cells[idx]?.dataset.date ?? '').slice(0, 7);
|
||||
const mi = monthsReal.indexOf(mk);
|
||||
if (mi >= 0) bars.slice(0, mi + 1).forEach((b) => b.classList.add('on'));
|
||||
if (i >= samples.length) {
|
||||
i = 0; // 无缝循环:路径本身首尾相接
|
||||
}
|
||||
};
|
||||
|
||||
new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting && !timer) timer = window.setInterval(tick, 24);
|
||||
if (!e.isIntersecting && timer) {
|
||||
window.clearInterval(timer);
|
||||
timer = 0;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15 },
|
||||
).observe(stage);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.heatmap { margin-top: var(--space-6); }
|
||||
.hm-title {
|
||||
@@ -91,6 +248,45 @@ const summary = labels.summary.replace('{n}', String(total));
|
||||
.hm-l2 { background: var(--ink-3); }
|
||||
.hm-l3 { background: var(--ink-2); }
|
||||
.hm-l4 { background: var(--ink-1); }
|
||||
.hm-streak {
|
||||
display: flex; align-items: baseline; gap: var(--space-4); flex-wrap: wrap;
|
||||
margin: 0 0 var(--space-2); font-size: 12.5px; color: var(--ink-3);
|
||||
}
|
||||
.hm-streak-item strong {
|
||||
font-size: 20px; color: var(--ink-1); font-variant-numeric: tabular-nums;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.hm-streak-note { color: var(--ink-4); font-size: 11.5px; }
|
||||
/* P1 智能寻食蛇(选定方案):头与拖尾是浮动态,位置由引擎 transform 控制 */
|
||||
.hm-stage { position: relative; }
|
||||
.hm-head {
|
||||
position: absolute; left: 0; top: 6px; width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 28%, transparent), 0 0 10px color-mix(in oklab, var(--accent) 55%, transparent);
|
||||
pointer-events: none; z-index: 3;
|
||||
transition: transform 90ms linear;
|
||||
}
|
||||
.hm-trail { position: absolute; left: 0; top: 6px; width: 1px; height: 1px; pointer-events: none; z-index: 2; }
|
||||
.hm-eaten { background: var(--accent) !important; }
|
||||
.hm-bars {
|
||||
display: flex; align-items: flex-end; gap: 5px; list-style: none;
|
||||
padding: 0; margin: var(--space-2) 0 0; height: 56px;
|
||||
}
|
||||
.hm-bars li { display: flex; flex-direction: column; align-items: center; gap: 3px; flex: 1; height: 100%; justify-content: flex-end; }
|
||||
.hm-bar-btn { width: 100%; height: 100%; display: flex; align-items: flex-end; }
|
||||
.hm-bar-fill {
|
||||
display: block; width: 100%; height: 0;
|
||||
background: var(--ink-4); border-radius: 2px 2px 0 0;
|
||||
transition: height 420ms cubic-bezier(0.22, 0.8, 0.3, 1), background 160ms var(--ease);
|
||||
}
|
||||
.hm-bar-fill.on { height: var(--h, 0%); background: var(--accent); }
|
||||
.hm-bar-label { font-size: 10px; color: var(--ink-4); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hm-head, .hm-trail { display: none; }
|
||||
/* 引擎不启动时柱子也要立起来(否则少动用户看到的柱子全是 0 高度) */
|
||||
.hm-bar-fill { height: var(--h, 0%) !important; transition: none; }
|
||||
}
|
||||
|
||||
.hm-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { t } from '../../i18n/ui';
|
||||
import type { Locale } from '../../lib/locale';
|
||||
import { aboutIntroMarkdown, pageTypography, renderMarkdown } from '../../lib/site-content';
|
||||
import { getAboutPage } from '../../lib/page-blocks';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { pickBi } from '../../lib/site-content';
|
||||
import BlockRenderer from '../blocks/BlockRenderer.astro';
|
||||
|
||||
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
||||
@@ -12,10 +14,14 @@ interface Props {
|
||||
locale?: Locale;
|
||||
}
|
||||
const { locale = 'zh' } = Astro.props;
|
||||
// 三级回落(零回归):blocks 驱动 → intro markdown → 内置默认文案
|
||||
// 四级回落(Phase 5k 方案 A):doc 白纸直编 → blocks 块式 → intro markdown → 内置默认
|
||||
// doc 非空时整页就是这一段 markdown(用户拍板的编辑模型:想写什么写什么)
|
||||
const entry = (await getCollection('siteAbout'))[0];
|
||||
const { blocks, showIdCard } = await getAboutPage(locale);
|
||||
const docMd = pickBi(entry?.data.doc, { zh: '', en: '' }, locale);
|
||||
const docHtml = docMd.trim() ? await renderMarkdown(docMd) : '';
|
||||
const introHtml =
|
||||
blocks.length === 0 ? await renderMarkdown(await aboutIntroMarkdown(locale)) : '';
|
||||
!docHtml && blocks.length === 0 ? await renderMarkdown(await aboutIntroMarkdown(locale)) : '';
|
||||
const typo = await pageTypography('siteAbout', 'about');
|
||||
---
|
||||
|
||||
@@ -23,7 +29,16 @@ const typo = await pageTypography('siteAbout', 'about');
|
||||
<h1 class="page-title">{t('about_title', locale)}</h1>
|
||||
|
||||
<div class={typo.className || undefined} style={typo.style || undefined}>
|
||||
{blocks.length > 0 ? (
|
||||
{docHtml ? (
|
||||
<>
|
||||
{showIdCard && (
|
||||
<div class="about-id">
|
||||
<IdCard />
|
||||
</div>
|
||||
)}
|
||||
<article class="about-doc rich" set:html={docHtml} />
|
||||
</>
|
||||
) : blocks.length > 0 ? (
|
||||
<>
|
||||
{blocks.map((block) => <BlockRenderer block={block} />)}
|
||||
{showIdCard && <IdCard />}
|
||||
@@ -157,4 +172,19 @@ const typo = await pageTypography('siteAbout', 'about');
|
||||
color: var(--ink-2);
|
||||
max-width: var(--measure);
|
||||
}
|
||||
/* 方案 A 白纸直编:单栏、行距放宽,交给全局 .rich 排版体系 */
|
||||
.about-doc { max-width: var(--measure); }
|
||||
.about-doc :global(h2) {
|
||||
font-size: 15px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink-3);
|
||||
letter-spacing: 0.06em;
|
||||
margin: var(--space-6) 0 var(--space-3);
|
||||
}
|
||||
.about-doc :global(p) { line-height: 1.85; }
|
||||
.about-id { margin-bottom: var(--space-6); }
|
||||
.about-doc :global(li a) { transition: color var(--dur) var(--ease); }
|
||||
.about-doc :global(li a)::after { content: ' ↗'; opacity: 0; transition: opacity var(--dur) var(--ease); }
|
||||
.about-doc :global(li a:hover)::after { opacity: 0.7; }
|
||||
.about-doc :global(li a:hover) { color: var(--accent); }
|
||||
</style>
|
||||
@@ -13,21 +13,25 @@
|
||||
* 展开/收起是另一个形状的 chevron,避免两个箭头混读
|
||||
*/
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isVisiblePost } from '../../lib/post-visibility';
|
||||
import { Image } from 'astro:assets';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
import type { Locale } from '../../lib/locale';
|
||||
import { sortPosts } from '../../lib/post-order';
|
||||
import { pageNote } from '../../lib/site-content';
|
||||
|
||||
interface Props {
|
||||
locale?: Locale;
|
||||
}
|
||||
const { locale = 'zh' } = Astro.props;
|
||||
// 标题下说明:文字与样式都可后台改(白名单校验,非法值回落)
|
||||
const note = await pageNote('books', locale);
|
||||
|
||||
const books = sortPosts(
|
||||
await getCollection(
|
||||
'posts',
|
||||
({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden'),
|
||||
({ data }) => isVisiblePost(data) && data.category === 'reading',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -58,10 +62,6 @@ const pinnedCount = items.filter((i) => i.pinned).length;
|
||||
const L = {
|
||||
shelf: t('books_title', locale),
|
||||
count: locale === 'zh' ? `${items.length} 本` : `${items.length} books`,
|
||||
note:
|
||||
locale === 'zh'
|
||||
? '点一行展开封面与简介 · 置顶的书默认展开'
|
||||
: 'Tap a row for the cover and blurb · pinned books start open',
|
||||
read: locale === 'zh' ? '读这篇书评 →' : 'Read the review →',
|
||||
pinned: t('pinned_label', locale),
|
||||
expand: locale === 'zh' ? '展开封面与简介' : 'Expand cover and blurb',
|
||||
@@ -77,7 +77,8 @@ const L = {
|
||||
{L.shelf}<span class="count mono">{L.count}</span>
|
||||
{pinnedCount > 0 && <span class="pinned-count mono">{pinnedCount} {L.pinned}</span>}
|
||||
</h1>
|
||||
<p class="page-note">{L.note}</p>
|
||||
<!-- 标题下说明:由 content/site/pages.yaml 的 books.note/style 驱动 -->
|
||||
<p class:list={['page-note', note.className]} style={note.style} set:html={note.html} />
|
||||
|
||||
<ul class="books">
|
||||
{items.map((b) => (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 首页(zh/en 共用):名片区 / 文章列表 / 最新影像 / 热力图 / 项目墙 / 心跳
|
||||
// 两个 index.astro 只是薄壳(传 locale),区块逻辑与样式集中在这里,避免双份漂移。
|
||||
import { Image } from 'astro:assets';
|
||||
import { isVisiblePost } from '../../lib/post-visibility';
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import IdCard from '../IdCard.astro';
|
||||
@@ -33,7 +34,7 @@ const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) :
|
||||
|
||||
// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
|
||||
const posts = sortPosts(
|
||||
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
|
||||
await getCollection('posts', ({ data }) => isVisiblePost(data)),
|
||||
);
|
||||
|
||||
// en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
|
||||
|
||||
@@ -5,7 +5,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import MomentComments from '../MomentComments.astro';
|
||||
import ViewMeta from '../geek/ViewMeta.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
import { renderRich } from '../../lib/site-content';
|
||||
import { pageNote, renderRich } from '../../lib/site-content';
|
||||
import type { Locale } from '../../lib/locale';
|
||||
|
||||
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
||||
@@ -13,6 +13,8 @@ interface Props {
|
||||
locale?: Locale;
|
||||
}
|
||||
const { locale = 'zh' } = Astro.props;
|
||||
// 标题下说明:文字与样式都可后台改(白名单校验,非法值回落)
|
||||
const note = await pageNote('moments', locale);
|
||||
|
||||
const moments = (await getCollection('moments')).sort(
|
||||
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
||||
@@ -32,7 +34,8 @@ const iso = (d: Date) => d.toISOString();
|
||||
|
||||
<BaseLayout title={`${t('moments_title', locale)} · wait4x`}>
|
||||
<h1 class="page-title">{t('moments_title', locale)}</h1>
|
||||
<p class="page-note">{t('moments_note', locale)}</p>
|
||||
<!-- 标题下说明:文案/字号/颜色/字体均由后台「页面文案 → 页面说明」控制(site-content.pageNote) -->
|
||||
<p class:list={['page-note', note.className]} style={note.style} set:html={note.html} />
|
||||
<!-- 整页计数(Phase 5h T4):这是本页唯一的计数点(page 模式);
|
||||
每条瞬间卡片底部的徽标是 readonly,读者开评论不会虚增浏览数 -->
|
||||
<ViewMeta class="mom-page-meta" pageKey={locale === 'en' ? '/en/moments/' : '/moments/'} {locale} variant="chip" />
|
||||
|
||||
@@ -125,6 +125,12 @@ const postsSchema = z.object({
|
||||
poster: z.string().optional(),
|
||||
/** 置顶:列表排序优先(只影响顺序,不改 pubDate/URL/RSS 时间语义) */
|
||||
pinned: z.boolean().optional(),
|
||||
/**
|
||||
* 隐藏:不进首页/归档/书架/RSS/sitemap(页面本身仍生成,链接可分享)。
|
||||
* 与置顶可以同时为真——此时隐藏优先,不显示也不排序。
|
||||
* 历史写法 `tags: [hidden]` 仍然生效,判定收敛在 src/lib/post-visibility.ts。
|
||||
*/
|
||||
hidden: z.boolean().optional(),
|
||||
draft: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -273,6 +279,12 @@ export const collections = {
|
||||
// → src/lib/page-blocks.ts warn + skip,保证后台填错块型不炸构建
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
/**
|
||||
* 白纸直编(Phase 5k 用户选定方案 A):整页正文一个 markdown 字段,
|
||||
* 想写什么写什么。非空时整页只渲染它(IdCard 仍在顶部);
|
||||
* 为空时回落 blocks 块式通道 → intro → 内置默认(兼容链保持不断)。
|
||||
*/
|
||||
doc: bilingual.optional(),
|
||||
intro: bilingual.optional(),
|
||||
/** 页面级排版覆盖(层 2,T3 车道):正文默认字体类 / 强调色类 / 字号缩放 */
|
||||
typography: z
|
||||
|
||||
@@ -114,6 +114,50 @@ async function loadFallbackDays(): Promise<Map<string, number>> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连续贡献统计(Phase 5j T2):从同一份日历数据本地计算,不再构建期抓取 streak.svg。
|
||||
*
|
||||
* 为什么这么做(用户 2026-11-03 的问题:「连续贡献是否是实时更新的?」)——旧实现抓的是
|
||||
* 第三方 SVG 快照,构建不跑就不更新,且与热力图数据源重复。现在热力图与连续天数共用
|
||||
* 一份 GraphQL 日历,天然同源;界面标注「数据更新于 <构建日期>」,不宣称实时。
|
||||
*
|
||||
* 口径:「当前连续」以最后一个有贡献的日子为终点往回数——今天还没贡献不该把连续归零
|
||||
* (GitHub 同口径);「最长连续」按日历顺序数。
|
||||
*/
|
||||
export interface StreakStats {
|
||||
total: number;
|
||||
current: number;
|
||||
longest: number;
|
||||
/** 最后一个有贡献的日子(YYYY-MM-DD),用于界面标注截至日期 */
|
||||
lastActive: string;
|
||||
}
|
||||
|
||||
export function streakStats(days: HeatDay[]): StreakStats {
|
||||
const countOf = new Map(days.map((d) => [d.date, d.count]));
|
||||
const isoOf = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? '';
|
||||
|
||||
let current = 0;
|
||||
if (lastActive) {
|
||||
const start = new Date(`${lastActive}T00:00:00Z`);
|
||||
for (let i = 0; ; i++) {
|
||||
const d = new Date(start);
|
||||
d.setUTCDate(start.getUTCDate() - i);
|
||||
if ((countOf.get(isoOf(d)) ?? 0) > 0) current++;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
|
||||
let longest = 0;
|
||||
let run = 0;
|
||||
for (const d of days) {
|
||||
run = d.count > 0 ? run + 1 : 0;
|
||||
if (run > longest) longest = run;
|
||||
}
|
||||
|
||||
return { total: days.reduce((a, b) => a + b.count, 0), current, longest, lastActive };
|
||||
}
|
||||
|
||||
export async function loadHeatmap(): Promise<HeatmapYear> {
|
||||
const byDate = (await fetchGitHubDays()) ?? (await loadFallbackDays());
|
||||
// 53 周网格装配:窗口内逐日取数,未来日 0(渲染空档)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 文章可见性契约(单一事实源)。
|
||||
*
|
||||
* 为什么需要它:后台「隐藏」开关长期是个假开关——它写的是 `hiddenTag: true`,
|
||||
* 而全站过滤只认 `tags` 里有没有 `hidden`,于是「隐藏 + 置顶」的文章照样出现在
|
||||
* 首页/归档/书架,还因为置顶排在第一位(2026-11-04 用户实测报障)。
|
||||
*
|
||||
* 现在两条通道都认:
|
||||
* - `hidden: true`(后台开关写入的真字段)
|
||||
* - `tags` 含 `hidden`(历史写法,保留兼容)
|
||||
* - `draft: true`(草稿,构建期即不生成页面的语义保持由各处 getCollection 过滤承担)
|
||||
*/
|
||||
type PostVisibility = {
|
||||
draft?: boolean;
|
||||
hidden?: boolean;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
/** 文章是否应在列表/RSS/站点地图等处出现 */
|
||||
export function isVisiblePost(data: PostVisibility): boolean {
|
||||
return !data.draft && !data.hidden && !(data.tags ?? []).includes('hidden');
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export const SITE_DEFAULTS = {
|
||||
const pick = (value: string | undefined, fallback: string): string =>
|
||||
value && value.trim() ? value : fallback;
|
||||
|
||||
const pickBi = (value: { zh?: string; en?: string } | undefined, fallback: Bi, locale: Locale): string =>
|
||||
export const pickBi = (value: { zh?: string; en?: string } | undefined, fallback: Bi, locale: Locale): string =>
|
||||
pick(value?.[locale], fallback[locale]);
|
||||
|
||||
/** 主页 tagline(zh/en) */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { isVisiblePost } from '../../lib/post-visibility';
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import LinkCardStyles from '../../components/geek/LinkCardStyles.astro';
|
||||
import PhotoZoom from '../../components/geek/PhotoZoom.astro';
|
||||
@@ -15,7 +16,7 @@ export async function getStaticPaths() {
|
||||
const all = (await getCollection('posts', ({ data }) => !data.draft)).sort(
|
||||
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
||||
);
|
||||
const nav = all.filter(({ data }) => !data.tags.includes('hidden'));
|
||||
const nav = all.filter(({ data }) => isVisiblePost(data));
|
||||
return all.map((entry) => {
|
||||
const i = nav.findIndex((e) => e.id === entry.id);
|
||||
return {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
---
|
||||
// 导航「文章」落点:全量归档列表(年份/轨道分节逻辑与首页共用 PostList)
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isVisiblePost } from '../../lib/post-visibility';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import PostList from '../../components/PostList.astro';
|
||||
import { sortPosts } from '../../lib/post-order';
|
||||
|
||||
// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
|
||||
const posts = sortPosts(
|
||||
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
|
||||
await getCollection('posts', ({ data }) => isVisiblePost(data)),
|
||||
);
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
// /en/archives/{slug}:英文版文章页——postsEn 优先;缺译回退 zh 正文 + 醒目标注
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { isVisiblePost } from '../../../lib/post-visibility';
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import LinkCardStyles from '../../../components/geek/LinkCardStyles.astro';
|
||||
import PhotoZoom from '../../../components/geek/PhotoZoom.astro';
|
||||
@@ -15,7 +16,7 @@ export async function getStaticPaths() {
|
||||
const all = (await getCollection('posts', ({ data }) => !data.draft)).sort(
|
||||
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
||||
);
|
||||
const nav = all.filter(({ data }) => !data.tags.includes('hidden'));
|
||||
const nav = all.filter(({ data }) => isVisiblePost(data));
|
||||
const en = new Map((await getCollection('postsEn')).map((e) => [e.id, e]));
|
||||
return all.map((entry) => {
|
||||
const i = nav.findIndex((e) => e.id === entry.id);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
// /en/archives/ 列表页(补缺:此前 404)
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isVisiblePost } from '../../../lib/post-visibility';
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||
import PostList from '../../../components/PostList.astro';
|
||||
import { sortPosts } from '../../../lib/post-order';
|
||||
|
||||
const posts = sortPosts(
|
||||
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
|
||||
await getCollection('posts', ({ data }) => isVisiblePost(data)),
|
||||
);
|
||||
const enTitle = Object.fromEntries(
|
||||
(await getCollection('postsEn')).map((e) => [e.id, e.data.title]),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import rss from '@astrojs/rss';
|
||||
import { isVisiblePost } from '../../lib/post-visibility';
|
||||
import { getCollection } from 'astro:content';
|
||||
import type { APIContext } from 'astro';
|
||||
|
||||
// 英文 RSS:postsEn 全量;标题缺译时回退 zh 标题(与 /en/ 页同策略)
|
||||
export async function GET(context: APIContext) {
|
||||
const en = new Map((await getCollection('postsEn')).map((e) => [e.id, e]));
|
||||
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
|
||||
const posts = (await getCollection('posts', ({ data }) => isVisiblePost(data))).sort(
|
||||
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
|
||||
);
|
||||
return rss({
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
---
|
||||
/**
|
||||
* 热力图动效方案预览(Phase 5j):一页并陈 5 个方案,供用户点选。
|
||||
*
|
||||
* 为什么单开预览路由:热力图的"动"是纯审美 + 体感决策,改错要回滚一轮;
|
||||
* 一页五案 + 页顶锚点,用户在任何设备上点开就能比。选定后我把选中方案
|
||||
* 落进 Heatmap.astro,然后删掉本路由。
|
||||
*
|
||||
* 设计红线(五个方案都守):灰阶骨架、只允许 accent 做强调、prefers-reduced-motion
|
||||
* 下一律退化为静态、零第三方依赖、不引入持续 rAF 循环(省电)。
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import HeatGrid from '../components/geek/HeatGrid.astro';
|
||||
import { loadHeatmap, levelOf, type HeatDay } from '../lib/heatmap';
|
||||
|
||||
const { weeks, total } = await loadHeatmap();
|
||||
const days: HeatDay[] = weeks.flat();
|
||||
const buildTime = new Date().toISOString().slice(0, 10);
|
||||
|
||||
/* ── 连续贡献(本地计算,替代此前构建期抓取的 streak.svg 快照)── */
|
||||
const countOf = new Map(days.map((d) => [d.date, d.count]));
|
||||
const today = new Date();
|
||||
const isoOf = (d: Date) => d.toISOString().slice(0, 10);
|
||||
// 「当前连续」按最后一个有贡献的日子往回数:今天尚未贡献不该让连续归零(GitHub 同口径),
|
||||
// 若严格按"今天必须是活跃日"计算,早上访问本站会看到 0,与直觉冲突。
|
||||
let current = 0;
|
||||
const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? '';
|
||||
if (lastActive) {
|
||||
const start = new Date(`${lastActive}T00:00:00Z`);
|
||||
for (let i = 0; ; i++) {
|
||||
const d = new Date(start);
|
||||
d.setUTCDate(start.getUTCDate() - i);
|
||||
const c = countOf.get(isoOf(d)) ?? 0;
|
||||
if (c > 0) current++;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
let longest = 0;
|
||||
let run = 0;
|
||||
for (const d of days) {
|
||||
run = d.count > 0 ? run + 1 : 0;
|
||||
if (run > longest) longest = run;
|
||||
}
|
||||
const bestDay = days.reduce((a, b) => (b.count > a.count ? b : a), days[0]);
|
||||
const activeDays = days.filter((d) => d.count > 0).length;
|
||||
|
||||
const stats = { total, current, longest, best: bestDay?.count ?? 0, bestDate: bestDay?.date ?? '', activeDays };
|
||||
---
|
||||
|
||||
<BaseLayout title="热力图动效方案 · wait4x" description="五个贡献热力图动效方案的并陈预览">
|
||||
<h1 class="pv-title">热力图:五个动效方案</h1>
|
||||
<p class="pv-note">
|
||||
同一份真实数据(近一年 <strong>{total}</strong> 次贡献 · 活跃 {stats.activeDays} 天 · 当前连续
|
||||
<strong>{stats.current}</strong> 天 · 最长 {stats.longest} 天),数据更新于 {buildTime}(构建期快照)。
|
||||
<br />
|
||||
<strong>看完告诉我选哪个</strong>(回「A」「B」「C」「D」「E」,也可以说「C 但速度慢一点」)。
|
||||
五个方案都守灰阶红线、都尊重「减少动态效果」系统设置。
|
||||
</p>
|
||||
|
||||
<nav class="pv-switch" aria-label="方案跳转">
|
||||
<a href="#ha" class="pv-chip">A · 入场波纹</a>
|
||||
<a href="#hb" class="pv-chip">B · 光标光晕</a>
|
||||
<a href="#hc" class="pv-chip">C · 年度回放</a>
|
||||
<a href="#hd" class="pv-chip">D · 连续电流</a>
|
||||
<a href="#he" class="pv-chip">E · 悬停周详情</a>
|
||||
</nav>
|
||||
|
||||
<!-- ═══════════ A · 入场波纹 ═══════════ -->
|
||||
<section id="ha" class="pv-sec">
|
||||
<header class="pv-head">
|
||||
<span class="pv-tag mono">A</span>
|
||||
<h2>入场波纹</h2>
|
||||
<p class="pv-pros">滚动到热力图时,格子像水面一样按「周 + 星期」斜向漫过、逐个亮起(约 0.9s)。
|
||||
<b>优点</b>有生命感、不打扰、只播一次;<b>代价</b>纯装饰,不提供额外信息。</p>
|
||||
</header>
|
||||
<div class="hm-wrap" data-cascade>
|
||||
<HeatGrid weeks={weeks} variant="cascade" step={7} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ B · 光标光晕 ═══════════ -->
|
||||
<section id="hb" class="pv-sec">
|
||||
<header class="pv-head">
|
||||
<span class="pv-tag mono">B</span>
|
||||
<h2>光标光晕</h2>
|
||||
<p class="pv-pros">鼠标在网格上移动时,一小片光晕跟着走,被照到的格子更亮,右下角浮出「日期 · 贡献数」。
|
||||
<b>优点</b>能精确读数、零轮播;<b>代价</b>触屏设备没有指针,需退化为点按读数。</p>
|
||||
</header>
|
||||
<div class="hm-wrap hm-spot" data-spot>
|
||||
<HeatGrid weeks={weeks} variant="spotlight" />
|
||||
<span class="hm-glow" aria-hidden="true"></span>
|
||||
</div>
|
||||
<p class="hm-read mono" data-spot-read>把指针移到格子上 →</p>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ C · 年度回放 ═══════════ -->
|
||||
<section id="hc" class="pv-sec">
|
||||
<header class="pv-head">
|
||||
<span class="pv-tag mono">C</span>
|
||||
<h2>年度回放</h2>
|
||||
<p class="pv-pros">拖动滑块从头"重放"这一年,累计贡献数跟着涨;也可点播放自动扫过。
|
||||
<b>优点</b>最有叙事感、能直观感受节奏;<b>代价</b>多一层交互,需要一点耐心。</p>
|
||||
</header>
|
||||
<div class="hm-wrap" data-scrub>
|
||||
<HeatGrid weeks={weeks} variant="scrub" />
|
||||
</div>
|
||||
<div class="hm-ctrl">
|
||||
<button class="hm-btn" type="button" data-scrub-play>▶ 播放</button>
|
||||
<input class="hm-range" type="range" min="0" max={weeks.length} value={weeks.length} data-scrub-range aria-label="回放进度" />
|
||||
<span class="hm-read mono" data-scrub-read>累计 {total} 次 · 第 {weeks.length} 周</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ D · 连续电流 ═══════════ -->
|
||||
<section id="hd" class="pv-sec">
|
||||
<header class="pv-head">
|
||||
<span class="pv-tag mono">D</span>
|
||||
<h2>连续电流</h2>
|
||||
<p class="pv-pros">当前连续的那几天会有一条光带从最早一格流到最新一格,像电流经过;旁边显示连续天数。
|
||||
<b>优点</b>把"坚持"这件事讲成故事;<b>代价</b>只覆盖最近一小段,全年的信息仍靠静态格子。</p>
|
||||
</header>
|
||||
<div class="hm-streak">
|
||||
<div class="hm-streak-num">
|
||||
<strong>{stats.current}</strong>
|
||||
<span class="mono">连续天数{lastActive ? `(截至 ${lastActive})` : ''}</span>
|
||||
<span class="hm-streak-sub mono">最长 {stats.longest} 天 · 单日最高 {stats.best} 次</span>
|
||||
</div>
|
||||
<div class="hm-wrap" data-streak>
|
||||
<HeatGrid weeks={weeks} variant="streak" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ E · 悬停周详情 ═══════════ -->
|
||||
<section id="he" class="pv-sec">
|
||||
<header class="pv-head">
|
||||
<span class="pv-tag mono">E</span>
|
||||
<h2>悬停周详情</h2>
|
||||
<p class="pv-pros">指针落在任一格上,整列(那一周)轻轻抬起,右侧给出「这一周合计 / 最好的一天」。
|
||||
<b>优点</b>信息密度最高、动效克制;<b>代价</b>仍是鼠标优先,触屏靠点按。</p>
|
||||
</header>
|
||||
<div class="hm-inspector">
|
||||
<div class="hm-wrap" data-insp>
|
||||
<HeatGrid weeks={weeks} variant="inspector" />
|
||||
</div>
|
||||
<aside class="hm-week" data-insp-panel>
|
||||
<p class="mono">把指针移到任意一周</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="pv-foot mono">数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})。切到"减少动态效果"时五个方案全部退化为静态网格。</p>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
// ── A · 入场波纹:进视口一次性播放(不进视口不播,省电)──
|
||||
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const cascades = document.querySelectorAll<HTMLElement>('[data-cascade]');
|
||||
if (!reduce && cascades.length) {
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
e.target.classList.add('play');
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '0px 0px -10% 0px' },
|
||||
);
|
||||
cascades.forEach((el) => io.observe(el));
|
||||
}
|
||||
|
||||
// ── B · 光标光晕:只更新两个 CSS 变量,不跑 rAF 循环 ──
|
||||
for (const wrap of document.querySelectorAll<HTMLElement>('[data-spot]')) {
|
||||
const glow = wrap.querySelector<HTMLElement>('.hm-glow');
|
||||
const read = document.querySelector<HTMLElement>('[data-spot-read]');
|
||||
const move = (x: number, y: number) => {
|
||||
const r = wrap.getBoundingClientRect();
|
||||
if (!glow) return;
|
||||
glow.style.setProperty('--x', `${x - r.left}px`);
|
||||
glow.style.setProperty('--y', `${y - r.top}px`);
|
||||
glow.style.opacity = '1';
|
||||
};
|
||||
wrap.addEventListener('pointermove', (e) => move(e.clientX, e.clientY));
|
||||
wrap.addEventListener('pointerleave', () => {
|
||||
if (glow) glow.style.opacity = '0';
|
||||
});
|
||||
// 读数:事件委托到格子(点按在触屏也work)
|
||||
const show = (cell: HTMLElement) => {
|
||||
if (!read) return;
|
||||
read.textContent = `${cell.dataset.date} · ${cell.dataset.count} 次`;
|
||||
};
|
||||
wrap.addEventListener('pointerover', (e) => {
|
||||
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
|
||||
if (t) show(t);
|
||||
});
|
||||
wrap.addEventListener('click', (e) => {
|
||||
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
|
||||
if (t) show(t);
|
||||
});
|
||||
}
|
||||
|
||||
// ── C · 年度回放:滑块控制显示到第几周 + 累计数 ──
|
||||
const counts = Array.from(document.querySelectorAll<HTMLElement>('[data-scrub] .hg-c')).reduce<
|
||||
Record<number, number>
|
||||
>((acc, cell) => {
|
||||
const c = Number(cell.dataset.c ?? 0);
|
||||
acc[c] = (acc[c] ?? 0) + Number(cell.dataset.count ?? 0);
|
||||
return acc;
|
||||
}, {});
|
||||
const weeksTotal = Object.keys(counts).length;
|
||||
const cum = (upto: number) => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < upto; i++) sum += counts[i] ?? 0;
|
||||
return sum;
|
||||
};
|
||||
for (const wrap of document.querySelectorAll<HTMLElement>('[data-scrub]')) {
|
||||
const range = document.querySelector<HTMLInputElement>('[data-scrub-range]');
|
||||
const read = document.querySelector<HTMLElement>('[data-scrub-read]');
|
||||
const play = document.querySelector<HTMLButtonElement>('[data-scrub-play]');
|
||||
const apply = (v: number) => {
|
||||
for (const cell of wrap.querySelectorAll<HTMLElement>('.hg-c')) {
|
||||
const visible = Number(cell.dataset.c ?? 0) < v;
|
||||
cell.style.opacity = visible ? '' : '0.06';
|
||||
}
|
||||
if (read) read.textContent = `累计 ${cum(v)} 次 · 第 ${v} 周`;
|
||||
};
|
||||
range?.addEventListener('input', () => apply(Number(range.value)));
|
||||
let timer = 0;
|
||||
play?.addEventListener('click', () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = 0;
|
||||
play.textContent = '▶ 播放';
|
||||
return;
|
||||
}
|
||||
play.textContent = '⏸ 暂停';
|
||||
if (!range) return;
|
||||
range.value = '0';
|
||||
apply(0);
|
||||
timer = window.setInterval(() => {
|
||||
if (!range) return;
|
||||
const next = Math.min(weeksTotal, Number(range.value) + 2);
|
||||
range.value = String(next);
|
||||
apply(next);
|
||||
if (next >= weeksTotal) {
|
||||
clearInterval(timer);
|
||||
timer = 0;
|
||||
play.textContent = '▶ 播放';
|
||||
}
|
||||
}, 60);
|
||||
});
|
||||
apply(Number(range?.value ?? weeksTotal));
|
||||
}
|
||||
|
||||
// ── D · 连续电流:最近连续段逐格流光(延迟按位置递增)──
|
||||
for (const wrap of document.querySelectorAll<HTMLElement>('[data-streak]')) {
|
||||
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')].filter((c) => Number(c.dataset.count ?? 0) > 0);
|
||||
const tail = cells.slice(-12);
|
||||
tail.forEach((c, i) => {
|
||||
c.style.setProperty('--flow', `${i * 90}ms`);
|
||||
c.classList.add('hg-flow');
|
||||
});
|
||||
wrap.classList.toggle('no-flow', reduce);
|
||||
}
|
||||
|
||||
// ── E · 悬停周详情:抬列 + 面板读数 ──
|
||||
for (const wrap of document.querySelectorAll<HTMLElement>('[data-insp]')) {
|
||||
const panel = document.querySelector<HTMLElement>('[data-insp-panel]');
|
||||
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')];
|
||||
const byWeek = new Map<number, HTMLElement[]>();
|
||||
for (const c of cells) {
|
||||
const w = Number(c.dataset.c ?? 0);
|
||||
(byWeek.get(w) ?? byWeek.set(w, []).get(w)!).push(c);
|
||||
}
|
||||
const showWeek = (w: number) => {
|
||||
wrap.querySelectorAll('.hg-c.lift').forEach((c) => c.classList.remove('lift'));
|
||||
const group = byWeek.get(w);
|
||||
if (!group) return;
|
||||
group.forEach((c) => c.classList.add('lift'));
|
||||
const totalWeek = group.reduce((s, c) => s + Number(c.dataset.count ?? 0), 0);
|
||||
const best = group.reduce((a, b) => (Number(b.dataset.count ?? 0) > Number(a.dataset.count ?? 0) ? b : a));
|
||||
const first = group[0]?.dataset.date ?? '';
|
||||
const last = group[group.length - 1]?.dataset.date ?? '';
|
||||
if (panel) {
|
||||
panel.innerHTML =
|
||||
`<p class="mono">${first} → ${last}</p>` +
|
||||
`<p class="hm-week-total"><strong>${totalWeek}</strong> 次</p>` +
|
||||
`<p class="mono hm-week-best">最好的一天 ${best.dataset.date} · ${best.dataset.count} 次</p>`;
|
||||
}
|
||||
};
|
||||
wrap.addEventListener('pointerover', (e) => {
|
||||
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
|
||||
if (t) showWeek(Number(t.dataset.c ?? 0));
|
||||
});
|
||||
wrap.addEventListener('click', (e) => {
|
||||
const t = (e.target as HTMLElement).closest<HTMLElement>('.hg-c');
|
||||
if (t) showWeek(Number(t.dataset.c ?? 0));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.pv-title { font-size: var(--text-h1); color: var(--ink-1); margin-top: var(--space-5); }
|
||||
.pv-note { font-size: 14px; color: var(--ink-3); line-height: 1.75; max-width: var(--measure); margin-block: var(--space-2) var(--space-4); }
|
||||
.pv-switch {
|
||||
position: sticky; top: 0; z-index: 3; display: flex; flex-wrap: wrap; gap: var(--space-2);
|
||||
padding: var(--space-2) 0; background: color-mix(in oklab, var(--bg) 92%, transparent);
|
||||
backdrop-filter: blur(6px); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.pv-chip {
|
||||
font-family: var(--font-mono); font-size: 12.5px; color: var(--ink-2);
|
||||
border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px;
|
||||
transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease);
|
||||
}
|
||||
.pv-chip:hover { border-color: var(--accent); color: var(--ink-1); }
|
||||
.pv-sec { margin-top: var(--space-6); scroll-margin-top: 56px; }
|
||||
.pv-head { border-top: 1px solid var(--line); padding-top: var(--space-3); }
|
||||
.pv-tag { display: inline-block; font-size: 11px; color: var(--ink-4); border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; }
|
||||
.pv-head h2 { font-size: 20px; color: var(--ink-1); margin: var(--space-1) 0; }
|
||||
.pv-pros { font-size: 13px; color: var(--ink-3); line-height: 1.7; max-width: var(--measure); margin: 0 0 var(--space-3); }
|
||||
.pv-pros b { color: var(--ink-2); }
|
||||
.pv-foot { font-size: 12px; color: var(--ink-4); margin-top: var(--space-6); }
|
||||
|
||||
.hm-wrap { overflow-x: auto; padding: 4px 0; }
|
||||
|
||||
/* ── A:入场波纹 ── */
|
||||
/* 注意:网格是子组件渲染的,页面级 scoped 样式够不到它内部元素,必须 :global() */
|
||||
[data-cascade] :global(.hg-c) { opacity: 0.18; }
|
||||
[data-cascade].play :global(.hg-c) {
|
||||
animation: hmRise 420ms cubic-bezier(0.22, 0.8, 0.3, 1) var(--d) forwards;
|
||||
}
|
||||
@keyframes hmRise {
|
||||
from { opacity: 0.18; transform: translateY(3px) scale(0.86); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* ── B:光标光晕 ── */
|
||||
.hm-spot { position: relative; isolation: isolate; }
|
||||
.hm-glow {
|
||||
position: absolute; inset: 0; pointer-events: none; opacity: 0;
|
||||
transition: opacity 200ms var(--ease);
|
||||
background: radial-gradient(90px 90px at var(--x, 50%) var(--y, 50%),
|
||||
color-mix(in oklab, var(--accent) 26%, transparent), transparent 70%);
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
.hm-read { font-size: 12px; color: var(--ink-4); margin-top: var(--space-2); }
|
||||
|
||||
/* ── C:年度回放 ── */
|
||||
.hm-ctrl { display: flex; align-items: center; gap: var(--space-3); margin-top: var(--space-2); flex-wrap: wrap; }
|
||||
.hm-btn {
|
||||
font-family: var(--font-mono); font-size: 12px; color: var(--ink-2);
|
||||
background: var(--bg-soft); border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; cursor: pointer;
|
||||
}
|
||||
.hm-btn:hover { border-color: var(--accent); }
|
||||
.hm-range { flex: 1; min-width: 160px; accent-color: var(--accent); }
|
||||
|
||||
/* ── D:连续电流 ── */
|
||||
.hm-streak { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.hm-streak-num { display: flex; align-items: baseline; gap: var(--space-2); flex-wrap: wrap; }
|
||||
.hm-streak-num strong { font-size: 34px; color: var(--accent); line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.hm-streak-num span { font-size: 12.5px; color: var(--ink-3); }
|
||||
.hm-streak-sub { color: var(--ink-4) !important; }
|
||||
:global(.hg-flow) { position: relative; }
|
||||
:global(.hg-flow)::after {
|
||||
content: ''; position: absolute; inset: 0; border-radius: 2px;
|
||||
background: var(--accent); opacity: 0;
|
||||
animation: hmFlow 1.6s ease-in-out var(--flow, 0ms) infinite;
|
||||
}
|
||||
@keyframes hmFlow {
|
||||
0%, 100% { opacity: 0; }
|
||||
45% { opacity: 0.55; }
|
||||
}
|
||||
.no-flow :global(.hg-flow)::after { animation: none; }
|
||||
|
||||
/* ── E:悬停周详情 ── */
|
||||
.hm-inspector { display: flex; gap: var(--space-4); align-items: flex-start; flex-wrap: wrap; }
|
||||
.hm-inspector .hm-wrap { flex: 1 1 560px; min-width: 0; }
|
||||
:global(.hg-c.lift) {
|
||||
transform: translateY(-2px);
|
||||
outline: 1px solid color-mix(in oklab, var(--accent) 45%, transparent);
|
||||
transition: transform 140ms var(--ease);
|
||||
}
|
||||
.hm-week {
|
||||
flex: 0 0 190px; border: 1px solid var(--line); border-radius: 8px;
|
||||
padding: var(--space-3); background: var(--bg-soft); font-size: 12.5px; color: var(--ink-3);
|
||||
}
|
||||
.hm-week p { margin: 0 0 4px; }
|
||||
.hm-week-total strong { font-size: 22px; color: var(--ink-1); font-variant-numeric: tabular-nums; }
|
||||
.hm-week-best { color: var(--ink-4); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-cascade].play :global(.hg-c) { animation: none; opacity: 1; }
|
||||
:global(.hg-flow)::after { animation: none; }
|
||||
.hm-glow { display: none; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.hm-week { flex-basis: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,11 @@
|
||||
import rss from '@astrojs/rss';
|
||||
import { isVisiblePost } from '../lib/post-visibility';
|
||||
import { getCollection } from 'astro:content';
|
||||
import type { APIContext } from 'astro';
|
||||
|
||||
// RSS:posts 集合全量(draft 排除);Phase 3 起双语各出一路(/rss.xml 与 /en/rss.xml)
|
||||
export async function GET(context: APIContext) {
|
||||
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')))
|
||||
const posts = (await getCollection('posts', ({ data }) => isVisiblePost(data)))
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
return rss({
|
||||
title: 'wait4x',
|
||||
|
||||
+11
-8
@@ -89,16 +89,17 @@ await page.goto(BASE + '/opensource/', { waitUntil: 'networkidle' });
|
||||
const osState = await page.evaluate(() => {
|
||||
const html = document.documentElement.outerHTML;
|
||||
return {
|
||||
thirdParty: ['img.shields.io', 'readme-typing-svg', 'github-readme-stats', 'streak-stats', 'komarev']
|
||||
// 5j T2:streak/贡献蛇退役(信息并入热力图区),第三方图仍必须为零
|
||||
thirdParty: ['img.shields.io', 'readme-typing-svg', 'github-readme-stats', 'streak-stats', 'komarev', '/github-assets/']
|
||||
.some((u) => html.includes(u)),
|
||||
typing: !!document.querySelector('[class*=typing]'),
|
||||
board: document.querySelectorAll('.pf-card').length,
|
||||
localAssets: [...document.querySelectorAll('img')].filter((i) => i.src.includes('/github-assets/')).length,
|
||||
streakLine: /天连续|day streak/.test(html),
|
||||
updated: html.includes('数据更新于'),
|
||||
};
|
||||
});
|
||||
ok('5i·开源页零第三方外链图', !osState.thirdParty);
|
||||
ok('5i·开源页零第三方外链图(含退役资产)', !osState.thirdParty);
|
||||
ok('5i·打字机动效存在', osState.typing);
|
||||
ok('5i·数据板渲染', osState.board > 0 && osState.localAssets > 0, `cards=${osState.board} assets=${osState.localAssets}`);
|
||||
ok('5j·连续贡献同源计算 + 更新时间标注', osState.streakLine && osState.updated);
|
||||
|
||||
// 1) 明暗切换:按钮 + 状态持久 + t 键
|
||||
const before = await page.getAttribute('html', 'data-theme');
|
||||
@@ -193,11 +194,13 @@ const books = await page.evaluate(() => {
|
||||
return { total: all.length, open: all.filter((d) => d.hasAttribute('open')).length };
|
||||
});
|
||||
ok('书架 抽书行渲染', books.total >= 1, JSON.stringify(books));
|
||||
// 置顶书默认展开(C 设计):点击已展开的行是"收起",未展开的行是"抽开"——
|
||||
// 断言口径 = 行数不变、开合状态恰好翻转一次(2026-11-04 修:曾误点已展开的置顶行)
|
||||
const openBefore = await page.evaluate(() => document.querySelectorAll('.book[open]').length);
|
||||
await page.locator('.book').first().locator('summary').click();
|
||||
await page.waitForTimeout(250);
|
||||
ok('书架 点击可抽开', (await page.locator('.book[open] .body').count()) === 1);
|
||||
await page.locator('.book').first().locator('summary').click();
|
||||
await page.waitForTimeout(200);
|
||||
const openAfter = await page.evaluate(() => document.querySelectorAll('.book[open]').length);
|
||||
ok('书架 抽书开合翻转', Math.abs(openAfter - openBefore) === 1, `${openBefore}→${openAfter}`);
|
||||
|
||||
// 4) 命令面板 + 搜索
|
||||
await page.keyboard.press('Control+k');
|
||||
|
||||
Reference in new issue
Block a user