Files
blog/src/components/PostList.astro
T
wait4xandGLM-5.3 9b11070dd4 fix(archives): 置顶文章不再把整月拽到顶部——置顶浮出分组流自成首节
用户实测:某月有一篇置顶时,该月全部文章跟着上浮。根因:sortPosts
置顶优先后,年/月分组按顺次建桶——置顶文章让所在月提前建桶到顶部。
修复:置顶文章单独渲染为分组流之前的一节(带徽标与计数),年/月分组
只对非置顶文章按自然时序建桶,月份各归各位。

验证: pnpm astro build exit 0;产物断言分组头顺序=置顶,2026-11,2026-04,2026-03,2026-02 且置顶文仅出现一次;E2E 61/61

Spec: none (用户实测报告的排序分组 bug 紧急修复)

Co-authored-by: GLM-5.3
2026-11-05 19:20:02 +08:00

453 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
// 年份分组 + 轨道分节的文章列表(首页与归档页共用)
// 轨道标签单一事实源: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';
interface Props {
/** 调用方已完成 draft 过滤与 pubDate 倒序 */
posts: CollectionEntry<'posts'>[];
locale?: 'zh' | 'en';
/** slug → 译题(en 列表显示英文标题,视觉复核范围外观察) */
titleOverrides?: Record<string, string>;
/** 截断条数(首页 6 篇);缺省全量 */
limit?: number;
/** 截断后的「全部文章 →」出口链接;缺省不渲染 */
moreHref?: string;
/** 分轨模式:阅读轨与技朮轨各取 N 条(首页两段式,各段自带「更多」出口) */
perTrack?: number;
/** true = 归档折叠形态:按年 details(最新年开)+年内按月分组 */
fold?: boolean;
}
const { posts, locale = 'zh', titleOverrides, limit, moreHref, perTrack, 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),
tech: t('category_tech', locale),
photo: t('category_photo', locale),
};
const hrefOf = (id: string) => (locale === 'en' ? `/en/archives/${id}/` : `/archives/${id}/`);
// 置顶浮出分组流(2026-11-05 用户实测修复):置顶文章若留在分组流里,
// 它所在月会因「顺次建桶」被提前建到顶部,整月文章跟着上浮——
// 所以置顶单独一节渲染在年/月分组之前,分组只对非置顶文章按自然时序建桶。
const pinned = visible.filter((p) => p.data.pinned);
const flow = visible.filter((p) => !p.data.pinned);
const byYear = new Map<number, CollectionEntry<'posts'>[]>();
for (const post of flow) {
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()];
};
---
{perTrack ? (
<div class="post-list per-track">
{(
[
{ key: 'reading' as PostCategory, more: locale === 'en' ? '/en/books/' : '/books/' },
{ key: 'tech' as PostCategory, more: locale === 'en' ? '/en/archives/' : '/archives/' },
] as const
).map(({ key, more }) => {
const rows = posts.filter((post) => post.data.category === key).slice(0, perTrack);
if (rows.length === 0) return null;
return (
<section class="track-sec" aria-label={TRACK_LABEL[key]}>
<h2 class="track-sec-head">
<span>{TRACK_LABEL[key]}</span>
<a class="more-link tap" href={more}>{t('home_more_posts', locale)}</a>
</h2>
<ul>
{rows.map((post) => (
<li>
<a class="row tap" href={hrefOf(post.id)}>
{key === '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.pinned && (
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</span>
)}
{key !== '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>
) : fold ? (
<div class="post-list post-list-fold">
{pinned.length > 0 && (
<section class="month-block pinned-block">
<h3 class="month-head">
<span>{t('pinned_label', locale)}</span>
<span class="mono month-count">· {pinned.length} {units}</span>
</h3>
<ul>
{pinned.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>
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</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>
)}
{[...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.pinned && (
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</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">
{pinned.length > 0 && (
<section class="year-block pinned-block" aria-label={t('pinned_label', locale)}>
<h2 class="year">{t('pinned_label', locale)}</h2>
<ul>
{pinned.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>
)}
{[...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>
{post.data.pinned && (
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</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); }
.year {
font-size: 15px;
font-weight: 600;
color: var(--ink-2);
font-variant-numeric: tabular-nums;
}
.shelf-link { float: right; font-size: 12.5px; padding-block: 4px; color: var(--accent); font-weight: 400; }
.track {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-3) 0 var(--space-1);
font-size: 12.5px;
font-weight: 400;
color: var(--ink-4);
letter-spacing: 0.06em;
}
.track::before {
content: '';
width: 7px;
height: 7px;
border-radius: 2px;
background: var(--ink-4);
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
.row {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
border-bottom: 1px solid var(--line);
}
li:last-child > .row { border-bottom: none; }
.emo {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
flex: none;
}
.row-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
min-width: 0;
font-size: 16px;
color: var(--ink-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color var(--dur) var(--ease);
}
.row:hover .row-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden; color: var(--accent); }
/* 置顶标记:灰阶 hairline 小徽标,不抢标题注意力 */
.pin {
font-family: var(--font-mono);
font-size: 10.5px;
line-height: 1.5;
color: var(--ink-4);
border: 1px solid var(--line);
border-radius: 4px;
padding: 0 5px;
white-space: nowrap;
flex: none;
}
.tag {
flex: none;
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-2);
background: var(--bg-soft);
border-radius: 5px;
padding: 1px 7px;
white-space: nowrap;
}
.row-date {
flex: none;
font-family: var(--font-mono);
font-size: 13px;
color: var(--ink-4);
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 { margin-left: auto; }
.more-link:hover { border-bottom-color: var(--accent); }
/* ---- 首页分轨形态(perTrack):阅读/技朮两段,各段自带更多出口 ---- */
.track-sec + .track-sec { margin-top: var(--space-5); }
.track-sec-head {
display: flex;
align-items: baseline;
gap: 0.35rem;
/* 不能用 space-between:本行有三个 flex 子项(::before 灰点 + 标题文本 +
右侧「全部文章 →」),space-between 会把中间的标题挤到正中(用户实测
"阅读/技术居中了")。改为贴左,链接自行右推。 */
justify-content: flex-start;
margin: 0 0 var(--space-1);
font-size: 13px;
font-weight: 600;
color: var(--ink-3);
letter-spacing: 0.05em;
}
.track-sec-head::before {
content: '';
width: 7px;
height: 7px;
border-radius: 2px;
background: var(--ink-4);
margin-right: 0.35rem;
}
/* ---- 归档折叠形态(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); }
/* 置顶节与第一个年折叠块之间的间隔 */
.pinned-block + .year-fold { margin-top: var(--space-4); }
.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>