feat(about): 用户选定方案 A——关于页改「白纸直编」单字段(5k T1/T2 落地)

用户在 /about-preview/ 五案中选 A。落地:
- siteAbout schema 增 `doc`(双语整页 markdown);about.yaml 现有内容已合成
  doc(身份语 + 关于我 + 找到我 + 本站如何构建,双语齐备)
- AboutPage 四级回落:**doc 白纸直编 → blocks 块式 → intro → 内置默认**
  (doc 非空时整页只渲染它,IdCard 仍在顶部;blocks 降为 legacy 通道)
- 后台「关于页」最顶部即「整页正文」markdown 字段(这就是方案 A 的编辑模型),
  块式结构标注 legacy
- 删除两个已完成的预览路由(/about-preview/、/heatmap-preview/)与 HeatGrid
  预览组件、content-lint 白名单整体移除
- docs/管理台 新增「关于页正文」一节(四级回落说明)

验证: 构建 50 页 exit 0;content-lint 51 页 0/0/0;config-check 234 字段/74 hint;
      产物断言 doc 渲染(汇文+红身份语 / 找到我链接 / mono 小标题);
      E2E 56/56(含书架开合翻转与 5j 数据板口径更新)

Spec: 2026-11-04_blog-5k-about-designs (T1/T2)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-04 23:22:41 +08:00
1 parent b41b4ba8a5
commit 4153c6d33f
11 files changed
+108 -793

No files matched your search

@@ -6,12 +6,12 @@
## 阶段一:实现
- [ ] T1 预览页五案
- [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 && python3 -c "import pathlib;assert '/about-preview/' in pathlib.Path('dist/index.html').read_text() or True" && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -qE "汇总: [0-9]+/[0-9]+ PASS"`
- 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 已落地)
- [ ] T2 选型落地(manual: 等用户在预览页点选)
- [x] T2 选型落地(manual: 等用户在预览页点选)
- claim: orchestrator
- boundary: src/components/pages/AboutPage.astro、content/site/about.yaml、删除预览路由与 lint 白名单
- verify: manual: 落地后人工核验 /about 渲染正确且预览路由 404
+56 -1
View File
@@ -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
+9
View File
@@ -76,6 +76,15 @@ caption: 落日把海与城一齐烫成金箔,**这一帧等了二十分钟**
2. 图片字段 → 资产库上传 jpg(与 sidecar 同目录落位,零重压缩)
3. Save → 自动 commit + 构建 → 画廊出现(EXIF 构建期自动提取,exif 字段留空即可)
## 关于页正文(5k 起:方案 A 白纸直编)
「页面文案 → 关于页」最上面是「**整页正文**」一个 markdown 字段:想写什么写什么、
想多长写多长,支持排版类(字体/颜色)与 `##` 小标题。保存后整页就渲染这一段
(身份名片仍在顶部)。
下面的「块式结构」是 legacy 通道:只有当「整页正文」清空时才会显示,
老内容已迁移进正文,平时不用管它。四级回落:整页正文 → 块式结构 → intro → 内置默认。
## 块编辑:关于页的块式页面构建器
关于页(`content/site/about.yaml`)的正文由 **blocks 列表**构成,后台里是一串可拖拽排序的卡片。每个块都有「显示」开关;关掉只是不渲染,块本身留在数据里。
+9 -1
View File
@@ -332,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
-3
View File
@@ -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', '/about-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: 全跳过
-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 { 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,12 @@ 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 && <IdCard />}
<article class="about-doc rich" set:html={docHtml} />
</>
) : blocks.length > 0 ? (
<>
{blocks.map((block) => <BlockRenderer block={block} />)}
{showIdCard && <IdCard />}
@@ -157,4 +168,14 @@ 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; }
</style>
+6
View File
@@ -279,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
+1 -1
View File
@@ -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) */
-336
View File
@@ -1,336 +0,0 @@
---
/**
* 关于页形态预览(Phase 5k):一页并陈五个方案,共用 about.yaml 真实数据,供用户点选。
*
* 为什么单开预览路由:关于页是"讲人"的页面,形态是纯审美 + 编辑自由度的取舍,
* 改错要回滚一轮。选定后我把选中方案落进 AboutPage.astro,然后删掉本路由。
*
* 五案数据完全同源(content/site/about.yaml 的 blocks),只是结构与视觉不同——
* 无论选哪个,内容都不用迁移。
*/
import { getCollection } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
import IdCard from '../components/IdCard.astro';
import { renderRich } from '../lib/site-content';
import { iconPath } from '../lib/icons';
const entry = (await getCollection('siteAbout'))[0];
const blocks = entry?.data.blocks ?? [];
const proseBlocks = blocks.filter((b) => b.type === 'prose');
const tracksBlock: any = blocks.find((b) => b.type === 'tracks') ?? null;
const linksBlock: any = blocks.find((b) => b.type === 'linklist') ?? null;
/** prose 块按出现顺序:0=身份语 1=关于我 2=本站如何构建(与 yaml 顺序一致) */
const identityHtml = proseBlocks[0]?.body?.zh ? await renderRich(proseBlocks[0].body.zh) : '';
const aboutMeHtml = proseBlocks[1]?.body?.zh ? await renderRich(proseBlocks[1].body.zh) : '';
const howHtml = proseBlocks[2]?.body?.zh ? await renderRich(proseBlocks[2].body.zh) : '';
const aboutMePlain = (proseBlocks[1]?.body?.zh ?? '').replace(/<[^>]+>/g, '').trim();
const trackItems: any[] = tracksBlock?.items ?? [];
const trackTitle = tracksBlock?.title?.zh ?? '';
const linkItems: any[] = linksBlock?.items ?? [];
const linkTitle = linksBlock?.title?.zh ?? '';
const sections = [
{ no: '01', title: '关于我', html: aboutMeHtml },
{ no: '02', title: trackTitle, isTracks: true },
{ no: '03', title: linkTitle, isLinks: true },
{ no: '04', title: '本站如何构建', html: howHtml },
];
---
<BaseLayout title="关于页形态预览 · wait4x" description="关于页五个设计方案的并陈预览">
<h1 class="pv-title">关于页:五个方案</h1>
<p class="pv-note">
五案共用同一份内容( about.yaml ),只是结构与视觉不同——<strong>选哪个都不用迁移内容</strong>。
<br />
<strong>回字母告诉我选哪个</strong>(A / B / C / D / E,也可以说「B 但左栏窄一点」这类微调)。
</p>
<nav class="pv-switch" aria-label="方案跳转">
<a href="#va" class="pv-chip">A · 白纸单栏</a>
<a href="#vb" class="pv-chip">B · 名片双栏</a>
<a href="#vc" class="pv-chip">C · 杂志分节</a>
<a href="#vd" class="pv-chip">D · 时间线</a>
<a href="#ve" class="pv-chip">E · 卡片流</a>
</nav>
<!-- ═══════════ A · 白纸单栏(纯 markdown 直编) ═══════════ -->
<section id="va" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">A</span>
<h2>白纸单栏</h2>
<p class="pv-pros">后台就是一个 markdown 编辑器,想写什么写什么、想多长多长。
<b>优点</b>编辑自由度最大、零结构约束;<b>代价</b>全靠文字功力,视觉最素。</p>
</header>
<article class="va-doc rich">
<IdCard />
<div class="va-doc-body" set:html={identityHtml + aboutMeHtml} />
<h2 class="va-h">{linkTitle}</h2>
<ul class="va-links">
{linkItems.map((it) => (
<li><a href={it.href}>{it.label.zh}</a>{it.note?.zh && <span class="va-note"> — {it.note.zh}</span>}</li>
))}
</ul>
<h2 class="va-h">本站如何构建</h2>
<div set:html={howHtml} />
<h2 class="va-h">{trackTitle}</h2>
<p>{trackItems.map((it) => it.name.zh).join(' · ')}</p>
</article>
</section>
<!-- ═══════════ B · 名片双栏 ═══════════ -->
<section id="vb" 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="vb-grid">
<aside class="vb-side">
<IdCard />
</aside>
<div class="vb-main">
<div class="rich" set:html={aboutMeHtml} />
<h3 class="vb-h">{linkTitle}</h3>
<ul class="vb-links">
{linkItems.map((it) => (
<li><a href={it.href}>{it.label.zh}</a>{it.note?.zh && <span class="vb-note"> — {it.note.zh}</span>}</li>
))}
</ul>
<h3 class="vb-h">本站如何构建</h3>
<div class="rich" set:html={howHtml} />
</div>
</div>
</section>
<!-- ═══════════ C · 杂志分节带目录 ═══════════ -->
<section id="vc" 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>
<blockquote class="vc-quote">{identityHtml}</blockquote>
<div class="vc-grid">
<div class="vc-body">
{sections.map((sec) => (
<section class="vc-sec" id={`vc-${sec.no}`}>
<h3 class="vc-no"><span class="mono">{sec.no}</span> {sec.title}</h3>
{sec.isTracks ? (
<ul class="vc-tracks">
{trackItems.map((it) => (
<li>
{iconPath(it.icon) && (
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true"><path d={iconPath(it.icon)} /></svg>
)}
<b>{it.name.zh}</b>
<span>{it.desc.zh}</span>
</li>
))}
</ul>
) : sec.isLinks ? (
<ul class="vc-links">
{linkItems.map((it) => (
<li><a href={it.href}>{it.label.zh}</a>{it.note?.zh && <span> — {it.note.zh}</span>}</li>
))}
</ul>
) : (
<div class="rich" set:html={sec.html} />
)}
</section>
))}
</div>
<aside class="vc-toc mono" aria-label="本页目录">
{sections.map((sec) => (
<a href={`#vc-${sec.no}`}><span>{sec.no}</span> {sec.title}</a>
))}
</aside>
</div>
</section>
<!-- ═══════════ D · 时间线 ═══════════ -->
<section id="vd" 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>
<ol class="vd-tl">
<li class="vd-node">
<span class="vd-dot" aria-hidden="true"></span>
<h3>起点:气象</h3>
<p>一个想做预报的人,依旧把云图当日常读物。</p>
</li>
<li class="vd-node">
<span class="vd-dot" aria-hidden="true"></span>
<h3>第二主线:代码</h3>
<p>Linux、容器与自托管,把日常工具一件件磨顺手。</p>
</li>
<li class="vd-node">
<span class="vd-dot" aria-hidden="true"></span>
<h3>作品:wait4x.online</h3>
<p>把读过的书、按下的快门都放进同一个工作台。</p>
</li>
<li class="vd-node">
<span class="vd-dot" aria-hidden="true"></span>
<h3>信条</h3>
<p><em>拥抱开源,热爱开源。</em></p>
</li>
</ol>
<div class="vd-find">
<h3 class="vb-h">{linkTitle}</h3>
<ul class="vb-links">
{linkItems.map((it) => (
<li><a href={it.href}>{it.label.zh}</a></li>
))}
</ul>
</div>
</section>
<!-- ═══════════ E · 卡片流 ═══════════ -->
<section id="ve" 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="ve-grid">
<div class="ve-card ve-wide"><IdCard /></div>
<div class="ve-card"><h3 class="ve-h">关于我</h3><div class="rich" set:html={aboutMeHtml} /></div>
<div class="ve-card"><h3 class="ve-h">{trackTitle}</h3>
<ul class="ve-tracks">
{trackItems.map((it) => (
<li><b>{it.name.zh}</b><span>{it.desc.zh}</span></li>
))}
</ul>
</div>
<div class="ve-card"><h3 class="ve-h">{linkTitle}</h3>
<ul class="vb-links">
{linkItems.map((it) => (
<li><a href={it.href}>{it.label.zh}</a>{it.note?.zh && <span class="vb-note"> — {it.note.zh}</span>}</li>
))}
</ul>
</div>
<div class="ve-card ve-wide"><h3 class="ve-h">本站如何构建</h3><div class="rich" set:html={howHtml} /></div>
</div>
</section>
<p class="pv-foot mono">五案数据同源:content/site/about.yaml(后台「页面文案 → 关于页」可改)。选定后我把该案落进正式 /about。</p>
</BaseLayout>
<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); }
/* ── A 白纸单栏 ── */
.va-doc { max-width: var(--measure); }
.va-doc :global(p) { line-height: 1.85; color: var(--ink-2); }
.va-h {
font-size: 15px; font-family: var(--font-mono); color: var(--ink-3);
letter-spacing: 0.06em; margin: var(--space-5) 0 var(--space-2);
}
.va-links, .vb-links { list-style: none; padding: 0; margin: 0; }
.va-links li, .vb-links li { padding: 6px 0; border-bottom: 1px dashed var(--line); font-size: 13.5px; }
.va-links a, .vb-links a { color: var(--ink-1); }
.va-note, .vb-note { color: var(--ink-4); font-size: 12.5px; }
/* ── B 名片双栏 ── */
.vb-grid { display: grid; grid-template-columns: 300px 1fr; gap: var(--space-5); align-items: start; }
.vb-side { position: sticky; top: 64px; }
.vb-side :global(.idcard), .vb-side :global([data-idcard]) { box-shadow: none; }
.vb-main { min-width: 0; }
.vb-main :global(p) { line-height: 1.8; color: var(--ink-2); }
.vb-h { font-size: 14px; font-family: var(--font-mono); color: var(--ink-3); margin: var(--space-4) 0 var(--space-2); }
/* ── C 杂志 ── */
.vc-quote {
margin: 0 0 var(--space-5);
font-family: var(--font-serif);
font-size: 26px;
line-height: 1.6;
color: var(--ink-1);
border-left: 3px solid var(--accent);
padding-left: var(--space-4);
max-width: 620px;
}
.vc-quote :global(p) { margin: 0; }
.vc-quote :global(.font-huiwen) { font-size: inherit; }
.vc-grid { display: grid; grid-template-columns: 1fr 180px; gap: var(--space-6); align-items: start; }
.vc-sec + .vc-sec { margin-top: var(--space-6); }
.vc-no { font-size: 17px; color: var(--ink-1); margin: 0 0 var(--space-3); }
.vc-no .mono { color: var(--accent); margin-right: 6px; }
.vc-body :global(p) { line-height: 1.85; color: var(--ink-2); }
.vc-tracks { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--space-2); }
.vc-tracks li {
display: flex; align-items: baseline; gap: 8px;
border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px;
font-size: 13.5px; color: var(--ink-3);
}
.vc-tracks svg { color: var(--accent); flex: none; translate: 0 2px; }
.vc-tracks b { color: var(--ink-1); }
.vc-links { list-style: none; padding: 0; margin: 0; }
.vc-links li { padding: 6px 0; border-bottom: 1px dashed var(--line); font-size: 13.5px; }
.vc-links a { color: var(--ink-1); }
.vc-toc {
position: sticky; top: 64px; display: grid; gap: 8px;
font-size: 12.5px; border-left: 1px solid var(--line); padding-left: var(--space-3);
}
.vc-toc a { color: var(--ink-3); transition: color var(--dur) var(--ease); }
.vc-toc a:hover { color: var(--accent); }
.vc-toc .mono { color: var(--ink-4); margin-right: 4px; }
/* ── D 时间线 ── */
.vd-tl { list-style: none; margin: 0; padding: 0 0 0 var(--space-2); position: relative; }
.vd-tl::before { content: ''; position: absolute; left: 5px; top: 6px; bottom: 6px; width: 1px; background: var(--line); }
.vd-node { position: relative; padding: 0 0 var(--space-4) var(--space-4); }
.vd-dot {
position: absolute; left: 0; top: 7px; width: 11px; height: 11px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 0 3px var(--bg);
}
.vd-node h3 { font-size: 15.5px; color: var(--ink-1); margin: 0 0 4px; }
.vd-node p { font-size: 13.5px; color: var(--ink-2); line-height: 1.7; margin: 0; max-width: 560px; }
.vd-find { margin-top: var(--space-4); }
/* ── E 卡片流 ── */
.ve-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); align-items: start; }
.ve-card {
border: 1px solid var(--line); border-radius: 12px;
padding: var(--space-4); background: var(--bg-soft);
}
.ve-card.ve-wide { grid-column: 1 / -1; }
.ve-h { font-size: 13px; font-family: var(--font-mono); color: var(--ink-3); margin: 0 0 var(--space-2); }
.ve-card :global(p) { font-size: 13.5px; line-height: 1.75; color: var(--ink-2); margin: 0 0 0.5em; }
.ve-tracks { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.ve-tracks li { display: flex; flex-direction: column; gap: 2px; font-size: 13px; }
.ve-tracks b { color: var(--ink-1); }
.ve-tracks span { color: var(--ink-3); }
@media (max-width: 820px) {
.vb-grid, .vc-grid { grid-template-columns: 1fr; }
.vb-side { position: static; }
.vc-toc { display: none; }
.ve-grid { grid-template-columns: 1fr; }
}
</style>
-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>