From 7c00f3227156fa57501f4de48eabcee0ddc8c396 Mon Sep 17 00:00:00 2001 From: lsy Date: Mon, 19 May 2025 12:59:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=A4=9A=E4=BD=99=E7=9A=84?= =?UTF-8?q?=E8=B1=86=E7=93=A3=E6=96=87=E4=BB=B6=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=BB=93=E6=9E=84=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E8=AF=BB=E5=8F=96=E8=8E=B7=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/DoubanCollection.tsx | 659 +++++++++++------- src/components/Layout.astro | 2 +- src/components/MediaGrid.tsx | 468 ------------- src/components/WereadBookList.tsx | 160 +++++ src/{scripts => components}/swup-init.js | 2 - src/consts.ts | 4 +- src/content/echoes博客使用说明.md | 75 +- .../技术日志/docker/Docker部署gitea.md | 29 - src/pages/api/weread.ts | 464 ++++++++++++ src/pages/books.astro | 11 +- src/pages/movies.astro | 4 +- 11 files changed, 1072 insertions(+), 806 deletions(-) delete mode 100644 src/components/MediaGrid.tsx create mode 100644 src/components/WereadBookList.tsx rename src/{scripts => components}/swup-init.js (99%) create mode 100644 src/pages/api/weread.ts diff --git a/src/components/DoubanCollection.tsx b/src/components/DoubanCollection.tsx index 6306706..79485b2 100644 --- a/src/components/DoubanCollection.tsx +++ b/src/components/DoubanCollection.tsx @@ -1,324 +1,469 @@ -import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import ReactMasonryCss from 'react-masonry-css'; - -interface DoubanItem { - imageUrl: string; - title: string; - subtitle: string; - link: string; - intro: string; - rating: number; - date: string; -} - -interface Pagination { - current: number; - total: number; - hasNext: boolean; - hasPrev: boolean; -} +import React, { useEffect, useRef, useState, useCallback } from "react"; interface DoubanCollectionProps { - type: 'movie' | 'book'; - doubanId?: string; // 可选参数,使其与 MediaGrid 保持一致 - className?: string; // 添加自定义类名 + type: "movie" | "book"; + doubanId: string; + className?: string; // 添加可选的className属性以提高灵活性 +} + +interface DoubanItem { + title: string; + imageUrl: string; + link: string; } const DoubanCollection: React.FC = ({ type, doubanId, className = '' }) => { const [items, setItems] = useState([]); - const [pagination, setPagination] = useState({ current: 1, total: 1, hasNext: false, hasPrev: false }); - const [loading, setLoading] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [hasMoreContent, setHasMoreContent] = useState(true); + const [currentPage, setCurrentPage] = useState(1); const [error, setError] = useState(null); - const [isPageChanging, setIsPageChanging] = useState(false); - - // 使用 ref 避免竞态条件 + const itemsPerPage = 15; + const contentListRef = useRef(null); + const lastScrollTime = useRef(0); const abortControllerRef = useRef(null); - const isMountedRef = useRef(true); + const observerRef = useRef(null); + const scrollDetectorRef = useRef(null); + // 添加一个 ref 来标记组件是否已挂载 + const isMountedRef = useRef(true); - // 标题文本 - const titleText = useMemo(() => - type === 'movie' ? '观影记录' : '读书记录', - [type]); + // 使用ref来跟踪关键状态,避免闭包问题 + const stateRef = useRef({ + isLoading: false, + hasMoreContent: true, + currentPage: 1, + error: null as string | null, + }); - // 加载动画组件 - const LoadingSpinner = useCallback(() => ( - - - - - ), []); + // 封装fetch函数使用useCallback避免重新创建 + const fetchDoubanData = useCallback(async (page = 1, append = false) => { + // 使用ref中的最新状态 + if ( + stateRef.current.isLoading || + (!append && !stateRef.current.hasMoreContent) || + (append && !stateRef.current.hasMoreContent) + ) { + return; + } - // 公共标题组件 - const Title = useCallback(() => ( -

- {titleText} -

- ), [titleText]); - - const fetchData = useCallback(async (start = 0) => { - // 如果已经有一个请求在进行中,取消它 + // 取消之前的请求 if (abortControllerRef.current) { abortControllerRef.current.abort(); } - // 创建新的 AbortController + // 创建新的AbortController abortControllerRef.current = new AbortController(); - - setLoading(true); - setError(null); - - const params = new URLSearchParams(); - params.append('type', type); - params.append('start', start.toString()); - - if (doubanId) { - params.append('doubanId', doubanId); + + // 更新状态和ref + setIsLoading(true); + stateRef.current.isLoading = true; + + // 只在首次加载时清除错误 + if (!append) { + setError(null); + stateRef.current.error = null; } - - const url = `/api/douban?${params.toString()}`; - + + const start = (page - 1) * itemsPerPage; try { - const response = await fetch(url, { - signal: abortControllerRef.current.signal - }); + const response = await fetch( + `/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`, + { signal: abortControllerRef.current.signal } + ); - // 如果组件已卸载,不继续处理 - if (!isMountedRef.current) return; - - if (!response.ok) { - throw new Error(`获取数据失败:状态码 ${response.status}`); - } - - const data = await response.json(); - - if (data.error) { - throw new Error(data.error); - } - - setItems(data.items || []); - setPagination(data.pagination || { current: 1, total: 1, hasNext: false, hasPrev: false }); - } catch (err) { - // 如果是取消请求的错误,不设置错误状态 - if (err instanceof Error && err.name === 'AbortError') { + // 检查组件是否已卸载,如果卸载则不继续处理 + if (!isMountedRef.current) { return; } - // 如果组件已卸载,不设置状态 - if (!isMountedRef.current) return; + if (!response.ok) { + // 解析响应内容,获取详细错误信息 + let errorMessage = `获取${type === "movie" ? "电影" : "图书"}数据失败`; + try { + const errorData = await response.json(); + if (errorData && errorData.error) { + errorMessage = errorData.error; + if (errorData.message) { + errorMessage += `: ${errorData.message}`; + } + } + } catch (e) { + // 无法解析JSON,使用默认错误信息 + } + + // 针对不同错误提供更友好的提示 + if (response.status === 403) { + errorMessage = "豆瓣接口访问受限,可能是请求过于频繁,请稍后再试"; + } else if (response.status === 404) { + // 对于404错误,如果是追加模式,说明已经到了最后一页,设置hasMoreContent为false + if (append) { + setHasMoreContent(false); + stateRef.current.hasMoreContent = false; + setIsLoading(false); + stateRef.current.isLoading = false; + return; // 直接返回,不设置错误,不清空已有数据 + } else { + errorMessage = "未找到相关内容,请检查豆瓣ID是否正确"; + } + } + + // 设置错误状态和ref + setError(errorMessage); + stateRef.current.error = errorMessage; + + // 只有非追加模式才清空数据 + if (!append) { + setItems([]); + } + + throw new Error(errorMessage); + } + + const data = await response.json(); + + // 再次检查组件是否已卸载 + if (!isMountedRef.current) { + return; + } + + if (data.items.length === 0) { + // 如果返回的项目为空,则认为已经没有更多内容 + setHasMoreContent(false); + stateRef.current.hasMoreContent = false; + if (!append) { + setItems([]); + } + } else { + if (append) { + setItems((prev) => { + const newItems = [...prev, ...data.items]; + return newItems; + }); + } else { + setItems(data.items); + } + // 更新页码状态和ref + setCurrentPage(data.pagination.current); + stateRef.current.currentPage = data.pagination.current; + + // 更新是否有更多内容的状态和ref + const newHasMoreContent = data.pagination.hasNext; + setHasMoreContent(newHasMoreContent); + stateRef.current.hasMoreContent = newHasMoreContent; + } + } catch (error) { + // 检查组件是否已卸载 + if (!isMountedRef.current) { + return; + } - console.error('获取豆瓣数据失败:', err); - setError(err instanceof Error ? err.message : '未知错误'); - setItems([]); + // 如果是取消的请求,不显示错误 + if (error instanceof Error && error.name === 'AbortError') { + console.log('请求被取消', error.message); + // 如果是取消请求,重置加载状态但不显示错误 + setIsLoading(false); + stateRef.current.isLoading = false; + return; + } + + // 只有在非追加模式下才清空已加载的内容 + if (!append) { + setItems([]); + } } finally { - // 如果组件已卸载,不设置状态 - if (!isMountedRef.current) return; - - setLoading(false); - setIsPageChanging(false); + // 检查组件是否已卸载 + if (isMountedRef.current) { + // 重置加载状态 + setIsLoading(false); + stateRef.current.isLoading = false; + } } }, [type, doubanId]); + // 处理滚动事件 + const handleScroll = useCallback(() => { + // 获取关键滚动值 + const scrollY = window.scrollY; + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + const scrollPosition = scrollY + windowHeight; + const threshold = documentHeight - 300; + + // 限制滚动日志频率,每秒最多输出一次 + const now = Date.now(); + if (now - lastScrollTime.current < 1000) { + return; + } + lastScrollTime.current = now; + + // 使用ref中的最新状态来检查 + if ( + stateRef.current.isLoading || + !stateRef.current.hasMoreContent || + stateRef.current.error + ) { + return; + } + + // 当滚动到距离底部300px时加载更多 + if (scrollPosition >= threshold) { + fetchDoubanData(stateRef.current.currentPage + 1, true); + } + }, [fetchDoubanData]); + + // 更新ref值以跟踪状态变化 useEffect(() => { - // 组件挂载时设置标记 + stateRef.current.isLoading = isLoading; + }, [isLoading]); + + useEffect(() => { + stateRef.current.hasMoreContent = hasMoreContent; + }, [hasMoreContent]); + + useEffect(() => { + stateRef.current.currentPage = currentPage; + }, [currentPage]); + + useEffect(() => { + 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 + ) { + fetchDoubanData(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; + + // 确保contentListRef有父元素 + if (contentListRef.current && contentListRef.current.parentElement) { + // 插入到grid后面而不是内部 + contentListRef.current.parentElement.insertBefore( + footer, + contentListRef.current.nextSibling, + ); + observerRef.current.observe(footer); + } + }, [fetchDoubanData]); + + // 组件初始化和依赖变化时重置 + useEffect(() => { + // 设置组件挂载状态 isMountedRef.current = true; - fetchData(); + // 重置状态 + setCurrentPage(1); + stateRef.current.currentPage = 1; + + setHasMoreContent(true); + stateRef.current.hasMoreContent = true; + + setError(null); + stateRef.current.error = null; + + setIsLoading(false); + stateRef.current.isLoading = false; + + // 清空列表 + setItems([]); + + // 取消可能存在的请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // 加载第一页数据 + fetchDoubanData(1, false); + + // 设置滚动事件监听器 + window.addEventListener("scroll", handleScroll, { passive: true }); - // 组件卸载时清理 + // 设置IntersectionObserver + setupIntersectionObserver(); + + // 初始检查一次,以防内容不足一屏 + const timeoutId = setTimeout(() => { + if (stateRef.current.hasMoreContent && !stateRef.current.isLoading) { + handleScroll(); + } + }, 500); + + // 清理函数 return () => { + // 标记组件已卸载 isMountedRef.current = false; + + clearTimeout(timeoutId); + 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(); } }; - }, [fetchData]); + }, [type, doubanId, handleScroll, fetchDoubanData, setupIntersectionObserver]); - const handlePageChange = useCallback((page: number) => { - if (isPageChanging) return; - - setIsPageChanging(true); - - // 计算新页面的起始项 - const start = (page - 1) * 15; - - // 更新分页状态 - setPagination(prev => ({ - ...prev, - current: page - })); - - // 获取新页面的数据 - fetchData(start); - }, [fetchData, isPageChanging]); + // 错误提示组件 + const ErrorMessage = () => { + if (!error) return null; - const renderStars = useCallback((rating: number) => { return ( -
- {[1, 2, 3, 4, 5].map((star) => ( -
+ - + - ))} -
- ); - }, []); +

访问错误

+

{error}

+