feat(about): 块式页面构建器——九类区块 + 三级回落零回归(5g T1/T2)

- schema:siteAbout 增 layout{showIdCard} + blocks(z.discriminatedUnion
  九类:prose/tracks/cards/timeline/quote/figure/linklist/cta/divider,
  图片字段走 Astro image());对未知块型用 z.union([knownUnion,
  z.looseObject({type})]) 复合——已知块严格校验,未知块落宽松分支再由
  访问器 WARN+丢弃(容错与严格校验的张力取舍)
- page-blocks.ts:enabled:false 过滤、未知 type WARN+skip、pickBi 回落
  zh 并 WARN、上限(块≤24/单块文本≤4000,超限截断并 WARN)
- BlockRenderer 统一外壳 `<section class="blk" data-block={type}>`(E2E
  抓手)+ 九个块组件(样式自 AboutPage 原 <style> 搬迁,只吃既有 token)
- AboutPage 三级回落:blocks → intro markdown → 内置默认;legacy
  tracks/build 段整体挪入回退分支(文案逐字一致,零回归)
- 图标白名单 src/lib/icons.ts(12 键,未登记回退首字占位)
- 后台 about 单例四段结构(blocks/layout/intro/typography),块为
  list+types+typeKey,每块带「显示」开关与中文标签

协调报备:车道修了一处跨车道插入事故(T3 把 typography 插进了 intro
的 widget 内导致字段错位),重写为四段正确结构;并发构建擦 dist 的
假失败改用「build→快照 dist→快照起服务跑 E2E」规避(37/37 稳定)

验证: 构建 48 页 exit 0;E2E 37/37(快照口径);九类块顺序与配置一致、
      figure 走 /_astro/*.webp 管线、未知块与外链图被拦并 WARN、
      终版 3 块零回归、en 同构、admin about 四段字段齐全

Spec: 2026-11-02_blog-5g-pages (T1/T2)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-02 18:21:33 +08:00
1 parent 6096a9dafe
commit 2f7cbcf7bd
16 files changed
+1289 -72

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
---
// 块统一渲染器:switch(type) → 子组件;外壳带 data-block 属性(E2E 断言抓手)
import type { ResolvedBlock } from '../../lib/page-blocks';
import Prose from './Prose.astro';
import Tracks from './Tracks.astro';
import Cards from './Cards.astro';
import Timeline from './Timeline.astro';
import Quote from './Quote.astro';
import Figure from './Figure.astro';
import LinkList from './LinkList.astro';
import Cta from './Cta.astro';
import Divider from './Divider.astro';
interface Props {
block: ResolvedBlock;
}
const { block } = Astro.props;
---
<section class="blk" data-block={block.type}>
{block.type === 'prose' && <Prose block={block} />}
{block.type === 'tracks' && <Tracks block={block} />}
{block.type === 'cards' && <Cards block={block} />}
{block.type === 'timeline' && <Timeline block={block} />}
{block.type === 'quote' && <Quote block={block} />}
{block.type === 'figure' && <Figure block={block} />}
{block.type === 'linklist' && <LinkList block={block} />}
{block.type === 'cta' && <Cta block={block} />}
{block.type === 'divider' && <Divider block={block} />}
</section>
<style>
.blk { margin-block: 0; }
</style>
+75
View File
@@ -0,0 +1,75 @@
---
// cards 块:技能/资源卡(2 或 3 列;复用 .track-row 的边框与内距语言)
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'cards' }>;
}
const { block } = Astro.props;
---
{block.title && <h2 class="sec-title">{block.title}</h2>}
<ul class:list={['cards', `cols-${block.columns}`]}>
{block.items.map((item) => {
const body = (
<>
{item.tag && <span class="card-tag mono">{item.tag}</span>}
<strong class="card-title">{item.title}</strong>
{item.desc && <p class="card-desc">{item.desc}</p>}
</>
);
return (
<li class="card">
{item.href ? (
<a class="card-link" href={item.href}>{body}</a>
) : (
<div class="card-link">{body}</div>
)}
</li>
);
})}
</ul>
<style>
.sec-title {
font-size: 14px;
font-family: var(--font-mono);
color: var(--ink-3);
letter-spacing: 0.08em;
margin: var(--space-5) 0 var(--space-3);
}
.cards {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-2);
}
.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@media (max-width: 640px) {
.cols-2, .cols-3 { grid-template-columns: minmax(0, 1fr); }
}
.card {
border: 1px solid var(--line);
border-radius: 10px;
padding: var(--space-3) var(--space-4);
display: flex;
}
.card-link {
display: flex;
flex-direction: column;
gap: 2px;
color: inherit;
text-decoration: none;
width: 100%;
}
a.card-link:hover .card-title { color: var(--accent); }
.card-tag {
font-size: 11.5px;
color: var(--ink-4);
letter-spacing: 0.04em;
}
.card-title { font-size: 15px; color: var(--ink-1); transition: color var(--dur) var(--ease); }
.card-desc { font-size: 14px; color: var(--ink-3); margin-top: 2px; }
</style>
+48
View File
@@ -0,0 +1,48 @@
---
// cta 块:单按钮行动点(克制:同屏最多 1 个,由编辑纪律保证)
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'cta' }>;
}
const { block } = Astro.props;
const isExternal = block.href.startsWith('http');
---
<div class="cta">
{block.text && <p class="cta-text">{block.text}</p>}
<a
class="cta-btn tap mono"
href={block.href}
rel={isExternal ? 'noopener noreferrer' : undefined}
target={isExternal ? '_blank' : undefined}
>
{block.label}
</a>
</div>
<style>
.cta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin: var(--space-4) 0;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--line);
border-radius: 10px;
max-width: var(--measure);
}
.cta-text { font-size: 14.5px; color: var(--ink-2); }
.cta-btn {
margin-left: auto;
font-size: 13px;
color: var(--ink-1);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.32rem 0.9rem;
text-decoration: none;
white-space: nowrap;
}
.cta-btn:hover { border-color: var(--accent); color: var(--accent); }
</style>
+20
View File
@@ -0,0 +1,20 @@
---
// divider 块:节奏控制(line = hairline 分隔;space = 纯留白)
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'divider' }>;
}
const { block } = Astro.props;
---
{block.style === 'space' ? <div class="gap" aria-hidden="true"></div> : <hr />}
<style>
hr {
border: 0;
border-top: 1px solid var(--line);
margin: var(--space-5) 0;
}
.gap { height: var(--space-6); }
</style>
+38
View File
@@ -0,0 +1,38 @@
---
// figure 块:图片(Astro 图片管线,宽度自适应;caption 图注)
import { Image } from 'astro:assets';
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'figure' }>;
}
const { block } = Astro.props;
---
<figure class="fig">
<Image
src={block.image}
alt={block.alt}
widths={[480, 960]}
sizes="(min-width: 820px) 640px, 92vw"
loading="lazy"
class="fig-img"
/>
{block.caption && <figcaption>{block.caption}</figcaption>}
</figure>
<style>
.fig { margin: var(--space-4) 0; }
.fig-img {
width: 100%;
height: auto;
border-radius: 10px;
border: 1px solid var(--line);
}
figcaption {
margin-top: var(--space-1);
font-size: 12.5px;
color: var(--ink-4);
text-align: center;
}
</style>
+58
View File
@@ -0,0 +1,58 @@
---
// linklist 块:外链清单(比裸 markdown 链接可控:带 note 与统一行距)
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'linklist' }>;
}
const { block } = Astro.props;
const isExternal = (href: string) => href.startsWith('http');
---
{block.title && <h2 class="sec-title">{block.title}</h2>}
<ul class="links">
{block.items.map((item) => (
<li>
<a
href={item.href}
rel={isExternal(item.href) ? 'noopener noreferrer' : undefined}
target={isExternal(item.href) ? '_blank' : undefined}
>
<span class="link-label">{item.label}</span>
{item.note && <span class="link-note">{item.note}</span>}
{isExternal(item.href) && <span class="link-mark" aria-hidden="true">↗</span>}
</a>
</li>
))}
</ul>
<style>
.sec-title {
font-size: 14px;
font-family: var(--font-mono);
color: var(--ink-3);
letter-spacing: 0.08em;
margin: var(--space-5) 0 var(--space-3);
}
.links {
list-style: none;
margin: 0;
padding: 0;
max-width: var(--measure);
}
.links li { border-bottom: 1px solid var(--line); }
.links li:last-child { border-bottom: none; }
.links a {
display: flex;
align-items: baseline;
gap: var(--space-2);
padding: var(--space-2) 0;
color: var(--ink-2);
text-decoration: none;
}
.links a:hover .link-label { color: var(--accent); }
.link-label { font-size: 15px; transition: color var(--dur) var(--ease); }
.link-note { font-size: 13px; color: var(--ink-4); flex: 1; }
.link-mark { font-size: 12px; color: var(--ink-4); }
</style>
+46
View File
@@ -0,0 +1,46 @@
---
// prose 块:自由正文(markdown 已由读取层渲染为 HTML,净化在 site-content 的插件链)
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'prose' }>;
}
const { block } = Astro.props;
---
{block.title && <h2 class="sec-title">{block.title}</h2>}
<div class:list={['prose-body', { full: block.width === 'full' }]} set:html={block.bodyHtml} />
<style>
.sec-title {
font-size: 14px;
font-family: var(--font-mono);
color: var(--ink-3);
letter-spacing: 0.08em;
margin: var(--space-5) 0 var(--space-3);
}
.prose-body {
font-size: 15.5px;
line-height: 1.75;
color: var(--ink-2);
max-width: var(--measure);
margin-top: var(--space-3);
}
.prose-body.full { max-width: none; }
.prose-body :global(p) { margin-block: 0 var(--space-2); }
.prose-body :global(ul) {
margin: var(--space-1) 0 var(--space-2);
padding-left: 1.2em;
list-style: disc;
}
.prose-body :global(li) { margin-block: 0.2em; }
.prose-body :global(a) { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.prose-body :global(strong) { color: var(--ink-1); font-weight: 600; }
.prose-body :global(code) {
font-family: var(--font-mono);
font-size: 0.88em;
background: var(--bg-soft);
border-radius: 5px;
padding: 1px 6px;
}
</style>
+36
View File
@@ -0,0 +1,36 @@
---
// quote 块:引文(复用 .quote-line 语言 + 楷体)
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'quote' }>;
}
const { block } = Astro.props;
---
<blockquote class="quote-line">
<p>{block.text}</p>
{block.cite && <cite>— {block.cite}</cite>}
</blockquote>
<style>
.quote-line {
margin: var(--space-4) 0;
padding-left: var(--space-4);
border-left: 2px solid var(--line);
max-width: var(--measure);
}
.quote-line p {
font-family: var(--font-kai, var(--font-serif));
font-size: 16.5px;
line-height: 1.8;
color: var(--ink-2);
}
.quote-line cite {
display: block;
margin-top: var(--space-1);
font-style: normal;
font-size: 13px;
color: var(--ink-4);
}
</style>
+66
View File
@@ -0,0 +1,66 @@
---
// timeline 块:经历/项目时间线(竖线语言,仅用既有 token)
import type { ResolvedBlock } from '../../lib/page-blocks';
interface Props {
block: Extract<ResolvedBlock, { type: 'timeline' }>;
}
const { block } = Astro.props;
---
{block.title && <h2 class="sec-title">{block.title}</h2>}
<ol class="tl">
{block.items.map((item) => (
<li class="tl-item">
<span class="tl-time mono">{item.time}</span>
<div class="tl-body">
<strong>{item.title}</strong>
{item.desc && <p>{item.desc}</p>}
</div>
</li>
))}
</ol>
<style>
.sec-title {
font-size: 14px;
font-family: var(--font-mono);
color: var(--ink-3);
letter-spacing: 0.08em;
margin: var(--space-5) 0 var(--space-3);
}
.tl {
list-style: none;
margin: 0;
padding: 0 0 0 var(--space-3);
border-left: 1px solid var(--line);
display: grid;
gap: var(--space-3);
}
.tl-item {
position: relative;
display: flex;
gap: var(--space-3);
align-items: baseline;
}
.tl-item::before {
content: '';
position: absolute;
left: calc(-1 * var(--space-3) - 4px);
top: 0.55em;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--line);
border: 1px solid var(--bg-1, transparent);
}
.tl-time {
flex: none;
font-size: 12.5px;
color: var(--ink-4);
font-variant-numeric: tabular-nums;
min-width: 5.5em;
}
.tl-body strong { font-size: 15px; color: var(--ink-1); }
.tl-body p { font-size: 14px; color: var(--ink-3); margin-top: 2px; }
</style>
+70
View File
@@ -0,0 +1,70 @@
---
// tracks 块:兴趣/能力版图(复用原 AboutPage 的 .track-row 视觉语言)
import type { ResolvedBlock } from '../../lib/page-blocks';
import { iconPath } from '../../lib/icons';
interface Props {
block: Extract<ResolvedBlock, { type: 'tracks' }>;
}
const { block } = Astro.props;
---
<h2 class="sec-title">{block.title}</h2>
<ul>
{block.items.map((item) => (
<li class="track-row">
<span class="track-icon" aria-hidden="true">
{iconPath(item.icon) ? (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d={iconPath(item.icon)}></path>
</svg>
) : (
<span class="track-icon-fallback">{item.name.slice(0, 1)}</span>
)}
</span>
<div>
<strong>{item.name}</strong>
{item.desc && <p>{item.desc}</p>}
</div>
</li>
))}
</ul>
<style>
.sec-title {
font-size: 14px;
font-family: var(--font-mono);
color: var(--ink-3);
letter-spacing: 0.08em;
margin: var(--space-5) 0 var(--space-3);
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-2);
}
.track-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
border: 1px solid var(--line);
border-radius: 10px;
padding: var(--space-3) var(--space-4);
}
.track-icon {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: var(--bg-soft);
color: var(--ink-3);
}
.track-icon-fallback { font-family: var(--font-mono); font-size: 13px; }
.track-row strong { font-size: 15px; color: var(--ink-1); }
.track-row p { font-size: 14px; color: var(--ink-3); margin-top: 2px; }
</style>
+66 -50
View File
@@ -3,70 +3,86 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import IdCard from '../../components/IdCard.astro';
import { t } from '../../i18n/ui';
import type { Locale } from '../../lib/locale';
import { aboutIntroMarkdown, renderMarkdown } from '../../lib/site-content';
import { aboutIntroMarkdown, pageTypography, renderMarkdown } from '../../lib/site-content';
import { getAboutPage } from '../../lib/page-blocks';
import BlockRenderer from '../blocks/BlockRenderer.astro';
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
interface Props {
locale?: Locale;
}
const { locale = 'zh' } = Astro.props;
// 身份陈述段改由 content/site/about.yaml 供给(多段 markdown,后台可改),缺值回落默认
const introHtml = await renderMarkdown(await aboutIntroMarkdown(locale));
// 三级回落(零回归):blocks 驱动 → intro markdown → 内置默认文案
const { blocks, showIdCard } = await getAboutPage(locale);
const introHtml =
blocks.length === 0 ? await renderMarkdown(await aboutIntroMarkdown(locale)) : '';
const typo = await pageTypography('siteAbout', 'about');
---
<BaseLayout title={`${t('about_title', locale)} · wait4x`}>
<h1 class="page-title">{t('about_title', locale)}</h1>
<div class="intro" set:html={introHtml} />
<div class={typo.className || undefined} style={typo.style || undefined}>
{blocks.length > 0 ? (
<>
{blocks.map((block) => <BlockRenderer block={block} />)}
{showIdCard && <IdCard />}
</>
) : (
<>
<div class="intro rich" set:html={introHtml} />
<IdCard />
<IdCard />
<section class="tracks" aria-label={t('about_tracks_title', locale)}>
<h2 class="sec-title">{t('about_tracks_title', locale)}</h2>
<ul>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 4h6a4 4 0 0 1 4 4v13a3 3 0 0 0-3-3H2z"></path>
<path d="M22 4h-6a4 4 0 0 0-4 4v13a3 3 0 0 1 3-3h7z"></path>
</svg>
</span>
<div>
<strong>{t('about_track_reading_name', locale)}</strong>
<p>{t('about_track_reading_desc', locale)}</p>
</div>
</li>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="m4 5.5 6.5 6.5L4 18.5"></path>
<path d="M12.5 18.5H20"></path>
</svg>
</span>
<div>
<strong>{t('about_track_tech_name', locale)}</strong>
<p>{t('about_track_tech_desc', locale)}</p>
</div>
</li>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path>
<circle cx="12" cy="13" r="4"></circle>
</svg>
</span>
<div>
<strong>{t('about_track_photo_name', locale)}</strong>
<p>{t('about_track_photo_desc', locale)}</p>
</div>
</li>
</ul>
</section>
<section class="tracks" aria-label={t('about_tracks_title', locale)}>
<h2 class="sec-title">{t('about_tracks_title', locale)}</h2>
<ul>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 4h6a4 4 0 0 1 4 4v13a3 3 0 0 0-3-3H2z"></path>
<path d="M22 4h-6a4 4 0 0 0-4 4v13a3 3 0 0 1 3-3h7z"></path>
</svg>
</span>
<div>
<strong>{t('about_track_reading_name', locale)}</strong>
<p>{t('about_track_reading_desc', locale)}</p>
</div>
</li>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="m4 5.5 6.5 6.5L4 18.5"></path>
<path d="M12.5 18.5H20"></path>
</svg>
</span>
<div>
<strong>{t('about_track_tech_name', locale)}</strong>
<p>{t('about_track_tech_desc', locale)}</p>
</div>
</li>
<li class="track-row">
<span class="track-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path>
<circle cx="12" cy="13" r="4"></circle>
</svg>
</span>
<div>
<strong>{t('about_track_photo_name', locale)}</strong>
<p>{t('about_track_photo_desc', locale)}</p>
</div>
</li>
</ul>
</section>
<section class="build" aria-label={t('about_build_aria', locale)}>
<h2 class="sec-title">{t('about_build_title', locale)}</h2>
<p>{t('about_build_desc', locale)}</p>
</section>
<section class="build" aria-label={t('about_build_aria', locale)}>
<h2 class="sec-title">{t('about_build_title', locale)}</h2>
<p>{t('about_build_desc', locale)}</p>
</section>
</>
)}
</div>
</BaseLayout>
<style>