修复对swup的正确dom监听和清理

This commit is contained in:
lsy 2025-04-20 15:34:45 +08:00
parent 5df41feb02
commit 4bb37c4a4d
11 changed files with 662 additions and 474 deletions

View File

@ -81,12 +81,10 @@ export const Countdown: React.FC<CountdownProps> = ({ targetDate, className = ''
setTimeLeft(newTimeLeft);
// 如果已经到期,清除计时器
if (newTimeLeft.expired) {
if (timerRef.current !== null) {
if (newTimeLeft.expired && timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
}, 1000);
// 清理函数

View File

@ -286,7 +286,63 @@ const normalizedPath =
</header>
<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 scrollThreshold = 50;
@ -294,7 +350,7 @@ const normalizedPath =
const navLinks = document.querySelectorAll('.hidden.md\\:flex a[href]');
// 更新导航高亮状态
function updateNavHighlight() {
function updateNavHighlight(): void {
const currentUrl = window.location.pathname;
const normalizedPath =
currentUrl === "/"
@ -343,7 +399,8 @@ const normalizedPath =
});
}
function updateHeaderBackground() {
// 处理滚动更新背景
function updateHeaderBackground(): void {
if (window.scrollY > scrollThreshold) {
header?.classList.add("scrolled");
} else {
@ -356,11 +413,11 @@ const normalizedPath =
updateNavHighlight();
// 添加滚动事件监听
window.addEventListener("scroll", updateHeaderBackground);
addListener(window, "scroll", updateHeaderBackground);
// 监听路由变化
document.addEventListener('astro:page-load', updateNavHighlight);
document.addEventListener('astro:after-swap', updateNavHighlight);
addListener(document, 'astro:page-load', updateNavHighlight);
addListener(document, 'astro:after-swap', updateNavHighlight);
// 移动端菜单逻辑
const mobileMenuButton = document.getElementById("mobile-menu-button");
@ -375,7 +432,7 @@ const normalizedPath =
const mobileSearchClose = document.getElementById("mobile-search-close");
// 关闭移动端菜单的函数
function closeMobileMenu() {
function closeMobileMenu(): void {
if (mobileMenuButton && mobileMenu && menuOpenIcon && menuCloseIcon) {
mobileMenuButton.setAttribute("aria-expanded", "false");
mobileMenu.classList.add("hidden");
@ -385,16 +442,21 @@ const normalizedPath =
}
// 关闭移动端搜索面板的函数
function closeMobileSearch() {
function closeMobileSearch(): void {
if (mobileSearchPanel) {
mobileSearchPanel.classList.add("hidden");
}
}
if (mobileMenuButton && mobileMenu && menuOpenIcon && menuCloseIcon) {
mobileMenuButton.addEventListener("click", () => {
const expanded =
mobileMenuButton.getAttribute("aria-expanded") === "true";
// 移动端菜单按钮点击事件 - 使用捕获模式确保事件优先处理
(mobileMenuButton as HTMLElement).style.pointerEvents = 'auto';
addListener(mobileMenuButton, "click", (e) => {
e.preventDefault();
e.stopPropagation();
const expanded = mobileMenuButton.getAttribute("aria-expanded") === "true";
// 切换菜单状态
mobileMenuButton.setAttribute("aria-expanded", (!expanded).toString());
@ -413,18 +475,29 @@ const normalizedPath =
// 切换图标
menuOpenIcon.classList.toggle("hidden");
menuCloseIcon.classList.toggle("hidden");
});
}, { capture: true });
// 为移动端导航链接添加点击事件
const mobileNavLinks = document.querySelectorAll('#mobile-menu a[href]');
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) {
mobileSearchButton.addEventListener("click", () => {
// 搜索按钮点击事件 - 使用捕获模式确保事件优先处理
(mobileSearchButton as HTMLElement).style.pointerEvents = 'auto';
addListener(mobileSearchButton, "click", (e) => {
e.preventDefault();
e.stopPropagation();
// 检查搜索面板是否已经打开
const isSearchVisible = !mobileSearchPanel.classList.contains("hidden");
@ -439,25 +512,47 @@ const normalizedPath =
mobileSearchPanel.classList.remove("hidden");
if (mobileSearch) mobileSearch.focus();
}
});
}, { capture: true });
// 搜索面板关闭按钮
if (mobileSearchClose) {
mobileSearchClose.addEventListener("click", () => {
(mobileSearchClose as HTMLElement).style.pointerEvents = 'auto';
addListener(mobileSearchClose, "click", (e) => {
e.preventDefault();
e.stopPropagation();
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>(
func: T,
wait: number,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | undefined;
return function (this: any, ...args: Parameters<T>): void {
return function(this: any, ...args: Parameters<T>): void {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
@ -474,22 +569,8 @@ const normalizedPath =
const mobileList = document.getElementById("mobile-search-list");
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;
try {
@ -522,8 +603,8 @@ const normalizedPath =
function searchArticles(
query: string,
resultsList: HTMLElement,
resultsMessage: HTMLElement,
) {
resultsMessage: HTMLElement
): void {
if (!query.trim()) {
resultsList.innerHTML = "";
resultsMessage.textContent = "输入关键词开始搜索";
@ -665,7 +746,7 @@ const normalizedPath =
searchArticles(
value,
desktopList as HTMLElement,
desktopMessage as HTMLElement,
desktopMessage as HTMLElement
);
}
}, 300);
@ -675,19 +756,21 @@ const normalizedPath =
searchArticles(
value,
mobileList as HTMLElement,
mobileMessage as HTMLElement,
mobileMessage as HTMLElement
);
}
}, 300);
// 桌面端搜索逻辑
if (desktopSearch && desktopResults) {
desktopSearch.addEventListener("focus", () => {
// 聚焦时显示结果
addListener(desktopSearch, "focus", () => {
desktopResults.classList.remove("hidden");
if (!isArticlesLoaded) fetchArticles();
});
desktopSearch.addEventListener("input", (e: Event) => {
// 输入时更新搜索结果
addListener(desktopSearch, "input", (e: Event) => {
const target = e.target as HTMLInputElement;
if (target && target.value !== undefined) {
debouncedDesktopSearch(target.value);
@ -695,7 +778,7 @@ const normalizedPath =
});
// 点击外部关闭结果
document.addEventListener("click", (e: MouseEvent) => {
const documentClickHandler = (e: MouseEvent) => {
const target = e.target as Node;
if (
desktopSearch &&
@ -704,19 +787,22 @@ const normalizedPath =
) {
desktopResults.classList.add("hidden");
}
});
};
addListener(document, "click", documentClickHandler as EventListener);
// ESC键关闭结果
desktopSearch.addEventListener("keydown", (e: KeyboardEvent) => {
addListener(desktopSearch, "keydown", ((e: KeyboardEvent) => {
if (e.key === "Escape") {
desktopResults.classList.add("hidden");
}
});
}) as EventListener);
}
// 移动端搜索逻辑
if (mobileSearch && mobileResults) {
mobileSearch.addEventListener("input", (e: Event) => {
// 输入时更新搜索结果
addListener(mobileSearch, "input", (e: Event) => {
mobileResults.classList.remove("hidden");
const target = e.target as HTMLInputElement;
if (target && target.value !== undefined) {
@ -726,43 +812,55 @@ const normalizedPath =
});
// ESC键关闭搜索面板
mobileSearch.addEventListener("keydown", (e: KeyboardEvent) => {
addListener(mobileSearch, "keydown", ((e: KeyboardEvent) => {
if (e.key === "Escape") {
const mobileSearchPanel = document.getElementById(
"mobile-search-panel",
);
const mobileSearchPanel = document.getElementById("mobile-search-panel");
if (mobileSearchPanel) {
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();
initSearch();
registerCleanup();
}
// 在文档加载时初始化一次
// 在页面加载时初始化
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", setupHeader);
document.addEventListener("DOMContentLoaded", setupHeader, { once: true });
} 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);
// 原事件监听 - 保留以兼容可能的旧版本
document.addEventListener("astro:swup:page:view", setupHeader);
// 清理
document.addEventListener("astro:before-swap", () => {
// 移除可能的全局事件监听器
window.removeEventListener("scroll", () => {});
});
// Swup页面内容替换后重新初始化
document.addEventListener('swup:contentReplaced', setupHeader);
})();
</script>

View File

@ -55,25 +55,46 @@ const { title = SITE_NAME, description = SITE_DESCRIPTION, date, author, tags, i
<meta property="article:tag" content={tag} />
))}
<script is:inline>
// 立即执行主题初始化,并防止变量重复声明
// 立即执行主题初始化,采用"无闪烁"加载方式
(function() {
// 检查变量是否已存在
if (typeof window.__themeInitDone === 'undefined') {
// 设置标志,表示初始化已完成
window.__themeInitDone = true;
try {
// 获取系统首选主题
const getSystemTheme = () => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const savedTheme = typeof localStorage !== 'undefined' ? localStorage.getItem('theme') : null;
const theme = savedTheme || systemTheme;
const storedTheme = typeof localStorage !== 'undefined' ? localStorage.getItem('theme') : null;
const systemTheme = getSystemTheme();
let theme = 'light'; // 默认浅色主题
// 立即设置文档主题
// 按照逻辑优先级应用主题
if (storedTheme) {
// 如果有存储的主题设置,则应用它
theme = storedTheme;
} else if (systemTheme) {
// 如果没有存储的设置,检查系统主题
theme = systemTheme;
}
// 立即设置文档主题在DOM渲染前应用避免闪烁
document.documentElement.dataset.theme = theme;
// 将主题信息存储在全局变量中,以便 React 组件可以立即访问
window.__THEME_DATA__ = {
currentTheme: theme,
systemTheme: systemTheme
// 监听系统主题变化(只有当主题设为跟随系统时才响应)
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
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>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState, useCallback } from "react";
interface MediaGridProps {
type: "movie" | "book";
@ -21,6 +21,9 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
const itemsPerPage = 15;
const mediaListRef = useRef<HTMLDivElement>(null);
const lastScrollTime = useRef(0);
const abortControllerRef = useRef<AbortController | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const scrollDetectorRef = useRef<HTMLDivElement | null>(null);
// 使用ref来跟踪关键状态避免闭包问题
const stateRef = useRef({
@ -30,8 +33,8 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
error: null as string | null,
});
// 封装fetch函数但不使用useCallback以避免依赖循环
const fetchMedia = async (page = 1, append = false) => {
// 封装fetch函数使用useCallback避免重新创建
const fetchMedia = useCallback(async (page = 1, append = false) => {
// 使用ref中的最新状态
if (
stateRef.current.isLoading ||
@ -41,6 +44,14 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
return;
}
// 取消之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// 创建新的AbortController
abortControllerRef.current = new AbortController();
// 更新状态和ref
setIsLoading(true);
stateRef.current.isLoading = true;
@ -55,6 +66,7 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
try {
const response = await fetch(
`/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`,
{ signal: abortControllerRef.current.signal }
);
if (!response.ok) {
// 解析响应内容,获取详细错误信息
@ -127,6 +139,11 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
stateRef.current.hasMoreContent = newHasMoreContent;
}
} catch (error) {
// 如果是取消的请求,不显示错误
if (error instanceof Error && error.name === 'AbortError') {
return;
}
// 只有在非追加模式下才清空已加载的内容
if (!append) {
setItems([]);
@ -136,10 +153,10 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
setIsLoading(false);
stateRef.current.isLoading = false;
}
};
}, [type, doubanId]);
// 处理滚动事件
const handleScroll = () => {
const handleScroll = useCallback(() => {
// 获取关键滚动值
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
@ -167,7 +184,7 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
if (scrollPosition >= threshold) {
fetchMedia(stateRef.current.currentPage + 1, true);
}
};
}, [fetchMedia]);
// 更新ref值以跟踪状态变化
useEffect(() => {
@ -186,6 +203,58 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
stateRef.current.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(() => {
// 重置状态
@ -204,69 +273,50 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
// 清空列表
setItems([]);
// 取消可能存在的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// 加载第一页数据
fetchMedia(1, false);
// 管理滚动事件
const scrollListener = handleScroll;
// 设置滚动事件监听器
window.addEventListener("scroll", handleScroll, { passive: true });
// 移除任何现有监听器
window.removeEventListener("scroll", scrollListener);
// 添加滚动事件监听器 - 使用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);
}
// 设置IntersectionObserver
setupIntersectionObserver();
// 初始检查一次,以防内容不足一屏
const timeoutId = setTimeout(() => {
if (stateRef.current.hasMoreContent && !stateRef.current.isLoading) {
scrollListener();
handleScroll();
}
}, 500);
// 清理函数
return () => {
clearTimeout(timeoutId);
window.removeEventListener("scroll", scrollListener);
intersectionObserver.disconnect();
document.getElementById("scroll-detector")?.remove();
window.removeEventListener("scroll", handleScroll);
// 清理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 = () => {
@ -345,6 +395,7 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
src={item.imageUrl}
alt={item.title}
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">
<h3 className="font-bold text-white text-sm line-clamp-2">

View File

@ -47,7 +47,58 @@ const {
</button>
<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');
@ -56,15 +107,29 @@ const {
}
let transitioning = false;
let transitionTimeout: number | null = null;
// 获取系统首选主题
const getSystemTheme = () => {
const getSystemTheme = (): string => {
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) {
return;
}
@ -79,9 +144,9 @@ const {
document.documentElement.dataset.theme = newTheme;
// 更新本地存储
const isSystemTheme = newTheme === getSystemTheme();
const systemTheme = getSystemTheme();
if (isSystemTheme) {
if (newTheme === systemTheme) {
localStorage.removeItem('theme');
} else {
localStorage.setItem('theme', newTheme);
@ -94,92 +159,92 @@ const {
transitionTimeout = setTimeout(() => {
transitioning = false;
}, 300) as unknown as number;
}, 300);
};
// 监听系统主题变化
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleMediaChange = (e: MediaQueryListEvent) => {
// 只有当主题设置为跟随系统时才更新主题
const handleMediaChange = (e: MediaQueryListEvent): void => {
if (!localStorage.getItem('theme')) {
const newTheme = e.matches ? 'dark' : 'light';
document.documentElement.dataset.theme = newTheme;
}
};
mediaQuery.addEventListener('change', handleMediaChange);
// 添加系统主题变化监听
addListener(mediaQuery, 'change', handleMediaChange as EventListener);
// 存储事件处理函数引用,用于清理
const clickHandlers = new Map();
// 为每个按钮添加点击事件
// 为每个按钮添加事件
themeToggleButtons.forEach(button => {
const handler = (e: Event) => {
// 阻止事件冒泡
(button as HTMLElement).style.pointerEvents = 'auto';
// 创建点击处理函数
const clickHandler = (e: Event) => {
e.preventDefault();
e.stopPropagation();
toggleTheme();
};
clickHandlers.set(button, handler);
button.addEventListener('click', handler);
// 点击事件 - 使用捕获模式
addListener(button, 'click', clickHandler, { capture: true });
// 恢复键盘事件
button.addEventListener('keydown', function(e) {
const keyEvent = e as KeyboardEvent;
if (keyEvent.key === 'Enter' || keyEvent.key === ' ') {
// 键盘事件
addListener(button, 'keydown', ((e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleTheme();
}
});
}) as EventListener);
});
// 处理移动端主题切换容器
let containerHandler: ((e: Event) => void) | null = null;
const themeToggleContainer = document.getElementById('theme-toggle-container');
if (themeToggleContainer) {
containerHandler = (e) => {
addListener(themeToggleContainer, 'click', (e: Event) => {
const target = e.target as HTMLElement;
// 如果点击的不是主题切换按钮本身,则手动触发主题切换
if (target.id !== 'theme-toggle-button' && !target.closest('#theme-toggle-button')) {
e.stopPropagation();
toggleTheme();
}
};
themeToggleContainer.addEventListener('click', containerHandler);
}
// 清理
return () => {
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);
}
};
// 初始化主题
initializeTheme();
}
// 页面加载时初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupThemeToggle);
} else {
// 注册清理函数
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 init(): void {
setupThemeToggle();
registerCleanup();
}
// 支持 Astro 视图转换
document.addEventListener('astro:after-swap', setupThemeToggle);
document.addEventListener('astro:page-load', setupThemeToggle);
// 在页面加载后初始化
if (document.readyState === 'loading') {
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>

View File

@ -8,7 +8,7 @@ export const NAV_LINKS = [
{ href: '/movies', text: '观影' },
{ href: '/books', text: '读书' },
{ href: '/projects', text: '项目' },
{ href: '/other', text: '其他' }
{ href: '/other', text: '其他' },
];
export const ICP = '渝ICP备2022009272号';

View File

@ -15,6 +15,7 @@ tags: []
5. **观影记录**:集成豆瓣观影数据
6. **读书记录**:集成豆瓣读书数据
7. **旅行足迹**:支持展示全球旅行足迹热力图
8. **丝滑页面过渡**:使用 Swup 集成实现页面间无缝过渡动画,提供类似 SPA 的浏览体验,保留静态站点的所有优势
## 基础配置

View File

@ -1,6 +1,10 @@
---
import Layout from '@/components/Layout.astro';
import { SITE_NAME } from '@/consts';
// 启用静态预渲染
export const prerender = true;
---
<Layout title={`404 - 页面未找到 | ${SITE_NAME}`}>

View File

@ -32,7 +32,6 @@ export const GET: APIRoute = async ({ request }) => {
if (path) {
const normalizedPath = path.toLowerCase();
filteredArticles = filteredArticles.filter(article => {
const articlePath = article.id.split('/');
return article.id.toLowerCase().includes(normalizedPath);
});
}

View File

@ -115,7 +115,7 @@ function getArticleUrl(articleId: string) {
<!-- 阅读进度条 -->
<div
class="fixed top-0 left-0 w-full h-1 bg-transparent z-50"
id="progress-container 9"
id="progress-container"
>
<div
class="h-full w-0 bg-primary-500 transition-width duration-100"
@ -369,13 +369,25 @@ function getArticleUrl(articleId: string) {
</svg>
</button>
</div>
</Layout>
<script>
// 阅读进度条
<script is:inline>
(function() {
const listeners = [];
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 =
@ -383,41 +395,31 @@ function getArticleUrl(articleId: string) {
document.documentElement.clientHeight;
const progress = (scrollTop / scrollHeight) * 100;
if (progressBar) {
progressBar.style.width = `${progress}%`;
}
// 显示/隐藏返回顶部按钮
if (backToTopButton) {
if (scrollTop > 300) {
backToTopButton.classList.add(
"opacity-100",
"visible",
"translate-y-0",
"opacity-100", "visible", "translate-y-0"
);
backToTopButton.classList.remove(
"opacity-0",
"invisible",
"translate-y-5",
"opacity-0", "invisible", "translate-y-5"
);
} else {
backToTopButton.classList.add(
"opacity-0",
"invisible",
"translate-y-5",
"opacity-0", "invisible", "translate-y-5"
);
backToTopButton.classList.remove(
"opacity-100",
"visible",
"translate-y-0",
"opacity-100", "visible", "translate-y-0"
);
}
}
}
// 返回顶部功能
addListener(window, "scroll", updateReadingProgress);
if (backToTopButton) {
backToTopButton.addEventListener("click", () => {
addListener(backToTopButton, "click", () => {
window.scrollTo({
top: 0,
behavior: "smooth",
@ -425,24 +427,16 @@ function getArticleUrl(articleId: string) {
});
}
// 监听滚动事件
window.addEventListener("scroll", updateReadingProgress);
// 初始化
updateReadingProgress();
}
// 目录功能
document.addEventListener("DOMContentLoaded", () => {
function setupTableOfContents() {
const tocContent = document.getElementById("toc-content");
const tocPanel = document.querySelector(
'[class*="2xl:block"][class*="fixed"]',
);
const tocPanel = document.querySelector('[class*="2xl:block"][class*="fixed"]');
if (!tocPanel || !tocContent) return;
// 检查是否有足够空间显示目录
function checkTocVisibility() {
if (!tocPanel) return;
// 如果窗口宽度小于1536px (2xl breakpoint),隐藏目录
if (window.innerWidth < 1536) {
tocPanel.classList.add("hidden");
tocPanel.classList.remove("2xl:block");
@ -452,69 +446,52 @@ function getArticleUrl(articleId: string) {
}
}
// 监听窗口大小变化
window.addEventListener("resize", checkTocVisibility);
addListener(window, "resize", checkTocVisibility);
// 初始检查
checkTocVisibility();
// 生成目录内容
function generateTableOfContents() {
// 获取文章中的所有标题元素
const article = document.querySelector("article");
if (!article || !tocContent) {
console.error("找不到文章内容或目录容器");
if (tocContent) {
tocContent.innerHTML =
'<p class="text-secondary-500 dark:text-secondary-400 italic">无法生成目录</p>';
}
if (!article) {
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>';
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 indent = (headingLevel - 1) * 0.75;
// 创建链接
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.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) => {
// 平滑滚动到目标位置
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 offset = 100;
const targetPosition =
targetElement.getBoundingClientRect().top +
window.scrollY -
@ -525,15 +502,12 @@ function getArticleUrl(articleId: string) {
behavior: "smooth",
});
// 高亮目标标题
targetElement.classList.add(
"bg-primary-50",
"dark:bg-primary-900/20",
"bg-primary-50", "dark:bg-primary-900/20"
);
setTimeout(() => {
targetElement.classList.remove(
"bg-primary-50",
"dark:bg-primary-900/20",
"bg-primary-50", "dark:bg-primary-900/20"
);
}, 2000);
}
@ -543,45 +517,26 @@ function getArticleUrl(articleId: string) {
tocList.appendChild(listItem);
});
// 将目录添加到面板
tocContent.innerHTML = "";
tocContent.appendChild(tocList);
}
// 页面加载时生成目录
try {
generateTableOfContents();
let ticking = false;
// 添加滚动监听,更新目录高亮
function updateActiveHeading() {
const article = document.querySelector("article");
if (!article || !tocContent) return;
const headings = Array.from(
article.querySelectorAll("h1, h2, h3, h4, h5, h6"),
);
if (headings.length === 0) return;
// 获取所有目录链接
const currentHeadings = Array.from(article.querySelectorAll("h1, h2, h3, h4, h5, h6"));
const tocLinks = Array.from(tocContent.querySelectorAll("a"));
if (tocLinks.length === 0) return;
// 移除所有活跃状态
tocLinks.forEach((link) => {
tocLinks.forEach(link => {
link.classList.remove(
"text-primary-600",
"dark:text-primary-400",
"font-medium",
"text-primary-600", "dark:text-primary-400", "font-medium"
);
});
// 找到当前视口中最靠近顶部的标题
const scrollPosition = window.scrollY + 150;
let currentHeading = null;
const scrollPosition = window.scrollY + 150; // 添加一些偏移量
for (const heading of headings) {
const headingTop =
heading.getBoundingClientRect().top + window.scrollY;
for (const heading of currentHeadings) {
const headingTop = heading.getBoundingClientRect().top + window.scrollY;
if (headingTop <= scrollPosition) {
currentHeading = heading;
} else {
@ -589,24 +544,19 @@ function getArticleUrl(articleId: string) {
}
}
// 如果找到当前标题,高亮对应的目录项
if (currentHeading) {
const activeLink = tocLinks.find(
(link) => link.getAttribute("href") === `#${currentHeading.id}`,
link => link.getAttribute("href") === `#${currentHeading.id}`
);
if (activeLink) {
activeLink.classList.add(
"text-primary-600",
"dark:text-primary-400",
"font-medium",
"text-primary-600", "dark:text-primary-400", "font-medium"
);
}
}
}
// 监听滚动事件,使用节流函数优化性能
let ticking = false;
window.addEventListener("scroll", () => {
addListener(window, "scroll", () => {
if (!ticking) {
window.requestAnimationFrame(() => {
updateActiveHeading();
@ -616,48 +566,31 @@ function getArticleUrl(articleId: string) {
}
});
// 初始化高亮
updateActiveHeading();
} catch (error) {
console.error("生成目录时发生错误:", error);
if (tocContent) {
tocContent.innerHTML =
'<p class="text-secondary-500 dark:text-secondary-400 italic">生成目录时发生错误</p>';
}
}
});
// 代码块增强功能
document.addEventListener("DOMContentLoaded", () => {
// 处理所有代码块
function setupCodeBlocks() {
const codeBlocks = document.querySelectorAll("pre");
if (!codeBlocks.length) return;
codeBlocks.forEach((pre) => {
// 获取代码语言
codeBlocks.forEach(pre => {
const code = pre.querySelector("code");
if (!code) return;
if (!code || pre.querySelector('.code-header')) return;
// 从类名中提取语言
const className = code.className;
const languageMatch = className.match(/language-(\w+)/);
const language = languageMatch ? languageMatch[1] : "text";
// 创建顶部栏
const header = document.createElement("div");
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";
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";
// 创建语言标签
const languageLabel = document.createElement("span");
languageLabel.className = "code-language font-mono";
languageLabel.textContent = language;
// 创建复制按钮
const copyButton = document.createElement("button");
copyButton.className =
"code-copy-button flex items-center gap-1 hover:text-white dark:hover:text-primary-400 transition-colors";
copyButton.className = "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 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>`;
@ -666,33 +599,23 @@ function getArticleUrl(articleId: string) {
copyButton.setAttribute("aria-label", "复制代码");
copyButton.setAttribute("title", "复制代码到剪贴板");
// 添加复制功能
copyButton.addEventListener("click", (e) => {
addListener(copyButton, "click", (e) => {
e.stopPropagation();
// 获取代码文本
const codeText = code.textContent || "";
// 复制到剪贴板
navigator.clipboard
.writeText(codeText)
navigator.clipboard.writeText(code.textContent || "")
.then(() => {
// 复制成功,更改按钮文本
copyButton.innerHTML = `${successIcon}<span>已复制</span>`;
copyButton.classList.add("text-green-400");
// 2秒后恢复按钮文本
setTimeout(() => {
copyButton.innerHTML = `${copyIcon}<span>复制</span>`;
copyButton.classList.remove("text-green-400");
}, 2000);
})
.catch(() => {
// 复制失败,更改按钮文本
copyButton.innerHTML = `${errorIcon}<span>失败</span>`;
copyButton.classList.add("text-red-400");
// 2秒后恢复按钮文本
setTimeout(() => {
copyButton.innerHTML = `${copyIcon}<span>复制</span>`;
copyButton.classList.remove("text-red-400");
@ -700,17 +623,48 @@ function getArticleUrl(articleId: string) {
});
});
// 将语言标签和复制按钮添加到顶部栏
header.appendChild(languageLabel);
header.appendChild(copyButton);
// 将顶部栏插入到代码块的最前面
pre.insertBefore(header, pre.firstChild);
// 调整代码块样式
pre.classList.add("rounded-b-lg", "mt-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>
</Layout>

View File

@ -1,6 +1,9 @@
---
import ArticlesPage, { getStaticPaths as getOriginalPaths } from './index.astro';
// 启用静态预渲染
export const prerender = true;
// 重新导出 getStaticPaths处理所有路径模式
export async function getStaticPaths() {
const paths = await getOriginalPaths();
@ -98,12 +101,6 @@ const mergedProps = {
view
};
// 生成页面标题
let pageTitle = path ? path : '文章列表';
if (tag) {
pageTitle = `标签: ${tag}`;
}
---
<h1 class="sr-only">{pageTitle}</h1>
<ArticlesPage {...mergedProps} />