newechoes/src/pages/articles/[...id].astro

1175 lines
39 KiB
Plaintext
Raw Normal View History

2025-03-03 21:16:16 +08:00
---
import { getCollection, render } from "astro:content";
import { getSpecialPath } from "@/content.config";
import Layout from "@/components/Layout.astro";
import Breadcrumb from "@/components/Breadcrumb.astro";
import { ARTICLE_EXPIRY_CONFIG } from "@/consts";
import "@/styles/articles.css";
2025-03-03 21:16:16 +08:00
// 定义文章类型
interface ArticleEntry {
id: string;
data: {
title: string;
date: Date;
tags?: string[];
summary?: string;
};
}
// 定义标题类型
interface Heading {
depth: number;
slug: string;
text: string;
}
2025-03-03 21:16:16 +08:00
export async function getStaticPaths() {
const articles = await getCollection("articles");
// 为每篇文章生成路由参数
2025-03-10 14:05:40 +08:00
const paths = [];
2025-03-10 17:35:21 +08:00
for (const article of articles) {
// 获取所有可能的路径形式
const possiblePaths = new Set([
article.id, // 只保留原始路径
2025-03-10 17:35:21 +08:00
]);
// 如果是多级目录,检查是否需要特殊处理
if (article.id.includes("/")) {
const parts = article.id.split("/");
2025-03-10 17:35:21 +08:00
const fileName = parts[parts.length - 1];
const dirName = parts[parts.length - 2];
2025-03-10 17:35:21 +08:00
// 只有当文件名与其父目录名相同时才添加特殊路径
if (fileName === dirName) {
possiblePaths.add(getSpecialPath(article.id));
}
2025-03-10 17:35:21 +08:00
}
// 为每个可能的路径生成路由
for (const path of possiblePaths) {
2025-03-10 14:05:40 +08:00
paths.push({
2025-03-10 17:35:21 +08:00
params: { id: path },
props: {
2025-03-10 17:35:21 +08:00
article,
section: article.id.includes("/")
? article.id.split("/").slice(0, -1).join("/")
: "",
2025-03-10 17:35:21 +08:00
originalId: path !== article.id ? article.id : undefined,
},
2025-03-10 14:05:40 +08:00
});
}
}
2025-03-10 17:35:21 +08:00
2025-03-10 14:05:40 +08:00
return paths;
2025-03-03 21:16:16 +08:00
}
// 获取文章内容
const { article, section, originalId } = Astro.props;
// 获取搜索参数
const searchParams = new URLSearchParams(Astro.url.search);
// 如果有原始ID使用它来渲染内容
const articleToRender = originalId ? { ...article, id: originalId } : article;
2025-03-03 21:16:16 +08:00
// 渲染文章内容
const { Content, headings } = await render(articleToRender);
2025-03-03 21:16:16 +08:00
// 获取面包屑路径段
const pathSegments = section ? section.split("/") : [];
2025-03-03 21:16:16 +08:00
// 获取相关文章
const allArticles = await getCollection("articles");
// 1. 尝试通过标签匹配相关文章
let relatedArticles = allArticles
.filter((a: ArticleEntry) => {
const hasCommonTags =
a.id !== article.id &&
a.data.tags &&
article.data.tags &&
a.data.tags.length > 0 &&
article.data.tags.length > 0 &&
a.data.tags.some((tag: string) => article.data.tags?.includes(tag));
return hasCommonTags;
})
.sort(
(a: ArticleEntry, b: ArticleEntry) =>
b.data.date.getTime() - a.data.date.getTime(),
)
2025-03-03 21:16:16 +08:00
.slice(0, 3);
2025-03-08 18:16:42 +08:00
// 跟踪相关文章的匹配方式: "tag", "directory", "latest"
let relatedArticlesMatchType = relatedArticles.length > 0 ? "tag" : "";
// 2. 如果标签匹配没有找到足够的相关文章,尝试根据目录结构匹配
if (relatedArticles.length < 3) {
// 获取当前文章的目录路径
const currentPath = article.id.includes("/")
? article.id.substring(0, article.id.lastIndexOf("/"))
: "";
// 如果有目录路径,查找同目录的其他文章
if (currentPath) {
// 收集同目录下的文章,但排除已经通过标签匹配的和当前文章
const dirRelatedArticles = allArticles
.filter(
(a: ArticleEntry) =>
a.id !== article.id &&
a.id.startsWith(currentPath + "/") &&
!relatedArticles.some((r: ArticleEntry) => r.id === a.id),
)
.sort(
(a: ArticleEntry, b: ArticleEntry) =>
b.data.date.getTime() - a.data.date.getTime(),
)
.slice(0, 3 - relatedArticles.length);
if (dirRelatedArticles.length > 0) {
relatedArticles = [...relatedArticles, ...dirRelatedArticles];
relatedArticlesMatchType =
relatedArticles.length > 0 && !relatedArticlesMatchType
? "directory"
: relatedArticlesMatchType;
}
}
}
// 3. 如果仍然没有找到足够的相关文章,则选择最新的文章(排除当前文章和已选择的文章)
if (relatedArticles.length < 3) {
const latestArticles = allArticles
.filter(
(a: ArticleEntry) =>
a.id !== article.id &&
!relatedArticles.some((r: ArticleEntry) => r.id === a.id),
)
.sort(
(a: ArticleEntry, b: ArticleEntry) =>
b.data.date.getTime() - a.data.date.getTime(),
)
.slice(0, 3 - relatedArticles.length);
if (latestArticles.length > 0) {
relatedArticles = [...relatedArticles, ...latestArticles];
relatedArticlesMatchType =
relatedArticles.length > 0 && !relatedArticlesMatchType
? "latest"
: relatedArticlesMatchType;
}
}
2025-03-08 18:16:42 +08:00
// 准备文章描述
const description =
article.data.summary ||
`${article.data.title} - 发布于 ${article.data.date.toLocaleDateString("zh-CN")}`;
// 处理特殊ID的函数
function getArticleUrl(articleId: string) {
return `/articles/${getSpecialPath(articleId)}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`;
}
// 预先生成目录结构
function generateTableOfContents(headings: Heading[]) {
if (!headings || headings.length === 0) {
return '<p class="text-secondary-500 dark:text-secondary-400 italic">此文章没有目录</p>';
}
// 查找最低级别的标题(数值最小)
const minDepth = Math.min(...headings.map((h) => h.depth));
2025-05-08 11:21:31 +08:00
// 按照标题层级构建嵌套结构
const tocTree: any[] = [];
const levelMap: Record<number, any[]> = {};
headings.forEach((heading) => {
const relativeDepth = heading.depth - minDepth;
2025-05-08 11:21:31 +08:00
// 构建标题项
const headingItem = {
slug: heading.slug,
text: heading.text,
depth: relativeDepth,
children: [],
};
// 更精确地处理嵌套关系
if (relativeDepth === 0) {
// 顶级标题直接加入到树中
tocTree.push(headingItem);
levelMap[0] = tocTree;
} else {
// 查找当前标题的父级
let parentDepth = relativeDepth - 1;
// 向上查找可能的父级
while (parentDepth >= 0 && !levelMap[parentDepth]) {
parentDepth--;
}
if (parentDepth >= 0 && levelMap[parentDepth] && levelMap[parentDepth].length > 0) {
// 找到父层级,将此标题添加到最近的父标题的子标题数组中
const parentItems = levelMap[parentDepth];
const parent = parentItems[parentItems.length - 1];
parent.children.push(headingItem);
// 更新当前深度的映射
if (!levelMap[relativeDepth]) {
levelMap[relativeDepth] = [];
}
levelMap[relativeDepth].push(headingItem);
} else {
// 找不到有效父级,作为顶级标题处理
tocTree.push(headingItem);
levelMap[relativeDepth] = [headingItem];
}
}
});
2025-05-08 11:21:31 +08:00
// 递归生成HTML
function generateTocHTML(items: any[], level = 0) {
if (items.length === 0) return '';
const isTopLevel = level === 0;
let html = `<ul class="space-y-2 toc-list ${isTopLevel ? '' : 'toc-sublist hidden'}" ${level > 0 ? 'aria-expanded="false"' : ''}>`;
items.forEach(item => {
const hasChildren = item.children && item.children.length > 0;
const isHigherLevel = item.depth <= 1; // 只有最高级和次高级标题使用较重的样式
html += `<li class="toc-item" data-depth="${item.depth}">
<div class="toc-item-container">
<a href="#${item.slug}"
class="toc-link block duration-50 ${
isHigherLevel
? "text-secondary-800 dark:text-secondary-200 font-medium"
: "text-secondary-600 dark:text-secondary-400"
}"
style="padding-left: ${item.depth * 0.75}rem;">
${item.text}
</a>
${hasChildren ? `<button class="toc-toggle ml-1 p-1 text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400" aria-expanded="false">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>` : ''}
</div>
${generateTocHTML(item.children, level + 1)}
</li>`;
});
html += '</ul>';
return html;
}
return generateTocHTML(tocTree);
}
// 生成目录HTML
const tableOfContents = generateTableOfContents(headings);
2025-03-03 21:16:16 +08:00
---
2025-03-08 18:16:42 +08:00
<Layout
title={article.data.title}
description={description}
date={article.data.date}
tags={article.data.tags}
skipSrTitle={false}
pageType="article"
2025-03-08 18:16:42 +08:00
>
<div class="max-w-5xl py-8">
2025-03-08 18:16:42 +08:00
<!-- 阅读进度条 -->
<div
class="fixed top-0 left-0 w-full h-1 bg-transparent z-50"
id="progress-container"
>
<div
class="h-full w-0 bg-primary-500 transition-width duration-100"
id="progress-bar"
>
</div>
2025-03-08 18:16:42 +08:00
</div>
2025-03-08 18:16:42 +08:00
<!-- 文章头部 -->
<header class="mb-8">
<!-- 导航区域 -->
<div
class="bg-white dark:bg-gray-800 rounded-xl p-4 mb-6 shadow-lg border border-gray-200 dark:border-gray-700 relative z-10"
>
<div
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
>
<div class="w-full overflow-hidden">
<Breadcrumb
pageType="article"
pathSegments={pathSegments}
searchParams={searchParams}
articleTitle={article.data.title}
path={section}
/>
</div>
2025-03-03 21:16:16 +08:00
</div>
2025-03-08 18:16:42 +08:00
</div>
<!-- 文章过期提醒 -->
{
(() => {
const publishDate = article.data.date;
const currentDate = new Date();
const daysDiff = Math.floor(
(currentDate.getTime() - publishDate.getTime()) /
(1000 * 60 * 60 * 24),
);
if (
ARTICLE_EXPIRY_CONFIG.enabled &&
daysDiff > ARTICLE_EXPIRY_CONFIG.expiryDays
) {
return (
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-4">
<div class="flex">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-yellow-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700">
{ARTICLE_EXPIRY_CONFIG.warningMessage}
</p>
</div>
</div>
</div>
);
}
return null;
})()
}
<h1 class="text-3xl font-bold mb-4 text-gray-900 dark:text-gray-100">
{article.data.title}
</h1>
<div
class="flex flex-wrap items-center gap-4 text-sm text-secondary-600 dark:text-secondary-400 mb-4"
>
<time
datetime={article.data.date.toISOString()}
class="flex items-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
></path>
2025-03-08 18:16:42 +08:00
</svg>
{article.data.date.toLocaleDateString("zh-CN")}
2025-03-08 18:16:42 +08:00
</time>
{
section && (
<span class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 mr-1 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
<a
href={`/articles/${section}/`}
class="hover:text-indigo-600 break-all"
>
{section}
</a>
</span>
)
}
2025-03-08 18:16:42 +08:00
</div>
{
article.data.tags && article.data.tags.length > 0 && (
<div class="flex flex-wrap gap-2 mb-6">
{article.data.tags.map((tag: string) => (
<a
href={`/articles?tags=${tag}`}
class="text-xs bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 py-1 px-2 rounded hover:bg-primary-100 dark:hover:bg-primary-800/30"
data-astro-prefetch="hover"
>
#{tag}
</a>
))}
</div>
)
}
2025-03-08 18:16:42 +08:00
</header>
<!-- 文章内容 -->
<article
class="prose prose-lg dark:prose-invert prose-primary prose-table:rounded-lg prose-table:border-separate prose-table:border-2 prose-thead:bg-primary-50 dark:prose-thead:bg-gray-800 prose-ul:list-disc prose-ol:list-decimal prose-li:my-1 prose-blockquote:border-l-4 prose-blockquote:border-primary-500 prose-blockquote:bg-gray-100 prose-blockquote:dark:bg-gray-800 prose-a:text-primary-600 prose-a:dark:text-primary-400 prose-a:no-underline prose-a:border-b prose-a:border-primary-300 prose-a:hover:border-primary-600 max-w-none mb-12"
>
<Content />
</article>
<!-- 目录 -->
<section
2025-05-08 11:21:31 +08:00
class="hidden 2xl:block"
id="toc-panel"
>
2025-05-08 11:21:31 +08:00
<div>
<div
2025-05-08 11:21:31 +08:00
class="panel-header"
>
2025-05-08 11:21:31 +08:00
<h3>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h7"
></path>
</svg>
文章目录
</h3>
</div>
<div
id="toc-content"
2025-05-08 11:21:31 +08:00
class="scrollbar-thin scrollbar-thumb-primary-200 dark:scrollbar-thumb-primary-800 scrollbar-track-transparent"
set:html={tableOfContents}
>
<!-- 目录内容在服务端生成 -->
2025-03-03 21:16:16 +08:00
</div>
2025-03-08 18:16:42 +08:00
</div>
</section>
2025-03-08 18:16:42 +08:00
<!-- 相关文章 -->
{
relatedArticles.length > 0 && (
<aside class="mt-12 pt-8 border-t border-secondary-200 dark:border-gray-700">
<h2 class="text-2xl font-bold mb-6 text-primary-900 dark:text-primary-100">
{relatedArticlesMatchType === "tag"
? "相关文章"
: relatedArticlesMatchType === "directory"
? "同类文章"
: "推荐阅读"}
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{relatedArticles.map((relatedArticle: ArticleEntry) => (
<a
href={getArticleUrl(relatedArticle.id)}
class="article-card"
data-astro-prefetch="viewport"
>
<div class="article-card-content">
<div class="article-card-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
/>
</svg>
</div>
<div class="article-card-body">
<h3 class="article-card-title">
{relatedArticle.data.title}
</h3>
<p class="article-card-date">
{relatedArticle.data.date.toLocaleDateString("zh-CN")}
</p>
{relatedArticle.data.summary && (
<p class="article-card-summary">
{relatedArticle.data.summary}
</p>
)}
<div class="article-card-footer">
<time
datetime={relatedArticle.data.date.toISOString()}
class="article-card-date"
>
{relatedArticle.data.date.toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
<span class="article-card-read-more">阅读全文</span>
</div>
</div>
</div>
</a>
))}
</div>
</aside>
)
}
2025-03-08 18:16:42 +08:00
<!-- 返回顶部按钮 -->
<button
id="back-to-top"
class="fixed bottom-8 right-8 w-12 h-12 rounded-full bg-primary-500 dark:bg-primary-600 text-white shadow-md flex items-center justify-center opacity-0 invisible translate-y-5 hover:bg-primary-600 dark:hover:bg-primary-700"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 10l7-7m0 0l7 7m-7-7v18"
></path>
2025-03-08 18:16:42 +08:00
</svg>
</button>
2025-03-03 21:16:16 +08:00
</div>
<!-- 文章页面脚本 -->
<script is:inline>
// 文章页面交互脚本 - 自销毁模式
(function () {
// 集中管理所有事件监听器
const allListeners = [];
// 为特殊清理任务准备的数组
const customCleanupTasks = [];
// 单独保存清理事件的监听器引用
const cleanupListeners = [];
// 添加事件监听器并记录,方便后续统一清理
function addListener(element, eventType, handler, options) {
if (!element) {
return null;
}
element.addEventListener(eventType, handler, options);
allListeners.push({ element, eventType, handler, options });
return handler;
}
// 统一的清理函数,执行完整清理并自销毁
function selfDestruct() {
// 1. 先移除普通事件监听器
allListeners.forEach(({ element, eventType, handler, options }) => {
try {
element.removeEventListener(eventType, handler, options);
} catch (err) {
console.error("移除事件监听器出错:", err);
}
});
// 清空监听器数组
allListeners.length = 0;
// 2. 执行特殊清理任务
customCleanupTasks.forEach((task) => {
try {
task();
} catch (err) {
console.error("执行特殊清理任务出错:", err);
}
});
// 清空特殊任务数组
customCleanupTasks.length = 0;
// 3. 最后移除清理事件监听器自身
cleanupListeners.forEach(({ element, eventType, handler, options }) => {
try {
element.removeEventListener(eventType, handler, options);
} catch (err) {
console.error("移除清理监听器出错:", err);
}
});
}
// 注册清理事件,并保存引用
function registerCleanupEvents() {
// 创建一次性事件处理函数
const beforeSwapHandler = () => {
selfDestruct();
};
const beforeUnloadHandler = () => {
selfDestruct();
};
// 添加清理事件监听器并保存引用
document.addEventListener("astro:before-swap", beforeSwapHandler, {
once: true,
});
window.addEventListener("beforeunload", beforeUnloadHandler, {
once: true,
});
// 保存清理事件引用,用于完全销毁
cleanupListeners.push(
{
element: document,
eventType: "astro:before-swap",
handler: beforeSwapHandler,
options: { once: true },
},
{
element: window,
eventType: "beforeunload",
handler: beforeUnloadHandler,
options: { once: true },
},
);
}
// 初始化所有功能
function initializeFeatures() {
// 1. 代码块复制功能
function setupCodeCopy() {
const copyButtons = document.querySelectorAll(".code-block-copy");
if (copyButtons.length === 0) {
return;
}
copyButtons.forEach((button) => {
addListener(button, "click", async () => {
try {
const encodedCode = button.getAttribute("data-code");
if (!encodedCode) return;
const code = atob(encodedCode);
await navigator.clipboard.writeText(code);
const originalHTML = button.innerHTML;
button.classList.add("copied");
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4">
<path d="M20 6L9 17l-5-5"></path>
</svg>
已复制
`;
setTimeout(() => {
button.classList.remove("copied");
button.innerHTML = originalHTML;
}, 2000);
} catch (err) {
console.error("复制失败:", err);
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
失败
`;
setTimeout(() => {
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
复制
`;
}, 2000);
}
});
});
}
// 2. 阅读进度条
function setupProgressBar() {
const progressBar = document.getElementById("progress-bar");
const backToTopButton = document.getElementById("back-to-top");
if (!progressBar) {
return;
}
function updateReadingProgress() {
const scrollTop =
window.scrollY || document.documentElement.scrollTop;
const scrollHeight =
document.documentElement.scrollHeight -
document.documentElement.clientHeight;
const progress = (scrollTop / scrollHeight) * 100;
progressBar.style.width = `${progress}%`;
if (backToTopButton) {
if (scrollTop > 300) {
backToTopButton.classList.add(
"opacity-100",
"visible",
"translate-y-0",
);
backToTopButton.classList.remove(
"opacity-0",
"invisible",
"translate-y-5",
);
} else {
backToTopButton.classList.add(
"opacity-0",
"invisible",
"translate-y-5",
);
backToTopButton.classList.remove(
"opacity-100",
"visible",
"translate-y-0",
);
}
}
}
addListener(window, "scroll", updateReadingProgress);
if (backToTopButton) {
addListener(backToTopButton, "click", () => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
});
}
// 初始更新一次进度条
updateReadingProgress();
}
// 3. 目录交互
function setupTableOfContents() {
const tocContent = document.getElementById("toc-content");
const tocPanel = document.querySelector("#toc-panel");
if (!tocPanel || !tocContent) {
return;
}
// 检查窗口大小调整目录面板显示
function checkTocVisibility() {
if (window.innerWidth < 1536) {
tocPanel.classList.add("hidden");
tocPanel.classList.remove("2xl:block");
} else {
tocPanel.classList.remove("hidden");
tocPanel.classList.add("2xl:block");
}
}
addListener(window, "resize", checkTocVisibility);
checkTocVisibility();
2025-05-08 11:21:31 +08:00
// 处理目录折叠/展开功能
const tocToggles = tocContent.querySelectorAll(".toc-toggle");
tocToggles.forEach((toggle) => {
addListener(toggle, "click", (e) => {
e.preventDefault();
e.stopPropagation();
const expanded = toggle.getAttribute("aria-expanded") === "true";
toggle.setAttribute("aria-expanded", expanded ? "false" : "true");
// 更新图标旋转
const svg = toggle.querySelector("svg");
if (svg) {
svg.style.transform = expanded ? "" : "rotate(-180deg)";
}
// 切换子菜单显示状态
const listItem = toggle.closest(".toc-item");
if (listItem) {
const sublist = listItem.querySelector(".toc-sublist");
if (sublist) {
if (expanded) {
sublist.classList.add("hidden");
sublist.setAttribute("aria-expanded", "false");
} else {
sublist.classList.remove("hidden");
sublist.setAttribute("aria-expanded", "true");
}
}
}
});
});
// 处理目录链接点击跳转
2025-05-08 11:21:31 +08:00
const tocLinks = tocContent.querySelectorAll(".toc-link");
tocLinks.forEach((link) => {
addListener(link, "click", (e) => {
e.preventDefault();
const targetId = link.getAttribute("href")?.substring(1);
if (!targetId) return;
const targetElement = document.getElementById(targetId);
if (targetElement) {
const offset = 100;
const targetPosition =
targetElement.getBoundingClientRect().top +
window.scrollY -
offset;
window.scrollTo({
top: targetPosition,
behavior: "smooth",
});
targetElement.classList.add(
"bg-primary-50",
"dark:bg-primary-900/20",
);
setTimeout(() => {
targetElement.classList.remove(
"bg-primary-50",
"dark:bg-primary-900/20",
);
}, 2000);
}
});
});
// 监听滚动以更新当前活动的目录项
const article = document.querySelector("article");
if (!article) {
return;
}
let ticking = false;
function updateActiveHeading() {
const headings = Array.from(
article.querySelectorAll("h1, h2, h3, h4, h5, h6"),
);
2025-05-08 11:21:31 +08:00
const tocLinks = Array.from(tocContent.querySelectorAll(".toc-link"));
const tocItems = Array.from(tocContent.querySelectorAll(".toc-item"));
// 清除所有活动状态
tocLinks.forEach((link) => {
link.classList.remove(
"text-primary-600",
"dark:text-primary-400",
"font-medium",
);
});
// 找出当前可见的标题
const scrollPosition = window.scrollY + 150;
let currentHeading = null;
for (const heading of headings) {
const headingTop =
heading.getBoundingClientRect().top + window.scrollY;
if (headingTop <= scrollPosition) {
currentHeading = heading;
} else {
break;
}
}
2025-05-08 11:21:31 +08:00
// 记录当前活动的项目和其所有父级
const activeItems = new Set();
// 高亮当前标题对应的目录项并展开父菜单
if (currentHeading) {
const id = currentHeading.getAttribute("id");
if (id) {
const activeLink = tocLinks.find(
(link) => link.getAttribute("href") === `#${id}`,
);
if (activeLink) {
// 高亮当前目录项
activeLink.classList.add(
"text-primary-600",
"dark:text-primary-400",
"font-medium",
);
2025-05-08 11:21:31 +08:00
// 展开当前激活项的所有父菜单并收集到活动项集合中
let parent = activeLink.closest(".toc-item");
while (parent) {
// 添加到活动项目集合
activeItems.add(parent);
const parentSublist = parent.querySelector(".toc-sublist");
const parentToggle = parent.querySelector(".toc-toggle");
if (parentSublist && parentSublist.classList.contains("hidden")) {
parentSublist.classList.remove("hidden");
parentSublist.setAttribute("aria-expanded", "true");
if (parentToggle) {
parentToggle.setAttribute("aria-expanded", "true");
const svg = parentToggle.querySelector("svg");
if (svg) {
svg.style.transform = "rotate(-180deg)";
}
}
}
// 向上查找父级
parent = parent.parentElement?.closest(".toc-item");
}
// 确保当前激活的目录项在可视区域内
const tocContainer = tocContent;
if (tocContainer) {
const linkOffsetTop = activeLink.offsetTop;
const containerScrollTop = tocContainer.scrollTop;
const containerHeight = tocContainer.clientHeight;
// 如果当前项不在视口内,滚动目录
if (
linkOffsetTop < containerScrollTop ||
linkOffsetTop > containerScrollTop + containerHeight
) {
tocContainer.scrollTop =
linkOffsetTop - containerHeight / 2;
}
}
}
}
}
2025-05-08 11:21:31 +08:00
// 关闭不在当前活动路径上的所有子菜单
tocItems.forEach(item => {
// 如果不在活动路径上且有子菜单
if (!activeItems.has(item)) {
const sublist = item.querySelector('.toc-sublist');
const toggle = item.querySelector('.toc-toggle');
if (sublist && !sublist.classList.contains('hidden')) {
sublist.classList.add('hidden');
sublist.setAttribute('aria-expanded', 'false');
if (toggle) {
toggle.setAttribute('aria-expanded', 'false');
const svg = toggle.querySelector('svg');
if (svg) {
svg.style.transform = '';
}
}
}
}
});
}
addListener(window, "scroll", () => {
if (!ticking) {
window.requestAnimationFrame(() => {
updateActiveHeading();
ticking = false;
});
ticking = true;
}
});
2025-05-08 11:21:31 +08:00
// 初始化时收起所有子菜单
const topLevelToggles = tocContent.querySelectorAll(".toc-list > .toc-item > .toc-item-container > .toc-toggle");
topLevelToggles.forEach(toggle => {
toggle.setAttribute("aria-expanded", "false");
const sublist = toggle.closest(".toc-item").querySelector(".toc-sublist");
if (sublist) {
sublist.classList.add("hidden");
sublist.setAttribute("aria-expanded", "false");
}
});
// 初始更新一次活动标题,确保相关父菜单展开
updateActiveHeading();
}
// 4. Mermaid图表渲染
function setupMermaid() {
// 查找所有mermaid代码块
const mermaidBlocks = document.querySelectorAll(
"pre.language-mermaid, pre > code.language-mermaid, .mermaid",
);
if (mermaidBlocks.length === 0) {
return;
}
// 动态加载mermaid库
const script = document.createElement("script");
script.src =
"https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
script.onload = function () {
if (!window.mermaid) {
console.error("Mermaid库加载后window.mermaid不存在");
return;
}
// 初始化mermaid配置
window.mermaid.initialize({
startOnLoad: false,
theme: "default",
securityLevel: "loose",
});
// 将所有mermaid代码块转换为可渲染的格式
mermaidBlocks.forEach((block, index) => {
// 获取mermaid代码
let code = "";
// 检查元素类型并相应处理
if (
block.tagName === "CODE" &&
block.classList.contains("language-mermaid")
) {
// 处理 code.language-mermaid 元素
code = block.textContent || "";
const pre = block.closest("pre");
if (pre) {
// 创建新的div元素替换整个pre
const div = document.createElement("div");
div.className = "mermaid";
div.id = "mermaid-diagram-" + index;
div.textContent = code;
pre.parentNode.replaceChild(div, pre);
}
} else if (
block.tagName === "PRE" &&
block.classList.contains("language-mermaid")
) {
// 处理 pre.language-mermaid 元素
code = block.textContent || "";
const div = document.createElement("div");
div.className = "mermaid";
div.id = "mermaid-diagram-" + index;
div.textContent = code;
block.parentNode.replaceChild(div, block);
} else if (
block.classList.contains("mermaid") &&
block.tagName !== "DIV"
) {
// 如果是其他带mermaid类的元素但不是div转换为div
code = block.textContent || "";
const div = document.createElement("div");
div.className = "mermaid";
div.id = "mermaid-diagram-" + index;
div.textContent = code;
block.parentNode.replaceChild(div, block);
}
});
// 初始化渲染
try {
window.mermaid.run().catch((err) => {
console.error("Mermaid渲染出错:", err);
});
} catch (error) {
console.error("初始化Mermaid渲染失败:", error);
}
};
script.onerror = function () {
console.error("加载Mermaid库失败");
// 显示错误信息
mermaidBlocks.forEach((block) => {
if (block.tagName === "CODE") block = block.closest("pre");
if (block) {
block.innerHTML =
'<div class="mermaid-error-message">无法加载Mermaid图表库</div>';
}
});
};
document.head.appendChild(script);
// 添加Mermaid清理任务
customCleanupTasks.push(() => {
// 移除脚本标签
if (script.parentNode) {
script.parentNode.removeChild(script);
}
// 清除全局mermaid对象
if (window.mermaid) {
try {
// 尝试清理mermaid内部状态
if (typeof window.mermaid.destroy === "function") {
window.mermaid.destroy();
}
window.mermaid = undefined;
} catch (e) {
console.error("清理mermaid对象出错:", e);
}
}
// 移除页面上可能留下的mermaid相关元素
try {
// 移除所有可能的mermaid样式和元素
const mermaidElements = [
"#mermaid-style",
"#mermaid-cloned-styles",
".mermaid-svg-reference",
'style[id^="mermaid-"]',
];
document
.querySelectorAll(mermaidElements.join(", "))
.forEach((el) => {
if (el && el.parentNode) {
el.parentNode.removeChild(el);
}
});
} catch (e) {
console.error("清理Mermaid元素时出错:", e);
}
});
}
// 启动所有功能
setupCodeCopy();
setupProgressBar();
setupTableOfContents();
setupMermaid();
}
// 执行初始化
registerCleanupEvents();
initializeFeatures();
})();
</script>
</Layout>