feat(archive): 文章折叠——首页 6 篇+归档年月折叠+书架年折叠

①首页 PostList limit=6 + 「全部文章 →」双轨入口(用户拍板 6 篇)
②/archives 年 <details>(最新年 open,summary「2026 · N 篇」+▸ 自绘
箭头)+ 年内按月分组小标题;零 JS 依赖、内容全在 DOM(SEO
无损)③/books 年折叠(色脊/书封渲染零改动)④E2E +3 断言
(首页≤6+入口/归档 open 唯一+月分组/书架年组不变量)

E2E 弦键竞态根治:折叠批的多次导航扰动 g-chords 时序——洁净起
点(先回稳定页)再测弦,测试意图不依赖前序状态,37/37 连跑稳定

车道:op-gitea(lint 43 页 0/0/0);收编复验首页 6 行+入口/归档
1 年开 12 月组/书架年折叠/en 轨全过

验证: E2E 37/37 连跑两次;content-lint 0/0/0;首页/归档/书架/
en 四页 playwright 实测

Spec: none (折叠批,车道级交付)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-02 10:00:45 +08:00
1 parent 9702c9faf4
commit 3b951d3de6
8 files changed
+257 -57

No files matched your search

+175 -41
View File
@@ -1,6 +1,9 @@
---
// 年份分组 + 轨道分节的文章列表(首页与 /archives/ 共用)
// 年份分组 + 轨道分节的文章列表(首页与归档页共用)
// 轨道标签单一事实源:content.config 的 POST_TRACKS,此处不另立词表
// 双形态(2026-11-02 用户拍板折叠需求):
// 默认形态 = 首页/列表平铺(年 → 轨道分节),可 limit 截断 + moreHref 出口
// fold 形态 = 归档页:按年 <details>(最新年默认开)+ 年内按月分组,零 JS 原生折叠
import type { CollectionEntry } from 'astro:content';
import { POST_TRACKS, type PostCategory } from '../content.config';
import { t } from '../i18n/ui';
@@ -11,8 +14,15 @@ interface Props {
locale?: 'zh' | 'en';
/** slug → 译题(en 列表显示英文标题,视觉复核范围外观察) */
titleOverrides?: Record<string, string>;
/** 截断条数(首页 6 篇);缺省全量 */
limit?: number;
/** 截断后的「全部文章 →」出口链接;缺省不渲染 */
moreHref?: string;
/** true = 归档折叠形态:按年 details(最新年开)+年内按月分组 */
fold?: boolean;
}
const { posts, locale = 'zh', titleOverrides } = Astro.props;
const { posts, locale = 'zh', titleOverrides, limit, moreHref, fold = false } = Astro.props;
const visible = typeof limit === 'number' ? posts.slice(0, limit) : posts;
// 轨道标签走字典(en 首页复用本组件时的双语位);zh 保持原词表语义
const TRACK_LABEL: Record<PostCategory, string> = {
reading: t('category_reading', locale),
@@ -22,59 +32,125 @@ const TRACK_LABEL: Record<PostCategory, string> = {
const hrefOf = (id: string) => (locale === 'en' ? `/en/archives/${id}/` : `/archives/${id}/`);
const byYear = new Map<number, CollectionEntry<'posts'>[]>();
for (const post of posts) {
for (const post of visible) {
const year = post.data.pubDate.getUTCFullYear();
const bucket = byYear.get(year);
if (bucket) bucket.push(post);
else byYear.set(year, [post]);
}
const units = t('unit_posts', locale);
const mmdd = (d: Date) =>
`${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
// 月分组标签:zh「2026 年 11 月」/ en「November 2026」(UTC,与年份口径一致)
const monthLabel = (d: Date) =>
locale === 'zh'
? `${d.getUTCFullYear()} 年 ${d.getUTCMonth() + 1} 月`
: `${d.toLocaleString('en-US', { month: 'long', timeZone: 'UTC' })} ${d.getUTCFullYear()}`;
const byMonth = (list: CollectionEntry<'posts'>[]) => {
const m = new Map<string, CollectionEntry<'posts'>[]>();
for (const post of list) {
const key = `${post.data.pubDate.getUTCFullYear()}-${post.data.pubDate.getUTCMonth()}`;
const bucket = m.get(key);
if (bucket) bucket.push(post);
else m.set(key, [post]);
}
return [...m.entries()];
};
---
<div class="post-list">
{[...byYear.entries()].map(([year, list]) => (
<section class="year-block" aria-label={`${year} 年文章`}>
<h2 class="year">{year}</h2>
{(Object.keys(POST_TRACKS) as PostCategory[]).map((cat) => {
const rows = list.filter((post) => post.data.category === cat);
if (rows.length === 0) return null;
return (
<div class="track-block">
<h3 class="track">
{fold ? (
<div class="post-list post-list-fold">
{[...byYear.entries()].map(([year, list], i) => (
<details class="year-fold" open={i === 0}>
<summary class="year-summary">
<span class="year-num">{year}</span>
<span class="year-count mono">· {list.length} {units}</span>
</summary>
<div class="year-body">
{byMonth(list).map(([key, rows]) => (
<section class="month-block">
<h3 class="month-head">
<span>{monthLabel(rows[0].data.pubDate)}</span>
<span class="mono month-count">· {rows.length} {units}</span>
</h3>
<ul>
{rows.map((post) => (
<li>
<a class="row tap" href={hrefOf(post.id)}>
{post.data.category === 'reading' && (
<i
class="emo"
style={`background:${post.data.mood ?? 'var(--mood)'}`}
title="情绪色"
aria-hidden="true"
/>
)}
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
{post.data.category !== 'reading' &&
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
{mmdd(post.data.pubDate)}
</time>
</a>
</li>
))}
</ul>
</section>
))}
</div>
</details>
))}
</div>
) : (
<div class="post-list">
{[...byYear.entries()].map(([year, list]) => (
<section class="year-block" aria-label={`${year} 年文章`}>
<h2 class="year">{year}</h2>
{(Object.keys(POST_TRACKS) as PostCategory[]).map((cat) => {
const rows = list.filter((post) => post.data.category === cat);
if (rows.length === 0) return null;
return (
<div class="track-block">
<h3 class="track">
{TRACK_LABEL[cat]}
{cat === 'reading' && (
<a class="shelf-link" href={locale === 'en' ? '/en/books/' : '/books/'}>{locale === 'en' ? 'Books →' : '书架 →'}</a>
)}
</h3>
<ul>
{rows.map((post) => (
<li>
<a class="row tap" href={hrefOf(post.id)}>
{cat === 'reading' && (
<i
class="emo"
style={`background:${post.data.mood ?? 'var(--mood)'}`}
title="情绪色"
aria-hidden="true"
/>
)}
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
{cat !== 'reading' &&
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
{mmdd(post.data.pubDate)}
</time>
</a>
</li>
))}
</ul>
</div>
);
})}
</section>
))}
</div>
<ul>
{rows.map((post) => (
<li>
<a class="row tap" href={hrefOf(post.id)}>
{cat === 'reading' && (
<i
class="emo"
style={`background:${post.data.mood ?? 'var(--mood)'}`}
title="情绪色"
aria-hidden="true"
/>
)}
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
{cat !== 'reading' &&
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
{mmdd(post.data.pubDate)}
</time>
</a>
</li>
))}
</ul>
</div>
);
})}
</section>
))}
{moreHref && (
<p class="more-row">
<a class="more-link tap" href={moreHref}>{t('home_more_posts', locale)}</a>
</p>
)}
</div>
)}
<style>
.year-block + .year-block { margin-top: var(--space-5); }
@@ -159,4 +235,62 @@ const mmdd = (d: Date) =>
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.more-row { margin: var(--space-3) 0 0; text-align: right; }
.more-link {
font-family: var(--font-mono);
font-size: 13px;
color: var(--accent);
border-bottom: 1px solid transparent;
}
.more-link:hover { border-bottom-color: var(--accent); }
/* ---- 归档折叠形态(fold)---- */
.year-fold { border-top: 1px solid var(--line); }
.year-fold:last-child { border-bottom: 1px solid var(--line); }
.year-summary {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: var(--space-2) 0;
cursor: pointer;
list-style: none;
color: var(--ink-2);
}
.year-summary::-webkit-details-marker { display: none; }
.year-summary::before {
content: '▸';
font-size: 11px;
color: var(--ink-4);
transition: transform var(--dur) var(--ease);
transform-origin: 50% 50%;
display: inline-block;
width: 1em;
}
.year-fold[open] > .year-summary::before { transform: rotate(90deg); }
.year-summary:hover { color: var(--ink-1); }
.year-num { font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
.year-count { font-size: 12px; color: var(--ink-4); }
.year-body { padding-bottom: var(--space-2); }
.month-block + .month-block { margin-top: var(--space-3); }
.month-head {
display: flex;
align-items: baseline;
gap: 0.35rem;
margin: var(--space-2) 0 var(--space-1);
font-size: 12.5px;
font-weight: 400;
color: var(--ink-4);
letter-spacing: 0.04em;
}
.month-head::before {
content: '';
width: 7px;
height: 7px;
border-radius: 2px;
background: var(--ink-4);
}
.month-count { font-size: 11.5px; }
@media (prefers-reduced-motion: reduce) {
.year-summary::before { transition: none; }
}
</style>
+33 -6
View File
@@ -28,9 +28,12 @@ const L = {
<BaseLayout track="reading" title={`${L.shelf} · wait4x`} description={locale === 'zh' ? '读过的书与一句策展' : 'Books read, one line each'}>
<h1 class="page-title">{L.shelf}<span class="count mono">{L.count(books.length)}</span></h1>
{years.map((year) => (
<section class="year-group">
<h2 class="y-label mono">{year}</h2>
{years.map((year, yi) => (
<details class="year-group" open={yi === 0}>
<summary class="y-summary">
<span class="y-label mono">{year}</span>
<span class="y-count mono">· {books.filter((b) => b.data.pubDate.getFullYear() === year).length} {locale === 'zh' ? '本' : 'books'}</span>
</summary>
<ul class="shelf">
{books.filter((b) => b.data.pubDate.getFullYear() === year).map((b) => (
<li>
@@ -62,7 +65,7 @@ const L = {
</li>
))}
</ul>
</section>
</details>
))}
</BaseLayout>
@@ -75,8 +78,32 @@ const L = {
gap: var(--space-3);
}
.count { font-size: 13px; color: var(--ink-3); }
.year-group { margin-top: var(--space-5); }
.y-label { font-size: 16px; color: var(--ink-3); margin-bottom: var(--space-3); }
.year-group { margin-top: var(--space-4); border-top: 1px solid var(--line); }
.y-summary {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: var(--space-2) 0;
cursor: pointer;
list-style: none;
}
.y-summary::-webkit-details-marker { display: none; }
.y-summary::before {
content: '▸';
font-size: 11px;
color: var(--ink-4);
transition: transform var(--dur) var(--ease);
transform-origin: 50% 50%;
display: inline-block;
width: 1em;
}
.year-group[open] > .y-summary::before { transform: rotate(90deg); }
.y-summary:hover .y-label { color: var(--ink-2); }
.y-count { font-size: 12px; color: var(--ink-4); }
@media (prefers-reduced-motion: reduce) {
.y-summary::before { transition: none; }
}
.y-label { font-size: 16px; color: var(--ink-3); margin-bottom: 0; transition: color var(--dur) var(--ease); }
.shelf {
list-style: none;
padding: 0;