删除多余的豆瓣文件,优化文件结构,增加微信读取获取
This commit is contained in:
parent
2f1fc8d285
commit
7c00f32271
@ -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<DoubanCollectionProps> = ({ type, doubanId, className = '' }) => {
|
||||
const [items, setItems] = useState<DoubanItem[]>([]);
|
||||
const [pagination, setPagination] = useState<Pagination>({ 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<string | null>(null);
|
||||
const [isPageChanging, setIsPageChanging] = useState(false);
|
||||
|
||||
// 使用 ref 避免竞态条件
|
||||
const itemsPerPage = 15;
|
||||
const contentListRef = useRef<HTMLDivElement>(null);
|
||||
const lastScrollTime = useRef(0);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const scrollDetectorRef = useRef<HTMLDivElement | null>(null);
|
||||
// 添加一个 ref 来标记组件是否已挂载
|
||||
const isMountedRef = useRef<boolean>(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(() => (
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
), []);
|
||||
// 封装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(() => (
|
||||
<h2 className="text-2xl font-bold mb-6 text-primary-700 dark:text-primary-400">
|
||||
{titleText}
|
||||
</h2>
|
||||
), [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 (
|
||||
<div className="flex">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg
|
||||
key={star}
|
||||
className={`w-4 h-4 ${star <= rating ? 'text-accent-400' : 'text-secondary-300 dark:text-secondary-600'}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
<div className="col-span-full text-center bg-red-50 p-4 rounded-md">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-12 w-12 text-red-500 mb-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, []);
|
||||
<h3 className="text-lg font-medium text-red-800">访问错误</h3>
|
||||
<p className="mt-1 text-sm text-red-700">{error}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
// 重置错误和加载状态
|
||||
setError(null);
|
||||
stateRef.current.error = null;
|
||||
|
||||
const breakpointColumnsObj = {
|
||||
default: 3,
|
||||
1100: 2,
|
||||
700: 1
|
||||
};
|
||||
|
||||
// 渲染内容的容器
|
||||
const Container = useCallback(({ children }: { children: React.ReactNode }) => (
|
||||
<div className={`douban-collection ${className}`}>
|
||||
<Title />
|
||||
{children}
|
||||
</div>
|
||||
), [className, Title]);
|
||||
// 允许再次加载
|
||||
setHasMoreContent(true);
|
||||
stateRef.current.hasMoreContent = true;
|
||||
|
||||
// 加载中状态
|
||||
if (loading && items.length === 0) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"></div>
|
||||
<p className="ml-2 text-gray-600 dark:text-gray-400">加载中...</p>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (error) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<p>错误: {error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchData()}
|
||||
className="mt-3 px-4 py-2 bg-red-100 dark:bg-red-800/30 hover:bg-red-200 dark:hover:bg-red-800/50 text-red-700 dark:text-red-300 rounded"
|
||||
// 重新获取当前页
|
||||
fetchDoubanData(currentPage, false);
|
||||
}}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 数据为空状态
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="text-center p-8 text-gray-500 dark:text-gray-400">
|
||||
暂无{type === 'movie' ? '观影' : '读书'}记录
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
// 没有更多内容提示
|
||||
const EndMessage = () => {
|
||||
if (isLoading || !items.length || error) return null;
|
||||
|
||||
// 渲染分页按钮
|
||||
const renderPaginationButton = useCallback((
|
||||
direction: 'prev' | 'next',
|
||||
onClick: () => void,
|
||||
disabled: boolean
|
||||
) => {
|
||||
const buttonText = direction === 'prev' ? '上一页' : '下一页';
|
||||
|
||||
const buttonClass = `px-4 py-2 rounded ${disabled
|
||||
? 'bg-secondary-200 dark:bg-secondary-700 text-secondary-500 dark:text-secondary-500 cursor-not-allowed'
|
||||
: 'bg-primary-600 text-white hover:bg-primary-700 dark:bg-primary-700 dark:hover:bg-primary-600'}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={buttonClass}
|
||||
aria-label={buttonText}
|
||||
>
|
||||
{isPageChanging ? (
|
||||
<span className="flex items-center">
|
||||
<LoadingSpinner />
|
||||
加载中
|
||||
</span>
|
||||
) : buttonText}
|
||||
</button>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">已加载全部内容</p>
|
||||
</div>
|
||||
);
|
||||
}, [isPageChanging, LoadingSpinner]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ReactMasonryCss
|
||||
breakpointCols={breakpointColumnsObj}
|
||||
className="flex -ml-4 w-auto"
|
||||
columnClassName="pl-4 bg-clip-padding"
|
||||
<div className={`w-full ${className}`}>
|
||||
<div
|
||||
ref={contentListRef}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={`${item.title}-${index}`}
|
||||
className="mb-6 bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:shadow-lg"
|
||||
>
|
||||
<a href={item.link} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<div className="relative pb-[140%] overflow-hidden">
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.title}
|
||||
className="absolute inset-0 w-full h-full object-cover hover:scale-105"
|
||||
{error && items.length === 0 ? (
|
||||
<ErrorMessage />
|
||||
) : items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<div
|
||||
key={`${item.title}-${index}`}
|
||||
className="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl"
|
||||
>
|
||||
<div className="relative pb-[150%] overflow-hidden">
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.title}
|
||||
className="absolute top-0 left-0 w-full h-full object-cover hover:scale-105"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.onerror = null;
|
||||
target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYwIiBoZWlnaHQ9IjIyNCIgdmlld0JveD0iMCAwIDE2MCAyMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgeD0iMCIgeT0iMCIgd2lkdGg9IjE2MCIgaGVpZ2h0PSIyMjQiIGZpbGw9IiNmMWYxZjEiLz48dGV4dCB4PSI4MCIgeT0iMTEyIiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iMTIiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiM5OTk5OTkiPuWbuuWumuWbvueJh+acquivu+WPlzwvdGV4dD48L3N2Zz4=';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-bold text-lg mb-1 line-clamp-1 text-primary-800 dark:text-primary-300">{item.title}</h3>
|
||||
{item.subtitle && <p className="text-secondary-600 dark:text-secondary-400 text-sm mb-2 line-clamp-1">{item.subtitle}</p>}
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
{renderStars(item.rating)}
|
||||
<span className="text-sm text-secondary-500 dark:text-secondary-400">{item.date}</span>
|
||||
<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">
|
||||
<a
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-blue-300"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-secondary-700 dark:text-secondary-300 text-sm line-clamp-3">{item.intro}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
) : !isLoading ? (
|
||||
<div className="col-span-full text-center">
|
||||
暂无{type === "movie" ? "电影" : "图书"}数据
|
||||
</div>
|
||||
))}
|
||||
</ReactMasonryCss>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{pagination.total > 1 && (
|
||||
<div className="flex justify-center mt-8 space-x-2">
|
||||
{renderPaginationButton(
|
||||
'prev',
|
||||
() => handlePageChange(pagination.current - 1),
|
||||
!pagination.hasPrev || isPageChanging
|
||||
)}
|
||||
|
||||
<span className="px-4 py-2 bg-secondary-100 dark:bg-secondary-800 rounded">
|
||||
{pagination.current} / {pagination.total}
|
||||
</span>
|
||||
|
||||
{renderPaginationButton(
|
||||
'next',
|
||||
() => handlePageChange(pagination.current + 1),
|
||||
!pagination.hasNext || isPageChanging
|
||||
)}
|
||||
{error && items.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<ErrorMessage />
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
{isLoading && (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"></div>
|
||||
<p className="mt-2 text-gray-600">加载更多...(第{currentPage}页)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMoreContent && items.length > 0 && !isLoading && <EndMessage />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DoubanCollection;
|
||||
export default DoubanCollection;
|
||||
|
@ -280,7 +280,7 @@ const {
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import '../scripts/swup-init.js';
|
||||
import './swup-init.js';
|
||||
</script>
|
||||
</head>
|
||||
<body
|
||||
|
@ -1,468 +0,0 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
interface MediaGridProps {
|
||||
type: "movie" | "book";
|
||||
doubanId: string;
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
const MediaGrid: React.FC<MediaGridProps> = ({ type, doubanId }) => {
|
||||
const [items, setItems] = useState<MediaItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasMoreContent, setHasMoreContent] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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 isMountedRef = useRef<boolean>(true);
|
||||
|
||||
// 使用ref来跟踪关键状态,避免闭包问题
|
||||
const stateRef = useRef({
|
||||
isLoading: false,
|
||||
hasMoreContent: true,
|
||||
currentPage: 1,
|
||||
error: null as string | null,
|
||||
});
|
||||
|
||||
// 封装fetch函数使用useCallback避免重新创建
|
||||
const fetchMedia = useCallback(async (page = 1, append = false) => {
|
||||
// 使用ref中的最新状态
|
||||
if (
|
||||
stateRef.current.isLoading ||
|
||||
(!append && !stateRef.current.hasMoreContent) ||
|
||||
(append && !stateRef.current.hasMoreContent)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 取消之前的请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// 创建新的AbortController
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
// 更新状态和ref
|
||||
setIsLoading(true);
|
||||
stateRef.current.isLoading = true;
|
||||
|
||||
// 只在首次加载时清除错误
|
||||
if (!append) {
|
||||
setError(null);
|
||||
stateRef.current.error = null;
|
||||
}
|
||||
|
||||
const start = (page - 1) * itemsPerPage;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`,
|
||||
{ signal: abortControllerRef.current.signal }
|
||||
);
|
||||
|
||||
// 检查组件是否已卸载,如果卸载则不继续处理
|
||||
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;
|
||||
}
|
||||
|
||||
// 如果是取消的请求,不显示错误
|
||||
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) {
|
||||
// 重置加载状态
|
||||
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) {
|
||||
fetchMedia(stateRef.current.currentPage + 1, true);
|
||||
}
|
||||
}, [fetchMedia]);
|
||||
|
||||
// 更新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
|
||||
) {
|
||||
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(() => {
|
||||
// 设置组件挂载状态
|
||||
isMountedRef.current = true;
|
||||
|
||||
// 重置状态
|
||||
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();
|
||||
}
|
||||
|
||||
// 加载第一页数据
|
||||
fetchMedia(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();
|
||||
}
|
||||
};
|
||||
}, [type, doubanId, handleScroll, fetchMedia, setupIntersectionObserver]);
|
||||
|
||||
// 错误提示组件
|
||||
const ErrorMessage = () => {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className="col-span-full text-center bg-red-50 p-4 rounded-md">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-12 w-12 text-red-500 mb-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-red-800">访问错误</h3>
|
||||
<p className="mt-1 text-sm text-red-700">{error}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
// 重置错误和加载状态
|
||||
setError(null);
|
||||
stateRef.current.error = null;
|
||||
|
||||
// 允许再次加载
|
||||
setHasMoreContent(true);
|
||||
stateRef.current.hasMoreContent = true;
|
||||
|
||||
// 重新获取当前页
|
||||
fetchMedia(currentPage, false);
|
||||
}}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 没有更多内容提示
|
||||
const EndMessage = () => {
|
||||
if (isLoading || !items.length || error) return null;
|
||||
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">已加载全部内容</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
ref={mediaListRef}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
|
||||
>
|
||||
{error && items.length === 0 ? (
|
||||
<ErrorMessage />
|
||||
) : items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<div
|
||||
key={`${item.title}-${index}`}
|
||||
className="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl"
|
||||
>
|
||||
<div className="relative pb-[150%] overflow-hidden">
|
||||
<img
|
||||
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">
|
||||
<a
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-blue-300"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : !isLoading ? (
|
||||
<div className="col-span-full text-center">
|
||||
暂无{type === "movie" ? "电影" : "图书"}数据
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error && items.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<ErrorMessage />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"></div>
|
||||
<p className="mt-2 text-gray-600">加载更多...(第{currentPage}页)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMoreContent && items.length > 0 && !isLoading && <EndMessage />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaGrid;
|
160
src/components/WereadBookList.tsx
Normal file
160
src/components/WereadBookList.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface WereadBookListProps {
|
||||
listId: string;
|
||||
}
|
||||
|
||||
interface WereadBook {
|
||||
title: string;
|
||||
author: string;
|
||||
imageUrl: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
const WereadBookList: React.FC<WereadBookListProps> = ({ listId }) => {
|
||||
const [books, setBooks] = useState<WereadBook[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 获取微信读书数据
|
||||
const fetchWereadData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/weread?listId=${listId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
// 解析响应内容,获取详细错误信息
|
||||
let errorMessage = `获取微信读书数据失败`;
|
||||
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) {
|
||||
errorMessage = "未找到相关内容,请检查书单ID是否正确";
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
setBooks([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.books && Array.isArray(data.books)) {
|
||||
setBooks(data.books);
|
||||
} else {
|
||||
setBooks([]);
|
||||
}
|
||||
} catch (error) {
|
||||
setError("获取微信读书数据失败: " + (error instanceof Error ? error.message : "未知错误"));
|
||||
setBooks([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 组件初始化时获取数据
|
||||
useEffect(() => {
|
||||
fetchWereadData();
|
||||
}, [listId]);
|
||||
|
||||
// 错误提示组件
|
||||
const ErrorMessage = () => {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className="text-center bg-red-50 p-4 rounded-md">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-12 w-12 text-red-500 mb-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-red-800">访问错误</h3>
|
||||
<p className="mt-1 text-sm text-red-700">{error}</p>
|
||||
<button
|
||||
onClick={fetchWereadData}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{error ? (
|
||||
<ErrorMessage />
|
||||
) : isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent"></div>
|
||||
<p className="mt-2 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{books.length > 0 ? (
|
||||
books.map((book, index) => (
|
||||
<div
|
||||
key={`${book.title}-${index}`}
|
||||
className="bg-white rounded-lg overflow-hidden shadow-md"
|
||||
>
|
||||
<div className="relative pb-[150%] overflow-hidden">
|
||||
<img
|
||||
src={book.imageUrl}
|
||||
alt={book.title}
|
||||
className="absolute top-0 left-0 w-full h-full object-cover"
|
||||
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">
|
||||
<a
|
||||
href={book.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{book.title}
|
||||
</a>
|
||||
</h3>
|
||||
<p className="text-white/80 text-xs mt-1 line-clamp-1">
|
||||
{book.author}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full text-center">
|
||||
暂无图书数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WereadBookList;
|
@ -174,8 +174,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const spinner = createLoadingSpinner();
|
||||
|
||||
// 页面状态跟踪
|
||||
let isLoading = false;
|
||||
let contentReady = false;
|
||||
let animationInProgress = false;
|
||||
|
||||
|
@ -9,8 +9,8 @@ export const NAV_STRUCTURE = [
|
||||
href: '/'
|
||||
},
|
||||
{
|
||||
id: 'douban',
|
||||
text: '豆瓣',
|
||||
id: 'art',
|
||||
text: '艺术',
|
||||
items: [
|
||||
{ id: 'movies', text: '观影', href: '/movies' },
|
||||
{ id: 'books', text: '读书', href: '/books' }
|
||||
|
@ -135,8 +135,6 @@ export const ARTICLE_EXPIRY_CONFIG = {
|
||||
|
||||
用于展示 Git 平台的项目列表。
|
||||
|
||||
基本用法:
|
||||
|
||||
```astro
|
||||
---
|
||||
import GitProjectCollection from '@/components/GitProjectCollection';
|
||||
@ -156,43 +154,58 @@ import { GitPlatform } from '@/components/GitProjectCollection';
|
||||
|
||||
## 观影和读书记录
|
||||
|
||||
### MediaGrid 组件
|
||||
|
||||
`MediaGrid` 组件用于展示豆瓣的观影和读书记录。
|
||||
|
||||
基本用法:
|
||||
用于展示豆瓣的观影和读书记录。
|
||||
|
||||
```astro
|
||||
---
|
||||
import MediaGrid from '@/components/MediaGrid.astro';
|
||||
import DoubanCollection from '@/components/DoubanCollection.astro';
|
||||
---
|
||||
|
||||
// 展示电影记录
|
||||
<MediaGrid
|
||||
<DoubanCollection
|
||||
type="movie" // 类型:movie 或 book
|
||||
title="我看过的电影" // 显示标题
|
||||
doubanId="id" // 豆瓣ID
|
||||
doubanId="lsy22" // 豆瓣ID
|
||||
/>
|
||||
|
||||
// 展示读书记录
|
||||
<MediaGrid
|
||||
<DoubanCollection
|
||||
type="book"
|
||||
title="我读过的书"
|
||||
doubanId="id"
|
||||
doubanId="lsy22"
|
||||
/>
|
||||
```
|
||||
|
||||
## 微信读书书单组件
|
||||
|
||||
用于展示微信读书的书单内容,支持错误处理和优雅的加载状态。
|
||||
|
||||
```astro
|
||||
---
|
||||
import WereadBookList from '@/components/WereadBookList';
|
||||
---
|
||||
|
||||
<WereadBookList
|
||||
listId="12345678" // 必填:微信读书书单ID,从书单URL中获取
|
||||
client:load // Astro 指令:客户端加载
|
||||
/>
|
||||
```
|
||||
|
||||
### 获取微信读书书单ID
|
||||
|
||||
1. **打开微信读书**:在浏览器中访问微信读书网页版或使用微信读书小程序/App
|
||||
2. **打开书单页面**:找到你想展示的书单并打开,点击分享,分享到浏览器
|
||||
3. **获取书单ID**:从URL中提取书单ID,例如:`https://weread.qq.com/misc/booklist/12345678` 中的 `12345678` 即为书单ID
|
||||
|
||||
## 旅行足迹组件
|
||||
|
||||
`WorldHeatmap` 组件用于展示你去过的地方,以热力图的形式在世界地图上显示。
|
||||
用于展示你去过的地方,以热力图的形式在世界地图上显示。
|
||||
|
||||
基本用法:
|
||||
|
||||
在 `src/consts.ts` 中配置你去过的地方:
|
||||
|
||||
```typescript
|
||||
```astro
|
||||
---
|
||||
import WorldHeatmap from '@/components/WorldHeatmap';
|
||||
// 配置你去过的地方
|
||||
export const VISITED_PLACES = [
|
||||
const VISITED_PLACES = [
|
||||
// 国内地区格式:'中国-省份/城市'
|
||||
"中国-黑龙江",
|
||||
"中国-北京",
|
||||
@ -202,28 +215,12 @@ export const VISITED_PLACES = [
|
||||
"泰国",
|
||||
"美国",
|
||||
];
|
||||
```
|
||||
|
||||
然后在页面中使用:
|
||||
|
||||
```astro
|
||||
---
|
||||
import Layout from "@/components/Layout.astro";
|
||||
import WorldHeatmap from '@/components/WorldHeatmap';
|
||||
import { VISITED_PLACES } from '@/consts';
|
||||
---
|
||||
|
||||
<Layout title="旅行足迹">
|
||||
<section>
|
||||
<h2 class="text-3xl font-semibold text-center mb-6">我的旅行足迹</h2>
|
||||
<div class="mx-auto bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<WorldHeatmap
|
||||
client:only="react"
|
||||
visitedPlaces={VISITED_PLACES}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
<WorldHeatmap
|
||||
client:only="react"
|
||||
visitedPlaces={VISITED_PLACES}
|
||||
/>
|
||||
```
|
||||
|
||||
## 代码块与 Mermaid 图表支持
|
||||
|
@ -4,35 +4,6 @@ date: 2023-05-26T20:21:00+00:00
|
||||
tags: ["Docker-compose"]
|
||||
---
|
||||
|
||||
## 准备数据库
|
||||
|
||||
### 1. 登录到数据库
|
||||
|
||||
```bash
|
||||
mysql -u root -p
|
||||
```
|
||||
|
||||
### 2. 创建一个将被 Gitea 使用的数据库用户,并使用密码进行身份验证
|
||||
|
||||
```sql
|
||||
CREATE USER 'gitea' IDENTIFIED BY 'Password';
|
||||
```
|
||||
|
||||
> 将`Password`改为自己的密码
|
||||
|
||||
### 3. 使用 UTF-8 字符集和大小写敏感的排序规则创建数据库
|
||||
|
||||
```sql
|
||||
CREATE DATABASE giteadb CHARACTER SET 'utf8mb4' COLLATE 'utf8mb4_bin';
|
||||
```
|
||||
|
||||
### 4. 将数据库上的所有权限授予上述创建的数据库用户
|
||||
|
||||
```sql
|
||||
GRANT ALL PRIVILEGES ON giteadb.* TO 'gitea';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
## 直通配置
|
||||
|
||||
### 1. 创建一个名为 `git` 的用户
|
||||
|
464
src/pages/api/weread.ts
Normal file
464
src/pages/api/weread.ts
Normal file
@ -0,0 +1,464 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { load } from 'cheerio';
|
||||
|
||||
// 添加服务器渲染标记
|
||||
export const prerender = false;
|
||||
|
||||
// 请求配置常量
|
||||
const MAX_RETRIES = 1; // 最大重试次数
|
||||
const RETRY_DELAY = 1500; // 重试延迟(毫秒)
|
||||
const REQUEST_TIMEOUT = 10000; // 请求超时时间(毫秒)
|
||||
|
||||
// 添加延迟函数
|
||||
function delay(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// 带超时的 fetch 函数
|
||||
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number) {
|
||||
// 检查是否已经提供了信号
|
||||
const existingSignal = options.signal;
|
||||
|
||||
// 创建我们自己的 AbortController 用于超时
|
||||
const timeoutController = new AbortController();
|
||||
const timeoutSignal = timeoutController.signal;
|
||||
|
||||
// 设置超时
|
||||
const timeout = setTimeout(() => {
|
||||
timeoutController.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
// 使用已有的信号和我们的超时信号
|
||||
if (existingSignal) {
|
||||
// 如果已经取消了,直接抛出异常
|
||||
if (existingSignal.aborted) {
|
||||
throw new DOMException('已被用户取消', 'AbortError');
|
||||
}
|
||||
|
||||
// 创建一个监听器,当外部信号中止时,也中止我们的控制器
|
||||
const abortListener = () => timeoutController.abort();
|
||||
existingSignal.addEventListener('abort', abortListener);
|
||||
|
||||
// 进行请求,但只使用我们的超时信号
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: timeoutSignal
|
||||
});
|
||||
|
||||
// 移除监听器
|
||||
existingSignal.removeEventListener('abort', abortListener);
|
||||
|
||||
return response;
|
||||
} else {
|
||||
// 如果没有提供信号,只使用我们的超时信号
|
||||
return await fetch(url, {
|
||||
...options,
|
||||
signal: timeoutSignal
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
// 从脚本中提取书籍数据(简单直接的方法)
|
||||
function extractBooksFromScript(html: string): { title: string; author: string; cover: string; }[] | null {
|
||||
try {
|
||||
// 1. 提取函数参数列表,用于解析参数引用
|
||||
const paramsMatch = html.match(/\(function\(([^)]*)\)/);
|
||||
if (!paramsMatch) {
|
||||
console.error('未找到函数参数列表');
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = paramsMatch[1].split(',').map(p => p.trim());
|
||||
|
||||
// 2. 提取bookEntities对象
|
||||
const bookEntitiesMatch = html.match(/bookEntities:(\{.*?\}),bookIds/s);
|
||||
if (!bookEntitiesMatch) {
|
||||
console.error('未找到bookEntities数据');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 提取bookIds数组
|
||||
const bookIdsMatch = html.match(/bookIds:\[(.*?)\]/s);
|
||||
if (!bookIdsMatch) {
|
||||
console.error('未找到bookIds数据');
|
||||
return null;
|
||||
}
|
||||
|
||||
const bookIdsStr = bookIdsMatch[1];
|
||||
const bookIds = bookIdsStr.split(',')
|
||||
.map(id => id.trim().replace(/"/g, ''))
|
||||
.filter(id => id && id !== '');
|
||||
|
||||
// 4. 创建参数到实际ID的映射
|
||||
// 首先提取所有bookId参数引用
|
||||
const bookIdParamPattern = /"([^"]+)":\{bookId:([^,]+),/g;
|
||||
const paramToIdMap = new Map<string, string>();
|
||||
let bookIdMatch;
|
||||
|
||||
while ((bookIdMatch = bookIdParamPattern.exec(bookEntitiesMatch[1])) !== null) {
|
||||
const entityId = bookIdMatch[1]; // 如 "728774"
|
||||
const paramRef = bookIdMatch[2]; // 如 "h"
|
||||
|
||||
if (paramRef.length === 1 && /[a-z]/.test(paramRef)) {
|
||||
paramToIdMap.set(paramRef, entityId);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 解析每本书的信息
|
||||
const bookMap = new Map();
|
||||
const bookPattern = /"([^"]+)":\{bookId:[^,]+,.*?author:([^,]+),.*?title:"([^"]+)",.*?cover:"([^"]+)"/g;
|
||||
|
||||
let match;
|
||||
let bookCount = 0;
|
||||
|
||||
while ((match = bookPattern.exec(bookEntitiesMatch[1])) !== null) {
|
||||
const bookId = match[1];
|
||||
const authorParam = match[2];
|
||||
const title = match[3];
|
||||
const cover = match[4].replace(/\\u002F/g, '/');
|
||||
|
||||
// 处理作者参数引用
|
||||
let author = authorParam;
|
||||
if (authorParam.length === 1 && /[a-z]/.test(authorParam)) {
|
||||
const paramIndex = authorParam.charCodeAt(0) - 'a'.charCodeAt(0);
|
||||
if (paramIndex >= 0 && paramIndex < params.length) {
|
||||
author = params[paramIndex].replace(/"/g, '');
|
||||
}
|
||||
} else if (authorParam.startsWith('"') && authorParam.endsWith('"')) {
|
||||
author = authorParam.substring(1, authorParam.length - 1);
|
||||
}
|
||||
|
||||
// 同时用实体ID和参数引用作为键存储书籍信息
|
||||
bookMap.set(bookId, { title, author, cover });
|
||||
bookCount++;
|
||||
}
|
||||
|
||||
// 6. 按照bookIds的顺序返回书籍,使用参数映射
|
||||
const orderedBooks = [];
|
||||
|
||||
for (const paramId of bookIds) {
|
||||
// 如果是参数引用,使用映射查找实际ID
|
||||
const actualId = paramToIdMap.get(paramId);
|
||||
|
||||
if (actualId) {
|
||||
const book = bookMap.get(actualId);
|
||||
if (book) {
|
||||
orderedBooks.push(book);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试直接使用paramId查找
|
||||
const directBook = bookMap.get(paramId);
|
||||
if (directBook) {
|
||||
orderedBooks.push(directBook);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到任何书籍,返回所有书籍
|
||||
if (orderedBooks.length === 0) {
|
||||
return Array.from(bookMap.values());
|
||||
}
|
||||
|
||||
return orderedBooks;
|
||||
} catch (error) {
|
||||
console.error('从脚本提取书籍数据时出错:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const listId = url.searchParams.get('listId'); // 从查询参数获取微信读书书单ID
|
||||
|
||||
if (!listId) {
|
||||
return new Response(JSON.stringify({ error: '缺少微信读书书单ID' }), {
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, max-age=0'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 尝试从缓存获取数据
|
||||
try {
|
||||
// 重试逻辑
|
||||
let retries = 0;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
while (retries <= MAX_RETRIES) {
|
||||
try {
|
||||
const wereadUrl = `https://weread.qq.com/misc/booklist/${listId}`;
|
||||
|
||||
// 使用带超时的fetch发送请求
|
||||
const response = await fetchWithTimeout(wereadUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
}
|
||||
}, REQUEST_TIMEOUT);
|
||||
|
||||
if (!response.ok) {
|
||||
// 根据状态码提供更详细的错误信息
|
||||
let errorMessage = `微信读书请求失败,状态码: ${response.status}`;
|
||||
|
||||
if (response.status === 403) {
|
||||
errorMessage = `微信读书接口返回403禁止访问,可能是请求频率受限`;
|
||||
console.error(errorMessage);
|
||||
|
||||
// 返回更友好的错误信息
|
||||
return new Response(JSON.stringify({
|
||||
error: '微信读书接口暂时不可用',
|
||||
message: '请求频率过高,服务器已限制访问,请稍后再试',
|
||||
status: 403
|
||||
}), {
|
||||
status: 403,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, max-age=0'
|
||||
}
|
||||
});
|
||||
} else if (response.status === 404) {
|
||||
errorMessage = `未找到微信读书书单 (ID: ${listId})`;
|
||||
} else if (response.status === 429) {
|
||||
errorMessage = '微信读书API请求过于频繁,被限流';
|
||||
} else if (response.status >= 500) {
|
||||
errorMessage = '微信读书服务器内部错误';
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// 检查是否包含验证码页面的特征
|
||||
if (html.includes('验证码') || html.includes('captcha')) {
|
||||
const errorMessage = '请求被微信读书限制,需要验证码';
|
||||
console.error(errorMessage);
|
||||
|
||||
// 返回更友好的错误信息
|
||||
return new Response(JSON.stringify({
|
||||
error: '微信读书接口暂时不可用',
|
||||
message: '请求需要验证码验证,可能是因为请求过于频繁',
|
||||
status: 403
|
||||
}), {
|
||||
status: 403,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, max-age=0'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 从脚本中提取书籍数据
|
||||
const scriptBooks = extractBooksFromScript(html);
|
||||
|
||||
if (scriptBooks && scriptBooks.length > 0) {
|
||||
// 将提取的数据转换为API响应格式
|
||||
const books = scriptBooks.map(book => ({
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
imageUrl: book.cover,
|
||||
link: `https://weread.qq.com/web/search/books?keyword=${encodeURIComponent(book.title)}`
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify({ books }), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=300', // 5分钟服务器缓存
|
||||
'CDN-Cache-Control': 'public, max-age=300' // CDN缓存
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 如果从脚本提取失败,尝试从HTML解析
|
||||
const $ = load(html);
|
||||
|
||||
// 定义书籍项目接口
|
||||
interface WereadBook {
|
||||
title: string;
|
||||
author: string;
|
||||
imageUrl: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
const books: WereadBook[] = [];
|
||||
|
||||
// 从HTML中解析书籍列表
|
||||
$('.booklist_books li').each((_, element) => {
|
||||
try {
|
||||
const $element = $(element);
|
||||
|
||||
// 提取标题和作者
|
||||
const title = $element.find('.booklist_book_title').text().trim();
|
||||
const author = $element.find('.booklist_book_author').text().trim();
|
||||
// 尝试直接从HTML中提取图片URL
|
||||
const imageUrl = $element.find('.wr_bookCover_img').attr('src') || '';
|
||||
|
||||
// 只有在找到标题和作者的情况下才添加书籍
|
||||
if (title && author) {
|
||||
books.push({
|
||||
title,
|
||||
author,
|
||||
imageUrl,
|
||||
link: `https://weread.qq.com/web/search/books?keyword=${encodeURIComponent(title)}`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析书籍时出错:', error);
|
||||
}
|
||||
});
|
||||
|
||||
if (books.length > 0) {
|
||||
return new Response(JSON.stringify({ books }), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=300',
|
||||
'CDN-Cache-Control': 'public, max-age=300'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error('未能从页面中提取书籍数据');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`尝试第 ${retries + 1}/${MAX_RETRIES + 1} 次失败:`, error);
|
||||
|
||||
// 判断是否是请求被中止
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted'))) {
|
||||
console.warn('请求被中止:', error.message);
|
||||
// 对于中止请求,我们可以直接返回404
|
||||
return new Response(JSON.stringify({
|
||||
error: '请求被中止',
|
||||
message: '请求已被用户或服务器中止',
|
||||
status: 499 // 使用499代表客户端中止请求
|
||||
}), {
|
||||
status: 499,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, max-age=0'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (retries < MAX_RETRIES) {
|
||||
retries++;
|
||||
// 增加重试延迟,避免频繁请求
|
||||
await delay(RETRY_DELAY * retries);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有尝试都失败了
|
||||
console.error('所有尝试都失败了:', lastError);
|
||||
|
||||
// 检查是否是常见错误类型并返回对应错误信息
|
||||
const errorMessage = lastError?.message || '未知错误';
|
||||
|
||||
// 检查是否是中止错误
|
||||
if (lastError && (lastError.name === 'AbortError' || errorMessage.includes('aborted'))) {
|
||||
return new Response(JSON.stringify({
|
||||
error: '请求被中止',
|
||||
message: '请求已被用户或系统中止',
|
||||
status: 499
|
||||
}), {
|
||||
status: 499,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, max-age=0'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 根据错误信息判断错误类型
|
||||
if (errorMessage.includes('403') || errorMessage.includes('禁止访问') || errorMessage.includes('频繁')) {
|
||||
return new Response(JSON.stringify({
|
||||
error: '微信读书接口访问受限',
|
||||
message: '请求频率过高,服务器已限制访问,请稍后再试',
|
||||
status: 403
|
||||
}), {
|
||||
status: 403,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, max-age=0'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (errorMessage.includes('404') || errorMessage.includes('未找到')) {
|
||||
return new Response(JSON.stringify({
|
||||
error: '未找到微信读书书单',
|
||||
message: `未找到ID为 ${listId} 的书单内容`,
|
||||
status: 404
|
||||
}), {
|
||||
status: 404,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, max-age=0'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (errorMessage.includes('超时')) {
|
||||
return new Response(JSON.stringify({
|
||||
error: '微信读书接口请求超时',
|
||||
message: '请求微信读书服务器超时,请稍后再试',
|
||||
status: 408
|
||||
}), {
|
||||
status: 408,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, max-age=0'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
error: '获取微信读书数据失败',
|
||||
message: errorMessage
|
||||
}), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, max-age=0'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('处理请求时出错:', error);
|
||||
|
||||
// 判断是否是中止错误
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted'))) {
|
||||
return new Response(JSON.stringify({
|
||||
error: '请求被中止',
|
||||
message: '请求已被用户或系统中止',
|
||||
status: 499
|
||||
}), {
|
||||
status: 499,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, max-age=0'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
error: '获取微信读书数据失败',
|
||||
message: error instanceof Error ? error.message : '未知错误'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, max-age=0'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -1,18 +1,17 @@
|
||||
---
|
||||
import Layout from "@/components/Layout.astro";
|
||||
import MediaGrid from "@/components/MediaGrid.tsx";
|
||||
import WereadBookList from "@/components/WereadBookList";
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={`豆瓣图书`}
|
||||
title={`书单`}
|
||||
description={`我读过的书`}
|
||||
skipSrTitle={false}
|
||||
>
|
||||
<h1 class="text-3xl font-bold mb-6">我读过的书</h1>
|
||||
|
||||
<MediaGrid
|
||||
type="book"
|
||||
doubanId="lsy22"
|
||||
<WereadBookList
|
||||
listId="333895983_80fTRWHwy"
|
||||
client:load
|
||||
/>
|
||||
</Layout>
|
||||
</Layout>
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
import Layout from "@/components/Layout.astro";
|
||||
import MediaGrid from "@/components/MediaGrid.tsx";
|
||||
import DoubanCollection from "@/components/DoubanCollection";
|
||||
---
|
||||
|
||||
<Layout
|
||||
@ -10,7 +10,7 @@ import MediaGrid from "@/components/MediaGrid.tsx";
|
||||
>
|
||||
<h1 class="text-3xl font-bold mb-6">我看过的电影</h1>
|
||||
|
||||
<MediaGrid
|
||||
<DoubanCollection
|
||||
type="movie"
|
||||
doubanId="lsy22"
|
||||
client:load
|
||||
|
Loading…
Reference in New Issue
Block a user