feat(about): 关于页五方案预览上线(5k T1)

用户要求关于页「深入探索和设计提出 3-5 个方案,并设计出页面供我审阅」。五案共用
about.yaml 真实数据(选哪个都不用迁移内容):

A 白纸单栏:后台就是一个 markdown 编辑器,身份卡 + 单栏全文连排——编辑自由度最大
B 名片双栏:左侧 sticky 身份名片,右侧正文分节——个人站经典形态
C 杂志分节:大字引言 + 编号分节(01 关于我 / 02 兴趣版图 / 03 找到我 / 04 本站构建)
  + 右侧粘性目录跟随滚动
D 时间线:把"我是谁"讲成轨迹(起点气象 → 第二主线代码 → 作品 → 信条),沿线节点
E 卡片流:身份卡打头,正文/兴趣/链接/构建各占一张卡片,两列错落

content-lint 白名单加一次性预览路由(无 en 对应页)。

验证: 构建 53 页 exit 0;content-lint 53 页 0/0/0;
      Playwright 实测五案均渲染(3 枚图标 svg / IdCard / 9 条找得到我链接)、
      1280 与 390 零横向溢出

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

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-04 23:10:55 +08:00
1 parent 129b23cd2b
commit b41b4ba8a5
4 files changed
+423 -1

No files matched your search

@@ -0,0 +1,65 @@
# 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 待确认问题
- [ ] 五案选型(用户在 /about-preview/ 点选)
### 2.4 可选解释与取舍
- 选定方案若为"纯 markdown 直编",blocks 保留为高级通道(后台仍可切回)
### 2.5 已证伪路径
- 无(本包是设计探索包)
## 3. 功能范围
### 3.1 核心功能(MVP)
- [ ] T1 预览页:/about-preview/ 五案并陈(A 白纸单栏 / B 名片双栏 / C 杂志分节带目录 / D 时间线主导 / E 卡片流),共享 about.yaml 真实数据
- [ ] T2 用户选型后落地正式 /about 并删除预览路由
### 3.2 扩展功能
- 无
### 3.3 不在范围内
- 关于页内容改写(内容是用户的)、后台表单重构
## 4. 最小实现路径
- 最简可行:预览页一次性路由 + 共用数据渲染五案;落地只搬选中方案的模板
- 暂不引入:新的后台字段(选 A 则零新字段;其它案沿用现有 blocks 字段)
## 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 渲染正确
- 升级触发:关于页为独立页面且不动全局样式,升级为「该页视觉复核」即可
## 6. 成功标准与验证方式
- [ ] 预览页五案可达且共用真实数据 -> verify: task_0001
- [ ] 用户选定方案落地正式 /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 白名单
## 阶段一:实现
- [ ] 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"`
- [ ] 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
+1 -1
View File
@@ -17,7 +17,7 @@ const SKIP_PREFIX = ['/admin/', '/photos-originals/']; // admin=CMS 路由;pho
const SKIP_FILE = new Set(['/404.html']); // Astro 404 页不参与配对
// 一次性预览路由(无 en 对应页):完全不参与检查。用户选定方案后随路由一起删除。
const SKIP_ALL = new Set(['/heatmap-preview/index.html']);
const SKIP_ALL = new Set(['/heatmap-preview/index.html', '/about-preview/index.html']);
function walk(dir, out = []) {
+336
View File
@@ -0,0 +1,336 @@
---
/**
* 关于页形态预览(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>