修复对swup的正确dom监听和清理
This commit is contained in:
parent
5df41feb02
commit
4bb37c4a4d
@ -81,11 +81,9 @@ export const Countdown: React.FC<CountdownProps> = ({ targetDate, className = ''
|
|||||||
setTimeLeft(newTimeLeft);
|
setTimeLeft(newTimeLeft);
|
||||||
|
|
||||||
// 如果已经到期,清除计时器
|
// 如果已经到期,清除计时器
|
||||||
if (newTimeLeft.expired) {
|
if (newTimeLeft.expired && timerRef.current !== null) {
|
||||||
if (timerRef.current !== null) {
|
clearInterval(timerRef.current);
|
||||||
clearInterval(timerRef.current);
|
timerRef.current = null;
|
||||||
timerRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
@ -286,7 +286,63 @@ const normalizedPath =
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function initHeader() {
|
// Header组件逻辑 - 使用"进入→绑定→退出完全清理"模式
|
||||||
|
(function() {
|
||||||
|
// 生成唯一ID用于日志跟踪
|
||||||
|
const headerId = 'header-' + (new Date().getTime());
|
||||||
|
|
||||||
|
// 存储所有事件监听器,便于统一清理
|
||||||
|
const listeners: Array<{
|
||||||
|
element: EventTarget;
|
||||||
|
eventType: string;
|
||||||
|
handler: EventListenerOrEventListenerObject;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// 记录是否已加载文章数据
|
||||||
|
interface Article {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
date: string | Date;
|
||||||
|
summary?: string;
|
||||||
|
tags?: string[];
|
||||||
|
image?: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let articles: Article[] = [];
|
||||||
|
let isArticlesLoaded = false;
|
||||||
|
|
||||||
|
// 添加事件监听器并记录,方便后续统一清理
|
||||||
|
function addListener<K extends keyof HTMLElementEventMap>(
|
||||||
|
element: EventTarget | null,
|
||||||
|
eventType: string,
|
||||||
|
handler: EventListenerOrEventListenerObject,
|
||||||
|
options?: boolean | AddEventListenerOptions
|
||||||
|
): EventListenerOrEventListenerObject | null {
|
||||||
|
if (!element) return null;
|
||||||
|
|
||||||
|
element.addEventListener(eventType, handler, options);
|
||||||
|
listeners.push({ element, eventType, handler });
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理函数 - 移除所有事件监听器
|
||||||
|
function cleanup(): void {
|
||||||
|
// 移除所有监听器
|
||||||
|
listeners.forEach(({ element, eventType, handler }) => {
|
||||||
|
try {
|
||||||
|
element.removeEventListener(eventType, handler);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`移除Header事件监听器出错:`, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清空数组
|
||||||
|
listeners.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header和导航高亮逻辑
|
||||||
|
function initHeader(): void {
|
||||||
const header = document.getElementById("header-bg");
|
const header = document.getElementById("header-bg");
|
||||||
const scrollThreshold = 50;
|
const scrollThreshold = 50;
|
||||||
|
|
||||||
@ -294,7 +350,7 @@ const normalizedPath =
|
|||||||
const navLinks = document.querySelectorAll('.hidden.md\\:flex a[href]');
|
const navLinks = document.querySelectorAll('.hidden.md\\:flex a[href]');
|
||||||
|
|
||||||
// 更新导航高亮状态
|
// 更新导航高亮状态
|
||||||
function updateNavHighlight() {
|
function updateNavHighlight(): void {
|
||||||
const currentUrl = window.location.pathname;
|
const currentUrl = window.location.pathname;
|
||||||
const normalizedPath =
|
const normalizedPath =
|
||||||
currentUrl === "/"
|
currentUrl === "/"
|
||||||
@ -343,7 +399,8 @@ const normalizedPath =
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHeaderBackground() {
|
// 处理滚动更新背景
|
||||||
|
function updateHeaderBackground(): void {
|
||||||
if (window.scrollY > scrollThreshold) {
|
if (window.scrollY > scrollThreshold) {
|
||||||
header?.classList.add("scrolled");
|
header?.classList.add("scrolled");
|
||||||
} else {
|
} else {
|
||||||
@ -356,11 +413,11 @@ const normalizedPath =
|
|||||||
updateNavHighlight();
|
updateNavHighlight();
|
||||||
|
|
||||||
// 添加滚动事件监听
|
// 添加滚动事件监听
|
||||||
window.addEventListener("scroll", updateHeaderBackground);
|
addListener(window, "scroll", updateHeaderBackground);
|
||||||
|
|
||||||
// 监听路由变化
|
// 监听路由变化
|
||||||
document.addEventListener('astro:page-load', updateNavHighlight);
|
addListener(document, 'astro:page-load', updateNavHighlight);
|
||||||
document.addEventListener('astro:after-swap', updateNavHighlight);
|
addListener(document, 'astro:after-swap', updateNavHighlight);
|
||||||
|
|
||||||
// 移动端菜单逻辑
|
// 移动端菜单逻辑
|
||||||
const mobileMenuButton = document.getElementById("mobile-menu-button");
|
const mobileMenuButton = document.getElementById("mobile-menu-button");
|
||||||
@ -375,7 +432,7 @@ const normalizedPath =
|
|||||||
const mobileSearchClose = document.getElementById("mobile-search-close");
|
const mobileSearchClose = document.getElementById("mobile-search-close");
|
||||||
|
|
||||||
// 关闭移动端菜单的函数
|
// 关闭移动端菜单的函数
|
||||||
function closeMobileMenu() {
|
function closeMobileMenu(): void {
|
||||||
if (mobileMenuButton && mobileMenu && menuOpenIcon && menuCloseIcon) {
|
if (mobileMenuButton && mobileMenu && menuOpenIcon && menuCloseIcon) {
|
||||||
mobileMenuButton.setAttribute("aria-expanded", "false");
|
mobileMenuButton.setAttribute("aria-expanded", "false");
|
||||||
mobileMenu.classList.add("hidden");
|
mobileMenu.classList.add("hidden");
|
||||||
@ -385,16 +442,21 @@ const normalizedPath =
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 关闭移动端搜索面板的函数
|
// 关闭移动端搜索面板的函数
|
||||||
function closeMobileSearch() {
|
function closeMobileSearch(): void {
|
||||||
if (mobileSearchPanel) {
|
if (mobileSearchPanel) {
|
||||||
mobileSearchPanel.classList.add("hidden");
|
mobileSearchPanel.classList.add("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mobileMenuButton && mobileMenu && menuOpenIcon && menuCloseIcon) {
|
if (mobileMenuButton && mobileMenu && menuOpenIcon && menuCloseIcon) {
|
||||||
mobileMenuButton.addEventListener("click", () => {
|
// 移动端菜单按钮点击事件 - 使用捕获模式确保事件优先处理
|
||||||
const expanded =
|
(mobileMenuButton as HTMLElement).style.pointerEvents = 'auto';
|
||||||
mobileMenuButton.getAttribute("aria-expanded") === "true";
|
|
||||||
|
addListener(mobileMenuButton, "click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const expanded = mobileMenuButton.getAttribute("aria-expanded") === "true";
|
||||||
|
|
||||||
// 切换菜单状态
|
// 切换菜单状态
|
||||||
mobileMenuButton.setAttribute("aria-expanded", (!expanded).toString());
|
mobileMenuButton.setAttribute("aria-expanded", (!expanded).toString());
|
||||||
@ -413,18 +475,29 @@ const normalizedPath =
|
|||||||
// 切换图标
|
// 切换图标
|
||||||
menuOpenIcon.classList.toggle("hidden");
|
menuOpenIcon.classList.toggle("hidden");
|
||||||
menuCloseIcon.classList.toggle("hidden");
|
menuCloseIcon.classList.toggle("hidden");
|
||||||
});
|
}, { capture: true });
|
||||||
|
|
||||||
// 为移动端导航链接添加点击事件
|
// 为移动端导航链接添加点击事件
|
||||||
const mobileNavLinks = document.querySelectorAll('#mobile-menu a[href]');
|
const mobileNavLinks = document.querySelectorAll('#mobile-menu a[href]');
|
||||||
mobileNavLinks.forEach(link => {
|
mobileNavLinks.forEach(link => {
|
||||||
link.addEventListener('click', closeMobileMenu);
|
(link as HTMLElement).style.pointerEvents = 'auto';
|
||||||
|
addListener(link, 'click', (e) => {
|
||||||
|
// 不要阻止默认行为,因为需要跳转
|
||||||
|
e.stopPropagation();
|
||||||
|
closeMobileMenu();
|
||||||
|
}, { capture: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移动端搜索按钮
|
// 移动端搜索按钮
|
||||||
if (mobileSearchButton && mobileSearchPanel) {
|
if (mobileSearchButton && mobileSearchPanel) {
|
||||||
mobileSearchButton.addEventListener("click", () => {
|
// 搜索按钮点击事件 - 使用捕获模式确保事件优先处理
|
||||||
|
(mobileSearchButton as HTMLElement).style.pointerEvents = 'auto';
|
||||||
|
|
||||||
|
addListener(mobileSearchButton, "click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
// 检查搜索面板是否已经打开
|
// 检查搜索面板是否已经打开
|
||||||
const isSearchVisible = !mobileSearchPanel.classList.contains("hidden");
|
const isSearchVisible = !mobileSearchPanel.classList.contains("hidden");
|
||||||
|
|
||||||
@ -439,25 +512,47 @@ const normalizedPath =
|
|||||||
mobileSearchPanel.classList.remove("hidden");
|
mobileSearchPanel.classList.remove("hidden");
|
||||||
if (mobileSearch) mobileSearch.focus();
|
if (mobileSearch) mobileSearch.focus();
|
||||||
}
|
}
|
||||||
});
|
}, { capture: true });
|
||||||
|
|
||||||
|
// 搜索面板关闭按钮
|
||||||
if (mobileSearchClose) {
|
if (mobileSearchClose) {
|
||||||
mobileSearchClose.addEventListener("click", () => {
|
(mobileSearchClose as HTMLElement).style.pointerEvents = 'auto';
|
||||||
|
addListener(mobileSearchClose, "click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
closeMobileSearch();
|
closeMobileSearch();
|
||||||
});
|
}, { capture: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理移动端主题切换容器
|
||||||
|
const themeToggleContainer = document.getElementById('theme-toggle-container');
|
||||||
|
if (themeToggleContainer) {
|
||||||
|
(themeToggleContainer as HTMLElement).style.pointerEvents = 'auto';
|
||||||
|
addListener(themeToggleContainer, 'click', (e: Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
// 如果点击的不是主题切换按钮本身,则手动触发主题切换
|
||||||
|
if (target.id !== 'theme-toggle-button' && !target.closest('#theme-toggle-button')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
// 获取容器内的主题切换按钮并模拟点击
|
||||||
|
const toggleButton = themeToggleContainer.querySelector('#theme-toggle-button');
|
||||||
|
if (toggleButton) {
|
||||||
|
(toggleButton as HTMLElement).click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { capture: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索功能逻辑
|
// 搜索功能逻辑
|
||||||
function initSearch() {
|
function initSearch(): void {
|
||||||
// 搜索节流函数
|
// 搜索节流函数
|
||||||
function debounce<T extends (...args: any[]) => void>(
|
function debounce<T extends (...args: any[]) => void>(
|
||||||
func: T,
|
func: T,
|
||||||
wait: number,
|
wait: number
|
||||||
): (...args: Parameters<T>) => void {
|
): (...args: Parameters<T>) => void {
|
||||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
return function (this: any, ...args: Parameters<T>): void {
|
return function(this: any, ...args: Parameters<T>): void {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||||
};
|
};
|
||||||
@ -474,22 +569,8 @@ const normalizedPath =
|
|||||||
const mobileList = document.getElementById("mobile-search-list");
|
const mobileList = document.getElementById("mobile-search-list");
|
||||||
const mobileMessage = document.getElementById("mobile-search-message");
|
const mobileMessage = document.getElementById("mobile-search-message");
|
||||||
|
|
||||||
// 文章对象的接口定义
|
|
||||||
interface Article {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
date: string | Date;
|
|
||||||
summary?: string;
|
|
||||||
tags?: string[];
|
|
||||||
image?: string;
|
|
||||||
content?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let articles: Article[] = [];
|
|
||||||
let isArticlesLoaded = false;
|
|
||||||
|
|
||||||
// 获取文章数据
|
// 获取文章数据
|
||||||
async function fetchArticles() {
|
async function fetchArticles(): Promise<void> {
|
||||||
if (isArticlesLoaded && articles.length > 0) return;
|
if (isArticlesLoaded && articles.length > 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -522,8 +603,8 @@ const normalizedPath =
|
|||||||
function searchArticles(
|
function searchArticles(
|
||||||
query: string,
|
query: string,
|
||||||
resultsList: HTMLElement,
|
resultsList: HTMLElement,
|
||||||
resultsMessage: HTMLElement,
|
resultsMessage: HTMLElement
|
||||||
) {
|
): void {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
resultsList.innerHTML = "";
|
resultsList.innerHTML = "";
|
||||||
resultsMessage.textContent = "输入关键词开始搜索";
|
resultsMessage.textContent = "输入关键词开始搜索";
|
||||||
@ -665,7 +746,7 @@ const normalizedPath =
|
|||||||
searchArticles(
|
searchArticles(
|
||||||
value,
|
value,
|
||||||
desktopList as HTMLElement,
|
desktopList as HTMLElement,
|
||||||
desktopMessage as HTMLElement,
|
desktopMessage as HTMLElement
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
@ -675,19 +756,21 @@ const normalizedPath =
|
|||||||
searchArticles(
|
searchArticles(
|
||||||
value,
|
value,
|
||||||
mobileList as HTMLElement,
|
mobileList as HTMLElement,
|
||||||
mobileMessage as HTMLElement,
|
mobileMessage as HTMLElement
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
// 桌面端搜索逻辑
|
// 桌面端搜索逻辑
|
||||||
if (desktopSearch && desktopResults) {
|
if (desktopSearch && desktopResults) {
|
||||||
desktopSearch.addEventListener("focus", () => {
|
// 聚焦时显示结果
|
||||||
|
addListener(desktopSearch, "focus", () => {
|
||||||
desktopResults.classList.remove("hidden");
|
desktopResults.classList.remove("hidden");
|
||||||
if (!isArticlesLoaded) fetchArticles();
|
if (!isArticlesLoaded) fetchArticles();
|
||||||
});
|
});
|
||||||
|
|
||||||
desktopSearch.addEventListener("input", (e: Event) => {
|
// 输入时更新搜索结果
|
||||||
|
addListener(desktopSearch, "input", (e: Event) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
if (target && target.value !== undefined) {
|
if (target && target.value !== undefined) {
|
||||||
debouncedDesktopSearch(target.value);
|
debouncedDesktopSearch(target.value);
|
||||||
@ -695,7 +778,7 @@ const normalizedPath =
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 点击外部关闭结果
|
// 点击外部关闭结果
|
||||||
document.addEventListener("click", (e: MouseEvent) => {
|
const documentClickHandler = (e: MouseEvent) => {
|
||||||
const target = e.target as Node;
|
const target = e.target as Node;
|
||||||
if (
|
if (
|
||||||
desktopSearch &&
|
desktopSearch &&
|
||||||
@ -704,19 +787,22 @@ const normalizedPath =
|
|||||||
) {
|
) {
|
||||||
desktopResults.classList.add("hidden");
|
desktopResults.classList.add("hidden");
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
addListener(document, "click", documentClickHandler as EventListener);
|
||||||
|
|
||||||
// ESC键关闭结果
|
// ESC键关闭结果
|
||||||
desktopSearch.addEventListener("keydown", (e: KeyboardEvent) => {
|
addListener(desktopSearch, "keydown", ((e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
desktopResults.classList.add("hidden");
|
desktopResults.classList.add("hidden");
|
||||||
}
|
}
|
||||||
});
|
}) as EventListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移动端搜索逻辑
|
// 移动端搜索逻辑
|
||||||
if (mobileSearch && mobileResults) {
|
if (mobileSearch && mobileResults) {
|
||||||
mobileSearch.addEventListener("input", (e: Event) => {
|
// 输入时更新搜索结果
|
||||||
|
addListener(mobileSearch, "input", (e: Event) => {
|
||||||
mobileResults.classList.remove("hidden");
|
mobileResults.classList.remove("hidden");
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
if (target && target.value !== undefined) {
|
if (target && target.value !== undefined) {
|
||||||
@ -726,43 +812,55 @@ const normalizedPath =
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ESC键关闭搜索面板
|
// ESC键关闭搜索面板
|
||||||
mobileSearch.addEventListener("keydown", (e: KeyboardEvent) => {
|
addListener(mobileSearch, "keydown", ((e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
const mobileSearchPanel = document.getElementById(
|
const mobileSearchPanel = document.getElementById("mobile-search-panel");
|
||||||
"mobile-search-panel",
|
|
||||||
);
|
|
||||||
if (mobileSearchPanel) {
|
if (mobileSearchPanel) {
|
||||||
mobileSearchPanel.classList.add("hidden");
|
mobileSearchPanel.classList.add("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}) as EventListener);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化函数
|
// 注册清理函数
|
||||||
function setupHeader() {
|
function registerCleanup(): void {
|
||||||
|
// Astro 事件
|
||||||
|
document.addEventListener('astro:before-preparation', cleanup, { once: true });
|
||||||
|
document.addEventListener('astro:before-swap', cleanup, { once: true });
|
||||||
|
|
||||||
|
// Swup 事件
|
||||||
|
document.addEventListener('swup:willReplaceContent', cleanup, { once: true });
|
||||||
|
|
||||||
|
// 页面卸载
|
||||||
|
window.addEventListener('beforeunload', cleanup, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化全部功能
|
||||||
|
function setupHeader(): void {
|
||||||
|
// 先清理之前的事件监听器
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
// 初始化各个组件
|
||||||
initHeader();
|
initHeader();
|
||||||
initSearch();
|
initSearch();
|
||||||
|
registerCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在文档加载时初始化一次
|
// 在页面加载时初始化
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", setupHeader);
|
document.addEventListener("DOMContentLoaded", setupHeader, { once: true });
|
||||||
} else {
|
} else {
|
||||||
setupHeader();
|
// 使用setTimeout确保处于事件队列末尾,避免可能的事件冲突
|
||||||
|
setTimeout(setupHeader, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 支持 Astro 视图转换
|
// 在页面转换后重新初始化
|
||||||
document.addEventListener("astro:after-swap", setupHeader);
|
document.addEventListener('astro:after-swap', setupHeader);
|
||||||
|
document.addEventListener('astro:page-load', setupHeader);
|
||||||
document.addEventListener("astro:page-load", setupHeader);
|
|
||||||
|
// Swup页面内容替换后重新初始化
|
||||||
// 原事件监听 - 保留以兼容可能的旧版本
|
document.addEventListener('swup:contentReplaced', setupHeader);
|
||||||
document.addEventListener("astro:swup:page:view", setupHeader);
|
})();
|
||||||
|
|
||||||
// 清理
|
|
||||||
document.addEventListener("astro:before-swap", () => {
|
|
||||||
// 移除可能的全局事件监听器
|
|
||||||
window.removeEventListener("scroll", () => {});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -55,25 +55,46 @@ const { title = SITE_NAME, description = SITE_DESCRIPTION, date, author, tags, i
|
|||||||
<meta property="article:tag" content={tag} />
|
<meta property="article:tag" content={tag} />
|
||||||
))}
|
))}
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
// 立即执行主题初始化,并防止变量重复声明
|
// 立即执行主题初始化,采用"无闪烁"加载方式
|
||||||
(function() {
|
(function() {
|
||||||
// 检查变量是否已存在
|
try {
|
||||||
if (typeof window.__themeInitDone === 'undefined') {
|
// 获取系统首选主题
|
||||||
// 设置标志,表示初始化已完成
|
const getSystemTheme = () => {
|
||||||
window.__themeInitDone = true;
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
};
|
||||||
|
|
||||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
const storedTheme = typeof localStorage !== 'undefined' ? localStorage.getItem('theme') : null;
|
||||||
const savedTheme = typeof localStorage !== 'undefined' ? localStorage.getItem('theme') : null;
|
const systemTheme = getSystemTheme();
|
||||||
const theme = savedTheme || systemTheme;
|
let theme = 'light'; // 默认浅色主题
|
||||||
|
|
||||||
// 立即设置文档主题
|
// 按照逻辑优先级应用主题
|
||||||
|
if (storedTheme) {
|
||||||
|
// 如果有存储的主题设置,则应用它
|
||||||
|
theme = storedTheme;
|
||||||
|
} else if (systemTheme) {
|
||||||
|
// 如果没有存储的设置,检查系统主题
|
||||||
|
theme = systemTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即设置文档主题,在DOM渲染前应用,避免闪烁
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
|
|
||||||
// 将主题信息存储在全局变量中,以便 React 组件可以立即访问
|
// 监听系统主题变化(只有当主题设为跟随系统时才响应)
|
||||||
window.__THEME_DATA__ = {
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
currentTheme: theme,
|
|
||||||
systemTheme: systemTheme
|
const handleMediaChange = (e) => {
|
||||||
|
// 只有当主题设置为跟随系统时才更新主题
|
||||||
|
if (!localStorage.getItem('theme')) {
|
||||||
|
const newTheme = e.matches ? 'dark' : 'light';
|
||||||
|
document.documentElement.dataset.theme = newTheme;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 添加系统主题变化监听
|
||||||
|
mediaQuery.addEventListener('change', handleMediaChange);
|
||||||
|
} catch (error) {
|
||||||
|
// 出错时应用默认浅色主题,确保页面正常显示
|
||||||
|
document.documentElement.dataset.theme = 'light';
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
|
||||||
interface MediaGridProps {
|
interface MediaGridProps {
|
||||||
type: "movie" | "book";
|
type: "movie" | "book";
|
||||||
@ -21,6 +21,9 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
|||||||
const itemsPerPage = 15;
|
const itemsPerPage = 15;
|
||||||
const mediaListRef = useRef<HTMLDivElement>(null);
|
const mediaListRef = useRef<HTMLDivElement>(null);
|
||||||
const lastScrollTime = useRef(0);
|
const lastScrollTime = useRef(0);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
|
const scrollDetectorRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// 使用ref来跟踪关键状态,避免闭包问题
|
// 使用ref来跟踪关键状态,避免闭包问题
|
||||||
const stateRef = useRef({
|
const stateRef = useRef({
|
||||||
@ -30,8 +33,8 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
|||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 封装fetch函数但不使用useCallback以避免依赖循环
|
// 封装fetch函数使用useCallback避免重新创建
|
||||||
const fetchMedia = async (page = 1, append = false) => {
|
const fetchMedia = useCallback(async (page = 1, append = false) => {
|
||||||
// 使用ref中的最新状态
|
// 使用ref中的最新状态
|
||||||
if (
|
if (
|
||||||
stateRef.current.isLoading ||
|
stateRef.current.isLoading ||
|
||||||
@ -41,6 +44,14 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 取消之前的请求
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的AbortController
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
// 更新状态和ref
|
// 更新状态和ref
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
stateRef.current.isLoading = true;
|
stateRef.current.isLoading = true;
|
||||||
@ -55,6 +66,7 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`,
|
`/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`,
|
||||||
|
{ signal: abortControllerRef.current.signal }
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// 解析响应内容,获取详细错误信息
|
// 解析响应内容,获取详细错误信息
|
||||||
@ -127,6 +139,11 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
|||||||
stateRef.current.hasMoreContent = newHasMoreContent;
|
stateRef.current.hasMoreContent = newHasMoreContent;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 如果是取消的请求,不显示错误
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 只有在非追加模式下才清空已加载的内容
|
// 只有在非追加模式下才清空已加载的内容
|
||||||
if (!append) {
|
if (!append) {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
@ -136,10 +153,10 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
stateRef.current.isLoading = false;
|
stateRef.current.isLoading = false;
|
||||||
}
|
}
|
||||||
};
|
}, [type, doubanId]);
|
||||||
|
|
||||||
// 处理滚动事件
|
// 处理滚动事件
|
||||||
const handleScroll = () => {
|
const handleScroll = useCallback(() => {
|
||||||
// 获取关键滚动值
|
// 获取关键滚动值
|
||||||
const scrollY = window.scrollY;
|
const scrollY = window.scrollY;
|
||||||
const windowHeight = window.innerHeight;
|
const windowHeight = window.innerHeight;
|
||||||
@ -167,7 +184,7 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
|||||||
if (scrollPosition >= threshold) {
|
if (scrollPosition >= threshold) {
|
||||||
fetchMedia(stateRef.current.currentPage + 1, true);
|
fetchMedia(stateRef.current.currentPage + 1, true);
|
||||||
}
|
}
|
||||||
};
|
}, [fetchMedia]);
|
||||||
|
|
||||||
// 更新ref值以跟踪状态变化
|
// 更新ref值以跟踪状态变化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -186,6 +203,58 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
|||||||
stateRef.current.error = error;
|
stateRef.current.error = error;
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
|
// 设置和清理IntersectionObserver
|
||||||
|
const setupIntersectionObserver = useCallback(() => {
|
||||||
|
// 清理旧的Observer
|
||||||
|
if (observerRef.current) {
|
||||||
|
observerRef.current.disconnect();
|
||||||
|
observerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理旧的检测元素
|
||||||
|
if (scrollDetectorRef.current) {
|
||||||
|
scrollDetectorRef.current.remove();
|
||||||
|
scrollDetectorRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的IntersectionObserver
|
||||||
|
const observerOptions = {
|
||||||
|
root: null,
|
||||||
|
rootMargin: "300px",
|
||||||
|
threshold: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
observerRef.current = new IntersectionObserver((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
|
||||||
|
if (
|
||||||
|
entry.isIntersecting &&
|
||||||
|
!stateRef.current.isLoading &&
|
||||||
|
stateRef.current.hasMoreContent &&
|
||||||
|
!stateRef.current.error
|
||||||
|
) {
|
||||||
|
fetchMedia(stateRef.current.currentPage + 1, true);
|
||||||
|
}
|
||||||
|
}, observerOptions);
|
||||||
|
|
||||||
|
// 创建并添加检测底部的元素
|
||||||
|
const footer = document.createElement("div");
|
||||||
|
footer.id = "scroll-detector";
|
||||||
|
footer.style.width = "100%";
|
||||||
|
footer.style.height = "10px";
|
||||||
|
scrollDetectorRef.current = footer;
|
||||||
|
|
||||||
|
// 确保mediaListRef有父元素
|
||||||
|
if (mediaListRef.current && mediaListRef.current.parentElement) {
|
||||||
|
// 插入到grid后面而不是内部
|
||||||
|
mediaListRef.current.parentElement.insertBefore(
|
||||||
|
footer,
|
||||||
|
mediaListRef.current.nextSibling,
|
||||||
|
);
|
||||||
|
observerRef.current.observe(footer);
|
||||||
|
}
|
||||||
|
}, [fetchMedia]);
|
||||||
|
|
||||||
// 组件初始化和依赖变化时重置
|
// 组件初始化和依赖变化时重置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 重置状态
|
// 重置状态
|
||||||
@ -204,69 +273,50 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
|||||||
// 清空列表
|
// 清空列表
|
||||||
setItems([]);
|
setItems([]);
|
||||||
|
|
||||||
|
// 取消可能存在的请求
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
// 加载第一页数据
|
// 加载第一页数据
|
||||||
fetchMedia(1, false);
|
fetchMedia(1, false);
|
||||||
|
|
||||||
// 管理滚动事件
|
// 设置滚动事件监听器
|
||||||
const scrollListener = handleScroll;
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
|
||||||
// 移除任何现有监听器
|
// 设置IntersectionObserver
|
||||||
window.removeEventListener("scroll", scrollListener);
|
setupIntersectionObserver();
|
||||||
|
|
||||||
// 添加滚动事件监听器 - 使用passive: true可提高滚动性能
|
|
||||||
window.addEventListener("scroll", scrollListener, { passive: true });
|
|
||||||
|
|
||||||
// 创建一个IntersectionObserver作为备选检测方案
|
|
||||||
const observerOptions = {
|
|
||||||
root: null,
|
|
||||||
rootMargin: "300px",
|
|
||||||
threshold: 0.1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const intersectionObserver = new IntersectionObserver((entries) => {
|
|
||||||
const entry = entries[0];
|
|
||||||
|
|
||||||
if (
|
|
||||||
entry.isIntersecting &&
|
|
||||||
!stateRef.current.isLoading &&
|
|
||||||
stateRef.current.hasMoreContent &&
|
|
||||||
!stateRef.current.error
|
|
||||||
) {
|
|
||||||
fetchMedia(stateRef.current.currentPage + 1, true);
|
|
||||||
}
|
|
||||||
}, observerOptions);
|
|
||||||
|
|
||||||
// 添加检测底部的元素 - 放在grid容器的后面而不是内部
|
|
||||||
const footer = document.createElement("div");
|
|
||||||
footer.id = "scroll-detector";
|
|
||||||
footer.style.width = "100%";
|
|
||||||
footer.style.height = "10px";
|
|
||||||
|
|
||||||
// 确保mediaListRef有父元素
|
|
||||||
if (mediaListRef.current && mediaListRef.current.parentElement) {
|
|
||||||
// 插入到grid后面而不是内部
|
|
||||||
mediaListRef.current.parentElement.insertBefore(
|
|
||||||
footer,
|
|
||||||
mediaListRef.current.nextSibling,
|
|
||||||
);
|
|
||||||
intersectionObserver.observe(footer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始检查一次,以防内容不足一屏
|
// 初始检查一次,以防内容不足一屏
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (stateRef.current.hasMoreContent && !stateRef.current.isLoading) {
|
if (stateRef.current.hasMoreContent && !stateRef.current.isLoading) {
|
||||||
scrollListener();
|
handleScroll();
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
// 清理函数
|
// 清理函数
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
window.removeEventListener("scroll", scrollListener);
|
window.removeEventListener("scroll", handleScroll);
|
||||||
intersectionObserver.disconnect();
|
|
||||||
document.getElementById("scroll-detector")?.remove();
|
// 清理IntersectionObserver
|
||||||
|
if (observerRef.current) {
|
||||||
|
observerRef.current.disconnect();
|
||||||
|
observerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理scroll detector元素
|
||||||
|
if (scrollDetectorRef.current) {
|
||||||
|
scrollDetectorRef.current.remove();
|
||||||
|
scrollDetectorRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消正在进行的请求
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [type, doubanId]); // 只在关键属性变化时执行
|
}, [type, doubanId, handleScroll, fetchMedia, setupIntersectionObserver]);
|
||||||
|
|
||||||
// 错误提示组件
|
// 错误提示组件
|
||||||
const ErrorMessage = () => {
|
const ErrorMessage = () => {
|
||||||
@ -345,6 +395,7 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
|||||||
src={item.imageUrl}
|
src={item.imageUrl}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
className="absolute top-0 left-0 w-full h-full object-cover hover:scale-105"
|
className="absolute top-0 left-0 w-full h-full object-cover hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent">
|
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent">
|
||||||
<h3 className="font-bold text-white text-sm line-clamp-2">
|
<h3 className="font-bold text-white text-sm line-clamp-2">
|
||||||
|
@ -47,7 +47,58 @@ const {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function setupThemeToggle() {
|
// 主题切换逻辑
|
||||||
|
(function() {
|
||||||
|
// 存储所有事件监听器,便于统一清理
|
||||||
|
const listeners: Array<{
|
||||||
|
element: EventTarget;
|
||||||
|
eventType: string;
|
||||||
|
handler: EventListenerOrEventListenerObject;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// 定时器
|
||||||
|
let transitionTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// 添加事件监听器并记录,方便后续统一清理
|
||||||
|
function addListener<K extends keyof HTMLElementEventMap>(
|
||||||
|
element: EventTarget | null,
|
||||||
|
eventType: string,
|
||||||
|
handler: EventListenerOrEventListenerObject,
|
||||||
|
options?: boolean | AddEventListenerOptions
|
||||||
|
): EventListenerOrEventListenerObject | null {
|
||||||
|
if (!element) return null;
|
||||||
|
|
||||||
|
element.addEventListener(eventType, handler, options);
|
||||||
|
listeners.push({ element, eventType, handler });
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理函数 - 移除所有事件监听器
|
||||||
|
function cleanup(): void {
|
||||||
|
// 移除所有监听器
|
||||||
|
listeners.forEach(({ element, eventType, handler }) => {
|
||||||
|
try {
|
||||||
|
element.removeEventListener(eventType, handler);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`移除主题切换事件监听器出错:`, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清空数组
|
||||||
|
listeners.length = 0;
|
||||||
|
|
||||||
|
// 清理任何定时器
|
||||||
|
if (transitionTimeout) {
|
||||||
|
clearTimeout(transitionTimeout);
|
||||||
|
transitionTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化主题切换功能
|
||||||
|
function setupThemeToggle(): void {
|
||||||
|
// 确保当前没有活动的主题切换按钮事件
|
||||||
|
cleanup();
|
||||||
|
|
||||||
// 获取所有主题切换按钮
|
// 获取所有主题切换按钮
|
||||||
const themeToggleButtons = document.querySelectorAll('#theme-toggle-button');
|
const themeToggleButtons = document.querySelectorAll('#theme-toggle-button');
|
||||||
|
|
||||||
@ -56,15 +107,29 @@ const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let transitioning = false;
|
let transitioning = false;
|
||||||
let transitionTimeout: number | null = null;
|
|
||||||
|
|
||||||
// 获取系统首选主题
|
// 获取系统首选主题
|
||||||
const getSystemTheme = () => {
|
const getSystemTheme = (): string => {
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 初始化主题
|
||||||
|
const initializeTheme = (): void => {
|
||||||
|
const storedTheme = localStorage.getItem('theme');
|
||||||
|
const systemTheme = getSystemTheme();
|
||||||
|
|
||||||
|
// 按照逻辑优先级应用主题
|
||||||
|
if (storedTheme) {
|
||||||
|
document.documentElement.dataset.theme = storedTheme;
|
||||||
|
} else if (systemTheme) {
|
||||||
|
document.documentElement.dataset.theme = systemTheme;
|
||||||
|
} else {
|
||||||
|
document.documentElement.dataset.theme = 'light';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 切换主题
|
// 切换主题
|
||||||
const toggleTheme = () => {
|
const toggleTheme = (): void => {
|
||||||
if (transitioning) {
|
if (transitioning) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -79,9 +144,9 @@ const {
|
|||||||
document.documentElement.dataset.theme = newTheme;
|
document.documentElement.dataset.theme = newTheme;
|
||||||
|
|
||||||
// 更新本地存储
|
// 更新本地存储
|
||||||
const isSystemTheme = newTheme === getSystemTheme();
|
const systemTheme = getSystemTheme();
|
||||||
|
|
||||||
if (isSystemTheme) {
|
if (newTheme === systemTheme) {
|
||||||
localStorage.removeItem('theme');
|
localStorage.removeItem('theme');
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem('theme', newTheme);
|
localStorage.setItem('theme', newTheme);
|
||||||
@ -94,92 +159,92 @@ const {
|
|||||||
|
|
||||||
transitionTimeout = setTimeout(() => {
|
transitionTimeout = setTimeout(() => {
|
||||||
transitioning = false;
|
transitioning = false;
|
||||||
}, 300) as unknown as number;
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 监听系统主题变化
|
// 监听系统主题变化
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
const handleMediaChange = (e: MediaQueryListEvent) => {
|
const handleMediaChange = (e: MediaQueryListEvent): void => {
|
||||||
// 只有当主题设置为跟随系统时才更新主题
|
|
||||||
if (!localStorage.getItem('theme')) {
|
if (!localStorage.getItem('theme')) {
|
||||||
const newTheme = e.matches ? 'dark' : 'light';
|
const newTheme = e.matches ? 'dark' : 'light';
|
||||||
document.documentElement.dataset.theme = newTheme;
|
document.documentElement.dataset.theme = newTheme;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaQuery.addEventListener('change', handleMediaChange);
|
// 添加系统主题变化监听
|
||||||
|
addListener(mediaQuery, 'change', handleMediaChange as EventListener);
|
||||||
// 存储事件处理函数引用,用于清理
|
|
||||||
const clickHandlers = new Map();
|
|
||||||
|
|
||||||
// 为每个按钮添加点击事件
|
// 为每个按钮添加事件
|
||||||
themeToggleButtons.forEach(button => {
|
themeToggleButtons.forEach(button => {
|
||||||
const handler = (e: Event) => {
|
(button as HTMLElement).style.pointerEvents = 'auto';
|
||||||
// 阻止事件冒泡
|
|
||||||
|
// 创建点击处理函数
|
||||||
|
const clickHandler = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleTheme();
|
toggleTheme();
|
||||||
};
|
};
|
||||||
|
|
||||||
clickHandlers.set(button, handler);
|
// 点击事件 - 使用捕获模式
|
||||||
button.addEventListener('click', handler);
|
addListener(button, 'click', clickHandler, { capture: true });
|
||||||
|
|
||||||
// 恢复键盘事件
|
// 键盘事件
|
||||||
button.addEventListener('keydown', function(e) {
|
addListener(button, 'keydown', ((e: KeyboardEvent) => {
|
||||||
const keyEvent = e as KeyboardEvent;
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
if (keyEvent.key === 'Enter' || keyEvent.key === ' ') {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggleTheme();
|
toggleTheme();
|
||||||
}
|
}
|
||||||
});
|
}) as EventListener);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 处理移动端主题切换容器
|
// 处理移动端主题切换容器
|
||||||
let containerHandler: ((e: Event) => void) | null = null;
|
|
||||||
const themeToggleContainer = document.getElementById('theme-toggle-container');
|
const themeToggleContainer = document.getElementById('theme-toggle-container');
|
||||||
if (themeToggleContainer) {
|
if (themeToggleContainer) {
|
||||||
containerHandler = (e) => {
|
addListener(themeToggleContainer, 'click', (e: Event) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
// 如果点击的不是主题切换按钮本身,则手动触发主题切换
|
|
||||||
if (target.id !== 'theme-toggle-button' && !target.closest('#theme-toggle-button')) {
|
if (target.id !== 'theme-toggle-button' && !target.closest('#theme-toggle-button')) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleTheme();
|
toggleTheme();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
themeToggleContainer.addEventListener('click', containerHandler);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理
|
// 初始化主题
|
||||||
return () => {
|
initializeTheme();
|
||||||
mediaQuery.removeEventListener('change', handleMediaChange);
|
|
||||||
|
|
||||||
// 清理按钮事件
|
|
||||||
themeToggleButtons.forEach(button => {
|
|
||||||
const handler = clickHandlers.get(button);
|
|
||||||
if (handler) {
|
|
||||||
button.removeEventListener('click', handler);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 清理容器事件
|
|
||||||
if (themeToggleContainer && containerHandler) {
|
|
||||||
themeToggleContainer.removeEventListener('click', containerHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transitionTimeout) {
|
|
||||||
clearTimeout(transitionTimeout);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页面加载时初始化
|
// 注册清理函数
|
||||||
if (document.readyState === 'loading') {
|
function registerCleanup(): void {
|
||||||
document.addEventListener('DOMContentLoaded', setupThemeToggle);
|
// Astro 事件
|
||||||
} else {
|
document.addEventListener('astro:before-preparation', cleanup, { once: true });
|
||||||
|
document.addEventListener('astro:before-swap', cleanup, { once: true });
|
||||||
|
|
||||||
|
// Swup 事件
|
||||||
|
document.addEventListener('swup:willReplaceContent', cleanup, { once: true });
|
||||||
|
|
||||||
|
// 页面卸载
|
||||||
|
window.addEventListener('beforeunload', cleanup, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化函数
|
||||||
|
function init(): void {
|
||||||
setupThemeToggle();
|
setupThemeToggle();
|
||||||
|
registerCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 支持 Astro 视图转换
|
// 在页面加载后初始化
|
||||||
document.addEventListener('astro:after-swap', setupThemeToggle);
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('astro:page-load', setupThemeToggle);
|
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||||
|
} else {
|
||||||
|
setTimeout(init, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在页面转换后重新初始化
|
||||||
|
document.addEventListener('astro:after-swap', init);
|
||||||
|
document.addEventListener('astro:page-load', init);
|
||||||
|
|
||||||
|
// Swup页面内容替换后重新初始化
|
||||||
|
document.addEventListener('swup:contentReplaced', init);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
@ -8,7 +8,7 @@ export const NAV_LINKS = [
|
|||||||
{ href: '/movies', text: '观影' },
|
{ href: '/movies', text: '观影' },
|
||||||
{ href: '/books', text: '读书' },
|
{ href: '/books', text: '读书' },
|
||||||
{ href: '/projects', text: '项目' },
|
{ href: '/projects', text: '项目' },
|
||||||
{ href: '/other', text: '其他' }
|
{ href: '/other', text: '其他' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ICP = '渝ICP备2022009272号';
|
export const ICP = '渝ICP备2022009272号';
|
||||||
|
@ -15,6 +15,7 @@ tags: []
|
|||||||
5. **观影记录**:集成豆瓣观影数据
|
5. **观影记录**:集成豆瓣观影数据
|
||||||
6. **读书记录**:集成豆瓣读书数据
|
6. **读书记录**:集成豆瓣读书数据
|
||||||
7. **旅行足迹**:支持展示全球旅行足迹热力图
|
7. **旅行足迹**:支持展示全球旅行足迹热力图
|
||||||
|
8. **丝滑页面过渡**:使用 Swup 集成实现页面间无缝过渡动画,提供类似 SPA 的浏览体验,保留静态站点的所有优势
|
||||||
|
|
||||||
## 基础配置
|
## 基础配置
|
||||||
|
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
---
|
---
|
||||||
import Layout from '@/components/Layout.astro';
|
import Layout from '@/components/Layout.astro';
|
||||||
import { SITE_NAME } from '@/consts';
|
import { SITE_NAME } from '@/consts';
|
||||||
|
|
||||||
|
// 启用静态预渲染
|
||||||
|
export const prerender = true;
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title={`404 - 页面未找到 | ${SITE_NAME}`}>
|
<Layout title={`404 - 页面未找到 | ${SITE_NAME}`}>
|
||||||
|
@ -32,7 +32,6 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
if (path) {
|
if (path) {
|
||||||
const normalizedPath = path.toLowerCase();
|
const normalizedPath = path.toLowerCase();
|
||||||
filteredArticles = filteredArticles.filter(article => {
|
filteredArticles = filteredArticles.filter(article => {
|
||||||
const articlePath = article.id.split('/');
|
|
||||||
return article.id.toLowerCase().includes(normalizedPath);
|
return article.id.toLowerCase().includes(normalizedPath);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -115,7 +115,7 @@ function getArticleUrl(articleId: string) {
|
|||||||
<!-- 阅读进度条 -->
|
<!-- 阅读进度条 -->
|
||||||
<div
|
<div
|
||||||
class="fixed top-0 left-0 w-full h-1 bg-transparent z-50"
|
class="fixed top-0 left-0 w-full h-1 bg-transparent z-50"
|
||||||
id="progress-container 9"
|
id="progress-container"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="h-full w-0 bg-primary-500 transition-width duration-100"
|
class="h-full w-0 bg-primary-500 transition-width duration-100"
|
||||||
@ -369,80 +369,74 @@ function getArticleUrl(articleId: string) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
|
||||||
|
|
||||||
<script>
|
<script is:inline>
|
||||||
// 阅读进度条
|
(function() {
|
||||||
const progressBar = document.getElementById("progress-bar");
|
const listeners = [];
|
||||||
const backToTopButton = document.getElementById("back-to-top");
|
|
||||||
|
function addListener(element, eventType, handler, options) {
|
||||||
|
if (!element) return null;
|
||||||
|
|
||||||
|
element.addEventListener(eventType, handler, options);
|
||||||
|
listeners.push({ element, eventType, handler });
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
function updateReadingProgress() {
|
|
||||||
const scrollTop = window.scrollY || document.documentElement.scrollTop;
|
|
||||||
const scrollHeight =
|
|
||||||
document.documentElement.scrollHeight -
|
|
||||||
document.documentElement.clientHeight;
|
|
||||||
const progress = (scrollTop / scrollHeight) * 100;
|
|
||||||
|
|
||||||
if (progressBar) {
|
|
||||||
progressBar.style.width = `${progress}%`;
|
progressBar.style.width = `${progress}%`;
|
||||||
}
|
|
||||||
|
|
||||||
// 显示/隐藏返回顶部按钮
|
if (backToTopButton) {
|
||||||
if (backToTopButton) {
|
if (scrollTop > 300) {
|
||||||
if (scrollTop > 300) {
|
backToTopButton.classList.add(
|
||||||
backToTopButton.classList.add(
|
"opacity-100", "visible", "translate-y-0"
|
||||||
"opacity-100",
|
);
|
||||||
"visible",
|
backToTopButton.classList.remove(
|
||||||
"translate-y-0",
|
"opacity-0", "invisible", "translate-y-5"
|
||||||
);
|
);
|
||||||
backToTopButton.classList.remove(
|
} else {
|
||||||
"opacity-0",
|
backToTopButton.classList.add(
|
||||||
"invisible",
|
"opacity-0", "invisible", "translate-y-5"
|
||||||
"translate-y-5",
|
);
|
||||||
);
|
backToTopButton.classList.remove(
|
||||||
} else {
|
"opacity-100", "visible", "translate-y-0"
|
||||||
backToTopButton.classList.add(
|
);
|
||||||
"opacity-0",
|
}
|
||||||
"invisible",
|
|
||||||
"translate-y-5",
|
|
||||||
);
|
|
||||||
backToTopButton.classList.remove(
|
|
||||||
"opacity-100",
|
|
||||||
"visible",
|
|
||||||
"translate-y-0",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
addListener(window, "scroll", updateReadingProgress);
|
||||||
// 返回顶部功能
|
|
||||||
if (backToTopButton) {
|
if (backToTopButton) {
|
||||||
backToTopButton.addEventListener("click", () => {
|
addListener(backToTopButton, "click", () => {
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
updateReadingProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听滚动事件
|
function setupTableOfContents() {
|
||||||
window.addEventListener("scroll", updateReadingProgress);
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
updateReadingProgress();
|
|
||||||
|
|
||||||
// 目录功能
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
const tocContent = document.getElementById("toc-content");
|
const tocContent = document.getElementById("toc-content");
|
||||||
const tocPanel = document.querySelector(
|
const tocPanel = document.querySelector('[class*="2xl:block"][class*="fixed"]');
|
||||||
'[class*="2xl:block"][class*="fixed"]',
|
|
||||||
);
|
if (!tocPanel || !tocContent) return;
|
||||||
|
|
||||||
// 检查是否有足够空间显示目录
|
|
||||||
function checkTocVisibility() {
|
function checkTocVisibility() {
|
||||||
if (!tocPanel) return;
|
|
||||||
|
|
||||||
// 如果窗口宽度小于1536px (2xl breakpoint),隐藏目录
|
|
||||||
if (window.innerWidth < 1536) {
|
if (window.innerWidth < 1536) {
|
||||||
tocPanel.classList.add("hidden");
|
tocPanel.classList.add("hidden");
|
||||||
tocPanel.classList.remove("2xl:block");
|
tocPanel.classList.remove("2xl:block");
|
||||||
@ -451,213 +445,152 @@ function getArticleUrl(articleId: string) {
|
|||||||
tocPanel.classList.add("2xl:block");
|
tocPanel.classList.add("2xl:block");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听窗口大小变化
|
addListener(window, "resize", checkTocVisibility);
|
||||||
window.addEventListener("resize", checkTocVisibility);
|
|
||||||
|
|
||||||
// 初始检查
|
|
||||||
checkTocVisibility();
|
checkTocVisibility();
|
||||||
|
|
||||||
// 生成目录内容
|
const article = document.querySelector("article");
|
||||||
function generateTableOfContents() {
|
if (!article) {
|
||||||
// 获取文章中的所有标题元素
|
tocContent.innerHTML = '<p class="text-secondary-500 dark:text-secondary-400 italic">无法生成目录</p>';
|
||||||
const article = document.querySelector("article");
|
return;
|
||||||
if (!article || !tocContent) {
|
|
||||||
console.error("找不到文章内容或目录容器");
|
|
||||||
if (tocContent) {
|
|
||||||
tocContent.innerHTML =
|
|
||||||
'<p class="text-secondary-500 dark:text-secondary-400 italic">无法生成目录</p>';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headings = article.querySelectorAll("h1, h2, h3, h4, h5, h6");
|
|
||||||
if (headings.length === 0) {
|
|
||||||
tocContent.innerHTML =
|
|
||||||
'<p class="text-secondary-500 dark:text-secondary-400 italic">此文章没有目录</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建目录列表
|
|
||||||
const tocList = document.createElement("ul");
|
|
||||||
tocList.className = "space-y-2";
|
|
||||||
|
|
||||||
// 为每个标题创建目录项
|
|
||||||
headings.forEach((heading, index) => {
|
|
||||||
// 为每个标题添加ID,如果没有的话
|
|
||||||
if (!heading.id) {
|
|
||||||
heading.id = `heading-${index}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建目录项
|
|
||||||
const listItem = document.createElement("li");
|
|
||||||
|
|
||||||
// 根据标题级别设置缩进
|
|
||||||
const headingLevel = parseInt(heading.tagName.substring(1));
|
|
||||||
const indent = (headingLevel - 1) * 0.75; // 每级缩进0.75rem
|
|
||||||
|
|
||||||
// 创建链接
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = `#${heading.id}`;
|
|
||||||
link.className = `block hover:text-primary-600 dark:hover:text-primary-400 duration-50 ${headingLevel > 2 ? "text-secondary-600 dark:text-secondary-400" : "text-secondary-800 dark:text-secondary-200 font-medium"}`;
|
|
||||||
link.style.paddingLeft = `${indent}rem`;
|
|
||||||
link.textContent = heading.textContent;
|
|
||||||
|
|
||||||
// 点击链接时滚动到目标位置
|
|
||||||
link.addEventListener("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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
listItem.appendChild(link);
|
|
||||||
tocList.appendChild(listItem);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 将目录添加到面板
|
|
||||||
tocContent.innerHTML = "";
|
|
||||||
tocContent.appendChild(tocList);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页面加载时生成目录
|
const headings = article.querySelectorAll("h1, h2, h3, h4, h5, h6");
|
||||||
try {
|
if (headings.length === 0) {
|
||||||
generateTableOfContents();
|
tocContent.innerHTML = '<p class="text-secondary-500 dark:text-secondary-400 italic">此文章没有目录</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 添加滚动监听,更新目录高亮
|
const tocList = document.createElement("ul");
|
||||||
function updateActiveHeading() {
|
tocList.className = "space-y-2";
|
||||||
const article = document.querySelector("article");
|
|
||||||
if (!article || !tocContent) return;
|
headings.forEach((heading, index) => {
|
||||||
|
if (!heading.id) {
|
||||||
const headings = Array.from(
|
heading.id = `heading-${index}`;
|
||||||
article.querySelectorAll("h1, h2, h3, h4, h5, h6"),
|
|
||||||
);
|
|
||||||
if (headings.length === 0) return;
|
|
||||||
|
|
||||||
// 获取所有目录链接
|
|
||||||
const tocLinks = Array.from(tocContent.querySelectorAll("a"));
|
|
||||||
if (tocLinks.length === 0) return;
|
|
||||||
|
|
||||||
// 移除所有活跃状态
|
|
||||||
tocLinks.forEach((link) => {
|
|
||||||
link.classList.remove(
|
|
||||||
"text-primary-600",
|
|
||||||
"dark:text-primary-400",
|
|
||||||
"font-medium",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 找到当前视口中最靠近顶部的标题
|
|
||||||
let currentHeading = null;
|
|
||||||
const scrollPosition = window.scrollY + 150; // 添加一些偏移量
|
|
||||||
|
|
||||||
for (const heading of headings) {
|
|
||||||
const headingTop =
|
|
||||||
heading.getBoundingClientRect().top + window.scrollY;
|
|
||||||
if (headingTop <= scrollPosition) {
|
|
||||||
currentHeading = heading;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果找到当前标题,高亮对应的目录项
|
|
||||||
if (currentHeading) {
|
|
||||||
const activeLink = tocLinks.find(
|
|
||||||
(link) => link.getAttribute("href") === `#${currentHeading.id}`,
|
|
||||||
);
|
|
||||||
if (activeLink) {
|
|
||||||
activeLink.classList.add(
|
|
||||||
"text-primary-600",
|
|
||||||
"dark:text-primary-400",
|
|
||||||
"font-medium",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听滚动事件,使用节流函数优化性能
|
const listItem = document.createElement("li");
|
||||||
let ticking = false;
|
const headingLevel = parseInt(heading.tagName.substring(1));
|
||||||
window.addEventListener("scroll", () => {
|
const indent = (headingLevel - 1) * 0.75;
|
||||||
if (!ticking) {
|
|
||||||
window.requestAnimationFrame(() => {
|
const link = document.createElement("a");
|
||||||
updateActiveHeading();
|
link.href = `#${heading.id}`;
|
||||||
ticking = false;
|
link.className = `block hover:text-primary-600 dark:hover:text-primary-400 duration-50 ${
|
||||||
|
headingLevel > 2
|
||||||
|
? "text-secondary-600 dark:text-secondary-400"
|
||||||
|
: "text-secondary-800 dark:text-secondary-200 font-medium"
|
||||||
|
}`;
|
||||||
|
link.style.paddingLeft = `${indent}rem`;
|
||||||
|
link.textContent = heading.textContent;
|
||||||
|
|
||||||
|
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",
|
||||||
});
|
});
|
||||||
ticking = true;
|
|
||||||
|
targetElement.classList.add(
|
||||||
|
"bg-primary-50", "dark:bg-primary-900/20"
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
targetElement.classList.remove(
|
||||||
|
"bg-primary-50", "dark:bg-primary-900/20"
|
||||||
|
);
|
||||||
|
}, 2000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 初始化高亮
|
listItem.appendChild(link);
|
||||||
updateActiveHeading();
|
tocList.appendChild(listItem);
|
||||||
} catch (error) {
|
});
|
||||||
console.error("生成目录时发生错误:", error);
|
|
||||||
if (tocContent) {
|
tocContent.innerHTML = "";
|
||||||
tocContent.innerHTML =
|
tocContent.appendChild(tocList);
|
||||||
'<p class="text-secondary-500 dark:text-secondary-400 italic">生成目录时发生错误</p>';
|
|
||||||
|
let ticking = false;
|
||||||
|
|
||||||
|
function updateActiveHeading() {
|
||||||
|
const currentHeadings = Array.from(article.querySelectorAll("h1, h2, h3, h4, h5, h6"));
|
||||||
|
const tocLinks = Array.from(tocContent.querySelectorAll("a"));
|
||||||
|
|
||||||
|
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 currentHeadings) {
|
||||||
|
const headingTop = heading.getBoundingClientRect().top + window.scrollY;
|
||||||
|
if (headingTop <= scrollPosition) {
|
||||||
|
currentHeading = heading;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentHeading) {
|
||||||
|
const activeLink = tocLinks.find(
|
||||||
|
link => link.getAttribute("href") === `#${currentHeading.id}`
|
||||||
|
);
|
||||||
|
if (activeLink) {
|
||||||
|
activeLink.classList.add(
|
||||||
|
"text-primary-600", "dark:text-primary-400", "font-medium"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
addListener(window, "scroll", () => {
|
||||||
// 代码块增强功能
|
if (!ticking) {
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
window.requestAnimationFrame(() => {
|
||||||
// 处理所有代码块
|
updateActiveHeading();
|
||||||
|
ticking = false;
|
||||||
|
});
|
||||||
|
ticking = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateActiveHeading();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCodeBlocks() {
|
||||||
const codeBlocks = document.querySelectorAll("pre");
|
const codeBlocks = document.querySelectorAll("pre");
|
||||||
|
if (!codeBlocks.length) return;
|
||||||
codeBlocks.forEach((pre) => {
|
|
||||||
// 获取代码语言
|
codeBlocks.forEach(pre => {
|
||||||
const code = pre.querySelector("code");
|
const code = pre.querySelector("code");
|
||||||
if (!code) return;
|
if (!code || pre.querySelector('.code-header')) return;
|
||||||
|
|
||||||
// 从类名中提取语言
|
|
||||||
const className = code.className;
|
const className = code.className;
|
||||||
const languageMatch = className.match(/language-(\w+)/);
|
const languageMatch = className.match(/language-(\w+)/);
|
||||||
const language = languageMatch ? languageMatch[1] : "text";
|
const language = languageMatch ? languageMatch[1] : "text";
|
||||||
|
|
||||||
// 创建顶部栏
|
|
||||||
const header = document.createElement("div");
|
const header = document.createElement("div");
|
||||||
header.className =
|
header.className = "code-header flex justify-between items-center text-xs px-4 py-2 bg-secondary-800 dark:bg-dark-card text-secondary-300 dark:text-secondary-400 rounded-t-lg";
|
||||||
"code-header flex justify-between items-center text-xs px-4 py-2 bg-secondary-800 dark:bg-dark-card text-secondary-300 dark:text-secondary-400 rounded-t-lg";
|
|
||||||
|
|
||||||
// 创建语言标签
|
|
||||||
const languageLabel = document.createElement("span");
|
const languageLabel = document.createElement("span");
|
||||||
languageLabel.className = "code-language font-mono";
|
languageLabel.className = "code-language font-mono";
|
||||||
languageLabel.textContent = language;
|
languageLabel.textContent = language;
|
||||||
|
|
||||||
// 创建复制按钮
|
|
||||||
const copyButton = document.createElement("button");
|
const copyButton = document.createElement("button");
|
||||||
copyButton.className =
|
copyButton.className = "code-copy-button flex items-center gap-1 hover:text-white dark:hover:text-primary-400 transition-colors";
|
||||||
"code-copy-button flex items-center gap-1 hover:text-white dark:hover:text-primary-400 transition-colors";
|
|
||||||
|
|
||||||
// 创建SVG图标和文本
|
|
||||||
const copyIcon = `<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>`;
|
const copyIcon = `<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>`;
|
||||||
const successIcon = `<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>`;
|
const successIcon = `<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>`;
|
||||||
const errorIcon = `<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="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>`;
|
const errorIcon = `<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="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>`;
|
||||||
@ -666,33 +599,23 @@ function getArticleUrl(articleId: string) {
|
|||||||
copyButton.setAttribute("aria-label", "复制代码");
|
copyButton.setAttribute("aria-label", "复制代码");
|
||||||
copyButton.setAttribute("title", "复制代码到剪贴板");
|
copyButton.setAttribute("title", "复制代码到剪贴板");
|
||||||
|
|
||||||
// 添加复制功能
|
addListener(copyButton, "click", (e) => {
|
||||||
copyButton.addEventListener("click", (e) => {
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// 获取代码文本
|
navigator.clipboard.writeText(code.textContent || "")
|
||||||
const codeText = code.textContent || "";
|
|
||||||
|
|
||||||
// 复制到剪贴板
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(codeText)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// 复制成功,更改按钮文本
|
|
||||||
copyButton.innerHTML = `${successIcon}<span>已复制</span>`;
|
copyButton.innerHTML = `${successIcon}<span>已复制</span>`;
|
||||||
copyButton.classList.add("text-green-400");
|
copyButton.classList.add("text-green-400");
|
||||||
|
|
||||||
// 2秒后恢复按钮文本
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copyButton.innerHTML = `${copyIcon}<span>复制</span>`;
|
copyButton.innerHTML = `${copyIcon}<span>复制</span>`;
|
||||||
copyButton.classList.remove("text-green-400");
|
copyButton.classList.remove("text-green-400");
|
||||||
}, 2000);
|
}, 2000);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// 复制失败,更改按钮文本
|
|
||||||
copyButton.innerHTML = `${errorIcon}<span>失败</span>`;
|
copyButton.innerHTML = `${errorIcon}<span>失败</span>`;
|
||||||
copyButton.classList.add("text-red-400");
|
copyButton.classList.add("text-red-400");
|
||||||
|
|
||||||
// 2秒后恢复按钮文本
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copyButton.innerHTML = `${copyIcon}<span>复制</span>`;
|
copyButton.innerHTML = `${copyIcon}<span>复制</span>`;
|
||||||
copyButton.classList.remove("text-red-400");
|
copyButton.classList.remove("text-red-400");
|
||||||
@ -700,17 +623,48 @@ function getArticleUrl(articleId: string) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 将语言标签和复制按钮添加到顶部栏
|
|
||||||
header.appendChild(languageLabel);
|
header.appendChild(languageLabel);
|
||||||
header.appendChild(copyButton);
|
header.appendChild(copyButton);
|
||||||
|
|
||||||
// 将顶部栏插入到代码块的最前面
|
|
||||||
pre.insertBefore(header, pre.firstChild);
|
pre.insertBefore(header, pre.firstChild);
|
||||||
|
|
||||||
// 调整代码块样式
|
|
||||||
pre.classList.add("rounded-b-lg", "mt-0");
|
pre.classList.add("rounded-b-lg", "mt-0");
|
||||||
pre.style.marginTop = "0";
|
pre.style.marginTop = "0";
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
listeners.forEach(({ element, eventType, handler }) => {
|
||||||
|
try {
|
||||||
|
element.removeEventListener(eventType, handler);
|
||||||
|
} catch (err) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
listeners.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (!document.querySelector("article")) return;
|
||||||
|
|
||||||
|
setupProgressBar();
|
||||||
|
setupTableOfContents();
|
||||||
|
setupCodeBlocks();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init, { once: true });
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerCleanup() {
|
||||||
|
document.addEventListener("astro:before-preparation", cleanup, { once: true });
|
||||||
|
document.addEventListener("astro:before-swap", cleanup, { once: true });
|
||||||
|
document.addEventListener("swup:willReplaceContent", cleanup, { once: true });
|
||||||
|
window.addEventListener("beforeunload", cleanup, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
registerCleanup();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
</Layout>
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
import ArticlesPage, { getStaticPaths as getOriginalPaths } from './index.astro';
|
import ArticlesPage, { getStaticPaths as getOriginalPaths } from './index.astro';
|
||||||
|
|
||||||
|
// 启用静态预渲染
|
||||||
|
export const prerender = true;
|
||||||
|
|
||||||
// 重新导出 getStaticPaths,处理所有路径模式
|
// 重新导出 getStaticPaths,处理所有路径模式
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const paths = await getOriginalPaths();
|
const paths = await getOriginalPaths();
|
||||||
@ -98,12 +101,6 @@ const mergedProps = {
|
|||||||
view
|
view
|
||||||
};
|
};
|
||||||
|
|
||||||
// 生成页面标题
|
|
||||||
let pageTitle = path ? path : '文章列表';
|
|
||||||
if (tag) {
|
|
||||||
pageTitle = `标签: ${tag}`;
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<h1 class="sr-only">{pageTitle}</h1>
|
|
||||||
<ArticlesPage {...mergedProps} />
|
<ArticlesPage {...mergedProps} />
|
Loading…
Reference in New Issue
Block a user