feat: ship blog platform admin and deploy stack

This commit is contained in:
2026-03-31 21:48:39 +08:00
parent a9a05aa105
commit 313f174fbc
210 changed files with 25476 additions and 5803 deletions

View File

@@ -3,16 +3,17 @@ import { createMarkdownProcessor } from '@astrojs/markdown-remark';
import BaseLayout from '../../layouts/BaseLayout.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
import TableOfContents from '../../components/TableOfContents.astro';
import RelatedPosts from '../../components/RelatedPosts.astro';
import ReadingProgress from '../../components/ReadingProgress.astro';
import BackToTop from '../../components/BackToTop.astro';
import Lightbox from '../../components/Lightbox.astro';
import CodeCopyButton from '../../components/CodeCopyButton.astro';
import Comments from '../../components/Comments.astro';
import ParagraphComments from '../../components/ParagraphComments.astro';
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { formatReadTime, getI18n } from '../../lib/i18n';
import type { PopularPostHighlight } from '../../lib/types';
import {
getAccentVars,
getCategoryTheme,
@@ -28,10 +29,12 @@ const { slug } = Astro.params;
let post = null;
let siteSettings = DEFAULT_SITE_SETTINGS;
let homeData: Awaited<ReturnType<typeof apiClient.getHomePageData>> | null = null;
const [postResult, siteSettingsResult] = await Promise.allSettled([
const [postResult, siteSettingsResult, homeDataResult] = await Promise.allSettled([
apiClient.getPostBySlug(slug ?? ''),
apiClient.getSiteSettings(),
apiClient.getHomePageData(),
]);
if (postResult.status === 'fulfilled') {
@@ -46,10 +49,23 @@ if (siteSettingsResult.status === 'fulfilled') {
console.error('Site settings API Error:', siteSettingsResult.reason);
}
if (homeDataResult.status === 'fulfilled') {
homeData = homeDataResult.value;
if (siteSettingsResult.status !== 'fulfilled') {
siteSettings = homeData.siteSettings;
}
} else {
console.error('Home data API Error:', homeDataResult.reason);
}
if (!post) {
return new Response(null, { status: 404 });
}
if (slug && post.slug !== slug) {
return Astro.redirect(`/articles/${post.slug}`, 301);
}
const typeColor = getPostTypeColor(post.type || 'article');
const typeTheme = getPostTypeTheme(post.type || 'article');
const categoryTheme = getCategoryTheme(post.category);
@@ -62,11 +78,109 @@ const paragraphCommentsEnabled = siteSettings.comments.paragraphsEnabled;
const markdownProcessor = await createMarkdownProcessor();
const renderedContent = await markdownProcessor.render(articleMarkdown);
const siteBaseUrl = siteSettings.siteUrl || new URL(Astro.request.url).origin;
const canonicalUrl = post.canonicalUrl || new URL(`/articles/${post.slug}`, siteBaseUrl).toString();
const ogImage = post.ogImage || `/og/${post.slug}.svg`;
const noindex = Boolean(post.noindex || post.visibility === 'unlisted');
const publishedAt = post.publishAt || `${post.date}T00:00:00Z`;
const hotRelatedPosts = (homeData?.popularPosts ?? [])
.filter((item): item is PopularPostHighlight & { post: NonNullable<PopularPostHighlight['post']> } => Boolean(item.post))
.filter((item) => item.slug !== post.slug)
.map((item) => {
const sharedTags = item.post.tags.filter((tag) => post.tags.includes(tag));
const sameCategory = item.post.category === post.category;
const sameType = item.post.type === post.type;
const relevance = (sameCategory ? 4 : 0) + sharedTags.length * 3 + (sameType ? 1 : 0);
const popularityScore =
item.pageViews + item.readCompletes * 2 + item.avgProgressPercent / 20;
return {
...item,
sharedTags,
sameCategory,
relevance,
popularityScore,
finalScore: relevance * 100 + popularityScore,
};
})
.filter((item) => item.relevance > 0)
.sort((left, right) => {
return (
right.finalScore - left.finalScore ||
right.pageViews - left.pageViews ||
right.readCompletes - left.readCompletes
);
})
.slice(0, 3);
const articleJsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.description,
image: [new URL(ogImage, siteBaseUrl).toString()],
mainEntityOfPage: canonicalUrl,
url: canonicalUrl,
datePublished: publishedAt,
dateModified: publishedAt,
author: {
'@type': 'Person',
name: siteSettings.ownerName,
},
publisher: {
'@type': 'Organization',
name: siteSettings.siteName,
logo: siteSettings.ownerAvatarUrl
? {
'@type': 'ImageObject',
url: siteSettings.ownerAvatarUrl,
}
: undefined,
},
articleSection: post.category,
keywords: post.tags,
inLanguage: locale,
isAccessibleForFree: true,
wordCount,
timeRequired: `PT${Math.max(readTimeMinutes, 1)}M`,
};
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: siteSettings.siteName,
item: siteBaseUrl,
},
{
'@type': 'ListItem',
position: 2,
name: 'Articles',
item: new URL('/articles', siteBaseUrl).toString(),
},
{
'@type': 'ListItem',
position: 3,
name: post.title,
item: canonicalUrl,
},
],
};
---
<BaseLayout title={post.title} description={post.description} siteSettings={siteSettings}>
<BaseLayout
title={post.title}
description={post.description}
siteSettings={siteSettings}
canonical={canonicalUrl}
noindex={noindex}
ogImage={ogImage}
ogType="article"
twitterCard="summary_large_image"
jsonLd={[articleJsonLd, breadcrumbJsonLd]}
>
<ReadingProgress />
<BackToTop />
<Lightbox />
<CodeCopyButton />
@@ -143,11 +257,16 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
<div class="ml-4 mt-4 space-y-6">
{post.image && (
<div class="terminal-panel-muted overflow-hidden">
<img
<ResponsiveImage
src={resolveFileRef(post.image)}
alt={post.title}
data-lightbox-image="true"
class="w-full h-auto rounded-xl border border-[var(--border-color)] cursor-zoom-in"
pictureClass="block"
imgClass="w-full h-auto rounded-xl border border-[var(--border-color)] cursor-zoom-in"
widths={[640, 960, 1280, 1600, 1920]}
sizes="(min-width: 1280px) 60rem, 100vw"
loading="eager"
fetchpriority="high"
lightbox={true}
/>
</div>
)}
@@ -156,11 +275,14 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{post.images.map((image, index) => (
<div class="terminal-panel-muted overflow-hidden">
<img
<ResponsiveImage
src={resolveFileRef(image)}
alt={`${post.title} 图片 ${index + 1}`}
data-lightbox-image="true"
class="h-full w-full rounded-xl border border-[var(--border-color)] object-cover cursor-zoom-in"
pictureClass="block h-full w-full"
imgClass="h-full w-full rounded-xl border border-[var(--border-color)] object-cover cursor-zoom-in"
widths={[480, 720, 960, 1280]}
sizes="(min-width: 1280px) 30vw, (min-width: 640px) 45vw, 100vw"
lightbox={true}
/>
</div>
))}
@@ -201,6 +323,103 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
currentTags={post.tags}
/>
{hotRelatedPosts.length > 0 && (
<section class="terminal-panel mt-8">
<div class="space-y-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-3">
<span class="terminal-kicker">
<i class="fas fa-fire"></i>
{t('relatedPosts.hotKicker')}
</span>
<div class="terminal-section-title">
<span class="terminal-section-icon">
<i class="fas fa-chart-line"></i>
</span>
<div>
<h3 class="text-xl font-semibold text-[var(--title-color)]">{t('relatedPosts.hotTitle')}</h3>
<p class="text-sm text-[var(--text-secondary)]">
{t('relatedPosts.hotDescription')}
</p>
</div>
</div>
</div>
<span class="terminal-stat-pill">
<i class="fas fa-signal text-[var(--primary)]"></i>
{t('relatedPosts.linked', { count: hotRelatedPosts.length })}
</span>
</div>
<div class="grid gap-4 md:grid-cols-3">
{hotRelatedPosts.map((item) => (
<a
href={`/articles/${item.slug}`}
class="terminal-panel-muted terminal-panel-accent terminal-interactive-card group flex h-full flex-col gap-4 p-4"
style={getAccentVars(getPostTypeTheme(item.post.type))}
>
{item.post.image ? (
<div class="overflow-hidden rounded-xl border border-[var(--border-color)]">
<ResponsiveImage
src={resolveFileRef(item.post.image)}
alt={item.post.title}
pictureClass="block"
imgClass="aspect-[16/9] w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
widths={[320, 480, 640, 960]}
sizes="(min-width: 1280px) 18rem, (min-width: 768px) 30vw, 100vw"
/>
</div>
) : null}
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getPostTypeTheme(item.post.type))}>
{item.post.type === 'article' ? t('common.article') : t('common.tweet')}
</span>
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getCategoryTheme(item.post.category))}>
<i class="fas fa-folder-tree text-[11px]"></i>
{item.post.category}
</span>
</div>
<h4 class="text-base font-semibold text-[var(--title-color)] group-hover:text-[var(--primary)]">
{item.post.title}
</h4>
<p class="text-sm leading-7 text-[var(--text-secondary)]">{item.post.description}</p>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-1">
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
<i class="fas fa-eye text-[var(--primary)]"></i>
{t('home.views')}: {item.pageViews}
</span>
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
<i class="fas fa-check-double text-[var(--primary)]"></i>
{t('home.completes')}: {item.readCompletes}
</span>
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
<i class="fas fa-chart-line text-[var(--primary)]"></i>
{t('home.avgProgress')}: {Math.round(item.avgProgressPercent)}%
</span>
</div>
{item.sharedTags.length > 0 && (
<div class="flex flex-wrap gap-2">
{item.sharedTags.slice(0, 3).map((tag) => (
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getTagTheme(tag))}>
<i class="fas fa-hashtag text-[11px]"></i>
{tag}
</span>
))}
</div>
)}
</a>
))}
</div>
</div>
</section>
)}
<section class="mt-8">
<Comments postSlug={post.slug} class="terminal-panel" />
</section>
@@ -210,3 +429,101 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
</div>
</div>
</BaseLayout>
<script is:inline define:vars={{ postSlug: post.slug }}>
(() => {
const endpoint = '/api/analytics/content';
const sessionStorageKey = `termi:content-session:${postSlug}`;
const startedAt = Date.now();
let sentPageView = false;
let lastReportedProgress = 0;
function ensureSessionId() {
try {
const existing = window.sessionStorage.getItem(sessionStorageKey);
if (existing) return existing;
const nextId = crypto.randomUUID();
window.sessionStorage.setItem(sessionStorageKey, nextId);
return nextId;
} catch {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
}
function getProgressPercent() {
const doc = document.documentElement;
const scrollTop = window.scrollY || doc.scrollTop || 0;
const scrollHeight = Math.max(doc.scrollHeight - window.innerHeight, 1);
return Math.max(0, Math.min(100, Math.round((scrollTop / scrollHeight) * 100)));
}
function sendEvent(eventType, extras = {}, useBeacon = false) {
const payload = JSON.stringify({
event_type: eventType,
path: window.location.pathname,
post_slug: postSlug,
session_id: ensureSessionId(),
referrer: document.referrer || undefined,
...extras,
});
if (useBeacon && navigator.sendBeacon) {
navigator.sendBeacon(endpoint, new Blob([payload], { type: 'application/json' }));
return;
}
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true,
}).catch(() => undefined);
}
function reportProgress(forceComplete = false) {
const progress = forceComplete ? 100 : getProgressPercent();
const durationMs = Date.now() - startedAt;
const eventType = forceComplete || progress >= 95 ? 'read_complete' : 'read_progress';
if (!forceComplete && progress <= lastReportedProgress + 9) {
return;
}
lastReportedProgress = Math.max(lastReportedProgress, progress);
sendEvent(
eventType,
{
progress_percent: progress,
duration_ms: durationMs,
metadata: {
viewportHeight: window.innerHeight,
},
},
eventType === 'read_complete',
);
}
if (!sentPageView) {
sentPageView = true;
sendEvent('page_view', {
metadata: {
title: document.title,
},
});
}
window.addEventListener('scroll', () => {
reportProgress(false);
}, { passive: true });
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
reportProgress(getProgressPercent() >= 95);
}
});
window.addEventListener('pagehide', () => {
reportProgress(getProgressPercent() >= 95);
});
})();
</script>