commit
57d406c277
@ -9,6 +9,7 @@ import rehypeExternalLinks from "rehype-external-links";
|
||||
import sitemap from "@astrojs/sitemap";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import swup from "@swup/astro"
|
||||
import { SITE_URL } from "./src/consts";
|
||||
|
||||
import vercel from "@astrojs/vercel";
|
||||
@ -88,6 +89,7 @@ export default defineConfig({
|
||||
},
|
||||
gfm: true
|
||||
}),
|
||||
swup(),
|
||||
react(),
|
||||
sitemap({
|
||||
filter: (page) => !page.includes("/api/"),
|
||||
|
27
package.json
27
package.json
@ -9,23 +9,24 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.2.2",
|
||||
"@astrojs/node": "^9.1.3",
|
||||
"@astrojs/react": "^4.2.2",
|
||||
"@astrojs/mdx": "^4.2.4",
|
||||
"@astrojs/node": "^9.2.0",
|
||||
"@astrojs/react": "^4.2.4",
|
||||
"@astrojs/sitemap": "^3.3.0",
|
||||
"@astrojs/vercel": "^8.1.3",
|
||||
"@tailwindcss/vite": "^4.0.9",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@swup/astro": "^1.6.0",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/three": "^0.174.0",
|
||||
"astro": "^5.5.5",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"node-fetch": "^3.3.0",
|
||||
"octokit": "^3.1.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"astro": "^5.7.4",
|
||||
"cheerio": "^1.0.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"octokit": "^3.2.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-masonry-css": "^1.0.16",
|
||||
"tailwindcss": "^4.0.9",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"three": "^0.174.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -1,47 +1,122 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface CountdownProps {
|
||||
targetDate: string; // 目标日期,格式:'YYYY-MM-DD'
|
||||
className?: string; // 自定义类名
|
||||
}
|
||||
|
||||
export const Countdown: React.FC<CountdownProps> = ({ targetDate }) => {
|
||||
const [timeLeft, setTimeLeft] = useState({
|
||||
interface TimeLeft {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
export const Countdown: React.FC<CountdownProps> = ({ targetDate, className = '' }) => {
|
||||
const [timeLeft, setTimeLeft] = useState<TimeLeft>({
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0
|
||||
seconds: 0,
|
||||
expired: false
|
||||
});
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
const calculateTimeLeft = () => {
|
||||
try {
|
||||
const now = new Date().getTime();
|
||||
const target = new Date(targetDate).getTime();
|
||||
const difference = target - now;
|
||||
|
||||
if (difference > 0) {
|
||||
// 检查目标日期是否有效
|
||||
if (isNaN(target)) {
|
||||
console.error(`无效的目标日期: ${targetDate}`);
|
||||
return {
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
expired: true
|
||||
};
|
||||
}
|
||||
|
||||
const difference = target - now;
|
||||
const expired = difference <= 0;
|
||||
|
||||
if (expired) {
|
||||
return {
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
expired: true
|
||||
};
|
||||
}
|
||||
|
||||
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((difference % (1000 * 60)) / 1000);
|
||||
|
||||
setTimeLeft({ days, hours, minutes, seconds });
|
||||
return { days, hours, minutes, seconds, expired: false };
|
||||
} catch (error) {
|
||||
console.error('计算倒计时发生错误:', error);
|
||||
return {
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
expired: true
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 立即计算一次时间
|
||||
setTimeLeft(calculateTimeLeft());
|
||||
|
||||
// 设置定时器
|
||||
timerRef.current = window.setInterval(() => {
|
||||
const newTimeLeft = calculateTimeLeft();
|
||||
setTimeLeft(newTimeLeft);
|
||||
|
||||
// 如果已经到期,清除计时器
|
||||
if (newTimeLeft.expired) {
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [targetDate]);
|
||||
|
||||
const TimeBox = ({ value, label }: { value: number; label: string }) => (
|
||||
<div className="text-center px-4">
|
||||
<div className="text-4xl font-light">
|
||||
<div className="text-4xl font-light transition-all duration-300">
|
||||
{value.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-sm mt-1 text-gray-500">{label}</div>
|
||||
<div className="text-sm mt-1 text-gray-500 dark:text-gray-400">{label}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (timeLeft.expired) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className={`text-center ${className}`}>
|
||||
<div className="text-xl text-gray-500 dark:text-gray-400">时间已到</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${className}`}>
|
||||
<TimeBox value={timeLeft.days} label="天" />
|
||||
<TimeBox value={timeLeft.hours} label="时" />
|
||||
<TimeBox value={timeLeft.minutes} label="分" />
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import ReactMasonryCss from 'react-masonry-css';
|
||||
|
||||
interface DoubanItem {
|
||||
@ -20,73 +20,138 @@ interface Pagination {
|
||||
|
||||
interface DoubanCollectionProps {
|
||||
type: 'movie' | 'book';
|
||||
doubanId?: string; // 可选参数,使其与 MediaGrid 保持一致
|
||||
className?: string; // 添加自定义类名
|
||||
}
|
||||
|
||||
const DoubanCollection: React.FC<DoubanCollectionProps> = ({ type }) => {
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [isPageChanging, setIsPageChanging] = useState(false);
|
||||
|
||||
const fetchData = async (start = 0) => {
|
||||
// 使用 ref 避免竞态条件
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const fetchData = useCallback(async (start = 0) => {
|
||||
// 如果已经有一个请求在进行中,取消它
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// 创建新的 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);
|
||||
}
|
||||
|
||||
const url = `/api/douban?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(url, {
|
||||
signal: abortControllerRef.current.signal
|
||||
});
|
||||
|
||||
// 如果组件已卸载,不继续处理
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取数据失败');
|
||||
throw new Error(`获取数据失败:状态码 ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setItems(data.items);
|
||||
setPagination(data.pagination);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 如果组件已卸载,不设置状态
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
console.error('获取豆瓣数据失败:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
setItems([]);
|
||||
} finally {
|
||||
// 如果组件已卸载,不设置状态
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setLoading(false);
|
||||
setIsPageChanging(false);
|
||||
}
|
||||
}, [type, doubanId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [type]);
|
||||
// 组件挂载时设置标记
|
||||
isMountedRef.current = true;
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
fetchData();
|
||||
|
||||
// 组件卸载时清理
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
const handlePageChange = useCallback((page: number) => {
|
||||
if (isPageChanging) return;
|
||||
|
||||
setIsPageChanging(true);
|
||||
|
||||
// 计算新页面的起始项
|
||||
const start = (page - 1) * 15;
|
||||
|
||||
// 手动更新分页状态,不等待API响应
|
||||
// 更新分页状态
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: page
|
||||
}));
|
||||
|
||||
// 重置当前状态,显示加载中
|
||||
// 清空当前项目,显示加载状态
|
||||
setItems([]);
|
||||
setLoading(true);
|
||||
|
||||
// 获取新页面的数据
|
||||
fetchData(start);
|
||||
};
|
||||
}, [fetchData, isPageChanging]);
|
||||
|
||||
const renderStars = (rating: number) => {
|
||||
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'}`}
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const breakpointColumnsObj = {
|
||||
default: 3,
|
||||
@ -94,17 +159,65 @@ const DoubanCollection: React.FC<DoubanCollectionProps> = ({ type }) => {
|
||||
700: 1
|
||||
};
|
||||
|
||||
// 加载中状态
|
||||
if (loading && items.length === 0) {
|
||||
return <div className="flex justify-center p-8">加载中...</div>;
|
||||
return (
|
||||
<div className={`douban-collection ${className}`}>
|
||||
<h2 className="text-2xl font-bold mb-6 text-primary-700 dark:text-primary-400">
|
||||
{type === 'movie' ? '观影记录' : '读书记录'}
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (error) {
|
||||
return <div className="text-red-500 p-4">错误: {error}</div>;
|
||||
return (
|
||||
<div className={`douban-collection ${className}`}>
|
||||
<h2 className="text-2xl font-bold mb-6 text-primary-700 dark:text-primary-400">
|
||||
{type === 'movie' ? '观影记录' : '读书记录'}
|
||||
</h2>
|
||||
<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 transition-colors"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 数据为空状态
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className={`douban-collection ${className}`}>
|
||||
<h2 className="text-2xl font-bold mb-6 text-primary-700 dark:text-primary-400">
|
||||
{type === 'movie' ? '观影记录' : '读书记录'}
|
||||
</h2>
|
||||
<div className="text-center p-8 text-gray-500 dark:text-gray-400">
|
||||
暂无{type === 'movie' ? '观影' : '读书'}记录
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="douban-collection">
|
||||
<h2 className="text-2xl font-bold mb-6 text-primary-700">{type === 'movie' ? '观影记录' : '读书记录'}</h2>
|
||||
<div className={`douban-collection ${className}`}>
|
||||
<h2 className="text-2xl font-bold mb-6 text-primary-700 dark:text-primary-400">
|
||||
{type === 'movie' ? '观影记录' : '读书记录'}
|
||||
</h2>
|
||||
|
||||
<ReactMasonryCss
|
||||
breakpointCols={breakpointColumnsObj}
|
||||
@ -112,23 +225,32 @@ const DoubanCollection: React.FC<DoubanCollectionProps> = ({ type }) => {
|
||||
columnClassName="pl-4 bg-clip-padding"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="mb-6 bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300">
|
||||
<div
|
||||
key={`${item.title}-${index}`}
|
||||
className="mb-6 bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300"
|
||||
>
|
||||
<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 transition-transform duration-300 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">{item.title}</h3>
|
||||
{item.subtitle && <p className="text-secondary-600 text-sm mb-2 line-clamp-1">{item.subtitle}</p>}
|
||||
<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">{item.date}</span>
|
||||
<span className="text-sm text-secondary-500 dark:text-secondary-400">{item.date}</span>
|
||||
</div>
|
||||
<p className="text-secondary-700 text-sm line-clamp-3">{item.intro}</p>
|
||||
<p className="text-secondary-700 dark:text-secondary-300 text-sm line-clamp-3">{item.intro}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@ -139,67 +261,45 @@ const DoubanCollection: React.FC<DoubanCollectionProps> = ({ type }) => {
|
||||
{pagination.total > 1 && (
|
||||
<div className="flex justify-center mt-8 space-x-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (isPageChanging) return;
|
||||
|
||||
const prevPage = pagination.current - 1;
|
||||
|
||||
if (prevPage > 0) {
|
||||
setIsPageChanging(true);
|
||||
const prevStart = (prevPage - 1) * 15;
|
||||
|
||||
// 直接调用fetchData
|
||||
fetchData(prevStart);
|
||||
|
||||
// 手动更新分页状态
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: prevPage
|
||||
}));
|
||||
|
||||
setTimeout(() => setIsPageChanging(false), 2000);
|
||||
}
|
||||
}}
|
||||
onClick={() => handlePageChange(pagination.current - 1)}
|
||||
disabled={!pagination.hasPrev || pagination.current <= 1 || isPageChanging}
|
||||
className={`px-4 py-2 rounded ${!pagination.hasPrev || pagination.current <= 1 || isPageChanging ? 'bg-secondary-200 text-secondary-500 cursor-not-allowed' : 'bg-primary-600 text-white hover:bg-primary-700'}`}
|
||||
className={`px-4 py-2 rounded transition-colors ${!pagination.hasPrev || pagination.current <= 1 || isPageChanging
|
||||
? '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'}`}
|
||||
aria-label="上一页"
|
||||
>
|
||||
{isPageChanging ? '加载中...' : '上一页'}
|
||||
{isPageChanging ? (
|
||||
<span className="flex items-center">
|
||||
<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>
|
||||
加载中
|
||||
</span>
|
||||
) : '上一页'}
|
||||
</button>
|
||||
|
||||
<span className="px-4 py-2 bg-secondary-100 rounded">
|
||||
<span className="px-4 py-2 bg-secondary-100 dark:bg-secondary-800 rounded">
|
||||
{pagination.current} / {pagination.total}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault(); // 防止默认行为
|
||||
if (isPageChanging) return;
|
||||
|
||||
// 明确记录当前操作
|
||||
const nextPage = pagination.current + 1;
|
||||
|
||||
// 直接使用明确的页码而不是依赖state
|
||||
if (pagination.current < pagination.total) {
|
||||
setIsPageChanging(true);
|
||||
const nextStart = (nextPage - 1) * 15; // 修正计算方式
|
||||
|
||||
// 直接调用fetchData而不是通过handlePageChange
|
||||
fetchData(nextStart);
|
||||
|
||||
// 手动更新分页状态
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: nextPage
|
||||
}));
|
||||
|
||||
setTimeout(() => setIsPageChanging(false), 2000);
|
||||
}
|
||||
}}
|
||||
onClick={() => handlePageChange(pagination.current + 1)}
|
||||
disabled={!pagination.hasNext || pagination.current >= pagination.total || isPageChanging}
|
||||
className={`px-4 py-2 rounded ${!pagination.hasNext || pagination.current >= pagination.total || isPageChanging ? 'bg-secondary-200 text-secondary-500 cursor-not-allowed' : 'bg-primary-600 text-white hover:bg-primary-700'}`}
|
||||
className={`px-4 py-2 rounded transition-colors ${!pagination.hasNext || pagination.current >= pagination.total || isPageChanging
|
||||
? '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'}`}
|
||||
aria-label="下一页"
|
||||
>
|
||||
{isPageChanging ? '加载中...' : '下一页'}
|
||||
{isPageChanging ? (
|
||||
<span className="flex items-center">
|
||||
<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>
|
||||
加载中
|
||||
</span>
|
||||
) : '下一页'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
@ -23,7 +23,8 @@ const currentYear = new Date().getFullYear();
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200"
|
||||
aria-label="工信部备案信息"
|
||||
>
|
||||
{icp}
|
||||
</a>
|
||||
@ -34,22 +35,26 @@ const currentYear = new Date().getFullYear();
|
||||
href={psbIcpUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
class="flex items-center hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200"
|
||||
aria-label="公安部备案信息"
|
||||
>
|
||||
<img src="/images/national.png" alt="公安备案" class="h-4 mr-1" />
|
||||
<img src="/images/national.png" alt="公安备案" class="h-4 mr-1" width="14" height="16" loading="lazy" />
|
||||
{psbIcp}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-500 dark:text-gray-500 font-light flex items-center gap-2">
|
||||
<a href="https://blog.lsy22.com" class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors">© {currentYear} New Echoes. All rights reserved.</a>
|
||||
<span>·</span>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-500 font-light flex flex-wrap items-center justify-center gap-2">
|
||||
<a href="https://blog.lsy22.com" class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200">
|
||||
© {currentYear} New Echoes. All rights reserved.
|
||||
</a>
|
||||
<span aria-hidden="true" class="hidden sm:inline">·</span>
|
||||
<a
|
||||
href="/sitemap-index.xml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200"
|
||||
aria-label="网站地图"
|
||||
>
|
||||
Sitemap
|
||||
</a>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import ReactMasonryCss from 'react-masonry-css';
|
||||
|
||||
// Git 平台类型枚举
|
||||
@ -59,6 +59,7 @@ interface GitProjectCollectionProps {
|
||||
token?: string;
|
||||
perPage?: number;
|
||||
url?: string;
|
||||
className?: string; // 添加自定义类名
|
||||
}
|
||||
|
||||
const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
||||
@ -68,7 +69,8 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
||||
title,
|
||||
token,
|
||||
perPage = DEFAULT_GIT_CONFIG.perPage,
|
||||
url
|
||||
url,
|
||||
className = ''
|
||||
}) => {
|
||||
const [projects, setProjects] = useState<GitProject[]>([]);
|
||||
const [pagination, setPagination] = useState<Pagination>({ current: 1, total: 1, hasNext: false, hasPrev: false });
|
||||
@ -76,8 +78,21 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPageChanging, setIsPageChanging] = useState(false);
|
||||
|
||||
const fetchData = async (page = 1) => {
|
||||
// 使用 ref 跟踪组件挂载状态
|
||||
const isMountedRef = useRef(true);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchData = useCallback(async (page = 1) => {
|
||||
// 取消可能存在的之前的请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// 创建新的 AbortController
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (!platform || !Object.values(GitPlatform).includes(platform)) {
|
||||
setError('无效的平台参数');
|
||||
@ -113,65 +128,98 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
},
|
||||
signal: abortControllerRef.current.signal
|
||||
});
|
||||
|
||||
// 如果组件已卸载,不继续更新状态
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(`请求失败: ${response.status} ${response.statusText}\n${JSON.stringify(errorData, null, 2)}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setProjects(data.projects);
|
||||
setPagination(data.pagination);
|
||||
|
||||
// 如果组件已卸载,不继续更新状态
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setProjects(data.projects || []);
|
||||
setPagination(data.pagination || { current: page, total: 1, hasNext: false, hasPrev: page > 1 });
|
||||
} catch (err) {
|
||||
// 如果是取消的请求,不显示错误
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果组件已卸载,不继续更新状态
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
console.error('请求错误:', err);
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// 保持之前的项目列表,避免清空显示
|
||||
if (projects.length === 0) {
|
||||
setProjects([]);
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
// 如果组件已卸载,不继续更新状态
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setLoading(false);
|
||||
setIsPageChanging(false);
|
||||
}
|
||||
}, [platform, username, organization, token, perPage, url, projects.length]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(1);
|
||||
}, [platform, username, organization, token, perPage, url]);
|
||||
// 设置组件已挂载标志
|
||||
isMountedRef.current = true;
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
fetchData(1);
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
const handlePageChange = useCallback((page: number) => {
|
||||
if (isPageChanging) return;
|
||||
|
||||
setIsPageChanging(true);
|
||||
|
||||
// 重置当前状态,显示加载中
|
||||
setProjects([]);
|
||||
setLoading(true);
|
||||
|
||||
// 手动更新分页状态
|
||||
// 更新分页状态
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: page
|
||||
}));
|
||||
|
||||
// 不清空当前项目列表,但显示加载状态
|
||||
setLoading(true);
|
||||
|
||||
fetchData(page);
|
||||
setTimeout(() => setIsPageChanging(false), 2000);
|
||||
};
|
||||
}, [fetchData, isPageChanging]);
|
||||
|
||||
const getPlatformIcon = (platform: GitPlatform) => {
|
||||
switch (platform) {
|
||||
case GitPlatform.GITHUB:
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
);
|
||||
case GitPlatform.GITEA:
|
||||
return (
|
||||
<svg className="w-5 h-5" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg className="w-5 h-5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8.948.291c-1.412.274-2.223 1.793-2.223 1.793S4.22 3.326 2.4 5.469c-1.82 2.142-1.415 5.481-1.415 5.481s1.094 3.61 5.061 3.61c3.967 0 5.681-1.853 5.681-1.853s1.225-1.087 1.225-3.718c0-2.632-1.946-3.598-1.946-3.598s.324-1.335-1.061-3.118C8.59.49 8.948.291 8.948.291zM8.13 2.577c.386 0 .699.313.699.699 0 .386-.313.699-.699.699-.386 0-.699-.313-.699-.699 0-.386.313-.699.699-.699zm-3.366.699c.386 0 .699.313.699.699 0 .386-.313.699-.699.699-.386 0-.699-.313-.699-.699 0-.386.313-.699.699-.699zm6.033 0c.386 0 .699.313.699.699 0 .386-.313.699-.699.699-.386 0-.699-.313-.699-.699 0-.386.313-.699.699-.699zm-4.764 2.1c.386 0 .699.313.699.699 0 .386-.313.699-.699.699-.386 0-.699-.313-.699-.699 0-.386.313-.699.699-.699zm3.366 0c.386 0 .699.313.699.699 0 .386-.313.699-.699.699-.386 0-.699-.313-.699-.699 0-.386.313-.699.699-.699zm-5.049 2.1c.386 0 .699.313.699.699 0 .386-.313.699-.699.699-.386 0-.699-.313-.699-.699 0-.386.313-.699.699-.699zm6.732 0c.386 0 .699.313.699.699 0 .386-.313.699-.699.699-.386 0-.699-.313-.699-.699 0-.386.313-.699.699-.699zm-3.366.699c.386 0 .699.313.699.699 0 .386-.313.699-.699.699-.386 0-.699-.313-.699-.699 0-.386.313-.699.699-.699zm-1.683 1.4c.386 0 .699.313.699.699 0 .386-.313.699-.699.699-.386 0-.699-.313-.699-.699 0-.386.313-.699.699-.699z"/>
|
||||
</svg>
|
||||
);
|
||||
case GitPlatform.GITEE:
|
||||
return (
|
||||
<svg className="w-5 h-5" viewBox="0 0 1024 1024" fill="currentColor">
|
||||
<svg className="w-5 h-5" viewBox="0 0 1024 1024" fill="currentColor" aria-hidden="true">
|
||||
<path d="M512 1024C229.222 1024 0 794.778 0 512S229.222 0 512 0s512 229.222 512 512-229.222 512-512 512z m259.149-568.883h-290.74a25.293 25.293 0 0 0-25.292 25.293l-0.026 63.206c0 13.952 11.315 25.293 25.267 25.293h177.024c13.978 0 25.293 11.315 25.293 25.267v12.646a75.853 75.853 0 0 1-75.853 75.853h-240.23a25.293 25.293 0 0 1-25.267-25.293V417.203a75.853 75.853 0 0 1 75.827-75.853h353.946a25.293 25.293 0 0 0 25.267-25.292l0.077-63.207a25.293 25.293 0 0 0-25.268-25.293H417.152a189.62 189.62 0 0 0-189.62 189.645V771.15c0 13.977 11.316 25.293 25.294 25.293h372.94a170.65 170.65 0 0 0 170.65-170.65V480.384a25.293 25.293 0 0 0-25.293-25.267z" />
|
||||
</svg>
|
||||
);
|
||||
@ -217,25 +265,66 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
||||
// 自定义标题或使用默认标题
|
||||
const displayTitle = title || `${getPlatformName(platform)} 项目`;
|
||||
|
||||
return (
|
||||
<div className="git-project-collection max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-2xl font-bold mb-6 text-primary-700">
|
||||
{displayTitle}
|
||||
{username && <span className="ml-2 text-secondary-500">(@{username})</span>}
|
||||
{organization && <span className="ml-2 text-secondary-500">(组织: {organization})</span>}
|
||||
</h2>
|
||||
// 渲染加载状态
|
||||
const renderLoading = () => (
|
||||
<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>
|
||||
);
|
||||
|
||||
{loading && projects.length === 0 ? (
|
||||
<div className="flex justify-center p-8">加载中...</div>
|
||||
) : error ? (
|
||||
<div className="text-red-500 p-4">错误: {error}</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="text-secondary-500 p-4">
|
||||
// 渲染错误状态
|
||||
const renderError = () => (
|
||||
<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(pagination.current)}
|
||||
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 transition-colors"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染无数据状态
|
||||
const renderEmpty = () => (
|
||||
<div className="text-secondary-500 dark:text-secondary-400 p-4 text-center">
|
||||
{platform === GitPlatform.GITEE ?
|
||||
"无法获取 Gitee 项目数据,可能需要配置访问令牌。" :
|
||||
"没有找到项目数据。"}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`git-project-collection max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 ${className}`}>
|
||||
<h2 className="text-2xl font-bold mb-6 text-primary-700 dark:text-primary-400">
|
||||
{displayTitle}
|
||||
{username && <span className="ml-2 text-secondary-500 dark:text-secondary-400">(@{username})</span>}
|
||||
{organization && <span className="ml-2 text-secondary-500 dark:text-secondary-400">(组织: {organization})</span>}
|
||||
</h2>
|
||||
|
||||
{/* 内容区域 */}
|
||||
{loading && projects.length === 0 ? (
|
||||
renderLoading()
|
||||
) : error ? (
|
||||
renderError()
|
||||
) : projects.length === 0 ? (
|
||||
renderEmpty()
|
||||
) : (
|
||||
<>
|
||||
{/* 仅显示加载中指示器,不隐藏项目 */}
|
||||
{loading && projects.length > 0 && (
|
||||
<div className="flex justify-center items-center py-2 mb-4">
|
||||
<div className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"></div>
|
||||
<p className="ml-2 text-xs text-gray-500 dark:text-gray-400">更新中...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReactMasonryCss
|
||||
breakpointCols={breakpointColumnsObj}
|
||||
className="flex -ml-4 w-auto"
|
||||
@ -245,7 +334,7 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
||||
<div key={`${project.platform}-${project.owner}-${project.name}-${index}`} className="mb-4 overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:shadow-xl hover:-translate-y-1 transition-all duration-300 shadow-lg">
|
||||
<a href={project.url} target="_blank" rel="noopener noreferrer" className="block p-5">
|
||||
<div className="flex items-start">
|
||||
<div className="w-10 h-10 flex-shrink-0 flex items-center justify-center rounded-lg bg-primary-100 text-primary-600 group-hover:bg-primary-200 transition-colors">
|
||||
<div className="w-10 h-10 flex-shrink-0 flex items-center justify-center rounded-lg bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 group-hover:bg-primary-200 dark:group-hover:bg-primary-800/50 transition-colors">
|
||||
{getPlatformIcon(project.platform as GitPlatform)}
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
@ -254,6 +343,7 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
||||
src={project.avatarUrl}
|
||||
alt={`${project.owner}'s avatar`}
|
||||
className="w-5 h-5 rounded-full mr-2"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.onerror = null;
|
||||
@ -282,21 +372,21 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-1.5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 mr-1.5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
<span className="text-gray-600 dark:text-gray-400">{project.stars}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-1.5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 mr-1.5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
<span className="text-gray-600 dark:text-gray-400">{project.forks}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center ml-auto">
|
||||
<svg className="w-4 h-4 mr-1.5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 mr-1.5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-gray-500 dark:text-gray-400">{new Date(project.updatedAt).toLocaleDateString('zh-CN')}</span>
|
||||
@ -308,6 +398,7 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</ReactMasonryCss>
|
||||
</>
|
||||
)}
|
||||
|
||||
{pagination.total > 1 && (
|
||||
@ -315,21 +406,43 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.current - 1)}
|
||||
disabled={!pagination.hasPrev || pagination.current <= 1 || isPageChanging}
|
||||
className={`px-4 py-2 rounded ${!pagination.hasPrev || pagination.current <= 1 || isPageChanging ? 'bg-secondary-200 text-secondary-500 cursor-not-allowed' : 'bg-primary-600 text-white hover:bg-primary-700'}`}
|
||||
className={`px-4 py-2 rounded transition-colors ${!pagination.hasPrev || pagination.current <= 1 || isPageChanging
|
||||
? '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'}`}
|
||||
aria-label="上一页"
|
||||
>
|
||||
{isPageChanging ? '加载中...' : '上一页'}
|
||||
{isPageChanging ? (
|
||||
<span className="flex items-center">
|
||||
<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" aria-hidden="true">
|
||||
<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>
|
||||
加载中
|
||||
</span>
|
||||
) : '上一页'}
|
||||
</button>
|
||||
|
||||
<span className="px-4 py-2 bg-secondary-100 rounded">
|
||||
<span className="px-4 py-2 bg-secondary-100 dark:bg-secondary-800 rounded">
|
||||
{pagination.current} / {pagination.total}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.current + 1)}
|
||||
disabled={!pagination.hasNext || pagination.current >= pagination.total || isPageChanging}
|
||||
className={`px-4 py-2 rounded ${!pagination.hasNext || pagination.current >= pagination.total || isPageChanging ? 'bg-secondary-200 text-secondary-500 cursor-not-allowed' : 'bg-primary-600 text-white hover:bg-primary-700'}`}
|
||||
className={`px-4 py-2 rounded transition-colors ${!pagination.hasNext || pagination.current >= pagination.total || isPageChanging
|
||||
? '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'}`}
|
||||
aria-label="下一页"
|
||||
>
|
||||
{isPageChanging ? '加载中...' : '下一页'}
|
||||
{isPageChanging ? (
|
||||
<span className="flex items-center">
|
||||
<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" aria-hidden="true">
|
||||
<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>
|
||||
加载中
|
||||
</span>
|
||||
) : '下一页'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
@ -34,7 +34,7 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
||||
placeholder="搜索文章..."
|
||||
/>
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-4 w-4 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-4 w-4 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
@ -53,7 +53,7 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
||||
<a
|
||||
href={link.href}
|
||||
class:list={[
|
||||
'inline-flex items-center px-1 pt-1 text-sm font-medium',
|
||||
'inline-flex items-center px-1 pt-1 text-sm font-medium transition-colors duration-200',
|
||||
normalizedPath === (link.href === '/' ? '' : link.href)
|
||||
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-600 dark:border-primary-400'
|
||||
: 'text-secondary-600 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400 hover:border-b-2 hover:border-primary-300 dark:hover:border-primary-700'
|
||||
@ -71,26 +71,28 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
||||
<button
|
||||
type="button"
|
||||
id="mobile-search-button"
|
||||
class="inline-flex items-center justify-center p-2 rounded-md text-secondary-400 dark:text-secondary-500 hover:text-secondary-500 dark:hover:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-dark-card focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500 mr-2"
|
||||
class="inline-flex items-center justify-center p-2 rounded-md text-secondary-400 dark:text-secondary-500 hover:text-secondary-500 dark:hover:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-dark-card focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500 mr-2 transition-colors"
|
||||
aria-expanded="false"
|
||||
aria-label="搜索"
|
||||
>
|
||||
<span class="sr-only">搜索</span>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center p-2 rounded-md text-secondary-400 dark:text-secondary-500 hover:text-secondary-500 dark:hover:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-dark-card focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
|
||||
class="inline-flex items-center justify-center p-2 rounded-md text-secondary-400 dark:text-secondary-500 hover:text-secondary-500 dark:hover:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-dark-card focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500 transition-colors"
|
||||
id="mobile-menu-button"
|
||||
aria-expanded="false"
|
||||
aria-label="打开菜单"
|
||||
>
|
||||
<span class="sr-only">打开菜单</span>
|
||||
<svg class="h-6 w-6 block" id="menu-open-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-6 w-6 block" id="menu-open-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<svg class="h-6 w-6 hidden" id="menu-close-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-6 w-6 hidden" id="menu-close-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@ -108,15 +110,16 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
||||
placeholder="搜索文章..."
|
||||
/>
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-5 w-5 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
id="mobile-search-close"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
aria-label="关闭搜索"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@ -226,6 +229,8 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 确保脚本适用于视图转换
|
||||
function initHeader() {
|
||||
const header = document.getElementById('header-bg');
|
||||
const scrollThreshold = 50;
|
||||
|
||||
@ -271,7 +276,6 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
||||
}
|
||||
|
||||
// 移动端主题切换容器点击处理
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const themeToggleContainer = document.getElementById('theme-toggle-container');
|
||||
|
||||
if (themeToggleContainer) {
|
||||
@ -286,10 +290,31 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 移动端搜索按钮
|
||||
const mobileSearchButton = document.getElementById('mobile-search-button');
|
||||
const mobileSearchPanel = document.getElementById('mobile-search-panel');
|
||||
const mobileSearch = document.getElementById('mobile-search');
|
||||
const mobileSearchClose = document.getElementById('mobile-search-close');
|
||||
|
||||
if (mobileSearchButton && mobileSearchPanel) {
|
||||
mobileSearchButton.addEventListener('click', () => {
|
||||
mobileSearchPanel.classList.remove('hidden');
|
||||
mobileSearchPanel.classList.add('show');
|
||||
if (mobileSearch) mobileSearch.focus();
|
||||
});
|
||||
|
||||
if (mobileSearchClose) {
|
||||
mobileSearchClose.addEventListener('click', () => {
|
||||
mobileSearchPanel.classList.add('hidden');
|
||||
mobileSearchPanel.classList.remove('show');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索功能逻辑
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
function initSearch() {
|
||||
// 搜索节流函数
|
||||
function debounce<T extends (...args: any[]) => void>(func: T, wait: number): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
@ -305,13 +330,10 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
||||
const desktopList = document.getElementById('desktop-search-list');
|
||||
const desktopMessage = document.getElementById('desktop-search-message');
|
||||
|
||||
const mobileSearchButton = document.getElementById('mobile-search-button');
|
||||
const mobileSearchPanel = document.getElementById('mobile-search-panel');
|
||||
const mobileSearch = document.getElementById('mobile-search');
|
||||
const mobileResults = document.getElementById('mobile-search-results');
|
||||
const mobileList = document.getElementById('mobile-search-list');
|
||||
const mobileMessage = document.getElementById('mobile-search-message');
|
||||
const mobileSearchClose = document.getElementById('mobile-search-close');
|
||||
|
||||
// 文章对象的接口定义
|
||||
interface Article {
|
||||
@ -325,15 +347,19 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
||||
}
|
||||
|
||||
let articles: Article[] = [];
|
||||
let isArticlesLoaded = false;
|
||||
|
||||
// 获取文章数据
|
||||
async function fetchArticles() {
|
||||
if (isArticlesLoaded && articles.length > 0) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/search');
|
||||
if (!response.ok) {
|
||||
throw new Error('获取文章数据失败');
|
||||
}
|
||||
articles = await response.json();
|
||||
isArticlesLoaded = true;
|
||||
} catch (error) {
|
||||
console.error('获取文章失败:', error);
|
||||
}
|
||||
@ -481,7 +507,7 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
||||
if (desktopSearch && desktopResults) {
|
||||
desktopSearch.addEventListener('focus', () => {
|
||||
desktopResults.classList.remove('hidden');
|
||||
if (!articles.length) fetchArticles();
|
||||
if (!isArticlesLoaded) fetchArticles();
|
||||
});
|
||||
|
||||
desktopSearch.addEventListener('input', (e: Event) => {
|
||||
@ -508,39 +534,49 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
||||
}
|
||||
|
||||
// 移动端搜索逻辑
|
||||
if (mobileSearchButton && mobileSearchPanel) {
|
||||
mobileSearchButton.addEventListener('click', () => {
|
||||
mobileSearchPanel.classList.remove('hidden');
|
||||
mobileSearchPanel.classList.add('show');
|
||||
if (mobileSearch) mobileSearch.focus();
|
||||
if (!articles.length) fetchArticles();
|
||||
});
|
||||
|
||||
if (mobileSearchClose) {
|
||||
mobileSearchClose.addEventListener('click', () => {
|
||||
mobileSearchPanel.classList.add('hidden');
|
||||
mobileSearchPanel.classList.remove('show');
|
||||
});
|
||||
}
|
||||
|
||||
if (mobileSearch && mobileResults) {
|
||||
mobileSearch.addEventListener('input', (e: Event) => {
|
||||
mobileResults.classList.remove('hidden');
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target && target.value !== undefined) {
|
||||
debouncedMobileSearch(target.value);
|
||||
if (!isArticlesLoaded) fetchArticles();
|
||||
}
|
||||
});
|
||||
|
||||
// ESC键关闭搜索面板
|
||||
mobileSearch.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
const mobileSearchPanel = document.getElementById('mobile-search-panel');
|
||||
if (mobileSearchPanel) {
|
||||
mobileSearchPanel.classList.add('hidden');
|
||||
mobileSearchPanel.classList.remove('show');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化函数
|
||||
function setupHeader() {
|
||||
initHeader();
|
||||
initSearch();
|
||||
}
|
||||
|
||||
// 在文档加载时初始化一次
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupHeader);
|
||||
} else {
|
||||
setupHeader();
|
||||
}
|
||||
|
||||
// 支持 Astro 视图转换
|
||||
document.addEventListener('astro:swup:page:view', setupHeader);
|
||||
|
||||
// 清理
|
||||
document.addEventListener('astro:before-swap', () => {
|
||||
// 移除可能的全局事件监听器
|
||||
window.removeEventListener('scroll', () => {});
|
||||
});
|
||||
</script>
|
||||
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
import "@/styles/global.css";
|
||||
import Header from "@/components/header.astro";
|
||||
import Header from "@/components/Header.astro";
|
||||
import Footer from "@/components/Footer.astro";
|
||||
import { ICP, PSB_ICP, PSB_ICP_URL, SITE_NAME, SITE_DESCRIPTION } from "@/consts";
|
||||
|
||||
@ -54,7 +54,6 @@ const { title = SITE_NAME, description = SITE_DESCRIPTION, date, author, tags, i
|
||||
{tags && tags.map(tag => (
|
||||
<meta property="article:tag" content={tag} />
|
||||
))}
|
||||
|
||||
<script is:inline>
|
||||
// 立即执行主题初始化
|
||||
const theme = (() => {
|
||||
|
@ -6,75 +6,89 @@ interface Props {
|
||||
}
|
||||
|
||||
const { type, title, doubanId } = Astro.props;
|
||||
// Generate unique IDs for this specific instance of the component
|
||||
const uniquePrefix = `media-${type}`;
|
||||
const mediaListId = `${uniquePrefix}-list`;
|
||||
const loadingId = `${uniquePrefix}-loading`;
|
||||
const endMessageId = `${uniquePrefix}-end-message`;
|
||||
---
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-3xl font-bold mb-6">{title}</h1>
|
||||
|
||||
<div id="media-list" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<div id={mediaListId} class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<!-- 内容将通过JS动态加载 -->
|
||||
</div>
|
||||
|
||||
<div id="loading" class="text-center py-8">
|
||||
<div id={loadingId} class="text-center py-8">
|
||||
<div class="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 class="mt-2 text-gray-600">加载更多...</p>
|
||||
</div>
|
||||
|
||||
<div id="end-message" class="text-center py-8 hidden">
|
||||
<div id={endMessageId} class="text-center py-8 hidden">
|
||||
<p class="text-gray-600">已加载全部内容</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline define:vars={{ type, doubanId }}>
|
||||
<script define:vars={{ type, doubanId, mediaListId, loadingId, endMessageId }}>
|
||||
// Create a class with scoped methods instead of using global functions and variables
|
||||
class MediaLoader {
|
||||
constructor(type, doubanId, mediaListId, loadingId, endMessageId) {
|
||||
this.type = type;
|
||||
this.doubanId = doubanId;
|
||||
this.mediaListId = mediaListId;
|
||||
this.loadingId = loadingId;
|
||||
this.endMessageId = endMessageId;
|
||||
this.currentPage = 1;
|
||||
this.isLoading = false;
|
||||
this.hasMoreContent = true;
|
||||
this.itemsPerPage = 15; // 豆瓣每页显示的数量
|
||||
this.scrollHandler = this.handleScroll.bind(this);
|
||||
}
|
||||
|
||||
let currentPage = 1;
|
||||
let isLoading = false;
|
||||
let hasMoreContent = true;
|
||||
const itemsPerPage = 15; // 豆瓣每页显示的数量
|
||||
|
||||
async function fetchMedia(page = 1, append = false) {
|
||||
if (isLoading || (!append && !hasMoreContent)) {
|
||||
async fetchMedia(page = 1, append = false) {
|
||||
if (this.isLoading || (!append && !this.hasMoreContent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
showLoading(true);
|
||||
this.isLoading = true;
|
||||
this.showLoading(true);
|
||||
|
||||
const start = (page - 1) * itemsPerPage;
|
||||
const start = (page - 1) * this.itemsPerPage;
|
||||
try {
|
||||
const response = await fetch(`/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`);
|
||||
const response = await fetch(`/api/douban?type=${this.type}&start=${start}&doubanId=${this.doubanId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取${type === 'movie' ? '电影' : '图书'}数据失败`);
|
||||
throw new Error(`获取${this.type === 'movie' ? '电影' : '图书'}数据失败`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
renderMedia(data.items, append);
|
||||
this.renderMedia(data.items, append);
|
||||
|
||||
// 更新分页状态
|
||||
currentPage = data.pagination.current;
|
||||
hasMoreContent = data.pagination.hasNext;
|
||||
this.currentPage = data.pagination.current;
|
||||
this.hasMoreContent = data.pagination.hasNext;
|
||||
|
||||
if (!hasMoreContent) {
|
||||
showEndMessage(true);
|
||||
if (!this.hasMoreContent) {
|
||||
this.showEndMessage(true);
|
||||
}
|
||||
} catch (error) {
|
||||
const mediaList = document.getElementById('media-list');
|
||||
const mediaList = document.getElementById(this.mediaListId);
|
||||
if (mediaList && !append) {
|
||||
mediaList.innerHTML = '<div class="col-span-full text-center text-red-500">获取数据失败,请稍后再试</div>';
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
showLoading(false);
|
||||
this.isLoading = false;
|
||||
this.showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMedia(items, append = false) {
|
||||
const mediaList = document.getElementById('media-list');
|
||||
renderMedia(items, append = false) {
|
||||
const mediaList = document.getElementById(this.mediaListId);
|
||||
if (!mediaList) return;
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
if (!append) {
|
||||
mediaList.innerHTML = `<div class="col-span-full text-center">暂无${type === 'movie' ? '电影' : '图书'}数据</div>`;
|
||||
mediaList.innerHTML = `<div class="col-span-full text-center">暂无${this.type === 'movie' ? '电影' : '图书'}数据</div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@ -99,8 +113,8 @@ const { type, title, doubanId } = Astro.props;
|
||||
}
|
||||
}
|
||||
|
||||
function showLoading(show) {
|
||||
const loading = document.getElementById('loading');
|
||||
showLoading(show) {
|
||||
const loading = document.getElementById(this.loadingId);
|
||||
if (loading) {
|
||||
if (show) {
|
||||
loading.classList.remove('hidden');
|
||||
@ -110,23 +124,23 @@ const { type, title, doubanId } = Astro.props;
|
||||
}
|
||||
}
|
||||
|
||||
function showEndMessage(show) {
|
||||
const endMessage = document.getElementById('end-message');
|
||||
showEndMessage(show) {
|
||||
const endMessage = document.getElementById(this.endMessageId);
|
||||
if (endMessage) {
|
||||
endMessage.classList.toggle('hidden', !show);
|
||||
}
|
||||
}
|
||||
|
||||
function setupInfiniteScroll() {
|
||||
// 直接使用滚动事件
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
setupInfiniteScroll() {
|
||||
// 添加滚动事件
|
||||
window.addEventListener('scroll', this.scrollHandler);
|
||||
|
||||
// 初始检查一次,以防内容不足一屏
|
||||
setTimeout(handleScroll, 500);
|
||||
setTimeout(() => this.handleScroll(), 500);
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (isLoading || !hasMoreContent) {
|
||||
handleScroll() {
|
||||
if (this.isLoading || !this.hasMoreContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -136,16 +150,53 @@ const { type, title, doubanId } = Astro.props;
|
||||
|
||||
// 当滚动到距离底部300px时加载更多
|
||||
if (scrollY + windowHeight >= documentHeight - 300) {
|
||||
fetchMedia(currentPage + 1, true);
|
||||
this.fetchMedia(this.currentPage + 1, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchMedia(1, false).then(() => {
|
||||
setupInfiniteScroll();
|
||||
cleanup() {
|
||||
// 移除滚动事件监听器
|
||||
window.removeEventListener('scroll', this.scrollHandler);
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.fetchMedia(1, false).then(() => {
|
||||
this.setupInfiniteScroll();
|
||||
}).catch(err => {
|
||||
// 错误已在fetchMedia中处理
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 存储每个页面创建的媒体加载器,用于清理
|
||||
if (!window.mediaLoaders) {
|
||||
window.mediaLoaders = {};
|
||||
}
|
||||
|
||||
// 创建并初始化媒体加载器
|
||||
function initMediaLoader() {
|
||||
// 清理可能存在的旧实例
|
||||
if (window.mediaLoaders[mediaListId]) {
|
||||
window.mediaLoaders[mediaListId].cleanup();
|
||||
}
|
||||
|
||||
const loader = new MediaLoader(type, doubanId, mediaListId, loadingId, endMessageId);
|
||||
window.mediaLoaders[mediaListId] = loader;
|
||||
loader.initialize();
|
||||
}
|
||||
|
||||
// 页面首次加载
|
||||
document.addEventListener('astro:swup:page:view', initMediaLoader);
|
||||
|
||||
// 页面卸载前清理
|
||||
document.addEventListener('astro:before-swap', () => {
|
||||
if (window.mediaLoaders[mediaListId]) {
|
||||
window.mediaLoaders[mediaListId].cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// 如果已经加载了 DOM,立即初始化
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
initMediaLoader();
|
||||
}
|
||||
</script>
|
@ -1,17 +1,25 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
export function ThemeToggle({ height = 16, width = 16, fill = "currentColor" }) {
|
||||
export function ThemeToggle({ height = 16, width = 16, fill = "currentColor", className = "" }) {
|
||||
// 使用null作为初始状态,表示尚未确定主题
|
||||
const [theme, setTheme] = useState(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [transitioning, setTransitioning] = useState(false);
|
||||
const transitionTimeoutRef = useRef(null);
|
||||
|
||||
// 获取系统主题
|
||||
const getSystemTheme = useCallback(() => {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}, []);
|
||||
|
||||
// 在客户端挂载后再确定主题
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
// 从 localStorage 或 document.documentElement.dataset.theme 获取主题
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const rootTheme = document.documentElement.dataset.theme;
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
const systemTheme = getSystemTheme();
|
||||
|
||||
// 优先使用已保存的主题,其次是文档根元素的主题,最后是系统主题
|
||||
const initialTheme = savedTheme || rootTheme || systemTheme;
|
||||
@ -19,34 +27,64 @@ export function ThemeToggle({ height = 16, width = 16, fill = "currentColor" })
|
||||
|
||||
// 确保文档根元素的主题与状态一致
|
||||
document.documentElement.dataset.theme = initialTheme;
|
||||
}, []);
|
||||
|
||||
// 监听系统主题变化
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleMediaChange = (e) => {
|
||||
// 只有当主题设置为跟随系统时才更新主题
|
||||
if (!localStorage.getItem('theme')) {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
document.documentElement.dataset.theme = newTheme;
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleMediaChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleMediaChange);
|
||||
|
||||
// 清理可能的超时
|
||||
if (transitionTimeoutRef.current) {
|
||||
clearTimeout(transitionTimeoutRef.current);
|
||||
transitionTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [getSystemTheme]);
|
||||
|
||||
// 当主题改变时更新 DOM 和 localStorage
|
||||
useEffect(() => {
|
||||
if (!mounted || theme === null) return;
|
||||
|
||||
// 当主题改变时更新 DOM 和 localStorage
|
||||
document.documentElement.dataset.theme = theme;
|
||||
|
||||
if (theme === getSystemTheme()) {
|
||||
// 检查是否是跟随系统的主题
|
||||
const isSystemTheme = theme === getSystemTheme();
|
||||
|
||||
if (isSystemTheme) {
|
||||
localStorage.removeItem('theme');
|
||||
} else {
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
}, [theme, mounted]);
|
||||
}, [theme, mounted, getSystemTheme]);
|
||||
|
||||
function getSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
const toggleTheme = useCallback(() => {
|
||||
if (transitioning) return; // 避免快速连续点击
|
||||
|
||||
function toggleTheme() {
|
||||
setTransitioning(true);
|
||||
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
// 添加300ms的防抖,避免快速切换
|
||||
transitionTimeoutRef.current = setTimeout(() => {
|
||||
setTransitioning(false);
|
||||
}, 300);
|
||||
}, [transitioning]);
|
||||
|
||||
// 在客户端挂载前,返回一个空的占位符
|
||||
if (!mounted || theme === null) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center justify-center h-8 w-8 cursor-pointer rounded-md transition-all duration-200 hover:bg-gray-100 dark:hover:bg-gray-700/50 text-secondary-600 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400 mt-1"
|
||||
className={`inline-flex items-center justify-center h-8 w-8 cursor-pointer rounded-md transition-all duration-200 hover:bg-gray-100 dark:hover:bg-gray-700/50 text-secondary-600 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400 ${className}`}
|
||||
>
|
||||
<span className="sr-only">加载主题切换按钮...</span>
|
||||
</div>
|
||||
@ -55,10 +93,17 @@ export function ThemeToggle({ height = 16, width = 16, fill = "currentColor" })
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center justify-center h-8 w-8 cursor-pointer rounded-md transition-all duration-200 hover:bg-gray-100 dark:hover:bg-gray-700/50 text-secondary-600 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400 mt-1"
|
||||
className={`inline-flex items-center justify-center h-8 w-8 cursor-pointer rounded-md transition-all duration-200 hover:bg-gray-100 dark:hover:bg-gray-700/50 text-secondary-600 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400 ${transitioning ? 'pointer-events-none opacity-80' : ''} ${className}`}
|
||||
onClick={toggleTheme}
|
||||
role="button"
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleTheme();
|
||||
}
|
||||
}}
|
||||
aria-label={`切换到${theme === 'dark' ? '浅色' : '深色'}模式`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg
|
||||
@ -66,6 +111,7 @@ export function ThemeToggle({ height = 16, width = 16, fill = "currentColor" })
|
||||
fill={fill}
|
||||
viewBox="0 0 16 16"
|
||||
className="transition-transform duration-200 hover:scale-110"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
|
||||
</svg>
|
||||
@ -75,6 +121,7 @@ export function ThemeToggle({ height = 16, width = 16, fill = "currentColor" })
|
||||
fill={fill}
|
||||
viewBox="0 0 16 16"
|
||||
className="transition-transform duration-200 hover:scale-110"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"/>
|
||||
</svg>
|
||||
|
@ -4,6 +4,11 @@ import { load } from 'cheerio';
|
||||
// 添加服务器渲染标记
|
||||
export const prerender = false;
|
||||
|
||||
// 请求配置常量
|
||||
const MAX_RETRIES = 0; // 最大重试次数
|
||||
const RETRY_DELAY = 1500; // 重试延迟(毫秒)
|
||||
const REQUEST_TIMEOUT = 10000; // 请求超时时间(毫秒)
|
||||
|
||||
// 生成随机的bid Cookie值
|
||||
function generateBid() {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
@ -14,6 +19,31 @@ function generateBid() {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 添加延迟函数
|
||||
function delay(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// 带超时的 fetch 函数
|
||||
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number) {
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const type = url.searchParams.get('type') || 'movie';
|
||||
@ -23,10 +53,25 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
if (!doubanId) {
|
||||
return new Response(JSON.stringify({ error: '缺少豆瓣ID' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, max-age=0'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 添加缓存键的构建,用于区分不同的请求
|
||||
const cacheKey = `douban_${type}_${doubanId}_${start}`;
|
||||
|
||||
// 尝试从缓存获取数据
|
||||
try {
|
||||
// 如果有缓存系统,可以在这里检查和返回缓存数据
|
||||
|
||||
// 重试逻辑
|
||||
let retries = 0;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
while (retries <= MAX_RETRIES) {
|
||||
try {
|
||||
let doubanUrl = '';
|
||||
if (type === 'book') {
|
||||
@ -38,29 +83,42 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
// 生成随机bid
|
||||
const bid = generateBid();
|
||||
|
||||
const response = await fetch(doubanUrl, {
|
||||
// 随机化一些请求参数,减少被检测的风险
|
||||
const userAgents = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0'
|
||||
];
|
||||
|
||||
const randomUserAgent = userAgents[Math.floor(Math.random() * userAgents.length)];
|
||||
|
||||
// 使用带超时的fetch发送请求
|
||||
const response = await fetchWithTimeout(doubanUrl, {
|
||||
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',
|
||||
'User-Agent': randomUserAgent,
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Cache-Control': 'max-age=0',
|
||||
'Cookie': `bid=${bid}`
|
||||
}
|
||||
});
|
||||
}, REQUEST_TIMEOUT);
|
||||
|
||||
if (!response.ok) {
|
||||
return new Response(JSON.stringify({ error: '获取豆瓣数据失败' }), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
throw new Error(`豆瓣请求失败,状态码: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// 检查是否包含验证码页面的特征
|
||||
if (html.includes('验证码') || html.includes('captcha') || html.includes('too many requests')) {
|
||||
throw new Error('请求被豆瓣限制,需要验证码');
|
||||
}
|
||||
|
||||
const $ = load(html);
|
||||
|
||||
// 添加类型定义
|
||||
@ -86,9 +144,27 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
itemCount = $(itemSelector).length;
|
||||
}
|
||||
|
||||
if (itemCount === 0) {
|
||||
// 如果两个选择器都没有找到内容,可能是页面结构变化或被封锁
|
||||
console.error('未找到内容,页面结构可能已变化');
|
||||
|
||||
// 记录HTML以便调试
|
||||
console.debug('HTML片段:', html.substring(0, 500) + '...');
|
||||
|
||||
if (retries < MAX_RETRIES) {
|
||||
retries++;
|
||||
// 增加重试延迟,避免频繁请求
|
||||
await delay(RETRY_DELAY * retries);
|
||||
continue;
|
||||
} else {
|
||||
throw new Error('未找到电影/图书内容,可能是页面结构已变化');
|
||||
}
|
||||
}
|
||||
|
||||
$(itemSelector).each((_, element) => {
|
||||
const $element = $(element);
|
||||
|
||||
try {
|
||||
// 根据选择器调整查找逻辑
|
||||
let imageUrl = '';
|
||||
let title = '';
|
||||
@ -132,15 +208,20 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
date = $element.find('.info .date').text().trim();
|
||||
}
|
||||
|
||||
// 确保所有字段至少有空字符串
|
||||
items.push({
|
||||
imageUrl,
|
||||
title,
|
||||
subtitle,
|
||||
link,
|
||||
intro,
|
||||
rating,
|
||||
date
|
||||
imageUrl: imageUrl || '',
|
||||
title: title || '',
|
||||
subtitle: subtitle || '',
|
||||
link: link || '',
|
||||
intro: intro || '',
|
||||
rating: rating || 0,
|
||||
date: date || ''
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('解析项目时出错:', error);
|
||||
// 继续处理下一个项目,而不是终止整个循环
|
||||
}
|
||||
});
|
||||
|
||||
// 改进分页信息获取逻辑
|
||||
@ -169,16 +250,51 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
hasPrev: $('.paginator .prev a').length > 0
|
||||
};
|
||||
|
||||
// 如果有缓存系统,可以在这里保存数据到缓存
|
||||
|
||||
return new Response(JSON.stringify({ items, pagination }), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'public, s-maxage=300', // 5分钟服务器缓存
|
||||
'CDN-Cache-Control': 'public, max-age=300' // CDN缓存
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: '获取豆瓣数据失败' }), {
|
||||
console.error(`尝试第 ${retries + 1}/${MAX_RETRIES + 1} 次失败:`, error);
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (retries < MAX_RETRIES) {
|
||||
retries++;
|
||||
// 增加重试延迟,避免频繁请求
|
||||
await delay(RETRY_DELAY * retries);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有尝试都失败了
|
||||
console.error('所有尝试都失败了:', lastError);
|
||||
return new Response(JSON.stringify({
|
||||
error: '获取豆瓣数据失败',
|
||||
message: lastError?.message || '未知错误'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, max-age=0'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('处理请求时出错:', error);
|
||||
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'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user