Merge branch 'spec/2026-11-04_blog-5k-about-designs'
Phase 5k 交付:关于页形态选型闭环——五案预览(A 白纸直编 / B 名片双栏 / C 杂志分节 / D 时间线 / E 卡片流)→ 用户选定 A → 落地「白纸直编」单字段 (四级回落 doc → blocks → intro → 内置默认,blocks 降为 legacy 通道)+ 后台 markdown 字段置顶 + 预览路由退役。 Spec: 2026-11-04_blog-5k-about-designs # Conflicts: # scripts/build-perf/content-lint.mjs
This commit is contained in:
commit
0ef0fcd394
13 files changed
+287
-454
No files matched your search
@@ -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
|
||||||
+56
-1
@@ -1,11 +1,66 @@
|
|||||||
# 关于页(后台「页面文案 → 关于页」可改)
|
# 关于页(后台「页面文案 → 关于页」可改)
|
||||||
# ── 一级:blocks 块式正文(按顺序渲染,可拖拽排序;某块 enabled: false 即隐藏)
|
# ── legacy:blocks 块式正文(doc 非空时不渲染;保留作结构化备选通道)
|
||||||
# ── 二级:intro 多段 markdown(仅在 blocks 为空时使用)
|
# ── 二级:intro 多段 markdown(仅在 blocks 为空时使用)
|
||||||
# ── 三级:内置默认文案(两者都空时兜底,构建永不炸)
|
# ── 三级:内置默认文案(两者都空时兜底,构建永不炸)
|
||||||
layout:
|
layout:
|
||||||
# 名片区(头像/名字/身份语/社交)开关:头像等数据事实源在「主页设置」
|
# 名片区(头像/名字/身份语/社交)开关:头像等数据事实源在「主页设置」
|
||||||
showIdCard: true
|
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:
|
blocks:
|
||||||
- type: prose
|
- type: prose
|
||||||
width: narrow
|
width: narrow
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ caption: 落日把海与城一齐烫成金箔,**这一帧等了二十分钟**
|
|||||||
2. 图片字段 → 资产库上传 jpg(与 sidecar 同目录落位,零重压缩)
|
2. 图片字段 → 资产库上传 jpg(与 sidecar 同目录落位,零重压缩)
|
||||||
3. Save → 自动 commit + 构建 → 画廊出现(EXIF 构建期自动提取,exif 字段留空即可)
|
3. Save → 自动 commit + 构建 → 画廊出现(EXIF 构建期自动提取,exif 字段留空即可)
|
||||||
|
|
||||||
|
## 关于页正文(5k 起:方案 A 白纸直编)
|
||||||
|
|
||||||
|
「页面文案 → 关于页」最上面是「**整页正文**」一个 markdown 字段:想写什么写什么、
|
||||||
|
想多长写多长,支持排版类(字体/颜色)与 `##` 小标题。保存后整页就渲染这一段
|
||||||
|
(身份名片仍在顶部)。
|
||||||
|
|
||||||
|
下面的「块式结构」是 legacy 通道:只有当「整页正文」清空时才会显示,
|
||||||
|
老内容已迁移进正文,平时不用管它。四级回落:整页正文 → 块式结构 → intro → 内置默认。
|
||||||
|
|
||||||
## 块编辑:关于页的块式页面构建器
|
## 块编辑:关于页的块式页面构建器
|
||||||
|
|
||||||
关于页(`content/site/about.yaml`)的正文由 **blocks 列表**构成,后台里是一串可拖拽排序的卡片。每个块都有「显示」开关;关掉只是不渲染,块本身留在数据里。
|
关于页(`content/site/about.yaml`)的正文由 **blocks 列表**构成,后台里是一串可拖拽排序的卡片。每个块都有「显示」开关;关掉只是不渲染,块本身留在数据里。
|
||||||
|
|||||||
@@ -332,7 +332,15 @@ collections:
|
|||||||
media_folder: content/site/assets
|
media_folder: content/site/assets
|
||||||
public_folder: ./assets
|
public_folder: ./assets
|
||||||
fields:
|
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
|
name: blocks
|
||||||
widget: list
|
widget: list
|
||||||
typeKey: type
|
typeKey: type
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ if (MODE === 'skip') {
|
|||||||
const SKIP_PREFIX = ['/admin/', '/photos-originals/']; // admin=CMS 路由;photos-originals=部署期 rsync 叠加层(不在 dist,另有部署链保障)
|
const SKIP_PREFIX = ['/admin/', '/photos-originals/']; // admin=CMS 路由;photos-originals=部署期 rsync 叠加层(不在 dist,另有部署链保障)
|
||||||
const SKIP_FILE = new Set(['/404.html']); // Astro 404 页不参与配对
|
const SKIP_FILE = new Set(['/404.html']); // Astro 404 页不参与配对
|
||||||
|
|
||||||
// 一次性预览路由(无 en 对应页):完全不参与检查。用户选定方案后随路由一起删除。
|
|
||||||
const SKIP_ALL = new Set(['/heatmap-preview/index.html', '/about-preview/index.html']);
|
|
||||||
|
|
||||||
|
|
||||||
function walk(dir, out = []) {
|
function walk(dir, out = []) {
|
||||||
@@ -53,7 +51,6 @@ const warns = [];
|
|||||||
|
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const rel = f.slice(DIST.length) || '/';
|
const rel = f.slice(DIST.length) || '/';
|
||||||
if (SKIP_ALL.has(rel)) continue;
|
|
||||||
const html = readFileSync(f, 'utf8');
|
const html = readFileSync(f, 'utf8');
|
||||||
|
|
||||||
// 站内 <a href>:仅根相对路径;#锚点/mailto/tel/外部/data: 全跳过
|
// 站内 <a href>:仅根相对路径;#锚点/mailto/tel/外部/data: 全跳过
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -5,6 +5,8 @@ import { t } from '../../i18n/ui';
|
|||||||
import type { Locale } from '../../lib/locale';
|
import type { Locale } from '../../lib/locale';
|
||||||
import { aboutIntroMarkdown, pageTypography, renderMarkdown } from '../../lib/site-content';
|
import { aboutIntroMarkdown, pageTypography, renderMarkdown } from '../../lib/site-content';
|
||||||
import { getAboutPage } from '../../lib/page-blocks';
|
import { getAboutPage } from '../../lib/page-blocks';
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
import { pickBi } from '../../lib/site-content';
|
||||||
import BlockRenderer from '../blocks/BlockRenderer.astro';
|
import BlockRenderer from '../blocks/BlockRenderer.astro';
|
||||||
|
|
||||||
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
||||||
@@ -12,10 +14,14 @@ interface Props {
|
|||||||
locale?: Locale;
|
locale?: Locale;
|
||||||
}
|
}
|
||||||
const { locale = 'zh' } = Astro.props;
|
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 { blocks, showIdCard } = await getAboutPage(locale);
|
||||||
|
const docMd = pickBi(entry?.data.doc, { zh: '', en: '' }, locale);
|
||||||
|
const docHtml = docMd.trim() ? await renderMarkdown(docMd) : '';
|
||||||
const introHtml =
|
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');
|
const typo = await pageTypography('siteAbout', 'about');
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,7 +29,12 @@ const typo = await pageTypography('siteAbout', 'about');
|
|||||||
<h1 class="page-title">{t('about_title', locale)}</h1>
|
<h1 class="page-title">{t('about_title', locale)}</h1>
|
||||||
|
|
||||||
<div class={typo.className || undefined} style={typo.style || undefined}>
|
<div class={typo.className || undefined} style={typo.style || undefined}>
|
||||||
{blocks.length > 0 ? (
|
{docHtml ? (
|
||||||
|
<>
|
||||||
|
{showIdCard && <IdCard />}
|
||||||
|
<article class="about-doc rich" set:html={docHtml} />
|
||||||
|
</>
|
||||||
|
) : blocks.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{blocks.map((block) => <BlockRenderer block={block} />)}
|
{blocks.map((block) => <BlockRenderer block={block} />)}
|
||||||
{showIdCard && <IdCard />}
|
{showIdCard && <IdCard />}
|
||||||
@@ -157,4 +168,14 @@ const typo = await pageTypography('siteAbout', 'about');
|
|||||||
color: var(--ink-2);
|
color: var(--ink-2);
|
||||||
max-width: var(--measure);
|
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; }
|
||||||
</style>
|
</style>
|
||||||
@@ -279,6 +279,12 @@ export const collections = {
|
|||||||
// → src/lib/page-blocks.ts warn + skip,保证后台填错块型不炸构建
|
// → src/lib/page-blocks.ts warn + skip,保证后台填错块型不炸构建
|
||||||
schema: ({ image }) =>
|
schema: ({ image }) =>
|
||||||
z.object({
|
z.object({
|
||||||
|
/**
|
||||||
|
* 白纸直编(Phase 5k 用户选定方案 A):整页正文一个 markdown 字段,
|
||||||
|
* 想写什么写什么。非空时整页只渲染它(IdCard 仍在顶部);
|
||||||
|
* 为空时回落 blocks 块式通道 → intro → 内置默认(兼容链保持不断)。
|
||||||
|
*/
|
||||||
|
doc: bilingual.optional(),
|
||||||
intro: bilingual.optional(),
|
intro: bilingual.optional(),
|
||||||
/** 页面级排版覆盖(层 2,T3 车道):正文默认字体类 / 强调色类 / 字号缩放 */
|
/** 页面级排版覆盖(层 2,T3 车道):正文默认字体类 / 强调色类 / 字号缩放 */
|
||||||
typography: z
|
typography: z
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const SITE_DEFAULTS = {
|
|||||||
const pick = (value: string | undefined, fallback: string): string =>
|
const pick = (value: string | undefined, fallback: string): string =>
|
||||||
value && value.trim() ? value : fallback;
|
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]);
|
pick(value?.[locale], fallback[locale]);
|
||||||
|
|
||||||
/** 主页 tagline(zh/en) */
|
/** 主页 tagline(zh/en) */
|
||||||
|
|||||||
@@ -1,387 +0,0 @@
|
|||||||
---
|
|
||||||
/**
|
|
||||||
* 热力图方案 v4(Phase 5j):用户认可 FJ(蛇 + 月柱联动),要求**优化蛇的行动路径**。
|
|
||||||
*
|
|
||||||
* 现状问题(真实数据):371 格里只有 51 天有贡献(14%),原路径是"按列扫整张网格",
|
|
||||||
* 于是 86% 的行程在空格上、且格子跳变(每 26ms 瞬移一格)显得机械。
|
|
||||||
*
|
|
||||||
* 本版给三条路径优化,都保留 FJ 的月柱联动:
|
|
||||||
* P1 智能寻食:只按"最近的有贡献格子"走,中间用子格插值滑行——蛇总在吃东西附近
|
|
||||||
* P2 平滑蛇体:仍扫全网,但头是连续坐标滑行 + 拖尾,看起来像真蛇在游
|
|
||||||
* P3 分月清扫:按月成块清扫,月底停一拍并让该月柱子立起(节奏感来自"月")
|
|
||||||
*/
|
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
||||||
import HeatGrid from '../components/geek/HeatGrid.astro';
|
|
||||||
import { loadHeatmap, type HeatDay } from '../lib/heatmap';
|
|
||||||
|
|
||||||
const { weeks, total } = await loadHeatmap();
|
|
||||||
const days: HeatDay[] = weeks.flat();
|
|
||||||
const buildTime = new Date().toISOString().slice(0, 10);
|
|
||||||
const activeDays = days.filter((d) => d.count > 0).length;
|
|
||||||
|
|
||||||
/* 月度聚合(柱子) */
|
|
||||||
const byMonth = new Map<string, number>();
|
|
||||||
for (const d of days) {
|
|
||||||
const key = d.date.slice(0, 7);
|
|
||||||
byMonth.set(key, (byMonth.get(key) ?? 0) + d.count);
|
|
||||||
}
|
|
||||||
const months = [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
||||||
const maxMonth = Math.max(...months.map(([, v]) => v));
|
|
||||||
---
|
|
||||||
|
|
||||||
<BaseLayout title="热力图方案 v4 · wait4x" description="蛇的行动路径优化三案(FJ 骨架)">
|
|
||||||
<h1 class="pv-title">蛇的路径优化(FJ 骨架)</h1>
|
|
||||||
<p class="pv-note">
|
|
||||||
你认可 FJ,问题在路径。真实数据:371 格里只有 <strong>{activeDays}</strong> 天有贡献(
|
|
||||||
{Math.round((activeDays / days.length) * 100)}%),原路径"按列扫全网"意味着
|
|
||||||
<strong>{100 - Math.round((activeDays / days.length) * 100)}% 的行程在空转</strong>,
|
|
||||||
而且每 26ms 瞬移一格,看着机械。三案都保留月柱联动。<strong>回 P1 / P2 / P3 告诉我选哪个。</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<nav class="pv-switch" aria-label="方案跳转">
|
|
||||||
<a href="#p1" class="pv-chip">P1 · 智能寻食</a>
|
|
||||||
<a href="#p2" class="pv-chip">P2 · 平滑蛇体</a>
|
|
||||||
<a href="#p3" class="pv-chip">P3 · 分月清扫</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- ══════ P1 · 智能寻食 ══════ -->
|
|
||||||
<section id="p1" class="pv-sec">
|
|
||||||
<header class="pv-head">
|
|
||||||
<span class="pv-tag mono">P1</span>
|
|
||||||
<h2>智能寻食</h2>
|
|
||||||
<p class="pv-pros">
|
|
||||||
蛇只按「离当前最近的有贡献格子」前进(最近邻路径),两格之间用<strong>子格插值滑行</strong>,
|
|
||||||
不再逐格瞬移。<b>优点</b>行程从 371 格压到 ~<span data-calc="p1">—</span> 步,动作始终有目的、
|
|
||||||
永远在吃东西;<b>代价</b>不再"扫过整年",没贡献的日子只是路过。
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<div class="hm-stage" data-run data-mode="smart" data-body="5">
|
|
||||||
<div class="hm-wrap"><HeatGrid weeks={weeks} variant="p1" /></div>
|
|
||||||
<span class="hm-head" data-head aria-hidden="true"></span>
|
|
||||||
<span class="hm-trail" data-trail aria-hidden="true"></span>
|
|
||||||
</div>
|
|
||||||
<ul class="hm-bars" data-months>
|
|
||||||
{months.map(([m, v], i) => (
|
|
||||||
<li>
|
|
||||||
<span class="hm-bar-btn"><span class="hm-bar-fill" data-month-idx={i} style={`--h:${Math.round((v / maxMonth) * 100)}%`} /></span>
|
|
||||||
<span class="hm-bar-label mono">{m.slice(5)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<p class="hm-read mono"><span data-read>准备出发…</span> · 第 <span data-loop>1</span> 轮</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ══════ P2 · 平滑蛇体 ══════ -->
|
|
||||||
<section id="p2" class="pv-sec">
|
|
||||||
<header class="pv-head">
|
|
||||||
<span class="pv-tag mono">P2</span>
|
|
||||||
<h2>平滑蛇体</h2>
|
|
||||||
<p class="pv-pros">
|
|
||||||
仍然扫过整年,但蛇头是<strong>连续坐标滑行</strong>(不是格子跳变),身后拖 8 节渐隐的身体,
|
|
||||||
转弯处带一点缓动。<b>优点</b>最像真蛇、动作最顺;<b>代价</b>依旧会经过空格(但不再"顿")。
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<div class="hm-stage" data-run data-mode="smooth" data-body="8">
|
|
||||||
<div class="hm-wrap"><HeatGrid weeks={weeks} variant="p2" /></div>
|
|
||||||
<span class="hm-head" data-head aria-hidden="true"></span>
|
|
||||||
<span class="hm-trail" data-trail aria-hidden="true"></span>
|
|
||||||
</div>
|
|
||||||
<ul class="hm-bars" data-months>
|
|
||||||
{months.map(([m, v], i) => (
|
|
||||||
<li>
|
|
||||||
<span class="hm-bar-btn"><span class="hm-bar-fill" data-month-idx={i} style={`--h:${Math.round((v / maxMonth) * 100)}%`} /></span>
|
|
||||||
<span class="hm-bar-label mono">{m.slice(5)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<p class="hm-read mono"><span data-read>准备出发…</span> · 第 <span data-loop>1</span> 轮</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ══════ P3 · 分月清扫 ══════ -->
|
|
||||||
<section id="p3" class="pv-sec">
|
|
||||||
<header class="pv-head">
|
|
||||||
<span class="pv-tag mono">P3</span>
|
|
||||||
<h2>分月清扫</h2>
|
|
||||||
<p class="pv-pros">
|
|
||||||
按月成块清扫:一个月扫完停一拍(180ms)并让该月柱子"立起来",再进下个月。
|
|
||||||
<b>优点</b>节奏由"月"提供、与柱子天然同步、扫过空格时速度更快;<b>代价</b>每月有个小停顿。
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<div class="hm-stage" data-run data-mode="month" data-body="6">
|
|
||||||
<div class="hm-wrap"><HeatGrid weeks={weeks} variant="p3" /></div>
|
|
||||||
<span class="hm-head" data-head aria-hidden="true"></span>
|
|
||||||
<span class="hm-trail" data-trail aria-hidden="true"></span>
|
|
||||||
</div>
|
|
||||||
<ul class="hm-bars" data-months>
|
|
||||||
{months.map(([m, v], i) => (
|
|
||||||
<li>
|
|
||||||
<span class="hm-bar-btn"><span class="hm-bar-fill" data-month-idx={i} style={`--h:${Math.round((v / maxMonth) * 100)}%`} /></span>
|
|
||||||
<span class="hm-bar-label mono">{m.slice(5)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<p class="hm-read mono"><span data-read>准备出发…</span> · 当前 <span data-month-label>—</span></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p class="pv-foot mono">
|
|
||||||
数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})· 共 {total} 次贡献 / {activeDays} 天。
|
|
||||||
三案共用同一套「头 + 拖尾 + 月柱」引擎,只换路径策略;"减少动态效果"下静态呈终态。
|
|
||||||
</p>
|
|
||||||
</BaseLayout>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
||||||
const CELL = 10;
|
|
||||||
const GAP = 3;
|
|
||||||
const PITCH = CELL + GAP;
|
|
||||||
|
|
||||||
for (const stage of document.querySelectorAll<HTMLElement>('[data-run]')) {
|
|
||||||
const wrap = stage.querySelector<HTMLElement>('.hm-wrap');
|
|
||||||
const head = stage.querySelector<HTMLElement>('[data-head]');
|
|
||||||
const trailEl = stage.querySelector<HTMLElement>('[data-trail]');
|
|
||||||
const bars = [...stage.parentElement!.querySelectorAll<HTMLElement>('[data-month-idx]')];
|
|
||||||
const read = stage.parentElement?.querySelector<HTMLElement>('[data-read]');
|
|
||||||
const loopEl = stage.parentElement?.querySelector<HTMLElement>('[data-loop]');
|
|
||||||
const monthLabel = stage.parentElement?.querySelector<HTMLElement>('[data-month-label]');
|
|
||||||
if (!wrap || !head || !trailEl) continue;
|
|
||||||
|
|
||||||
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')];
|
|
||||||
const mode = stage.dataset.mode ?? 'smart';
|
|
||||||
const bodyLen = Number(stage.dataset.body ?? 5);
|
|
||||||
const weeks = Math.ceil(cells.length / 7);
|
|
||||||
// 列优先索引:idx → 列=floor(idx/7)、行=idx%7。此前 x/y 写反,
|
|
||||||
// 蛇头 y 会到 676px(网格仅 91px 高)一路走出下边界(用户实测「跑到窗口之外」)。
|
|
||||||
const posOf = (idx: number) => ({ x: Math.floor(idx / 7) * PITCH, y: (idx % 7) * PITCH });
|
|
||||||
|
|
||||||
/** 最近邻路径:只走有贡献的格子(P1 用;P2/P3 用蛇形全网) */
|
|
||||||
const smartPath = () => {
|
|
||||||
const startAt = cells.findIndex((c) => Number(c.dataset.count ?? 0) > 0);
|
|
||||||
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.includes(startAt) ? startAt : pool[0];
|
|
||||||
pool.splice(pool.indexOf(cur), 1);
|
|
||||||
order.push(cur);
|
|
||||||
while (pool.length) {
|
|
||||||
let best = 0;
|
|
||||||
let bestD = Infinity;
|
|
||||||
for (let k = 0; k < pool.length; k++) {
|
|
||||||
const a = posOf(cur);
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const serpentine = () => {
|
|
||||||
const p: number[] = [];
|
|
||||||
for (let c = 0; c < weeks; c++) {
|
|
||||||
const rows = [0, 1, 2, 3, 4, 5, 6];
|
|
||||||
if (c % 2 === 1) rows.reverse();
|
|
||||||
for (const r of rows) p.push(c * 7 + r);
|
|
||||||
}
|
|
||||||
return p.filter((i) => i < cells.length);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 真实月份序列(从格子日期去重排序):柱子的顺序与它一致 */
|
|
||||||
const monthsReal = (() => {
|
|
||||||
const set = new Set<string>();
|
|
||||||
cells.forEach((c) => set.add((c.dataset.date ?? '').slice(0, 7)));
|
|
||||||
return [...set].filter(Boolean).sort();
|
|
||||||
})();
|
|
||||||
|
|
||||||
const waypoints = mode === 'smart' ? smartPath() : serpentine();
|
|
||||||
// 子格插值:把路径展开成"连续坐标序列"(越长的段走越快,节奏才不拖)
|
|
||||||
const samples: { x: number; y: number; idx: number }[] = [];
|
|
||||||
for (let k = 0; k < waypoints.length; k++) {
|
|
||||||
const from = posOf(waypoints[k]);
|
|
||||||
const to = posOf(waypoints[(k + 1) % waypoints.length]);
|
|
||||||
const dist = Math.hypot(to.x - from.x, to.y - from.y);
|
|
||||||
const steps = Math.max(1, Math.round(dist / (mode === 'smart' ? 7 : 5)));
|
|
||||||
for (let s = 0; s < steps; s++) {
|
|
||||||
const t = s / steps;
|
|
||||||
samples.push({
|
|
||||||
x: from.x + (to.x - from.x) * t,
|
|
||||||
y: from.y + (to.y - from.y) * t,
|
|
||||||
idx: waypoints[k],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const calc = stage.parentElement?.querySelector<HTMLElement>('[data-calc="p1"]');
|
|
||||||
if (calc) calc.textContent = String(waypoints.length);
|
|
||||||
|
|
||||||
let i = 0;
|
|
||||||
let loop = 1;
|
|
||||||
let eaten = 0;
|
|
||||||
let timer = 0;
|
|
||||||
let paused = false;
|
|
||||||
let monthSeen = '';
|
|
||||||
|
|
||||||
const setHead = (x: number, y: number, visible = true) => {
|
|
||||||
head.style.transform = `translate(${x}px, ${y}px)`;
|
|
||||||
head.style.opacity = visible ? '1' : '0';
|
|
||||||
};
|
|
||||||
const trail: { x: number; y: number }[] = [];
|
|
||||||
const paintTrail = () => {
|
|
||||||
trailEl.style.boxShadow = trail
|
|
||||||
.map((p, k) => `${p.x}px ${p.y}px 0 ${Math.max(1, 4 - k)}px color-mix(in oklab, var(--accent) ${45 - k * 4}%, transparent)`)
|
|
||||||
.join(',');
|
|
||||||
};
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
i = 0;
|
|
||||||
eaten = 0;
|
|
||||||
trail.length = 0;
|
|
||||||
cells.forEach((c) => c.classList.remove('snake-eaton', 'snake-path'));
|
|
||||||
bars.forEach((b) => b.classList.remove('on'));
|
|
||||||
monthSeen = '';
|
|
||||||
paintTrail();
|
|
||||||
};
|
|
||||||
|
|
||||||
let holdUntil = 0;
|
|
||||||
const tick = () => {
|
|
||||||
if (holdUntil && Date.now() < holdUntil) return; // 换月小停顿
|
|
||||||
holdUntil = 0;
|
|
||||||
if (i >= samples.length) {
|
|
||||||
if (read) read.textContent = `第 ${loop} 轮完成:吃到 ${eaten} 天`;
|
|
||||||
loop++;
|
|
||||||
if (loopEl) loopEl.textContent = String(loop);
|
|
||||||
window.setTimeout(() => {
|
|
||||||
reset();
|
|
||||||
if (!paused) start();
|
|
||||||
}, 700);
|
|
||||||
window.clearInterval(timer);
|
|
||||||
timer = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const s = samples[i++];
|
|
||||||
setHead(s.x + CELL / 2, s.y + CELL / 2);
|
|
||||||
trail.unshift({ x: s.x + CELL / 2, y: s.y + CELL / 2 });
|
|
||||||
if (trail.length > bodyLen) trail.pop();
|
|
||||||
paintTrail();
|
|
||||||
|
|
||||||
const cell = cells[s.idx];
|
|
||||||
if (!cell) return;
|
|
||||||
// 月柱联动(三案共用):柱子点亮到当前格所属月份
|
|
||||||
const mk = (cell.dataset.date ?? '').slice(0, 7);
|
|
||||||
const mi = monthsReal.indexOf(mk);
|
|
||||||
if (mi >= 0) bars.slice(0, mi + 1).forEach((b) => b.classList.add('on'));
|
|
||||||
// P3:换月时额外停一拍 + 显示月份(只影响节奏,不重建计时器)
|
|
||||||
if (mode === 'month' && mk && mk !== monthSeen) {
|
|
||||||
monthSeen = mk;
|
|
||||||
if (monthLabel) monthLabel.textContent = mk;
|
|
||||||
if (holdUntil === 0) holdUntil = Date.now() + 180;
|
|
||||||
}
|
|
||||||
cell.classList.add('snake-path');
|
|
||||||
if (Number(cell.dataset.count ?? 0) > 0) {
|
|
||||||
cell.classList.add('snake-eaten');
|
|
||||||
eaten++;
|
|
||||||
if (read) read.textContent = `${cell.dataset.date} · 吃到 ${cell.dataset.count} 次(累计 ${eaten} 天)`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const speed = mode === 'smart' ? 22 : mode === 'smooth' ? 20 : 18;
|
|
||||||
const start = () => {
|
|
||||||
if (timer || paused) return;
|
|
||||||
if (!samples.length) return;
|
|
||||||
timer = window.setInterval(tick, speed);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 减少动态:静态终态
|
|
||||||
if (reduce) {
|
|
||||||
cells.forEach((c) => {
|
|
||||||
if (Number(c.dataset.count ?? 0) > 0) c.classList.add('snake-eaten');
|
|
||||||
});
|
|
||||||
bars.forEach((b) => b.classList.add('on'));
|
|
||||||
setHead(0, 0, false);
|
|
||||||
if (read) read.textContent = '已按"减少动态效果"静态呈现';
|
|
||||||
} else {
|
|
||||||
new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
for (const e of entries) {
|
|
||||||
if (e.isIntersecting) start();
|
|
||||||
else {
|
|
||||||
if (timer) window.clearInterval(timer);
|
|
||||||
timer = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.15 },
|
|
||||||
).observe(stage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</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-read { font-size: 12px; color: var(--ink-4); margin-top: var(--space-2); }
|
|
||||||
|
|
||||||
/* 舞台:网格 + 浮动的蛇头/拖尾(位置由 JS 用 transform 控制) */
|
|
||||||
.hm-stage { position: relative; }
|
|
||||||
.hm-stage .hm-wrap { overflow-x: auto; padding: 6px 0; }
|
|
||||||
.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;
|
|
||||||
transition: transform 90ms linear, opacity 200ms var(--ease);
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
.hm-trail { position: absolute; left: 0; top: 6px; width: 1px; height: 1px; pointer-events: none; z-index: 2; }
|
|
||||||
|
|
||||||
:global(.snake-path) { background: color-mix(in oklab, var(--accent) 18%, transparent); }
|
|
||||||
:global(.snake-eaten) {
|
|
||||||
background: var(--accent) !important;
|
|
||||||
transition: background 160ms var(--ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hm-bars {
|
|
||||||
display: flex; align-items: flex-end; gap: 6px; list-style: none;
|
|
||||||
padding: 0; margin: var(--space-2) 0 0; height: 76px;
|
|
||||||
}
|
|
||||||
.hm-bars li { display: flex; flex-direction: column; align-items: center; gap: 4px; 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 380ms 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: 10.5px; color: var(--ink-4); }
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.hm-head { transition: none; }
|
|
||||||
.hm-bar-fill { transition: none; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Reference in new issue
Block a user