网站信息卡
This commit is contained in:
+9
-3
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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="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>
|
||||||
(() => {
|
(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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