Files
blog/src/components/pages/HomePage.astro
T
wait4xandGLM-5.3 49acb4cbd1 fix(posts): 隐藏开关是假开关——修「隐藏+置顶仍显示并排第一」;贡献数据去重
用户实测:「有的文章同时选择了置顶和隐藏,还会显示并置顶」。

根因:后台「隐藏」开关(hiddenTag)是个假开关——它只写 `hiddenTag: true` 这个
没人读的字段,而全站过滤只认 `tags` 里的 `hidden`。于是隐藏从未生效;
叠加置顶后,这篇还在列表最前面。

- 新增 src/lib/post-visibility.ts:可见性契约单一事实源——`!draft && !hidden &&
  !tags.includes('hidden')`(两条通道都认,历史写法兼容)
- postsSchema 增真字段 `hidden`;七处消费点(首页双轨/归档 zh/en/书架/RSS zh/en/
  文章页导航)统一走 isVisiblePost
- 后台假开关 hiddenTag 换成真开关 hidden,hint 写清语义:不进列表/RSS/sitemap,
  页面本身仍生成(链接可分享);与置顶同时打开时以隐藏为准
- sitemap 过滤隐藏文章(astro.config 配置期扫 frontmatter,两种写法都认;
  内容目录不可读时宁可不排除也不炸构建)

贡献数据去重(5j T2,回答用户「连续贡献是否实时」):
- 连续贡献不再构建期抓取 streak.svg 快照(不实时、暗色需滤镜、与热力图同源重复),
  改为热力图同一份 GraphQL 日历本地计算(src/lib/heatmap.ts streakStats):
  当前连续(以最后有贡献日为终点)/ 最长 / 总贡献,界面上标注「数据更新于 X」
- 贡献蛇块与自托管 snake/streak SVG 删除,fetch 脚本 ASSETS 清空,
  build.sh 守卫改为退役说明;开源页数据板整块并入热力图区

验证: 构建 51 页 exit 0;content-lint 52 页 0/0/0;E2E 56/56;
      临时两篇「置顶+隐藏」(新字段/旧 tags 各一)实测——首页/归档/书架/
      双语首页/RSS 双语/sitemap 全部零命中,页面本身仍生成;
      清理后归档首条恢复 writing-template(普通置顶不受影响);
      开源页 snake/streak 资产零残留、连续统计条与更新时间标注就位

Spec: 2026-11-03_blog-5j-content-refine (T2 + 隐藏 bug 修复)

Co-authored-by: GLM-5.3
2026-11-04 22:00:15 +08:00

218 lines
7.2 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.
---
// 首页(zh/en 共用):名片区 / 文章列表 / 最新影像 / 热力图 / 项目墙 / 心跳
// 两个 index.astro 只是薄壳(传 locale),区块逻辑与样式集中在这里,避免双份漂移。
import { Image } from 'astro:assets';
import { isVisiblePost } from '../../lib/post-visibility';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import IdCard from '../IdCard.astro';
import PostList from '../PostList.astro';
import ProjectWall from '../geek/ProjectWall.astro';
import Heatmap from '../geek/Heatmap.astro';
import SocialLinks from '../SocialLinks.astro';
import { t } from '../../i18n/ui';
import { sortPosts } from '../../lib/post-order';
import type { Locale } from '../../lib/locale';
import {
homeConfig,
homeTagline,
pageTypography,
renderMarkdown,
renderRich,
} from '../../lib/site-content';
import { fetchHeartbeat, agoZh } from '../../lib/heartbeat';
interface Props {
locale?: Locale;
}
const { locale = 'zh' } = Astro.props;
// 首页可配项(后台主页设置):副标题/影像张数/区块开关/每轨条数/项目墙条数/公告/轨细分
const cfg = await homeConfig(locale);
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) : '';
// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
const posts = sortPosts(
await getCollection('posts', ({ data }) => isVisiblePost(data)),
);
// en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
const enTitle = locale === 'en'
? Object.fromEntries((await getCollection('postsEn')).map((e) => [e.id, e.data.title]))
: undefined;
// 最新影像取集合前 N 张;照片时间序由照片管线治理,此处只裁切
const photos = (await getCollection('photos')).slice(0, Math.max(1, cfg.latestPhotos));
const tagline = await homeTagline(locale);
const taglineHtml = await renderRich(tagline, { inline: true });
const typo = await pageTypography('siteHome', 'home');
// 心跳只出现在中文首页(en 轨历史形态如此,保持零回归)
const heartbeat = locale === 'zh' ? await fetchHeartbeat() : null;
const galleryHref = locale === 'en' ? '/en/gallery/' : '/gallery/';
// 轨细分开关:只保留开启的轨道(全开时等价于不过滤,零回归)
const activeTracks = Object.entries(cfg.postsTracks)
.filter(([, on]) => on)
.map(([k]) => k);
const allTracksOn = activeTracks.length === Object.keys(cfg.postsTracks).length;
const visiblePosts = allTracksOn
? posts
: posts.filter((p) => activeTracks.includes(p.data.category));
---
<BaseLayout
title="wait4x"
description={locale === 'en' ? "wait4x's blog: reading · code · photography" : undefined}
>
<div class={typo.className || undefined} style={typo.style || undefined}>
<IdCard>
<p class="tagline rich" set:html={taglineHtml} />
{subtitleHtml && <div class="subtitle" set:html={subtitleHtml} />}
<SocialLinks />
</IdCard>
{announcementHtml && (
<div class="announcement rich" set:html={announcementHtml} aria-label={locale === 'en' ? 'Announcement' : '公告'} />
)}
{cfg.sections.tracks && (
<PostList
posts={visiblePosts}
locale={locale}
titleOverrides={enTitle}
perTrack={cfg.perTrackLimit}
/>
)}
{cfg.sections.photos && cfg.latestPhotos > 0 && (
<section class="latest" aria-label={t('latest_photos', locale)}>
<div class="latest-head">
<span class="lab">{t('latest_photos', locale)}</span>
<a class="tap more" href={galleryHref}>{t('enter_gallery', locale)}</a>
</div>
<div class="thumbs">
{photos.map((photo, i) => (
<a class="thumb" href={galleryHref} aria-label={`${t('latest_photos', locale)}: ${photo.data.title}`}>
{/* 首两图 eager(Astro Image 默认 lazy 会把 LCP 压到 6s+),首图再拔 fetchpriority */}
<Image
src={photo.data.image}
alt={photo.data.alt ?? photo.data.title}
widths={[180, 360]}
sizes="(min-width: 820px) 20vw, 42vw"
loading={i < 2 ? 'eager' : 'lazy'}
fetchpriority={i === 0 ? 'high' : undefined}
/>
</a>
))}
</div>
</section>
)}
{cfg.sections.heatmap && (
<Heatmap
labels={{
title: t('heatmap_title', locale),
summary: t('heatmap_summary', locale),
less: t('heatmap_less', locale),
more: t('heatmap_more', locale),
note: t('heatmap_note', locale),
}}
/>
)}
{cfg.sections.projects && <ProjectWall limit={cfg.projectsLimit} />}
{heartbeat && (
<p class="heartbeat" aria-label="最近心跳">
<span class="beat-dot" aria-hidden="true"></span>
最近心跳:{agoZh(heartbeat.when)} · {heartbeat.msg}
</p>
)}
</div>
</BaseLayout>
<style>
.subtitle {
font-size: 13.5px;
color: var(--ink-4);
margin-top: 2px;
letter-spacing: 0.01em;
}
.subtitle :global(p) { margin: 0; }
.subtitle :global(a) { color: var(--accent); }
.tagline {
font-size: 14px;
color: var(--ink-3);
margin-top: 4px;
letter-spacing: 0.01em;
}
/* 公告块:独立于区块开关,空则不渲染(markdown 富文本,左缘细线做提示) */
.announcement {
margin-top: var(--space-4);
padding: var(--space-2) 0 var(--space-2) var(--space-3);
border-left: 2px solid var(--line);
font-size: 14px;
line-height: 1.7;
color: var(--ink-2);
}
.announcement :global(p) { margin: 0; }
.announcement :global(p + p) { margin-top: 0.6em; }
.announcement :global(a) { color: var(--accent); }
.heartbeat {
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--ink-3);
margin-top: var(--space-2);
display: flex;
align-items: center;
gap: 8px;
}
.beat-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--ink-2);
animation: beat 2.4s ease-in-out infinite;
}
@keyframes beat { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .beat-dot { animation: none; } }
.latest { margin-top: var(--space-6); }
.latest-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.lab {
font-size: 12.5px;
color: var(--ink-4);
letter-spacing: 0.08em;
}
.more {
font-size: 13px;
color: var(--accent);
padding-inline: var(--space-2);
transition: color var(--dur) var(--ease);
}
.more:hover { color: var(--ink-1); }
.thumbs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
margin-top: var(--space-3);
}
.thumb {
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
}
.thumb :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--dur) var(--ease);
}
.thumb:hover :global(img) { transform: scale(1.03); }
@media (min-width: 820px) {
.thumbs { grid-template-columns: repeat(4, 1fr); }
}
</style>