添加修订功能

This commit is contained in:
2026-07-17 16:56:32 +08:00 Unverified
parent c9384526a6
commit ed5c0c343a
7 changed files with 306 additions and 11 deletions
+34
View File
@@ -0,0 +1,34 @@
---
import type { PostChangelogEntry } from '@/lib/posts';
interface Props {
entries: PostChangelogEntry[];
}
const { entries } = Astro.props;
---
<section class="post-changelog" id="article-changelog" aria-labelledby="article-changelog-title">
<header class="post-changelog-head">
<div class="post-changelog-icon" aria-hidden="true"><i class="fa-solid fa-clock-rotate-left"></i></div>
<div>
<span>REVISION HISTORY</span>
<h2 id="article-changelog-title">文章修订记录</h2>
</div>
<strong>{entries.length} 次</strong>
</header>
<ol class="post-changelog-list">
{entries.map((entry) => (
<li>
<span class="post-changelog-marker" aria-hidden="true"></span>
<div class="post-changelog-entry">
<div>
<h3>{entry.title}</h3>
{entry.date && <time datetime={entry.date.toISOString()}>{entry.dateText}</time>}
</div>
{entry.description && <p>{entry.description}</p>}
</div>
</li>
))}
</ol>
</section>
+5
View File
@@ -17,6 +17,11 @@ const posts = defineCollection({
cover: z.any().optional(),
series: z.any().optional(),
seriesOrder: z.any().optional(),
changelog: z.any().optional(),
newVersion: z.any().optional(),
newVersionTitle: z.any().optional(),
supersededBy: z.any().optional(),
replacement: z.any().optional(),
}).passthrough(),
});
@@ -3,6 +3,13 @@ title: 使用GitHub推送Hexo到服务器
categories: 建站手札
cover: https://pic.biss.click/image/e0a08509-dea4-4af7-bafa-c81ca9d1cf8d.webp
series: webcustom
updated: 2026-07-17 13:20:00
newVersion: 8f3b9d21
newVersionTitle: 网站迁移日志:从 Hexo 到 Astro
changelog:
- date: 2026-07-17 13:20:00
title: 增加新版入口
description: 标记本文为早期 Hexo 部署记录,并补充当前 Astro 版本的网站迁移日志入口。
tags: 网站
abbrlink: ce1ec3fe
summary: >-
@@ -135,4 +142,4 @@ jobs:
`KEY`:密钥(私钥)
`PORT`SSH登录端口,一般为22
# 测试
运行一下这个Action无报错即可。
运行一下这个Action无报错即可。
+68 -1
View File
@@ -1,6 +1,13 @@
import type { CollectionEntry } from 'astro:content';
import { getCollection } from 'astro:content';
export type PostChangelogEntry = {
date?: Date;
dateText?: string;
title: string;
description?: string;
};
export type BlogPost = {
entry: CollectionEntry<'posts'>;
title: string;
@@ -13,6 +20,9 @@ export type BlogPost = {
categories: string[];
series?: string;
seriesOrder?: number;
changelog: PostChangelogEntry[];
newVersion?: string;
newVersionTitle?: string;
summary: string;
comments: boolean;
cover?: string;
@@ -62,6 +72,54 @@ function formatDate(date: Date): string {
}).format(date);
}
function makeChangelog(value: unknown): PostChangelogEntry[] {
if (!Array.isArray(value)) return [];
return value
.flatMap((item) => {
if (typeof item === 'string' && item.trim()) {
return [{ title: '内容更新', description: item.trim() }];
}
if (!item || typeof item !== 'object') return [];
const data = item as Record<string, unknown>;
const date = toOptionalDate(data.date ?? data.updated ?? data.at);
const titleValue = data.title ?? data.version ?? '内容更新';
const descriptionValue = data.description ?? data.note ?? data.content ?? data.changes;
const description = Array.isArray(descriptionValue)
? descriptionValue.map(String).filter(Boolean).join('')
: typeof descriptionValue === 'string' && descriptionValue.trim()
? descriptionValue.trim()
: undefined;
const title = String(titleValue).trim() || '内容更新';
if (!date && !description && title === '内容更新') return [];
return [{ date, dateText: date ? formatDate(date) : undefined, title, description }];
})
.sort((a, b) => (b.date?.valueOf() ?? 0) - (a.date?.valueOf() ?? 0));
}
function getNewVersion(data: Record<string, unknown>): { href?: string; title?: string } {
const value = data.newVersion ?? data.supersededBy ?? data.replacement;
if (typeof value === 'string' && value.trim()) {
return {
href: value.trim(),
title: typeof data.newVersionTitle === 'string' ? data.newVersionTitle.trim() || undefined : undefined,
};
}
if (value && typeof value === 'object') {
const item = value as Record<string, unknown>;
const hrefValue = item.href ?? item.url ?? item.slug;
return {
href: typeof hrefValue === 'string' ? hrefValue.trim() || undefined : undefined,
title: typeof item.title === 'string' ? item.title.trim() || undefined : undefined,
};
}
return {};
}
function fallbackSlug(id: string): string {
return id
.split('/')
@@ -88,8 +146,14 @@ export async function getAllPosts(): Promise<BlogPost[]> {
return entries
.map((entry) => {
const data = entry.data as Record<string, unknown>;
const date = toDate(entry.data.date);
const updated = toOptionalDate(entry.data.updated);
const changelog = makeChangelog(data.changelog);
const explicitUpdated = toOptionalDate(entry.data.updated);
const latestChangelogDate = changelog.find((item) => item.date)?.date;
const updatedCandidates = [explicitUpdated, latestChangelogDate].filter((item): item is Date => Boolean(item));
const updated = updatedCandidates.sort((a, b) => b.valueOf() - a.valueOf())[0];
const newVersion = getNewVersion(data);
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()
@@ -109,6 +173,9 @@ export async function getAllPosts(): Promise<BlogPost[]> {
categories: getCategories(entry),
series,
seriesOrder: Number.isFinite(rawSeriesOrder) ? rawSeriesOrder : undefined,
changelog,
newVersion: newVersion.href,
newVersionTitle: newVersion.title,
summary: makeSummary(entry),
comments: entry.data.comments !== false,
cover: typeof entry.data.cover === 'string' ? entry.data.cover : undefined,
+40 -9
View File
@@ -1,6 +1,7 @@
---
import ArticleSidebar from '@/components/ArticleSidebar.astro';
import PostCopyrightCard from '@/components/PostCopyrightCard.astro';
import PostChangelog from '@/components/PostChangelog.astro';
import PostSeries from '@/components/PostSeries.astro';
import TwikooComments from '@/components/TwikooComments.astro';
import BaseLayout from '@/layouts/BaseLayout.astro';
@@ -89,6 +90,18 @@ const seriesPosts = post.series
: [];
const canonicalUrl = new URL(post.href, siteConfig.site.url).href;
const articleImage = new URL(post.cover ?? siteConfig.author.avatar, siteConfig.site.url).href;
function getNewVersionHref(value?: string): string | undefined {
if (!value) return undefined;
if (/^https?:\/\//i.test(value) || value.startsWith('/')) return value;
return `/posts/${encodeURIComponent(value.replace(/^posts\//, '').replace(/\/$/, ''))}/`;
}
const newVersionHref = getNewVersionHref(post.newVersion);
const newVersionPost = newVersionHref
? allPosts.find((item) => item.href === newVersionHref || item.slug === post.newVersion)
: undefined;
const newVersionTitle = post.newVersionTitle ?? newVersionPost?.title ?? '查看新版文章';
const newVersionExternal = Boolean(newVersionHref && /^https?:\/\//i.test(newVersionHref));
const articleJsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
@@ -144,7 +157,10 @@ const articleJsonLd = {
<header class={post.cover ? 'article-header article-header-cover' : 'article-header'}>
{post.cover && <img class="article-header-cover-img" src={post.cover} alt="" loading="eager" decoding="async" />}
<div class="article-header-content">
<time datetime={post.date.toISOString()}>{post.dateText}</time>
<div class="article-date-line">
<time datetime={post.date.toISOString()}>发布于 {post.dateText}</time>
{post.updated && <time datetime={post.updated.toISOString()}>最后更新于 {post.updatedText}</time>}
</div>
<h1>{post.title}</h1>
{post.cover ? (
<div class="article-hero-meta" aria-label="文章信息">
@@ -181,19 +197,33 @@ const articleJsonLd = {
<aside
class="butterfly-note butterfly-note--warning article-outdated-note"
aria-label="文章时效性提示"
data-published-at={post.date.toISOString()}
data-freshness-at={(post.updated ?? post.date).toISOString()}
data-has-new-version={newVersionHref ? 'true' : 'false'}
hidden
>
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
<div class="butterfly-note__content">
<p>
注意:本文发布于 {post.dateText},距今已超过一年,部分内容可能已经过时,请结合最新资料判断。
</p>
{newVersionHref ? (
<p>
本文已有更新版本:
<a href={newVersionHref} target={newVersionExternal ? '_blank' : undefined} rel={newVersionExternal ? 'noreferrer' : undefined}>
{newVersionTitle}<i class="fa-solid fa-arrow-right" aria-hidden="true"></i>
</a>
。旧版内容保留作为历史参考。
{post.changelog.length > 0 && <a href="#article-changelog">查看修订记录</a>}
</p>
) : (
<p>
注意:本文最后维护于 {post.updatedText ?? post.dateText},距今已超过一年,部分内容可能已经过时,请结合最新资料判断。
{post.changelog.length > 0 && <a href="#article-changelog">查看修订记录</a>}
</p>
)}
</div>
</aside>
<div class="prose" data-toc={JSON.stringify(toc)}>
<Content />
</div>
{post.changelog.length > 0 && <PostChangelog entries={post.changelog} />}
<PostCopyrightCard post={post} />
{post.comments && <TwikooComments envId={siteConfig.comments.envId} region={siteConfig.comments.region} path={post.href} title={post.title} />}
</article>
@@ -288,13 +318,14 @@ const articleJsonLd = {
<script is:inline>
(() => {
document.querySelectorAll('.article-outdated-note[data-published-at]').forEach((note) => {
const publishedAt = new Date(note.dataset.publishedAt || '');
if (Number.isNaN(publishedAt.valueOf())) return;
document.querySelectorAll('.article-outdated-note[data-freshness-at]').forEach((note) => {
const freshnessAt = new Date(note.dataset.freshnessAt || '');
const hasNewVersion = note.dataset.hasNewVersion === 'true';
if (Number.isNaN(freshnessAt.valueOf())) return;
const threshold = new Date();
threshold.setFullYear(threshold.getFullYear() - 1);
note.hidden = publishedAt >= threshold;
note.hidden = !hasNewVersion && freshnessAt >= threshold;
});
})();
</script>
+1
View File
@@ -14,3 +14,4 @@
@import "./site/14-animations.css";
@import "./site/15-reading-series.css";
@import "./site/16-pwa.css";
@import "./site/17-revisions.css";
+150
View File
@@ -0,0 +1,150 @@
.article-date-line {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
color: var(--muted);
font-size: 0.92rem;
}
.article-date-line time + time::before {
content: "·";
margin-right: 14px;
}
.article-outdated-note a {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0 3px;
color: inherit;
font-weight: 900;
text-decoration: underline;
text-underline-offset: 3px;
}
.post-changelog {
margin-top: 38px;
overflow: hidden;
border: 1px solid rgba(15, 118, 110, 0.18);
border-radius: 12px;
background:
radial-gradient(circle at 10% 0%, rgba(255, 255, 255, 0.78), transparent 30%),
rgba(236, 253, 245, 0.46);
}
.post-changelog-head {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
align-items: center;
gap: 13px;
border-bottom: 1px solid rgba(15, 118, 110, 0.14);
padding: 17px 20px;
}
.post-changelog-icon {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 12px;
color: #fff;
background: #3eb8be;
}
.post-changelog-head span {
color: var(--accent);
font-size: 0.7rem;
font-weight: 900;
letter-spacing: 0.1em;
}
.post-changelog-head h2 {
margin: 1px 0 0;
font-size: 1.2rem;
}
.post-changelog-head > strong {
color: var(--muted);
font-size: 0.84rem;
}
.post-changelog-list {
margin: 0;
padding: 16px 20px 18px 34px;
list-style: none;
}
.post-changelog-list li {
position: relative;
border-left: 1px solid rgba(15, 118, 110, 0.22);
padding: 0 0 20px 22px;
}
.post-changelog-list li:last-child {
padding-bottom: 0;
}
.post-changelog-marker {
position: absolute;
top: 8px;
left: -5px;
width: 9px;
height: 9px;
border: 2px solid var(--surface);
border-radius: 999px;
background: #3eb8be;
box-shadow: 0 0 0 3px rgba(62, 184, 190, 0.16);
}
.post-changelog-entry > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
}
.post-changelog-entry h3,
.post-changelog-entry p {
margin: 0;
}
.post-changelog-entry h3 {
font-size: 1rem;
}
.post-changelog-entry time {
flex: 0 0 auto;
color: var(--muted);
font-size: 0.82rem;
}
.post-changelog-entry p {
margin-top: 4px;
color: var(--muted);
font-size: 0.92rem;
line-height: 1.7;
}
:root[data-theme='dark'] .post-changelog {
border-color: rgba(139, 224, 213, 0.22);
background:
radial-gradient(circle at 10% 0%, rgba(139, 224, 213, 0.1), transparent 32%),
rgba(13, 23, 29, 0.88);
}
:root[data-theme='dark'] .post-changelog-list li {
border-left-color: rgba(139, 224, 213, 0.24);
}
:root[data-theme='dark'] .post-changelog-marker {
border-color: #132028;
}
@media (max-width: 720px) {
.article-date-line { display: grid; gap: 1px; }
.article-date-line time + time::before { content: none; }
.post-changelog-head { grid-template-columns: 40px minmax(0, 1fr) auto; padding: 15px; }
.post-changelog-icon { width: 40px; height: 40px; }
.post-changelog-list { padding-right: 15px; padding-left: 27px; }
.post-changelog-entry > div { align-items: flex-start; flex-direction: column; gap: 1px; }
}