Merge pull request #3 from EveSunMaple/master

🎁 Enhancement
This commit is contained in:
zxcvbnm 2025-04-19 12:03:19 +08:00 committed by GitHub
commit 57d406c277
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1119 additions and 574 deletions

View File

@ -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/"),

View File

@ -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": {

View File

@ -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 now = new Date().getTime();
const target = new Date(targetDate).getTime();
const difference = target - now;
const calculateTimeLeft = () => {
try {
const now = new Date().getTime();
const target = new Date(targetDate).getTime();
// 检查目标日期是否有效
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
};
}
if (difference > 0) {
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={`text-center ${className}`}>
<div className="text-xl text-gray-500 dark:text-gray-400"></div>
</div>
);
}
return (
<div className="flex items-center justify-center">
<div className={`flex items-center justify-center ${className}`}>
<TimeBox value={timeLeft.days} label="天" />
<TimeBox value={timeLeft.hours} label="时" />
<TimeBox value={timeLeft.minutes} label="分" />

View File

@ -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());
const url = `/api/douban?${params.toString()}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error('获取数据失败');
}
const data = await response.json();
setItems(data.items);
setPagination(data.pagination);
} catch (err) {
setError(err instanceof Error ? err.message : '未知错误');
} finally {
setLoading(false);
if (doubanId) {
params.append('doubanId', doubanId);
}
};
const url = `/api/douban?${params.toString()}`;
try {
const response = await fetch(url, {
signal: abortControllerRef.current.signal
});
// 如果组件已卸载,不继续处理
if (!isMountedRef.current) return;
if (!response.ok) {
throw new Error(`获取数据失败:状态码 ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
setItems(data.items || []);
setPagination(data.pagination || { current: 1, total: 1, hasNext: false, hasPrev: false });
} catch (err) {
// 如果是取消请求的错误,不设置错误状态
if (err instanceof Error && err.name === 'AbortError') {
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>
)}

View File

@ -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>

View File

@ -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 : '未知错误');
// 保持之前的项目列表,避免清空显示
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,97 +265,140 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
// 自定义标题或使用默认标题
const displayTitle = title || `${getPlatformName(platform)} 项目`;
// 渲染加载状态
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>
);
// 渲染错误状态
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">
<h2 className="text-2xl font-bold mb-6 text-primary-700">
<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">(@{username})</span>}
{organization && <span className="ml-2 text-secondary-500">(: {organization})</span>}
{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 ? (
<div className="flex justify-center p-8">...</div>
renderLoading()
) : error ? (
<div className="text-red-500 p-4">: {error}</div>
renderError()
) : projects.length === 0 ? (
<div className="text-secondary-500 p-4">
{platform === GitPlatform.GITEE ?
"无法获取 Gitee 项目数据,可能需要配置访问令牌。" :
"没有找到项目数据。"}
</div>
renderEmpty()
) : (
<ReactMasonryCss
breakpointCols={breakpointColumnsObj}
className="flex -ml-4 w-auto"
columnClassName="pl-4 bg-clip-padding"
>
{projects.map((project, index) => (
<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">
{getPlatformIcon(project.platform as GitPlatform)}
</div>
<div className="ml-3 flex-1">
<div className="flex items-center">
<img
src={project.avatarUrl}
alt={`${project.owner}'s avatar`}
className="w-5 h-5 rounded-full mr-2"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null;
target.src = 'https://via.placeholder.com/40';
}}
/>
<span className="text-sm text-gray-600 dark:text-gray-400 truncate">{project.owner}</span>
</div>
<h3 className="font-bold text-base text-gray-800 dark:text-gray-100 group-hover:text-primary-700 dark:group-hover:text-primary-300 transition-colors line-clamp-1 mt-2">{project.name}</h3>
<div className="h-12 mb-3">
{project.description ? (
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">{project.description}</p>
) : (
<p className="text-sm text-gray-400 dark:text-gray-500 italic"></p>
)}
</div>
<div className="flex flex-wrap items-center text-xs gap-4">
{project.language && (
<div className="flex items-center">
<span className={`w-3 h-3 rounded-full mr-1.5 ${getLanguageColor(project.language)}`}></span>
<span className="text-gray-600 dark:text-gray-400">{project.language}</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">
<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">
<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">
<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>
</div>
</div>
</div>
</div>
</a>
<>
{/* 仅显示加载中指示器,不隐藏项目 */}
{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>
)}
<ReactMasonryCss
breakpointCols={breakpointColumnsObj}
className="flex -ml-4 w-auto"
columnClassName="pl-4 bg-clip-padding"
>
{projects.map((project, index) => (
<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 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">
<div className="flex items-center">
<img
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;
target.src = 'https://via.placeholder.com/40';
}}
/>
<span className="text-sm text-gray-600 dark:text-gray-400 truncate">{project.owner}</span>
</div>
<h3 className="font-bold text-base text-gray-800 dark:text-gray-100 group-hover:text-primary-700 dark:group-hover:text-primary-300 transition-colors line-clamp-1 mt-2">{project.name}</h3>
<div className="h-12 mb-3">
{project.description ? (
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">{project.description}</p>
) : (
<p className="text-sm text-gray-400 dark:text-gray-500 italic"></p>
)}
</div>
<div className="flex flex-wrap items-center text-xs gap-4">
{project.language && (
<div className="flex items-center">
<span className={`w-3 h-3 rounded-full mr-1.5 ${getLanguageColor(project.language)}`}></span>
<span className="text-gray-600 dark:text-gray-400">{project.language}</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" 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" 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" 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>
</div>
</div>
</div>
</div>
</a>
</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>
)}

View File

@ -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,52 +229,53 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
</style>
<script>
const header = document.getElementById('header-bg');
const scrollThreshold = 50;
// 确保脚本适用于视图转换
function initHeader() {
const header = document.getElementById('header-bg');
const scrollThreshold = 50;
function updateHeaderBackground() {
if (window.scrollY > scrollThreshold) {
header?.classList.add('scrolled');
} else {
header?.classList.remove('scrolled');
}
}
// 初始检查
updateHeaderBackground();
// 添加滚动事件监听
window.addEventListener('scroll', updateHeaderBackground);
// 移动端菜单逻辑
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
const menuOpenIcon = document.getElementById('menu-open-icon');
const menuCloseIcon = document.getElementById('menu-close-icon');
if (mobileMenuButton && mobileMenu && menuOpenIcon && menuCloseIcon) {
mobileMenuButton.addEventListener('click', () => {
const expanded = mobileMenuButton.getAttribute('aria-expanded') === 'true';
// 切换菜单状态
mobileMenuButton.setAttribute('aria-expanded', (!expanded).toString());
if (expanded) {
// 直接隐藏菜单,不使用过渡效果
mobileMenu.classList.add('hidden');
function updateHeaderBackground() {
if (window.scrollY > scrollThreshold) {
header?.classList.add('scrolled');
} else {
// 直接显示菜单,不使用过渡效果
mobileMenu.classList.remove('hidden');
header?.classList.remove('scrolled');
}
}
// 切换图标
menuOpenIcon.classList.toggle('hidden');
menuCloseIcon.classList.toggle('hidden');
});
}
// 初始检查
updateHeaderBackground();
// 移动端主题切换容器点击处理
document.addEventListener('DOMContentLoaded', () => {
// 添加滚动事件监听
window.addEventListener('scroll', updateHeaderBackground);
// 移动端菜单逻辑
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
const menuOpenIcon = document.getElementById('menu-open-icon');
const menuCloseIcon = document.getElementById('menu-close-icon');
if (mobileMenuButton && mobileMenu && menuOpenIcon && menuCloseIcon) {
mobileMenuButton.addEventListener('click', () => {
const expanded = mobileMenuButton.getAttribute('aria-expanded') === 'true';
// 切换菜单状态
mobileMenuButton.setAttribute('aria-expanded', (!expanded).toString());
if (expanded) {
// 直接隐藏菜单,不使用过渡效果
mobileMenu.classList.add('hidden');
} else {
// 直接显示菜单,不使用过渡效果
mobileMenu.classList.remove('hidden');
}
// 切换图标
menuOpenIcon.classList.toggle('hidden');
menuCloseIcon.classList.toggle('hidden');
});
}
// 移动端主题切换容器点击处理
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 (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();
}
});
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);
}
});
// ESC键关闭搜索面板
mobileSearch.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') {
// 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>

View File

@ -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 = (() => {

View File

@ -6,146 +6,197 @@ 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 }}>
let currentPage = 1;
let isLoading = false;
let hasMoreContent = true;
const itemsPerPage = 15; // 豆瓣每页显示的数量
async function fetchMedia(page = 1, append = false) {
if (isLoading || (!append && !hasMoreContent)) {
return;
<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);
}
isLoading = true;
showLoading(true);
const start = (page - 1) * itemsPerPage;
try {
const response = await fetch(`/api/douban?type=${type}&start=${start}&doubanId=${doubanId}`);
if (!response.ok) {
throw new Error(`获取${type === 'movie' ? '电影' : '图书'}数据失败`);
async fetchMedia(page = 1, append = false) {
if (this.isLoading || (!append && !this.hasMoreContent)) {
return;
}
const data = await response.json();
renderMedia(data.items, append);
this.isLoading = true;
this.showLoading(true);
// 更新分页状态
currentPage = data.pagination.current;
hasMoreContent = data.pagination.hasNext;
const start = (page - 1) * this.itemsPerPage;
try {
const response = await fetch(`/api/douban?type=${this.type}&start=${start}&doubanId=${this.doubanId}`);
if (!response.ok) {
throw new Error(`获取${this.type === 'movie' ? '电影' : '图书'}数据失败`);
}
if (!hasMoreContent) {
showEndMessage(true);
const data = await response.json();
this.renderMedia(data.items, append);
// 更新分页状态
this.currentPage = data.pagination.current;
this.hasMoreContent = data.pagination.hasNext;
if (!this.hasMoreContent) {
this.showEndMessage(true);
}
} catch (error) {
const mediaList = document.getElementById(this.mediaListId);
if (mediaList && !append) {
mediaList.innerHTML = '<div class="col-span-full text-center text-red-500">获取数据失败,请稍后再试</div>';
}
} finally {
this.isLoading = false;
this.showLoading(false);
}
} catch (error) {
const mediaList = document.getElementById('media-list');
if (mediaList && !append) {
mediaList.innerHTML = '<div class="col-span-full text-center text-red-500">获取数据失败,请稍后再试</div>';
}
} finally {
isLoading = false;
showLoading(false);
}
}
function renderMedia(items, append = false) {
const mediaList = document.getElementById('media-list');
if (!mediaList) return;
if (!items || items.length === 0) {
if (!append) {
mediaList.innerHTML = `<div class="col-span-full text-center">暂无${type === 'movie' ? '电影' : '图书'}数据</div>`;
}
return;
}
const mediaHTML = items.map(item => `
<div class="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-shadow duration-300">
<div class="relative pb-[150%] overflow-hidden">
<img src="${item.imageUrl}" alt="${item.title}" class="absolute top-0 left-0 w-full h-full object-cover transition-transform duration-300 hover:scale-105">
<div class="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent">
<h3 class="font-bold text-white text-sm line-clamp-2">
<a href="${item.link}" target="_blank" class="hover:text-blue-300 transition-colors">${item.title}</a>
</h3>
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">暂无${this.type === 'movie' ? '电影' : '图书'}数据</div>`;
}
return;
}
const mediaHTML = items.map(item => `
<div class="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-shadow duration-300">
<div class="relative pb-[150%] overflow-hidden">
<img src="${item.imageUrl}" alt="${item.title}" class="absolute top-0 left-0 w-full h-full object-cover transition-transform duration-300 hover:scale-105">
<div class="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent">
<h3 class="font-bold text-white text-sm line-clamp-2">
<a href="${item.link}" target="_blank" class="hover:text-blue-300 transition-colors">${item.title}</a>
</h3>
</div>
</div>
</div>
</div>
`).join('');
`).join('');
if (append) {
mediaList.innerHTML += mediaHTML;
} else {
mediaList.innerHTML = mediaHTML;
}
}
function showLoading(show) {
const loading = document.getElementById('loading');
if (loading) {
if (show) {
loading.classList.remove('hidden');
if (append) {
mediaList.innerHTML += mediaHTML;
} else {
loading.classList.add('hidden');
mediaList.innerHTML = mediaHTML;
}
}
}
function showEndMessage(show) {
const endMessage = document.getElementById('end-message');
if (endMessage) {
endMessage.classList.toggle('hidden', !show);
showLoading(show) {
const loading = document.getElementById(this.loadingId);
if (loading) {
if (show) {
loading.classList.remove('hidden');
} else {
loading.classList.add('hidden');
}
}
}
showEndMessage(show) {
const endMessage = document.getElementById(this.endMessageId);
if (endMessage) {
endMessage.classList.toggle('hidden', !show);
}
}
setupInfiniteScroll() {
// 添加滚动事件
window.addEventListener('scroll', this.scrollHandler);
// 初始检查一次,以防内容不足一屏
setTimeout(() => this.handleScroll(), 500);
}
handleScroll() {
if (this.isLoading || !this.hasMoreContent) {
return;
}
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// 当滚动到距离底部300px时加载更多
if (scrollY + windowHeight >= documentHeight - 300) {
this.fetchMedia(this.currentPage + 1, true);
}
}
cleanup() {
// 移除滚动事件监听器
window.removeEventListener('scroll', this.scrollHandler);
}
initialize() {
this.fetchMedia(1, false).then(() => {
this.setupInfiniteScroll();
}).catch(err => {
// 错误已在fetchMedia中处理
});
}
}
function setupInfiniteScroll() {
// 直接使用滚动事件
window.addEventListener('scroll', handleScroll);
// 初始检查一次,以防内容不足一屏
setTimeout(handleScroll, 500);
// 存储每个页面创建的媒体加载器,用于清理
if (!window.mediaLoaders) {
window.mediaLoaders = {};
}
function handleScroll() {
if (isLoading || !hasMoreContent) {
return;
// 创建并初始化媒体加载器
function initMediaLoader() {
// 清理可能存在的旧实例
if (window.mediaLoaders[mediaListId]) {
window.mediaLoaders[mediaListId].cleanup();
}
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// 当滚动到距离底部300px时加载更多
if (scrollY + windowHeight >= documentHeight - 300) {
fetchMedia(currentPage + 1, true);
}
const loader = new MediaLoader(type, doubanId, mediaListId, loadingId, endMessageId);
window.mediaLoaders[mediaListId] = loader;
loader.initialize();
}
// 初始加载
document.addEventListener('DOMContentLoaded', () => {
fetchMedia(1, false).then(() => {
setupInfiniteScroll();
}).catch(err => {
// 错误已在fetchMedia中处理
});
// 页面首次加载
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>

View File

@ -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>

View File

@ -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,162 +53,248 @@ 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 doubanUrl = '';
if (type === 'book') {
doubanUrl = `https://book.douban.com/people/${doubanId}/collect?start=${start}&sort=time&rating=all&filter=all&mode=grid`;
} else {
doubanUrl = `https://movie.douban.com/people/${doubanId}/collect?start=${start}&sort=time&rating=all&filter=all&mode=grid`;
}
// 如果有缓存系统,可以在这里检查和返回缓存数据
// 生成随机bid
const bid = generateBid();
// 重试逻辑
let retries = 0;
let lastError: Error | null = null;
const response = await fetch(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',
'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',
'Cookie': `bid=${bid}`
}
});
if (!response.ok) {
return new Response(JSON.stringify({ error: '获取豆瓣数据失败' }), {
status: 500,
headers: {
'Content-Type': 'application/json'
while (retries <= MAX_RETRIES) {
try {
let doubanUrl = '';
if (type === 'book') {
doubanUrl = `https://book.douban.com/people/${doubanId}/collect?start=${start}&sort=time&rating=all&filter=all&mode=grid`;
} else {
doubanUrl = `https://movie.douban.com/people/${doubanId}/collect?start=${start}&sort=time&rating=all&filter=all&mode=grid`;
}
});
}
const html = await response.text();
const $ = load(html);
// 生成随机bid
const bid = generateBid();
// 添加类型定义
interface DoubanItem {
imageUrl: string;
title: string;
subtitle: string;
link: string;
intro: string;
rating: number;
date: string;
}
// 随机化一些请求参数,减少被检测的风险
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 items: DoubanItem[] = [];
const randomUserAgent = userAgents[Math.floor(Math.random() * userAgents.length)];
// 尝试不同的选择器
let itemSelector = '.item.comment-item';
let itemCount = $(itemSelector).length;
// 使用带超时的fetch发送请求
const response = await fetchWithTimeout(doubanUrl, {
headers: {
'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,en;q=0.8',
'Cache-Control': 'max-age=0',
'Cookie': `bid=${bid}`
}
}, REQUEST_TIMEOUT);
if (itemCount === 0) {
// 尝试其他可能的选择器
itemSelector = '.subject-item';
itemCount = $(itemSelector).length;
}
if (!response.ok) {
throw new Error(`豆瓣请求失败,状态码: ${response.status}`);
}
$(itemSelector).each((_, element) => {
const $element = $(element);
const html = await response.text();
// 根据选择器调整查找逻辑
let imageUrl = '';
let title = '';
let subtitle = '';
let link = '';
let intro = '';
let rating = 0;
let date = '';
// 检查是否包含验证码页面的特征
if (html.includes('验证码') || html.includes('captcha') || html.includes('too many requests')) {
throw new Error('请求被豆瓣限制,需要验证码');
}
if (itemSelector === '.item.comment-item') {
// 原始逻辑
imageUrl = $element.find('.pic img').attr('src') || '';
title = $element.find('.title a em').text().trim();
subtitle = $element.find('.title a').text().replace(title, '').trim();
link = $element.find('.title a').attr('href') || '';
intro = $element.find('.intro').text().trim();
const $ = load(html);
// 获取评分从rating1-t到rating5-t
for (let i = 1; i <= 5; i++) {
if ($element.find(`.rating${i}-t`).length > 0) {
rating = i;
break;
// 添加类型定义
interface DoubanItem {
imageUrl: string;
title: string;
subtitle: string;
link: string;
intro: string;
rating: number;
date: string;
}
const items: DoubanItem[] = [];
// 尝试不同的选择器
let itemSelector = '.item.comment-item';
let itemCount = $(itemSelector).length;
if (itemCount === 0) {
// 尝试其他可能的选择器
itemSelector = '.subject-item';
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('未找到电影/图书内容,可能是页面结构已变化');
}
}
date = $element.find('.date').text().trim();
} else if (itemSelector === '.subject-item') {
// 新的图书页面结构
imageUrl = $element.find('.pic img').attr('src') || '';
title = $element.find('.info h2 a').text().trim();
link = $element.find('.info h2 a').attr('href') || '';
intro = $element.find('.info .pub').text().trim();
$(itemSelector).each((_, element) => {
const $element = $(element);
// 获取评分
const ratingClass = $element.find('.rating-star').attr('class') || '';
const ratingMatch = ratingClass.match(/rating(\d)-t/);
if (ratingMatch) {
rating = parseInt(ratingMatch[1]);
try {
// 根据选择器调整查找逻辑
let imageUrl = '';
let title = '';
let subtitle = '';
let link = '';
let intro = '';
let rating = 0;
let date = '';
if (itemSelector === '.item.comment-item') {
// 原始逻辑
imageUrl = $element.find('.pic img').attr('src') || '';
title = $element.find('.title a em').text().trim();
subtitle = $element.find('.title a').text().replace(title, '').trim();
link = $element.find('.title a').attr('href') || '';
intro = $element.find('.intro').text().trim();
// 获取评分从rating1-t到rating5-t
for (let i = 1; i <= 5; i++) {
if ($element.find(`.rating${i}-t`).length > 0) {
rating = i;
break;
}
}
date = $element.find('.date').text().trim();
} else if (itemSelector === '.subject-item') {
// 新的图书页面结构
imageUrl = $element.find('.pic img').attr('src') || '';
title = $element.find('.info h2 a').text().trim();
link = $element.find('.info h2 a').attr('href') || '';
intro = $element.find('.info .pub').text().trim();
// 获取评分
const ratingClass = $element.find('.rating-star').attr('class') || '';
const ratingMatch = ratingClass.match(/rating(\d)-t/);
if (ratingMatch) {
rating = parseInt(ratingMatch[1]);
}
date = $element.find('.info .date').text().trim();
}
// 确保所有字段至少有空字符串
items.push({
imageUrl: imageUrl || '',
title: title || '',
subtitle: subtitle || '',
link: link || '',
intro: intro || '',
rating: rating || 0,
date: date || ''
});
} catch (error) {
console.error('解析项目时出错:', error);
// 继续处理下一个项目,而不是终止整个循环
}
});
// 改进分页信息获取逻辑
let currentPage = 1;
let totalPages = 1;
// 尝试从当前页码元素获取信息
if ($('.paginator .thispage').length > 0) {
currentPage = parseInt($('.paginator .thispage').text() || '1');
// 豆瓣可能不直接提供总页数,需要计算
const paginatorLinks = $('.paginator a');
let maxPage = currentPage;
paginatorLinks.each((_, el) => {
const pageNum = parseInt($(el).text());
if (!isNaN(pageNum) && pageNum > maxPage) {
maxPage = pageNum;
}
});
totalPages = maxPage;
}
date = $element.find('.info .date').text().trim();
const pagination = {
current: currentPage,
total: totalPages,
hasNext: $('.paginator .next a').length > 0,
hasPrev: $('.paginator .prev a').length > 0
};
// 如果有缓存系统,可以在这里保存数据到缓存
return new Response(JSON.stringify({ items, pagination }), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, s-maxage=300', // 5分钟服务器缓存
'CDN-Cache-Control': 'public, max-age=300' // CDN缓存
}
});
} catch (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;
}
}
items.push({
imageUrl,
title,
subtitle,
link,
intro,
rating,
date
});
});
// 改进分页信息获取逻辑
let currentPage = 1;
let totalPages = 1;
// 尝试从当前页码元素获取信息
if ($('.paginator .thispage').length > 0) {
currentPage = parseInt($('.paginator .thispage').text() || '1');
// 豆瓣可能不直接提供总页数,需要计算
const paginatorLinks = $('.paginator a');
let maxPage = currentPage;
paginatorLinks.each((_, el) => {
const pageNum = parseInt($(el).text());
if (!isNaN(pageNum) && pageNum > maxPage) {
maxPage = pageNum;
}
});
totalPages = maxPage;
}
const pagination = {
current: currentPage,
total: totalPages,
hasNext: $('.paginator .next a').length > 0,
hasPrev: $('.paginator .prev a').length > 0
};
return new Response(JSON.stringify({ items, pagination }), {
// 所有尝试都失败了
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) {
return new Response(JSON.stringify({ error: '获取豆瓣数据失败' }), {
console.error('处理请求时出错:', error);
return new Response(JSON.stringify({
error: '获取豆瓣数据失败',
message: error instanceof Error ? error.message : '未知错误'
}), {
status: 500,
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'Cache-Control': 'no-store, max-age=0'
}
});
}