阅读进度 + 继续阅读 + 文章系列 + 完整 SEO

This commit is contained in:
2026-07-17 12:34:19 +08:00 Unverified
parent 336dcc7670
commit 6ac59f404b
9 changed files with 424 additions and 4 deletions
+49
View File
@@ -0,0 +1,49 @@
---
import type { BlogPost } from '@/lib/posts';
import { siteConfig } from '../../site.config.mjs';
interface Props {
currentPost: BlogPost;
posts: BlogPost[];
}
const { currentPost, posts } = Astro.props;
const currentIndex = posts.findIndex((post) => post.slug === currentPost.slug);
const previousPost = currentIndex > 0 ? posts[currentIndex - 1] : undefined;
const nextPost = currentIndex >= 0 && currentIndex < posts.length - 1 ? posts[currentIndex + 1] : undefined;
const labels = siteConfig.series?.labels ?? {};
const seriesKey = currentPost.series ?? '';
const seriesTitle = labels[seriesKey] ?? seriesKey;
---
<section class="post-series" aria-labelledby="post-series-title">
<header class="post-series-head">
<div>
<span>ARTICLE SERIES</span>
<h2 id="post-series-title">{seriesTitle}</h2>
</div>
<strong>第 {currentIndex + 1} / {posts.length} 篇</strong>
</header>
<ol class="post-series-list">
{posts.map((post, index) => (
<li class:list={{ 'is-current': post.slug === currentPost.slug }}>
<a href={post.href} aria-current={post.slug === currentPost.slug ? 'page' : undefined}>
<span>{String(index + 1).padStart(2, '0')}</span>
<strong>{post.title}</strong>
</a>
</li>
))}
</ol>
{(previousPost || nextPost) && (
<nav class="post-series-nav" aria-label="系列文章导航">
{previousPost ? (
<a href={previousPost.href} rel="prev"><span>上一篇</span><strong>{previousPost.title}</strong></a>
) : <span></span>}
{nextPost ? (
<a href={nextPost.href} rel="next"><span>下一篇</span><strong>{nextPost.title}</strong></a>
) : <span></span>}
</nav>
)}
</section>
+3
View File
@@ -6,6 +6,7 @@ const posts = defineCollection({
schema: z.object({
title: z.any().optional(),
date: z.any().optional(),
updated: z.any().optional(),
abbrlink: z.any().optional(),
tags: z.any().optional(),
category: z.any().optional(),
@@ -14,6 +15,8 @@ const posts = defineCollection({
description: z.any().optional(),
comments: z.any().optional(),
cover: z.any().optional(),
series: z.any().optional(),
seriesOrder: z.any().optional(),
}).passthrough(),
});
+42 -1
View File
@@ -7,11 +7,31 @@ import { publicSiteConfig, siteConfig } from '../../site.config.mjs';
interface Props {
title?: string;
description?: string;
canonicalPath?: string;
image?: string;
type?: 'website' | 'article';
publishedTime?: string;
modifiedTime?: string;
keywords?: string[];
jsonLd?: Record<string, unknown>;
}
const siteTitle = siteConfig.site.name;
const { title = siteTitle, description = siteConfig.site.description } = Astro.props;
const {
title = siteTitle,
description = siteConfig.site.description,
canonicalPath = Astro.url.pathname,
image = siteConfig.author.avatar,
type = 'website',
publishedTime,
modifiedTime,
keywords = [],
jsonLd,
} = Astro.props;
const pageTitle = title === siteTitle ? siteTitle : `${title} | ${siteTitle}`;
const canonicalUrl = new URL(canonicalPath, siteConfig.site.url).href;
const socialImageUrl = new URL(image, siteConfig.site.url).href;
const jsonLdHtml = jsonLd ? JSON.stringify(jsonLd).replace(/</g, '\\u003c') : '';
const currentYear = new Date().getFullYear();
const publicConfigJson = JSON.stringify(publicSiteConfig).replace(/</g, '\\u003c');
const consoleSites = [
@@ -48,6 +68,27 @@ const consoleSites = [
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<meta name="author" content={siteConfig.author.name} />
{keywords.length > 0 && <meta name="keywords" content={keywords.join(', ')} />}
<link rel="canonical" href={canonicalUrl} />
<link rel="alternate" type="application/rss+xml" title={`${siteTitle} RSS`} href={new URL('/rss.xml', siteConfig.site.url).href} />
<meta property="og:site_name" content={siteTitle} />
<meta property="og:locale" content="zh_CN" />
<meta property="og:type" content={type} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:image" content={socialImageUrl} />
<meta property="og:image:alt" content={title} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={socialImageUrl} />
{publishedTime && <meta property="article:published_time" content={publishedTime} />}
{modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}
{type === 'article' && <meta property="article:author" content={siteConfig.author.name} />}
{keywords.map((keyword) => <meta property="article:tag" content={keyword} />)}
{jsonLd && <script type="application/ld+json" is:inline set:html={jsonLdHtml}></script>}
<link rel="icon" href={siteConfig.assets.icon} />
<script defer src={siteConfig.analytics.umami.script} data-website-id={siteConfig.analytics.umami.websiteId}></script>
<script is:inline set:html={`window.SITE_CONFIG = ${publicConfigJson};`}></script>
+19
View File
@@ -7,8 +7,12 @@ export type BlogPost = {
slug: string;
date: Date;
dateText: string;
updated?: Date;
updatedText?: string;
tags: string[];
categories: string[];
series?: string;
seriesOrder?: number;
summary: string;
comments: boolean;
cover?: string;
@@ -44,6 +48,12 @@ function toDate(value: unknown): Date {
return new Date(0);
}
function toOptionalDate(value: unknown): Date | undefined {
if (value === undefined || value === null || value === '') return undefined;
const date = toDate(value);
return date.valueOf() > 0 ? date : undefined;
}
function formatDate(date: Date): string {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
@@ -79,8 +89,13 @@ export async function getAllPosts(): Promise<BlogPost[]> {
return entries
.map((entry) => {
const date = toDate(entry.data.date);
const updated = toOptionalDate(entry.data.updated);
const slug = String(entry.data.abbrlink ?? fallbackSlug(entry.id));
const title = String(entry.data.title ?? slug);
const series = typeof entry.data.series === 'string' && entry.data.series.trim()
? entry.data.series.trim()
: undefined;
const rawSeriesOrder = Number(entry.data.seriesOrder);
return {
entry,
@@ -88,8 +103,12 @@ export async function getAllPosts(): Promise<BlogPost[]> {
slug,
date,
dateText: formatDate(date),
updated,
updatedText: updated ? formatDate(updated) : undefined,
tags: asArray(entry.data.tags),
categories: getCategories(entry),
series,
seriesOrder: Number.isFinite(rawSeriesOrder) ? rawSeriesOrder : undefined,
summary: makeSummary(entry),
comments: entry.data.comments !== false,
cover: typeof entry.data.cover === 'string' ? entry.data.cover : undefined,
+2 -2
View File
@@ -20,10 +20,10 @@ const pageData = getPageSlice(posts, 1);
<h1>{siteConfig.site.name}</h1>
<p>把折腾、学习和生活片段安静地收在这里。</p>
</div>
<aside id="bber-talk" class="hero-talk" aria-label="璇磋婊氬姩灞曠ず">
<aside id="bber-talk" class="hero-talk" aria-label="说说滚动展示">
<div class="hero-talk-head">
<span>Recent Talks</span>
<a href="/shuoshuo/" aria-label="鏌ョ湅鍏ㄩ儴璇磋">
<a href="/shuoshuo/" aria-label="查看全部说说">
<i class="fa-regular fa-comments" aria-hidden="true"></i>
</a>
</div>
+148 -1
View File
@@ -1,6 +1,7 @@
---
import ArticleSidebar from '@/components/ArticleSidebar.astro';
import PostCopyrightCard from '@/components/PostCopyrightCard.astro';
import PostSeries from '@/components/PostSeries.astro';
import TwikooComments from '@/components/TwikooComments.astro';
import BaseLayout from '@/layouts/BaseLayout.astro';
import { render } from 'astro:content';
@@ -76,9 +77,68 @@ const relatedPosts = allPosts
.filter((item) => item.slug !== post.slug)
.filter((item) => item.categories.some((category) => post.categories.includes(category)))
.slice(0, 8);
const seriesPosts = post.series
? allPosts
.filter((item) => item.series === post.series)
.sort((a, b) => {
const orderA = a.seriesOrder ?? Number.POSITIVE_INFINITY;
const orderB = b.seriesOrder ?? Number.POSITIVE_INFINITY;
if (orderA !== orderB) return orderA - orderB;
return a.date.valueOf() - b.date.valueOf();
})
: [];
const canonicalUrl = new URL(post.href, siteConfig.site.url).href;
const articleImage = new URL(post.cover ?? siteConfig.author.avatar, siteConfig.site.url).href;
const articleJsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.summary,
image: [articleImage],
datePublished: post.date.toISOString(),
dateModified: (post.updated ?? post.date).toISOString(),
wordCount,
inLanguage: siteConfig.site.language,
mainEntityOfPage: { '@type': 'WebPage', '@id': canonicalUrl },
author: {
'@type': 'Person',
name: siteConfig.author.name,
url: siteConfig.author.github,
},
publisher: {
'@type': 'Person',
name: siteConfig.author.name,
image: new URL(siteConfig.author.avatar, siteConfig.site.url).href,
},
keywords: [...post.categories, ...post.tags].join(', '),
articleSection: post.categories[0],
isPartOf: post.series ? { '@type': 'CreativeWorkSeries', name: post.series } : undefined,
};
---
<BaseLayout title={post.title} description={post.summary}>
<BaseLayout
title={post.title}
description={post.summary}
canonicalPath={post.href}
image={post.cover}
type="article"
publishedTime={post.date.toISOString()}
modifiedTime={(post.updated ?? post.date).toISOString()}
keywords={[...post.categories, ...post.tags]}
jsonLd={articleJsonLd}
>
<div class="reading-progress" aria-hidden="true"><span data-reading-progress></span></div>
<aside class="reading-resume" data-reading-resume hidden aria-live="polite">
<div class="reading-resume-icon" aria-hidden="true"><i class="fa-solid fa-book-open"></i></div>
<div>
<strong>继续上次阅读?</strong>
<p>你上次读到了 <span data-reading-resume-percent>--</span>。</p>
</div>
<div class="reading-resume-actions">
<button type="button" data-reading-continue>继续阅读</button>
<button type="button" data-reading-restart>从头开始</button>
</div>
</aside>
<section class="post-layout">
<article class="article post-article">
<header class={post.cover ? 'article-header article-header-cover' : 'article-header'}>
@@ -89,6 +149,7 @@ const relatedPosts = allPosts
{post.cover ? (
<div class="article-hero-meta" aria-label="文章信息">
<span>发布于 {post.dateText}</span>
{post.updatedText && <span>更新于 {post.updatedText}</span>}
{post.categories[0] && <a href={getCategoryHref(post.categories[0])}>{post.categories[0]}</a>}
<span>总字数:{wordCountText}</span>
<span>阅读时长:{readingMinutes} 分钟</span>
@@ -121,6 +182,7 @@ const relatedPosts = allPosts
</div>
</section>
)}
{seriesPosts.length > 1 && <PostSeries currentPost={post} posts={seriesPosts} />}
<aside
class="butterfly-note butterfly-note--warning article-outdated-note"
aria-label="文章时效性提示"
@@ -144,6 +206,91 @@ const relatedPosts = allPosts
</section>
</BaseLayout>
<script define:vars={{ postSlug: post.slug }}>
(() => {
const article = document.querySelector('.post-article');
const progressBar = document.querySelector('[data-reading-progress]');
const resume = document.querySelector('[data-reading-resume]');
const resumePercent = document.querySelector('[data-reading-resume-percent]');
const continueButton = document.querySelector('[data-reading-continue]');
const restartButton = document.querySelector('[data-reading-restart]');
if (!article || !progressBar || !resume) return;
const storageKey = `reading-progress:${postSlug}`;
let saved = null;
let canPersist = true;
let frame = 0;
try {
saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
} catch {
saved = null;
}
const articleMetrics = () => {
const top = article.getBoundingClientRect().top + window.scrollY;
const distance = Math.max(1, article.offsetHeight - window.innerHeight);
return { top, distance };
};
const getProgress = () => {
const { top, distance } = articleMetrics();
return Math.min(1, Math.max(0, (window.scrollY - top) / distance));
};
const persist = (progress) => {
if (!canPersist) return;
try {
if (progress >= 0.97) localStorage.removeItem(storageKey);
else if (progress >= 0.02) localStorage.setItem(storageKey, JSON.stringify({ progress, savedAt: Date.now() }));
} catch {
// Storage can be unavailable in strict privacy modes.
}
};
const update = () => {
frame = 0;
const progress = getProgress();
progressBar.style.transform = `scaleX(${progress})`;
persist(progress);
};
const requestUpdate = () => {
if (!frame) frame = window.requestAnimationFrame(update);
};
const closeResume = () => {
resume.hidden = true;
canPersist = true;
};
const savedIsFresh = saved && Date.now() - Number(saved.savedAt) < 90 * 86400000;
const savedProgress = Number(saved?.progress);
if (savedIsFresh && savedProgress >= 0.05 && savedProgress < 0.97) {
canPersist = false;
if (resumePercent) resumePercent.textContent = `${Math.round(savedProgress * 100)}%`;
window.setTimeout(() => { resume.hidden = false; }, 350);
}
continueButton?.addEventListener('click', () => {
closeResume();
const { top, distance } = articleMetrics();
window.scrollTo({ top: top + distance * savedProgress, behavior: 'smooth' });
});
restartButton?.addEventListener('click', () => {
try { localStorage.removeItem(storageKey); } catch {}
closeResume();
window.scrollTo({ top: Math.max(0, articleMetrics().top - 90), behavior: 'smooth' });
});
update();
window.addEventListener('scroll', requestUpdate, { passive: true });
window.addEventListener('resize', requestUpdate);
window.addEventListener('pagehide', () => persist(getProgress()));
})();
</script>
<script is:inline>
(() => {
document.querySelectorAll('.article-outdated-note[data-published-at]').forEach((note) => {
+1
View File
@@ -12,3 +12,4 @@
@import "./site/12-theme.css";
@import "./site/13-responsive.css";
@import "./site/14-animations.css";
@import "./site/15-reading-series.css";
+153
View File
@@ -0,0 +1,153 @@
.reading-progress {
position: fixed;
inset: 0 0 auto;
z-index: 40;
height: 4px;
overflow: hidden;
background: rgba(15, 118, 110, 0.12);
pointer-events: none;
}
.reading-progress span {
display: block;
width: 100%;
height: 100%;
transform: scaleX(0);
transform-origin: left center;
background: linear-gradient(90deg, #3eb8be, #0f766e);
box-shadow: 0 0 12px rgba(62, 184, 190, 0.56);
will-change: transform;
}
.reading-resume {
position: fixed;
right: max(18px, var(--page-gutter));
bottom: 24px;
z-index: 32;
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
align-items: center;
gap: 13px;
width: min(520px, calc(100vw - 32px));
border: 1px solid rgba(255, 255, 255, 0.66);
border-radius: 14px;
padding: 14px;
color: #27313a;
background: rgba(255, 253, 248, 0.92);
box-shadow: 0 22px 60px rgba(44, 55, 63, 0.24);
backdrop-filter: blur(20px) saturate(165%);
-webkit-backdrop-filter: blur(20px) saturate(165%);
animation: reading-resume-in 0.3s ease both;
}
.reading-resume[hidden] { display: none; }
.reading-resume-icon {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border-radius: 12px;
color: #fff;
background: #3eb8be;
}
.reading-resume strong,
.reading-resume p { margin: 0; }
.reading-resume p { color: var(--muted); font-size: 0.88rem; }
.reading-resume-actions { display: flex; gap: 7px; }
.reading-resume button {
border: 1px solid rgba(15, 118, 110, 0.18);
border-radius: 999px;
padding: 7px 11px;
color: var(--accent);
font: inherit;
font-size: 0.84rem;
font-weight: 800;
background: rgba(236, 253, 245, 0.72);
cursor: pointer;
}
.reading-resume button:first-child { color: #fff; background: #3eb8be; }
.post-series {
margin: 0 0 30px;
overflow: hidden;
border: 1px solid rgba(15, 118, 110, 0.18);
border-radius: 12px;
background: rgba(236, 253, 245, 0.42);
}
.post-series-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 18px;
padding: 18px 20px;
border-bottom: 1px solid rgba(15, 118, 110, 0.14);
}
.post-series-head span { color: var(--accent); font-size: 0.72rem; font-weight: 900; letter-spacing: 0.1em; }
.post-series-head h2 { margin: 2px 0 0; font-size: 1.25rem; }
.post-series-head > strong { color: var(--muted); font-size: 0.86rem; white-space: nowrap; }
.post-series-list {
display: grid;
max-height: 310px;
margin: 0;
overflow-y: auto;
padding: 8px;
list-style: none;
}
.post-series-list a {
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
align-items: center;
gap: 10px;
border-radius: 8px;
padding: 8px 10px;
}
.post-series-list a:hover { background: rgba(62, 184, 190, 0.1); }
.post-series-list li.is-current a { color: #fff; background: #3eb8be; }
.post-series-list span { font-variant-numeric: tabular-nums; opacity: 0.7; }
.post-series-list strong { overflow: hidden; font-size: 0.92rem; text-overflow: ellipsis; white-space: nowrap; }
.post-series-nav {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
border-top: 1px solid rgba(15, 118, 110, 0.14);
}
.post-series-nav a { display: grid; gap: 2px; padding: 13px 18px; }
.post-series-nav a:last-child { text-align: right; border-left: 1px solid rgba(15, 118, 110, 0.14); }
.post-series-nav span { color: var(--muted); font-size: 0.76rem; }
.post-series-nav strong { overflow: hidden; color: var(--accent); font-size: 0.88rem; text-overflow: ellipsis; white-space: nowrap; }
:root[data-theme='dark'] .reading-resume,
:root[data-theme='dark'] .post-series {
border-color: rgba(139, 224, 213, 0.22);
color: #edf7f5;
background: rgba(13, 23, 29, 0.92);
}
:root[data-theme='dark'] .post-series-list a:hover { background: rgba(139, 224, 213, 0.1); }
@keyframes reading-resume-in {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 720px) {
.reading-resume {
right: 16px;
bottom: 16px;
grid-template-columns: 38px minmax(0, 1fr);
}
.reading-resume-icon { width: 38px; height: 38px; }
.reading-resume-actions { grid-column: 1 / -1; }
.reading-resume button { flex: 1; }
.post-series-head { align-items: start; flex-direction: column; gap: 6px; }
.post-series-nav { grid-template-columns: 1fr; }
.post-series-nav a:last-child { border-top: 1px solid rgba(15, 118, 110, 0.14); border-left: 0; }
}
@media (prefers-reduced-motion: reduce) {
.reading-resume { animation: none; }
}