网站信息卡

This commit is contained in:
2026-06-19 08:55:14 +08:00 Unverified
parent 3d5eb000f1
commit 2d4eeb362c
9 changed files with 329 additions and 5 deletions
+9 -3
View File
@@ -35,7 +35,7 @@ export const siteConfig = {
{ label: '隐私政策', href: '/privacy/' }, { label: '隐私政策', href: '/privacy/' },
], ],
poweredBy: [ poweredBy: [
{ label: 'EdgeOne', href: 'https://edgeone.ai/' }, { label: 'Fastly', href: 'https://fastly.com/' },
{ label: 'ASTRO', href: 'https://astro.build/' }, { label: 'ASTRO', href: 'https://astro.build/' },
], ],
}, },
@@ -52,14 +52,19 @@ export const siteConfig = {
region: '', region: '',
script: 'https://cdn.jsdelivr.net/npm/twikoo@1.7.7/dist/twikoo.min.js', 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: { externalLinks: {
waitSeconds: 10, waitSeconds: 10,
whitelist: [ whitelist: [
'blog.biss.click', 'blog.biss.click',
'biss.click', 'biss.click',
'github.com', 'github.com',
'astro.build',
'edgeone.ai',
'localhost', 'localhost',
'127.0.0.1', '127.0.0.1',
], ],
@@ -121,6 +126,7 @@ export const publicSiteConfig = {
site: siteConfig.site, site: siteConfig.site,
author: siteConfig.author, author: siteConfig.author,
comments: siteConfig.comments, comments: siteConfig.comments,
analytics: siteConfig.analytics,
talks: siteConfig.talks, talks: siteConfig.talks,
search: siteConfig.search, search: siteConfig.search,
externalLinks: siteConfig.externalLinks, externalLinks: siteConfig.externalLinks,
+5 -1
View File
@@ -1,5 +1,6 @@
--- ---
import { getCategoryHref, type BlogPost } from '@/lib/posts'; import { getCategoryHref, type BlogPost } from '@/lib/posts';
import SiteInfoCard from '@/components/SiteInfoCard.astro';
import { siteConfig } from '../../site.config.mjs'; import { siteConfig } from '../../site.config.mjs';
type TocItem = { type TocItem = {
@@ -17,9 +18,10 @@ interface Props {
post: BlogPost; post: BlogPost;
toc: TocItem[]; toc: TocItem[];
relatedPosts: BlogPost[]; relatedPosts: BlogPost[];
posts: BlogPost[];
} }
const { post, toc, relatedPosts } = Astro.props; const { post, toc, relatedPosts, posts } = Astro.props;
const avatar = siteConfig.author.avatar; const avatar = siteConfig.author.avatar;
const topDepth = toc.length > 0 ? Math.min(...toc.map((item) => item.depth)) : 1; const topDepth = toc.length > 0 ? Math.min(...toc.map((item) => item.depth)) : 1;
const tocSections = toc.reduce<TocSection[]>((sections, item) => { const tocSections = toc.reduce<TocSection[]>((sections, item) => {
@@ -107,5 +109,7 @@ const tocSections = toc.reduce<TocSection[]>((sections, item) => {
<p class="sidebar-empty">这个分类下暂时没有其他文章。</p> <p class="sidebar-empty">这个分类下暂时没有其他文章。</p>
)} )}
</section> </section>
<SiteInfoCard posts={posts} />
</div> </div>
</aside> </aside>
+3
View File
@@ -1,5 +1,6 @@
--- ---
import type { BlogPost } from '@/lib/posts'; import type { BlogPost } from '@/lib/posts';
import SiteInfoCard from '@/components/SiteInfoCard.astro';
import { siteConfig } from '../../site.config.mjs'; import { siteConfig } from '../../site.config.mjs';
interface Props { interface Props {
@@ -111,6 +112,8 @@ const recentPosts = posts.slice(0, 5);
))} ))}
</div> </div>
</section> </section>
<SiteInfoCard posts={posts} />
</div> </div>
</aside> </aside>
+88
View File
@@ -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>
+1
View File
@@ -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="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} /> <meta name="description" content={description} />
<link rel="icon" href={siteConfig.assets.icon} /> <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 set:html={`window.SITE_CONFIG = ${publicConfigJson};`}></script>
<script is:inline> <script is:inline>
(() => { (() => {
+1 -1
View File
@@ -127,7 +127,7 @@ const relatedPosts = allPosts
<PostCopyrightCard post={post} /> <PostCopyrightCard post={post} />
{post.comments && <TwikooComments envId={siteConfig.comments.envId} region={siteConfig.comments.region} path={post.href} title={post.title} />} {post.comments && <TwikooComments envId={siteConfig.comments.envId} region={siteConfig.comments.region} path={post.href} title={post.title} />}
</article> </article>
<ArticleSidebar post={post} toc={toc} relatedPosts={relatedPosts} /> <ArticleSidebar post={post} toc={toc} relatedPosts={relatedPosts} posts={allPosts} />
</section> </section>
</BaseLayout> </BaseLayout>
+55
View File
@@ -686,6 +686,56 @@ a {
line-height: 1.9; 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-info,
#welcome-ip-location-info { #welcome-ip-location-info {
margin: 0.65em 0; margin: 0.65em 0;
@@ -2885,6 +2935,7 @@ meting-js {
.notice-card p, .notice-card p,
#welcome-info, #welcome-info,
#welcome-ip-location-info, #welcome-ip-location-info,
.site-info-list dt,
.toc-link-child, .toc-link-child,
.same-category-list a, .same-category-list a,
.profile-links, .profile-links,
@@ -2893,6 +2944,10 @@ meting-js {
color: var(--muted); 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( :root[data-theme='dark'] :where(
.meta-list a, .meta-list a,
.article-tag-list a, .article-tag-list a,
+155
View File
@@ -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"