删除多余的豆瓣文件,优化文件结构,增加微信读取获取
This commit is contained in:
parent
2f1fc8d285
commit
7c00f32271
@ -1,324 +1,469 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import React, { useEffect, useRef, useState, useCallback } 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DoubanCollectionProps {
|
interface DoubanCollectionProps {
|
||||||
type: 'movie' | 'book';
|
type: "movie" | "book";
|
||||||
doubanId?: string; // 可选参数,使其与 MediaGrid 保持一致
|
doubanId: string;
|
||||||
className?: string; // 添加自定义类名
|
className?: string; // 添加可选的className属性以提高灵活性
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DoubanItem {
|
||||||
|
title: string;
|
||||||
|
imageUrl: string;
|
||||||
|
link: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DoubanCollection: React.FC<DoubanCollectionProps> = ({ type, doubanId, className = '' }) => {
|
const DoubanCollection: React.FC<DoubanCollectionProps> = ({ type, doubanId, className = '' }) => {
|
||||||
const [items, setItems] = useState<DoubanItem[]>([]);
|
const [items, setItems] = useState<DoubanItem[]>([]);
|
||||||
const [pagination, setPagination] = useState<Pagination>({ current: 1, total: 1, hasNext: false, hasPrev: false });
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [hasMoreContent, setHasMoreContent] = useState(true);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isPageChanging, setIsPageChanging] = useState(false);
|
const itemsPerPage = 15;
|
||||||
|
const contentListRef = useRef<HTMLDivElement>(null);
|
||||||
// 使用 ref 避免竞态条件
|
const lastScrollTime = useRef(0);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
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);
|
||||||
|
|
||||||
// 标题文本
|
// 使用ref来跟踪关键状态,避免闭包问题
|
||||||
const titleText = useMemo(() =>
|
const stateRef = useRef({
|
||||||
type === 'movie' ? '观影记录' : '读书记录',
|
isLoading: false,
|
||||||
[type]);
|
hasMoreContent: true,
|
||||||
|
currentPage: 1,
|
||||||
|
error: null as string | null,
|
||||||
|
});
|
||||||
|
|
||||||
// 加载动画组件
|
// 封装fetch函数使用useCallback避免重新创建
|
||||||
const LoadingSpinner = useCallback(() => (
|
const fetchDoubanData = useCallback(async (page = 1, append = false) => {
|
||||||
<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">
|
// 使用ref中的最新状态
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
if (
|
||||||
<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>
|
stateRef.current.isLoading ||
|
||||||
</svg>
|
(!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) {
|
if (abortControllerRef.current) {
|
||||||
abortControllerRef.current.abort();
|
abortControllerRef.current.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建新的 AbortController
|
// 创建新的AbortController
|
||||||
abortControllerRef.current = new AbortController();
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
setLoading(true);
|
// 更新状态和ref
|
||||||
setError(null);
|
setIsLoading(true);
|
||||||
|
stateRef.current.isLoading = true;
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append('type', type);
|
// 只在首次加载时清除错误
|
||||||
params.append('start', start.toString());
|
if (!append) {
|
||||||
|
setError(null);
|
||||||
if (doubanId) {
|
stateRef.current.error = null;
|
||||||
params.append('doubanId', doubanId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `/api/douban?${params.toString()}`;
|
const start = (page - 1) * itemsPerPage;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(
|
||||||
signal: abortControllerRef.current.signal
|
`/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`,
|
||||||
});
|
{ signal: abortControllerRef.current.signal }
|
||||||
|
);
|
||||||
|
|
||||||
// 如果组件已卸载,不继续处理
|
// 检查组件是否已卸载,如果卸载则不继续处理
|
||||||
if (!isMountedRef.current) return;
|
if (!isMountedRef.current) {
|
||||||
|
|
||||||
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') {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果组件已卸载,不设置状态
|
if (!response.ok) {
|
||||||
if (!isMountedRef.current) return;
|
// 解析响应内容,获取详细错误信息
|
||||||
|
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 : '未知错误');
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
setItems([]);
|
console.log('请求被取消', error.message);
|
||||||
|
// 如果是取消请求,重置加载状态但不显示错误
|
||||||
|
setIsLoading(false);
|
||||||
|
stateRef.current.isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有在非追加模式下才清空已加载的内容
|
||||||
|
if (!append) {
|
||||||
|
setItems([]);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// 如果组件已卸载,不设置状态
|
// 检查组件是否已卸载
|
||||||
if (!isMountedRef.current) return;
|
if (isMountedRef.current) {
|
||||||
|
// 重置加载状态
|
||||||
setLoading(false);
|
setIsLoading(false);
|
||||||
setIsPageChanging(false);
|
stateRef.current.isLoading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [type, doubanId]);
|
}, [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(() => {
|
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;
|
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 () => {
|
return () => {
|
||||||
|
// 标记组件已卸载
|
||||||
isMountedRef.current = false;
|
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) {
|
if (abortControllerRef.current) {
|
||||||
abortControllerRef.current.abort();
|
abortControllerRef.current.abort();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [fetchData]);
|
}, [type, doubanId, handleScroll, fetchDoubanData, setupIntersectionObserver]);
|
||||||
|
|
||||||
const handlePageChange = useCallback((page: number) => {
|
// 错误提示组件
|
||||||
if (isPageChanging) return;
|
const ErrorMessage = () => {
|
||||||
|
if (!error) return null;
|
||||||
setIsPageChanging(true);
|
|
||||||
|
|
||||||
// 计算新页面的起始项
|
|
||||||
const start = (page - 1) * 15;
|
|
||||||
|
|
||||||
// 更新分页状态
|
|
||||||
setPagination(prev => ({
|
|
||||||
...prev,
|
|
||||||
current: page
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 获取新页面的数据
|
|
||||||
fetchData(start);
|
|
||||||
}, [fetchData, isPageChanging]);
|
|
||||||
|
|
||||||
const renderStars = useCallback((rating: number) => {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex">
|
<div className="col-span-full text-center bg-red-50 p-4 rounded-md">
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
<div className="flex flex-col items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
key={star}
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
className={`w-4 h-4 ${star <= rating ? 'text-accent-400' : 'text-secondary-300 dark:text-secondary-600'}`}
|
className="h-12 w-12 text-red-500 mb-2"
|
||||||
fill="currentColor"
|
fill="none"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 24 24"
|
||||||
aria-hidden="true"
|
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>
|
</svg>
|
||||||
))}
|
<h3 className="text-lg font-medium text-red-800">访问错误</h3>
|
||||||
</div>
|
<p className="mt-1 text-sm text-red-700">{error}</p>
|
||||||
);
|
<button
|
||||||
}, []);
|
onClick={() => {
|
||||||
|
// 重置错误和加载状态
|
||||||
|
setError(null);
|
||||||
|
stateRef.current.error = null;
|
||||||
|
|
||||||
const breakpointColumnsObj = {
|
// 允许再次加载
|
||||||
default: 3,
|
setHasMoreContent(true);
|
||||||
1100: 2,
|
stateRef.current.hasMoreContent = true;
|
||||||
700: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染内容的容器
|
|
||||||
const Container = useCallback(({ children }: { children: React.ReactNode }) => (
|
|
||||||
<div className={`douban-collection ${className}`}>
|
|
||||||
<Title />
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
), [className, Title]);
|
|
||||||
|
|
||||||
// 加载中状态
|
// 重新获取当前页
|
||||||
if (loading && items.length === 0) {
|
fetchDoubanData(currentPage, false);
|
||||||
return (
|
}}
|
||||||
<Container>
|
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"
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
// 数据为空状态
|
// 没有更多内容提示
|
||||||
if (items.length === 0) {
|
const EndMessage = () => {
|
||||||
return (
|
if (isLoading || !items.length || error) return null;
|
||||||
<Container>
|
|
||||||
<div className="text-center p-8 text-gray-500 dark:text-gray-400">
|
|
||||||
暂无{type === 'movie' ? '观影' : '读书'}记录
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染分页按钮
|
|
||||||
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 (
|
return (
|
||||||
<button
|
<div className="text-center py-8">
|
||||||
onClick={onClick}
|
<p className="text-gray-600">已加载全部内容</p>
|
||||||
disabled={disabled}
|
</div>
|
||||||
className={buttonClass}
|
|
||||||
aria-label={buttonText}
|
|
||||||
>
|
|
||||||
{isPageChanging ? (
|
|
||||||
<span className="flex items-center">
|
|
||||||
<LoadingSpinner />
|
|
||||||
加载中
|
|
||||||
</span>
|
|
||||||
) : buttonText}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}, [isPageChanging, LoadingSpinner]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<div className={`w-full ${className}`}>
|
||||||
<ReactMasonryCss
|
<div
|
||||||
breakpointCols={breakpointColumnsObj}
|
ref={contentListRef}
|
||||||
className="flex -ml-4 w-auto"
|
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
|
||||||
columnClassName="pl-4 bg-clip-padding"
|
|
||||||
>
|
>
|
||||||
{items.map((item, index) => (
|
{error && items.length === 0 ? (
|
||||||
<div
|
<ErrorMessage />
|
||||||
key={`${item.title}-${index}`}
|
) : items.length > 0 ? (
|
||||||
className="mb-6 bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:shadow-lg"
|
items.map((item, index) => (
|
||||||
>
|
<div
|
||||||
<a href={item.link} target="_blank" rel="noopener noreferrer" className="block">
|
key={`${item.title}-${index}`}
|
||||||
<div className="relative pb-[140%] overflow-hidden">
|
className="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl"
|
||||||
<img
|
>
|
||||||
src={item.imageUrl}
|
<div className="relative pb-[150%] overflow-hidden">
|
||||||
alt={item.title}
|
<img
|
||||||
className="absolute inset-0 w-full h-full object-cover hover:scale-105"
|
src={item.imageUrl}
|
||||||
|
alt={item.title}
|
||||||
|
className="absolute top-0 left-0 w-full h-full object-cover hover:scale-105"
|
||||||
loading="lazy"
|
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="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent">
|
||||||
<div className="p-4">
|
<h3 className="font-bold text-white text-sm line-clamp-2">
|
||||||
<h3 className="font-bold text-lg mb-1 line-clamp-1 text-primary-800 dark:text-primary-300">{item.title}</h3>
|
<a
|
||||||
{item.subtitle && <p className="text-secondary-600 dark:text-secondary-400 text-sm mb-2 line-clamp-1">{item.subtitle}</p>}
|
href={item.link}
|
||||||
<div className="flex justify-between items-center mb-2">
|
target="_blank"
|
||||||
{renderStars(item.rating)}
|
rel="noopener noreferrer"
|
||||||
<span className="text-sm text-secondary-500 dark:text-secondary-400">{item.date}</span>
|
className="hover:text-blue-300"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-secondary-700 dark:text-secondary-300 text-sm line-clamp-3">{item.intro}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
|
))
|
||||||
|
) : !isLoading ? (
|
||||||
|
<div className="col-span-full text-center">
|
||||||
|
暂无{type === "movie" ? "电影" : "图书"}数据
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : null}
|
||||||
</ReactMasonryCss>
|
</div>
|
||||||
|
|
||||||
{/* 分页 */}
|
{error && items.length > 0 && (
|
||||||
{pagination.total > 1 && (
|
<div className="mt-4">
|
||||||
<div className="flex justify-center mt-8 space-x-2">
|
<ErrorMessage />
|
||||||
{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
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import '../scripts/swup-init.js';
|
import './swup-init.js';
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<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();
|
const spinner = createLoadingSpinner();
|
||||||
|
|
||||||
// 页面状态跟踪
|
// 页面状态跟踪
|
||||||
let isLoading = false;
|
|
||||||
let contentReady = false;
|
|
||||||
let animationInProgress = false;
|
let animationInProgress = false;
|
||||||
|
|
||||||
|
|
@ -9,8 +9,8 @@ export const NAV_STRUCTURE = [
|
|||||||
href: '/'
|
href: '/'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'douban',
|
id: 'art',
|
||||||
text: '豆瓣',
|
text: '艺术',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'movies', text: '观影', href: '/movies' },
|
{ id: 'movies', text: '观影', href: '/movies' },
|
||||||
{ id: 'books', text: '读书', href: '/books' }
|
{ id: 'books', text: '读书', href: '/books' }
|
||||||
|
@ -135,8 +135,6 @@ export const ARTICLE_EXPIRY_CONFIG = {
|
|||||||
|
|
||||||
用于展示 Git 平台的项目列表。
|
用于展示 Git 平台的项目列表。
|
||||||
|
|
||||||
基本用法:
|
|
||||||
|
|
||||||
```astro
|
```astro
|
||||||
---
|
---
|
||||||
import GitProjectCollection from '@/components/GitProjectCollection';
|
import GitProjectCollection from '@/components/GitProjectCollection';
|
||||||
@ -156,43 +154,58 @@ import { GitPlatform } from '@/components/GitProjectCollection';
|
|||||||
|
|
||||||
## 观影和读书记录
|
## 观影和读书记录
|
||||||
|
|
||||||
### MediaGrid 组件
|
用于展示豆瓣的观影和读书记录。
|
||||||
|
|
||||||
`MediaGrid` 组件用于展示豆瓣的观影和读书记录。
|
|
||||||
|
|
||||||
基本用法:
|
|
||||||
|
|
||||||
```astro
|
```astro
|
||||||
---
|
---
|
||||||
import MediaGrid from '@/components/MediaGrid.astro';
|
import DoubanCollection from '@/components/DoubanCollection.astro';
|
||||||
---
|
---
|
||||||
|
|
||||||
// 展示电影记录
|
// 展示电影记录
|
||||||
<MediaGrid
|
<DoubanCollection
|
||||||
type="movie" // 类型:movie 或 book
|
type="movie" // 类型:movie 或 book
|
||||||
title="我看过的电影" // 显示标题
|
title="我看过的电影" // 显示标题
|
||||||
doubanId="id" // 豆瓣ID
|
doubanId="lsy22" // 豆瓣ID
|
||||||
/>
|
/>
|
||||||
|
|
||||||
// 展示读书记录
|
// 展示读书记录
|
||||||
<MediaGrid
|
<DoubanCollection
|
||||||
type="book"
|
type="book"
|
||||||
title="我读过的书"
|
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` 组件用于展示你去过的地方,以热力图的形式在世界地图上显示。
|
用于展示你去过的地方,以热力图的形式在世界地图上显示。
|
||||||
|
|
||||||
基本用法:
|
```astro
|
||||||
|
---
|
||||||
在 `src/consts.ts` 中配置你去过的地方:
|
import WorldHeatmap from '@/components/WorldHeatmap';
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 配置你去过的地方
|
// 配置你去过的地方
|
||||||
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="旅行足迹">
|
<WorldHeatmap
|
||||||
<section>
|
client:only="react"
|
||||||
<h2 class="text-3xl font-semibold text-center mb-6">我的旅行足迹</h2>
|
visitedPlaces={VISITED_PLACES}
|
||||||
<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>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 代码块与 Mermaid 图表支持
|
## 代码块与 Mermaid 图表支持
|
||||||
|
@ -4,35 +4,6 @@ date: 2023-05-26T20:21:00+00:00
|
|||||||
tags: ["Docker-compose"]
|
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` 的用户
|
### 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 Layout from "@/components/Layout.astro";
|
||||||
import MediaGrid from "@/components/MediaGrid.tsx";
|
import WereadBookList from "@/components/WereadBookList";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout
|
<Layout
|
||||||
title={`豆瓣图书`}
|
title={`书单`}
|
||||||
description={`我读过的书`}
|
description={`我读过的书`}
|
||||||
skipSrTitle={false}
|
skipSrTitle={false}
|
||||||
>
|
>
|
||||||
<h1 class="text-3xl font-bold mb-6">我读过的书</h1>
|
<h1 class="text-3xl font-bold mb-6">我读过的书</h1>
|
||||||
|
|
||||||
<MediaGrid
|
<WereadBookList
|
||||||
type="book"
|
listId="333895983_80fTRWHwy"
|
||||||
doubanId="lsy22"
|
|
||||||
client:load
|
client:load
|
||||||
/>
|
/>
|
||||||
</Layout>
|
</Layout>
|
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Layout from "@/components/Layout.astro";
|
import Layout from "@/components/Layout.astro";
|
||||||
import MediaGrid from "@/components/MediaGrid.tsx";
|
import DoubanCollection from "@/components/DoubanCollection";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout
|
<Layout
|
||||||
@ -10,7 +10,7 @@ import MediaGrid from "@/components/MediaGrid.tsx";
|
|||||||
>
|
>
|
||||||
<h1 class="text-3xl font-bold mb-6">我看过的电影</h1>
|
<h1 class="text-3xl font-bold mb-6">我看过的电影</h1>
|
||||||
|
|
||||||
<MediaGrid
|
<DoubanCollection
|
||||||
type="movie"
|
type="movie"
|
||||||
doubanId="lsy22"
|
doubanId="lsy22"
|
||||||
client:load
|
client:load
|
||||||
|
Loading…
Reference in New Issue
Block a user