用户实测:某月有一篇置顶时,该月全部文章跟着上浮。根因: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
453 lines
16 KiB
Plaintext
453 lines
16 KiB
Plaintext
---
|
||
// 年份分组 + 轨道分节的文章列表(首页与归档页共用)
|
||
// 轨道标签单一事实源: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>
|