更换到Astro #9
+9
-3
@@ -35,7 +35,7 @@ export const siteConfig = {
|
||||
{ label: '隐私政策', href: '/privacy/' },
|
||||
],
|
||||
poweredBy: [
|
||||
{ label: 'EdgeOne', href: 'https://edgeone.ai/' },
|
||||
{ label: 'Fastly', href: 'https://fastly.com/' },
|
||||
{ label: 'ASTRO', href: 'https://astro.build/' },
|
||||
],
|
||||
},
|
||||
@@ -52,14 +52,19 @@ export const siteConfig = {
|
||||
region: '',
|
||||
script: 'https://cdn.jsdelivr.net/npm/twikoo@1.7.7/dist/twikoo.min.js',
|
||||
},
|
||||
analytics: {
|
||||
umami: {
|
||||
script: 'https://umami.biss.click/script.js',
|
||||
websiteId: '3d97b310-b241-4fc6-bc9c-68e317fc7d42',
|
||||
statsEndpoint: 'https://blogumami.biss.click',
|
||||
},
|
||||
},
|
||||
externalLinks: {
|
||||
waitSeconds: 10,
|
||||
whitelist: [
|
||||
'blog.biss.click',
|
||||
'biss.click',
|
||||
'github.com',
|
||||
'astro.build',
|
||||
'edgeone.ai',
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
],
|
||||
@@ -121,6 +126,7 @@ export const publicSiteConfig = {
|
||||
site: siteConfig.site,
|
||||
author: siteConfig.author,
|
||||
comments: siteConfig.comments,
|
||||
analytics: siteConfig.analytics,
|
||||
talks: siteConfig.talks,
|
||||
search: siteConfig.search,
|
||||
externalLinks: siteConfig.externalLinks,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import { getCategoryHref, type BlogPost } from '@/lib/posts';
|
||||
import SiteInfoCard from '@/components/SiteInfoCard.astro';
|
||||
import { siteConfig } from '../../site.config.mjs';
|
||||
|
||||
type TocItem = {
|
||||
@@ -17,9 +18,10 @@ interface Props {
|
||||
post: BlogPost;
|
||||
toc: TocItem[];
|
||||
relatedPosts: BlogPost[];
|
||||
posts: BlogPost[];
|
||||
}
|
||||
|
||||
const { post, toc, relatedPosts } = Astro.props;
|
||||
const { post, toc, relatedPosts, posts } = Astro.props;
|
||||
const avatar = siteConfig.author.avatar;
|
||||
const topDepth = toc.length > 0 ? Math.min(...toc.map((item) => item.depth)) : 1;
|
||||
const tocSections = toc.reduce<TocSection[]>((sections, item) => {
|
||||
@@ -107,5 +109,7 @@ const tocSections = toc.reduce<TocSection[]>((sections, item) => {
|
||||
<p class="sidebar-empty">这个分类下暂时没有其他文章。</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<SiteInfoCard posts={posts} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import type { BlogPost } from '@/lib/posts';
|
||||
import SiteInfoCard from '@/components/SiteInfoCard.astro';
|
||||
import { siteConfig } from '../../site.config.mjs';
|
||||
|
||||
interface Props {
|
||||
@@ -111,6 +112,8 @@ const recentPosts = posts.slice(0, 5);
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SiteInfoCard posts={posts} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
import type { BlogPost } from '@/lib/posts';
|
||||
import { siteConfig } from '../../site.config.mjs';
|
||||
|
||||
interface Props {
|
||||
posts: BlogPost[];
|
||||
}
|
||||
|
||||
const { posts } = Astro.props;
|
||||
const latestPost = posts[0];
|
||||
const totalWordCount = posts.reduce((total, post) => {
|
||||
const plainText = post.body
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/[#>*_`~\-\[\]\(\)]/g, '')
|
||||
.replace(/\s+/g, '');
|
||||
|
||||
return total + plainText.length;
|
||||
}, 0);
|
||||
const formatStatNumber = (value: number) => new Intl.NumberFormat('en-US').format(value);
|
||||
const siteStats = [
|
||||
{ label: '文章数目', value: formatStatNumber(posts.length) },
|
||||
{ label: '本站总字数', value: formatStatNumber(totalWordCount) },
|
||||
{ label: '本站访客数', value: '--', key: 'visitors' },
|
||||
{ label: '本站访问量', value: '--', key: 'pageviews' },
|
||||
{ label: '最后更新时间', value: latestPost?.dateText ?? '--' },
|
||||
];
|
||||
const statsEndpoint = siteConfig.analytics.umami.statsEndpoint;
|
||||
---
|
||||
|
||||
<section class="sidebar-card site-info-card" data-site-info-card data-stats-endpoint={statsEndpoint}>
|
||||
<h2><i class="fa-solid fa-chart-line" aria-hidden="true"></i> 网站信息</h2>
|
||||
<dl class="site-info-list">
|
||||
{siteStats.map((item) => (
|
||||
<div>
|
||||
<dt>{item.label}</dt>
|
||||
<dd data-stat-key={item.key}>{item.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const cacheDuration = 10 * 60 * 1000;
|
||||
const formatter = new Intl.NumberFormat('en-US');
|
||||
const cards = [...document.querySelectorAll('[data-site-info-card]')];
|
||||
const endpoint = cards.find((card) => card.dataset.statsEndpoint)?.dataset.statsEndpoint;
|
||||
|
||||
if (!endpoint) return;
|
||||
|
||||
const cacheKey = `site-info-stats:${endpoint}`;
|
||||
|
||||
const updateCards = (stats) => {
|
||||
const visitors = Number(stats?.visitors);
|
||||
const pageviews = Number(stats?.pageviews);
|
||||
|
||||
cards.forEach((card) => {
|
||||
const visitorsEl = card.querySelector('[data-stat-key="visitors"]');
|
||||
const pageviewsEl = card.querySelector('[data-stat-key="pageviews"]');
|
||||
if (visitorsEl && Number.isFinite(visitors)) visitorsEl.textContent = formatter.format(visitors);
|
||||
if (pageviewsEl && Number.isFinite(pageviews)) pageviewsEl.textContent = formatter.format(pageviews);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const cached = JSON.parse(localStorage.getItem(cacheKey) || 'null');
|
||||
if (cached?.time && Date.now() - cached.time < cacheDuration) {
|
||||
updateCards(cached.data);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(cacheKey);
|
||||
}
|
||||
|
||||
fetch(endpoint, { headers: { Accept: 'application/json' } })
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`Stats request failed: ${response.status}`);
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
localStorage.setItem(cacheKey, JSON.stringify({ time: Date.now(), data }));
|
||||
updateCards(data);
|
||||
})
|
||||
.catch(() => {
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -21,6 +21,7 @@ const publicConfigJson = JSON.stringify(publicSiteConfig).replace(/</g, '\\u003c
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content={description} />
|
||||
<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>
|
||||
<script is:inline>
|
||||
(() => {
|
||||
|
||||
@@ -127,7 +127,7 @@ const relatedPosts = allPosts
|
||||
<PostCopyrightCard post={post} />
|
||||
{post.comments && <TwikooComments envId={siteConfig.comments.envId} region={siteConfig.comments.region} path={post.href} title={post.title} />}
|
||||
</article>
|
||||
<ArticleSidebar post={post} toc={toc} relatedPosts={relatedPosts} />
|
||||
<ArticleSidebar post={post} toc={toc} relatedPosts={relatedPosts} posts={allPosts} />
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
|
||||
@@ -686,6 +686,56 @@ a {
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.site-info-card h2 i {
|
||||
width: 18px;
|
||||
color: #3eb8be;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.site-info-list {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.site-info-list div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
border-top: 1px dashed rgba(104, 111, 120, 0.2);
|
||||
padding: 9px 0;
|
||||
}
|
||||
|
||||
.site-info-list div:first-child {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.site-info-list div:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.site-info-list dt,
|
||||
.site-info-list dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.site-info-list dt {
|
||||
color: #68717a;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.site-info-list dd {
|
||||
min-width: 0;
|
||||
color: #1aa8f6;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#welcome-info,
|
||||
#welcome-ip-location-info {
|
||||
margin: 0.65em 0;
|
||||
@@ -2885,6 +2935,7 @@ meting-js {
|
||||
.notice-card p,
|
||||
#welcome-info,
|
||||
#welcome-ip-location-info,
|
||||
.site-info-list dt,
|
||||
.toc-link-child,
|
||||
.same-category-list a,
|
||||
.profile-links,
|
||||
@@ -2893,6 +2944,10 @@ meting-js {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .site-info-list div {
|
||||
border-top-color: rgba(174, 205, 197, 0.16);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.meta-list a,
|
||||
.article-tag-list a,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
const DEFAULT_CACHE_TTL_SECONDS = 600;
|
||||
|
||||
function jsonResponse(data, init = {}) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getCorsHeaders(request, env) {
|
||||
const origin = request.headers.get('Origin') || '';
|
||||
const allowedOrigin = env.ALLOWED_ORIGIN || 'https://blog.biss.click';
|
||||
const allowOrigin = origin === allowedOrigin ? origin : allowedOrigin;
|
||||
|
||||
return {
|
||||
'Access-Control-Allow-Origin': allowOrigin,
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Vary': 'Origin',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value || 'https://umami.biss.click').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getShareId(env) {
|
||||
if (env.UMAMI_SHARE_ID) return env.UMAMI_SHARE_ID;
|
||||
if (!env.UMAMI_SHARE_URL) return '';
|
||||
|
||||
try {
|
||||
const url = new URL(env.UMAMI_SHARE_URL);
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
const shareIndex = parts.indexOf('share');
|
||||
return shareIndex >= 0 ? parts[shareIndex + 1] || '' : parts.at(-1) || '';
|
||||
} catch {
|
||||
return env.UMAMI_SHARE_URL.split('/').filter(Boolean).at(-1) || '';
|
||||
}
|
||||
}
|
||||
|
||||
async function getShareData(env) {
|
||||
const baseUrl = normalizeBaseUrl(env.UMAMI_BASE_URL);
|
||||
const shareId = getShareId(env);
|
||||
|
||||
if (!shareId) {
|
||||
throw new Error('Missing UMAMI_SHARE_ID or UMAMI_SHARE_URL');
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/share/${shareId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Umami share lookup failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.websiteId || !data.token) {
|
||||
throw new Error('Umami share response did not include websiteId or token');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchUmamiStats(env, shareData) {
|
||||
const baseUrl = normalizeBaseUrl(env.UMAMI_BASE_URL);
|
||||
const websiteId = env.UMAMI_WEBSITE_ID || shareData.websiteId;
|
||||
|
||||
const startAt = Number(env.STATS_START_AT || 0);
|
||||
const endAt = Date.now();
|
||||
const url = `${baseUrl}/api/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}`;
|
||||
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'x-umami-share-context': '1',
|
||||
'x-umami-share-token': shareData.token,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getStats(env) {
|
||||
const shareData = await getShareData(env);
|
||||
const response = await fetchUmamiStats(env, shareData);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Umami stats failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const stats = await response.json();
|
||||
|
||||
return {
|
||||
visitors: stats.visitors ?? 0,
|
||||
pageviews: stats.pageviews ?? 0,
|
||||
visits: stats.visits ?? 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const corsHeaders = getCorsHeaders(request, env);
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
|
||||
if (request.method !== 'GET') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405, headers: corsHeaders });
|
||||
}
|
||||
|
||||
const cacheTtl = Number(env.CACHE_TTL_SECONDS || DEFAULT_CACHE_TTL_SECONDS);
|
||||
const cache = caches.default;
|
||||
const cacheKey = new Request(
|
||||
`https://site-stats-cache.local/umami-site-stats/${getShareId(env) || 'default'}`,
|
||||
);
|
||||
const cached = await cache.match(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return new Response(cached.body, {
|
||||
status: cached.status,
|
||||
headers: {
|
||||
...Object.fromEntries(cached.headers),
|
||||
...corsHeaders,
|
||||
'X-Stats-Cache': 'HIT',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await getStats(env);
|
||||
const response = jsonResponse(stats, {
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Cache-Control': `public, max-age=${cacheTtl}`,
|
||||
'X-Stats-Cache': 'MISS',
|
||||
},
|
||||
});
|
||||
|
||||
ctx.waitUntil(cache.put(cacheKey, response.clone()));
|
||||
return response;
|
||||
} catch (error) {
|
||||
return jsonResponse(
|
||||
{
|
||||
error: 'Failed to fetch stats',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 502, headers: corsHeaders },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
name = "umami-site-stats"
|
||||
main = "umami-site-stats.js"
|
||||
compatibility_date = "2026-06-19"
|
||||
|
||||
[vars]
|
||||
UMAMI_BASE_URL = "https://umami.biss.click"
|
||||
UMAMI_SHARE_ID = "Hqx6lIBhIHBR13RY"
|
||||
ALLOWED_ORIGIN = "https://blog.biss.click"
|
||||
CACHE_TTL_SECONDS = "600"
|
||||
# Optional: set this if you want a custom all-time starting point.
|
||||
# The value is a JavaScript timestamp in milliseconds.
|
||||
# STATS_START_AT = "1735660800000"
|
||||
Reference in New Issue
Block a user