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:
wait4x committed 2026-11-04 23:32:05 +08:00
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
View File
@@ -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
+9
View File
@@ -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 列表**构成,后台里是一串可拖拽排序的卡片。每个块都有「显示」开关;关掉只是不渲染,块本身留在数据里。
+9 -1
View File
@@ -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
-3
View File
@@ -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: 全跳过
-58
View File
@@ -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>
+24 -3
View File
@@ -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>
+6
View File
@@ -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
+1 -1
View File
@@ -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) */
-387
View File
@@ -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>