添加 Swup 支持以优化页面切换,更新依赖项版本,增强 Countdown 和 DoubanCollection 组件的功能,改进 API 请求处理,重构 MediaGrid 组件以支持唯一 ID,修复 header 组件的命名。
This commit is contained in:
parent
864e482210
commit
f6e3083849
@ -9,6 +9,7 @@ import rehypeExternalLinks from "rehype-external-links";
|
|||||||
import sitemap from "@astrojs/sitemap";
|
import sitemap from "@astrojs/sitemap";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import swup from "@swup/astro"
|
||||||
import { SITE_URL } from "./src/consts";
|
import { SITE_URL } from "./src/consts";
|
||||||
|
|
||||||
import vercel from "@astrojs/vercel";
|
import vercel from "@astrojs/vercel";
|
||||||
@ -88,6 +89,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
gfm: true
|
gfm: true
|
||||||
}),
|
}),
|
||||||
|
swup(),
|
||||||
react(),
|
react(),
|
||||||
sitemap({
|
sitemap({
|
||||||
filter: (page) => !page.includes("/api/"),
|
filter: (page) => !page.includes("/api/"),
|
||||||
|
27
package.json
27
package.json
@ -9,23 +9,24 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^4.2.2",
|
"@astrojs/mdx": "^4.2.4",
|
||||||
"@astrojs/node": "^9.1.3",
|
"@astrojs/node": "^9.2.0",
|
||||||
"@astrojs/react": "^4.2.2",
|
"@astrojs/react": "^4.2.4",
|
||||||
"@astrojs/sitemap": "^3.3.0",
|
"@astrojs/sitemap": "^3.3.0",
|
||||||
"@astrojs/vercel": "^8.1.3",
|
"@astrojs/vercel": "^8.1.3",
|
||||||
"@tailwindcss/vite": "^4.0.9",
|
"@swup/astro": "^1.6.0",
|
||||||
"@types/react": "^19.0.10",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
"@types/three": "^0.174.0",
|
"@types/three": "^0.174.0",
|
||||||
"astro": "^5.5.5",
|
"astro": "^5.7.4",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0",
|
||||||
"node-fetch": "^3.3.0",
|
"node-fetch": "^3.3.2",
|
||||||
"octokit": "^3.1.2",
|
"octokit": "^3.2.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-masonry-css": "^1.0.16",
|
"react-masonry-css": "^1.0.16",
|
||||||
"tailwindcss": "^4.0.9",
|
"tailwindcss": "^4.1.4",
|
||||||
"three": "^0.174.0"
|
"three": "^0.174.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
10132
pnpm-lock.yaml
generated
Normal file
10132
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,47 +1,122 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
interface CountdownProps {
|
interface CountdownProps {
|
||||||
targetDate: string; // 目标日期,格式:'YYYY-MM-DD'
|
targetDate: string; // 目标日期,格式:'YYYY-MM-DD'
|
||||||
|
className?: string; // 自定义类名
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Countdown: React.FC<CountdownProps> = ({ targetDate }) => {
|
interface TimeLeft {
|
||||||
const [timeLeft, setTimeLeft] = useState({
|
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,
|
days: 0,
|
||||||
hours: 0,
|
hours: 0,
|
||||||
minutes: 0,
|
minutes: 0,
|
||||||
seconds: 0
|
seconds: 0,
|
||||||
|
expired: false
|
||||||
});
|
});
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const calculateTimeLeft = () => {
|
||||||
const now = new Date().getTime();
|
try {
|
||||||
const target = new Date(targetDate).getTime();
|
const now = new Date().getTime();
|
||||||
const difference = target - now;
|
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 days = Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||||
const hours = Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
const hours = Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||||
const minutes = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60));
|
const minutes = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
const seconds = Math.floor((difference % (1000 * 60)) / 1000);
|
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);
|
}, 1000);
|
||||||
|
|
||||||
return () => clearInterval(timer);
|
// 清理函数
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [targetDate]);
|
}, [targetDate]);
|
||||||
|
|
||||||
const TimeBox = ({ value, label }: { value: number; label: string }) => (
|
const TimeBox = ({ value, label }: { value: number; label: string }) => (
|
||||||
<div className="text-center px-4">
|
<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')}
|
{value.toString().padStart(2, '0')}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (timeLeft.expired) {
|
||||||
|
return (
|
||||||
|
<div className={`text-center ${className}`}>
|
||||||
|
<div className="text-xl text-gray-500 dark:text-gray-400">时间已到</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center">
|
<div className={`flex items-center justify-center ${className}`}>
|
||||||
<TimeBox value={timeLeft.days} label="天" />
|
<TimeBox value={timeLeft.days} label="天" />
|
||||||
<TimeBox value={timeLeft.hours} label="时" />
|
<TimeBox value={timeLeft.hours} label="时" />
|
||||||
<TimeBox value={timeLeft.minutes} 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';
|
import ReactMasonryCss from 'react-masonry-css';
|
||||||
|
|
||||||
interface DoubanItem {
|
interface DoubanItem {
|
||||||
@ -20,73 +20,138 @@ interface Pagination {
|
|||||||
|
|
||||||
interface DoubanCollectionProps {
|
interface DoubanCollectionProps {
|
||||||
type: 'movie' | 'book';
|
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 [items, setItems] = useState<DoubanItem[]>([]);
|
||||||
const [pagination, setPagination] = useState<Pagination>({ current: 1, total: 1, hasNext: false, hasPrev: false });
|
const [pagination, setPagination] = useState<Pagination>({ current: 1, total: 1, hasNext: false, hasPrev: false });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isPageChanging, setIsPageChanging] = useState(false);
|
const [isPageChanging, setIsPageChanging] = useState(false);
|
||||||
|
|
||||||
|
// 使用 ref 避免竞态条件
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
const fetchData = async (start = 0) => {
|
const fetchData = useCallback(async (start = 0) => {
|
||||||
|
// 如果已经有一个请求在进行中,取消它
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的 AbortController
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append('type', type);
|
params.append('type', type);
|
||||||
params.append('start', start.toString());
|
params.append('start', start.toString());
|
||||||
|
|
||||||
const url = `/api/douban?${params.toString()}`;
|
if (doubanId) {
|
||||||
try {
|
params.append('doubanId', doubanId);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
|
// 组件挂载时设置标记
|
||||||
|
isMountedRef.current = true;
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [type]);
|
|
||||||
|
// 组件卸载时清理
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = useCallback((page: number) => {
|
||||||
|
if (isPageChanging) return;
|
||||||
|
|
||||||
|
setIsPageChanging(true);
|
||||||
|
|
||||||
|
// 计算新页面的起始项
|
||||||
const start = (page - 1) * 15;
|
const start = (page - 1) * 15;
|
||||||
|
|
||||||
// 手动更新分页状态,不等待API响应
|
// 更新分页状态
|
||||||
setPagination(prev => ({
|
setPagination(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
current: page
|
current: page
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 重置当前状态,显示加载中
|
// 清空当前项目,显示加载状态
|
||||||
setItems([]);
|
setItems([]);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
|
// 获取新页面的数据
|
||||||
fetchData(start);
|
fetchData(start);
|
||||||
};
|
}, [fetchData, isPageChanging]);
|
||||||
|
|
||||||
const renderStars = (rating: number) => {
|
const renderStars = useCallback((rating: number) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
<svg
|
<svg
|
||||||
key={star}
|
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"
|
fill="currentColor"
|
||||||
viewBox="0 0 20 20"
|
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" />
|
<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>
|
</svg>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const breakpointColumnsObj = {
|
const breakpointColumnsObj = {
|
||||||
default: 3,
|
default: 3,
|
||||||
@ -94,17 +159,65 @@ const DoubanCollection: React.FC<DoubanCollectionProps> = ({ type }) => {
|
|||||||
700: 1
|
700: 1
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 加载中状态
|
||||||
if (loading && items.length === 0) {
|
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) {
|
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 (
|
return (
|
||||||
<div className="douban-collection">
|
<div className={`douban-collection ${className}`}>
|
||||||
<h2 className="text-2xl font-bold mb-6 text-primary-700">{type === 'movie' ? '观影记录' : '读书记录'}</h2>
|
<h2 className="text-2xl font-bold mb-6 text-primary-700 dark:text-primary-400">
|
||||||
|
{type === 'movie' ? '观影记录' : '读书记录'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<ReactMasonryCss
|
<ReactMasonryCss
|
||||||
breakpointCols={breakpointColumnsObj}
|
breakpointCols={breakpointColumnsObj}
|
||||||
@ -112,23 +225,32 @@ const DoubanCollection: React.FC<DoubanCollectionProps> = ({ type }) => {
|
|||||||
columnClassName="pl-4 bg-clip-padding"
|
columnClassName="pl-4 bg-clip-padding"
|
||||||
>
|
>
|
||||||
{items.map((item, index) => (
|
{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">
|
<a href={item.link} target="_blank" rel="noopener noreferrer" className="block">
|
||||||
<div className="relative pb-[140%] overflow-hidden">
|
<div className="relative pb-[140%] overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={item.imageUrl}
|
src={item.imageUrl}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
className="absolute inset-0 w-full h-full object-cover transition-transform duration-300 hover:scale-105"
|
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>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="font-bold text-lg mb-1 line-clamp-1 text-primary-800">{item.title}</h3>
|
<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 text-sm mb-2 line-clamp-1">{item.subtitle}</p>}
|
{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">
|
<div className="flex justify-between items-center mb-2">
|
||||||
{renderStars(item.rating)}
|
{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>
|
</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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -139,67 +261,45 @@ const DoubanCollection: React.FC<DoubanCollectionProps> = ({ type }) => {
|
|||||||
{pagination.total > 1 && (
|
{pagination.total > 1 && (
|
||||||
<div className="flex justify-center mt-8 space-x-2">
|
<div className="flex justify-center mt-8 space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={() => handlePageChange(pagination.current - 1)}
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!pagination.hasPrev || pagination.current <= 1 || isPageChanging}
|
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>
|
</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}
|
{pagination.current} / {pagination.total}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={() => handlePageChange(pagination.current + 1)}
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!pagination.hasNext || pagination.current >= pagination.total || isPageChanging}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -207,4 +307,4 @@ const DoubanCollection: React.FC<DoubanCollectionProps> = ({ type }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DoubanCollection;
|
export default DoubanCollection;
|
@ -23,7 +23,8 @@ const currentYear = new Date().getFullYear();
|
|||||||
href="https://beian.miit.gov.cn/"
|
href="https://beian.miit.gov.cn/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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}
|
{icp}
|
||||||
</a>
|
</a>
|
||||||
@ -34,22 +35,26 @@ const currentYear = new Date().getFullYear();
|
|||||||
href={psbIcpUrl}
|
href={psbIcpUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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}
|
{psbIcp}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-500 font-light flex items-center gap-2">
|
<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">© {currentYear} New Echoes. All rights reserved.</a>
|
<a href="https://blog.lsy22.com" class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200">
|
||||||
<span>·</span>
|
© {currentYear} New Echoes. All rights reserved.
|
||||||
|
</a>
|
||||||
|
<span aria-hidden="true" class="hidden sm:inline">·</span>
|
||||||
<a
|
<a
|
||||||
href="/sitemap-index.xml"
|
href="/sitemap-index.xml"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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
|
Sitemap
|
||||||
</a>
|
</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';
|
import ReactMasonryCss from 'react-masonry-css';
|
||||||
|
|
||||||
// Git 平台类型枚举
|
// Git 平台类型枚举
|
||||||
@ -59,6 +59,7 @@ interface GitProjectCollectionProps {
|
|||||||
token?: string;
|
token?: string;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
className?: string; // 添加自定义类名
|
||||||
}
|
}
|
||||||
|
|
||||||
const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
||||||
@ -68,16 +69,30 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
|||||||
title,
|
title,
|
||||||
token,
|
token,
|
||||||
perPage = DEFAULT_GIT_CONFIG.perPage,
|
perPage = DEFAULT_GIT_CONFIG.perPage,
|
||||||
url
|
url,
|
||||||
|
className = ''
|
||||||
}) => {
|
}) => {
|
||||||
const [projects, setProjects] = useState<GitProject[]>([]);
|
const [projects, setProjects] = useState<GitProject[]>([]);
|
||||||
const [pagination, setPagination] = useState<Pagination>({ current: 1, total: 1, hasNext: false, hasPrev: false });
|
const [pagination, setPagination] = useState<Pagination>({ current: 1, total: 1, hasNext: false, hasPrev: false });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isPageChanging, setIsPageChanging] = useState(false);
|
const [isPageChanging, setIsPageChanging] = useState(false);
|
||||||
|
|
||||||
|
// 使用 ref 跟踪组件挂载状态
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const fetchData = async (page = 1) => {
|
const fetchData = useCallback(async (page = 1) => {
|
||||||
|
// 取消可能存在的之前的请求
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的 AbortController
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
if (!platform || !Object.values(GitPlatform).includes(platform)) {
|
if (!platform || !Object.values(GitPlatform).includes(platform)) {
|
||||||
setError('无效的平台参数');
|
setError('无效的平台参数');
|
||||||
@ -113,65 +128,98 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json'
|
'Accept': 'application/json'
|
||||||
}
|
},
|
||||||
|
signal: abortControllerRef.current.signal
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 如果组件已卸载,不继续更新状态
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
throw new Error(`请求失败: ${response.status} ${response.statusText}\n${JSON.stringify(errorData, null, 2)}`);
|
throw new Error(`请求失败: ${response.status} ${response.statusText}\n${JSON.stringify(errorData, null, 2)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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) {
|
} catch (err) {
|
||||||
|
// 如果是取消的请求,不显示错误
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果组件已卸载,不继续更新状态
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
console.error('请求错误:', err);
|
console.error('请求错误:', err);
|
||||||
setError(err instanceof Error ? err.message : '未知错误');
|
setError(err instanceof Error ? err.message : '未知错误');
|
||||||
|
// 保持之前的项目列表,避免清空显示
|
||||||
|
if (projects.length === 0) {
|
||||||
|
setProjects([]);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
// 如果组件已卸载,不继续更新状态
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setIsPageChanging(false);
|
||||||
}
|
}
|
||||||
};
|
}, [platform, username, organization, token, perPage, url, projects.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 设置组件已挂载标志
|
||||||
|
isMountedRef.current = true;
|
||||||
|
|
||||||
fetchData(1);
|
fetchData(1);
|
||||||
}, [platform, username, organization, token, perPage, url]);
|
|
||||||
|
// 清理函数
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = useCallback((page: number) => {
|
||||||
if (isPageChanging) return;
|
if (isPageChanging) return;
|
||||||
|
|
||||||
setIsPageChanging(true);
|
setIsPageChanging(true);
|
||||||
|
|
||||||
// 重置当前状态,显示加载中
|
// 更新分页状态
|
||||||
setProjects([]);
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
// 手动更新分页状态
|
|
||||||
setPagination(prev => ({
|
setPagination(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
current: page
|
current: page
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 不清空当前项目列表,但显示加载状态
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
fetchData(page);
|
fetchData(page);
|
||||||
setTimeout(() => setIsPageChanging(false), 2000);
|
}, [fetchData, isPageChanging]);
|
||||||
};
|
|
||||||
|
|
||||||
const getPlatformIcon = (platform: GitPlatform) => {
|
const getPlatformIcon = (platform: GitPlatform) => {
|
||||||
switch (platform) {
|
switch (platform) {
|
||||||
case GitPlatform.GITHUB:
|
case GitPlatform.GITHUB:
|
||||||
return (
|
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"/>
|
<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>
|
</svg>
|
||||||
);
|
);
|
||||||
case GitPlatform.GITEA:
|
case GitPlatform.GITEA:
|
||||||
return (
|
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"/>
|
<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>
|
</svg>
|
||||||
);
|
);
|
||||||
case GitPlatform.GITEE:
|
case GitPlatform.GITEE:
|
||||||
return (
|
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" />
|
<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>
|
</svg>
|
||||||
);
|
);
|
||||||
@ -217,97 +265,140 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
|||||||
// 自定义标题或使用默认标题
|
// 自定义标题或使用默认标题
|
||||||
const displayTitle = title || `${getPlatformName(platform)} 项目`;
|
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 (
|
return (
|
||||||
<div className="git-project-collection max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<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">
|
<h2 className="text-2xl font-bold mb-6 text-primary-700 dark:text-primary-400">
|
||||||
{displayTitle}
|
{displayTitle}
|
||||||
{username && <span className="ml-2 text-secondary-500">(@{username})</span>}
|
{username && <span className="ml-2 text-secondary-500 dark:text-secondary-400">(@{username})</span>}
|
||||||
{organization && <span className="ml-2 text-secondary-500">(组织: {organization})</span>}
|
{organization && <span className="ml-2 text-secondary-500 dark:text-secondary-400">(组织: {organization})</span>}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
{loading && projects.length === 0 ? (
|
{loading && projects.length === 0 ? (
|
||||||
<div className="flex justify-center p-8">加载中...</div>
|
renderLoading()
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="text-red-500 p-4">错误: {error}</div>
|
renderError()
|
||||||
) : projects.length === 0 ? (
|
) : projects.length === 0 ? (
|
||||||
<div className="text-secondary-500 p-4">
|
renderEmpty()
|
||||||
{platform === GitPlatform.GITEE ?
|
|
||||||
"无法获取 Gitee 项目数据,可能需要配置访问令牌。" :
|
|
||||||
"没有找到项目数据。"}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<ReactMasonryCss
|
<>
|
||||||
breakpointCols={breakpointColumnsObj}
|
{/* 仅显示加载中指示器,不隐藏项目 */}
|
||||||
className="flex -ml-4 w-auto"
|
{loading && projects.length > 0 && (
|
||||||
columnClassName="pl-4 bg-clip-padding"
|
<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>
|
||||||
{projects.map((project, index) => (
|
<p className="ml-2 text-xs text-gray-500 dark:text-gray-400">更新中...</p>
|
||||||
<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>
|
|
||||||
</div>
|
</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 && (
|
{pagination.total > 1 && (
|
||||||
@ -315,21 +406,43 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handlePageChange(pagination.current - 1)}
|
onClick={() => handlePageChange(pagination.current - 1)}
|
||||||
disabled={!pagination.hasPrev || pagination.current <= 1 || isPageChanging}
|
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>
|
</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}
|
{pagination.current} / {pagination.total}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePageChange(pagination.current + 1)}
|
onClick={() => handlePageChange(pagination.current + 1)}
|
||||||
disabled={!pagination.hasNext || pagination.current >= pagination.total || isPageChanging}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -34,7 +34,7 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
|||||||
placeholder="搜索文章..."
|
placeholder="搜索文章..."
|
||||||
/>
|
/>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@ -53,7 +53,7 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
|||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
class:list={[
|
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)
|
normalizedPath === (link.href === '/' ? '' : link.href)
|
||||||
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-600 dark:border-primary-400'
|
? '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'
|
: '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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="mobile-search-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-expanded="false"
|
||||||
|
aria-label="搜索"
|
||||||
>
|
>
|
||||||
<span class="sr-only">搜索</span>
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="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"
|
id="mobile-menu-button"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
|
aria-label="打开菜单"
|
||||||
>
|
>
|
||||||
<span class="sr-only">打开菜单</span>
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
</svg>
|
</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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@ -108,15 +110,16 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
|||||||
placeholder="搜索文章..."
|
placeholder="搜索文章..."
|
||||||
/>
|
/>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
id="mobile-search-close"
|
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"
|
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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@ -226,54 +229,55 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const header = document.getElementById('header-bg');
|
// 确保脚本适用于视图转换
|
||||||
const scrollThreshold = 50;
|
function initHeader() {
|
||||||
|
const header = document.getElementById('header-bg');
|
||||||
function updateHeaderBackground() {
|
const scrollThreshold = 50;
|
||||||
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');
|
|
||||||
} else {
|
|
||||||
// 直接显示菜单,不使用过渡效果
|
|
||||||
mobileMenu.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换图标
|
|
||||||
menuOpenIcon.classList.toggle('hidden');
|
|
||||||
menuCloseIcon.classList.toggle('hidden');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移动端主题切换容器点击处理
|
function updateHeaderBackground() {
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
if (window.scrollY > scrollThreshold) {
|
||||||
const themeToggleContainer = document.getElementById('theme-toggle-container');
|
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');
|
||||||
|
} else {
|
||||||
|
// 直接显示菜单,不使用过渡效果
|
||||||
|
mobileMenu.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换图标
|
||||||
|
menuOpenIcon.classList.toggle('hidden');
|
||||||
|
menuCloseIcon.classList.toggle('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端主题切换容器点击处理
|
||||||
|
const themeToggleContainer = document.getElementById('theme-toggle-container');
|
||||||
|
|
||||||
if (themeToggleContainer) {
|
if (themeToggleContainer) {
|
||||||
themeToggleContainer.addEventListener('click', (e) => {
|
themeToggleContainer.addEventListener('click', (e) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
@ -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 {
|
function debounce<T extends (...args: any[]) => void>(func: T, wait: number): (...args: Parameters<T>) => void {
|
||||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
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 desktopList = document.getElementById('desktop-search-list');
|
||||||
const desktopMessage = document.getElementById('desktop-search-message');
|
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 mobileSearch = document.getElementById('mobile-search');
|
||||||
const mobileResults = document.getElementById('mobile-search-results');
|
const mobileResults = document.getElementById('mobile-search-results');
|
||||||
const mobileList = document.getElementById('mobile-search-list');
|
const mobileList = document.getElementById('mobile-search-list');
|
||||||
const mobileMessage = document.getElementById('mobile-search-message');
|
const mobileMessage = document.getElementById('mobile-search-message');
|
||||||
const mobileSearchClose = document.getElementById('mobile-search-close');
|
|
||||||
|
|
||||||
// 文章对象的接口定义
|
// 文章对象的接口定义
|
||||||
interface Article {
|
interface Article {
|
||||||
@ -325,15 +347,19 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
|||||||
}
|
}
|
||||||
|
|
||||||
let articles: Article[] = [];
|
let articles: Article[] = [];
|
||||||
|
let isArticlesLoaded = false;
|
||||||
|
|
||||||
// 获取文章数据
|
// 获取文章数据
|
||||||
async function fetchArticles() {
|
async function fetchArticles() {
|
||||||
|
if (isArticlesLoaded && articles.length > 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/search');
|
const response = await fetch('/api/search');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('获取文章数据失败');
|
throw new Error('获取文章数据失败');
|
||||||
}
|
}
|
||||||
articles = await response.json();
|
articles = await response.json();
|
||||||
|
isArticlesLoaded = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取文章失败:', error);
|
console.error('获取文章失败:', error);
|
||||||
}
|
}
|
||||||
@ -481,7 +507,7 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
|||||||
if (desktopSearch && desktopResults) {
|
if (desktopSearch && desktopResults) {
|
||||||
desktopSearch.addEventListener('focus', () => {
|
desktopSearch.addEventListener('focus', () => {
|
||||||
desktopResults.classList.remove('hidden');
|
desktopResults.classList.remove('hidden');
|
||||||
if (!articles.length) fetchArticles();
|
if (!isArticlesLoaded) fetchArticles();
|
||||||
});
|
});
|
||||||
|
|
||||||
desktopSearch.addEventListener('input', (e: Event) => {
|
desktopSearch.addEventListener('input', (e: Event) => {
|
||||||
@ -508,39 +534,49 @@ const normalizedPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : cu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 移动端搜索逻辑
|
// 移动端搜索逻辑
|
||||||
if (mobileSearchButton && mobileSearchPanel) {
|
if (mobileSearch && mobileResults) {
|
||||||
mobileSearchButton.addEventListener('click', () => {
|
mobileSearch.addEventListener('input', (e: Event) => {
|
||||||
mobileSearchPanel.classList.remove('hidden');
|
mobileResults.classList.remove('hidden');
|
||||||
mobileSearchPanel.classList.add('show');
|
const target = e.target as HTMLInputElement;
|
||||||
if (mobileSearch) mobileSearch.focus();
|
if (target && target.value !== undefined) {
|
||||||
if (!articles.length) fetchArticles();
|
debouncedMobileSearch(target.value);
|
||||||
|
if (!isArticlesLoaded) fetchArticles();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mobileSearchClose) {
|
// ESC键关闭搜索面板
|
||||||
mobileSearchClose.addEventListener('click', () => {
|
mobileSearch.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
mobileSearchPanel.classList.add('hidden');
|
if (e.key === 'Escape') {
|
||||||
mobileSearchPanel.classList.remove('show');
|
const mobileSearchPanel = document.getElementById('mobile-search-panel');
|
||||||
});
|
if (mobileSearchPanel) {
|
||||||
}
|
|
||||||
|
|
||||||
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') {
|
|
||||||
mobileSearchPanel.classList.add('hidden');
|
mobileSearchPanel.classList.add('hidden');
|
||||||
mobileSearchPanel.classList.remove('show');
|
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>
|
</script>
|
||||||
|
|
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import "@/styles/global.css";
|
import "@/styles/global.css";
|
||||||
import Header from "@/components/header.astro";
|
import Header from "@/components/Header.astro";
|
||||||
import Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.astro";
|
||||||
import { ICP, PSB_ICP, PSB_ICP_URL, SITE_NAME, SITE_DESCRIPTION } from "@/consts";
|
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 => (
|
{tags && tags.map(tag => (
|
||||||
<meta property="article:tag" content={tag} />
|
<meta property="article:tag" content={tag} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
// 立即执行主题初始化
|
// 立即执行主题初始化
|
||||||
const theme = (() => {
|
const theme = (() => {
|
||||||
|
@ -6,146 +6,197 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { type, title, doubanId } = Astro.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">
|
<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>
|
<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动态加载 -->
|
<!-- 内容将通过JS动态加载 -->
|
||||||
</div>
|
</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>
|
<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>
|
<p class="mt-2 text-gray-600">加载更多...</p>
|
||||||
</div>
|
</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>
|
<p class="text-gray-600">已加载全部内容</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
||||||
let currentPage = 1;
|
class MediaLoader {
|
||||||
let isLoading = false;
|
constructor(type, doubanId, mediaListId, loadingId, endMessageId) {
|
||||||
let hasMoreContent = true;
|
this.type = type;
|
||||||
const itemsPerPage = 15; // 豆瓣每页显示的数量
|
this.doubanId = doubanId;
|
||||||
|
this.mediaListId = mediaListId;
|
||||||
async function fetchMedia(page = 1, append = false) {
|
this.loadingId = loadingId;
|
||||||
if (isLoading || (!append && !hasMoreContent)) {
|
this.endMessageId = endMessageId;
|
||||||
return;
|
this.currentPage = 1;
|
||||||
}
|
this.isLoading = false;
|
||||||
|
this.hasMoreContent = true;
|
||||||
isLoading = true;
|
this.itemsPerPage = 15; // 豆瓣每页显示的数量
|
||||||
showLoading(true);
|
this.scrollHandler = this.handleScroll.bind(this);
|
||||||
|
|
||||||
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' ? '电影' : '图书'}数据失败`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
renderMedia(data.items, append);
|
|
||||||
|
|
||||||
// 更新分页状态
|
|
||||||
currentPage = data.pagination.current;
|
|
||||||
hasMoreContent = data.pagination.hasNext;
|
|
||||||
|
|
||||||
if (!hasMoreContent) {
|
|
||||||
showEndMessage(true);
|
|
||||||
}
|
|
||||||
} 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 => `
|
async fetchMedia(page = 1, append = false) {
|
||||||
<div class="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-xl transition-shadow duration-300">
|
if (this.isLoading || (!append && !this.hasMoreContent)) {
|
||||||
<div class="relative pb-[150%] overflow-hidden">
|
return;
|
||||||
<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">
|
this.isLoading = true;
|
||||||
<a href="${item.link}" target="_blank" class="hover:text-blue-300 transition-colors">${item.title}</a>
|
this.showLoading(true);
|
||||||
</h3>
|
|
||||||
|
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' ? '电影' : '图书'}数据失败`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</div>
|
||||||
</div>
|
`).join('');
|
||||||
`).join('');
|
|
||||||
|
|
||||||
if (append) {
|
if (append) {
|
||||||
mediaList.innerHTML += mediaHTML;
|
mediaList.innerHTML += mediaHTML;
|
||||||
} else {
|
|
||||||
mediaList.innerHTML = mediaHTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLoading(show) {
|
|
||||||
const loading = document.getElementById('loading');
|
|
||||||
if (loading) {
|
|
||||||
if (show) {
|
|
||||||
loading.classList.remove('hidden');
|
|
||||||
} else {
|
} else {
|
||||||
loading.classList.add('hidden');
|
mediaList.innerHTML = mediaHTML;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function showEndMessage(show) {
|
showLoading(show) {
|
||||||
const endMessage = document.getElementById('end-message');
|
const loading = document.getElementById(this.loadingId);
|
||||||
if (endMessage) {
|
if (loading) {
|
||||||
endMessage.classList.toggle('hidden', !show);
|
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() {
|
// 存储每个页面创建的媒体加载器,用于清理
|
||||||
// 直接使用滚动事件
|
if (!window.mediaLoaders) {
|
||||||
window.addEventListener('scroll', handleScroll);
|
window.mediaLoaders = {};
|
||||||
|
|
||||||
// 初始检查一次,以防内容不足一屏
|
|
||||||
setTimeout(handleScroll, 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScroll() {
|
// 创建并初始化媒体加载器
|
||||||
if (isLoading || !hasMoreContent) {
|
function initMediaLoader() {
|
||||||
return;
|
// 清理可能存在的旧实例
|
||||||
}
|
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', () => {
|
document.addEventListener('astro:swup:page:view', initMediaLoader);
|
||||||
fetchMedia(1, false).then(() => {
|
|
||||||
setupInfiniteScroll();
|
// 页面卸载前清理
|
||||||
}).catch(err => {
|
document.addEventListener('astro:before-swap', () => {
|
||||||
// 错误已在fetchMedia中处理
|
if (window.mediaLoaders[mediaListId]) {
|
||||||
});
|
window.mediaLoaders[mediaListId].cleanup();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 如果已经加载了 DOM,立即初始化
|
||||||
|
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||||
|
initMediaLoader();
|
||||||
|
}
|
||||||
</script>
|
</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作为初始状态,表示尚未确定主题
|
// 使用null作为初始状态,表示尚未确定主题
|
||||||
const [theme, setTheme] = useState(null);
|
const [theme, setTheme] = useState(null);
|
||||||
const [mounted, setMounted] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
|
|
||||||
// 从 localStorage 或 document.documentElement.dataset.theme 获取主题
|
// 从 localStorage 或 document.documentElement.dataset.theme 获取主题
|
||||||
const savedTheme = localStorage.getItem('theme');
|
const savedTheme = localStorage.getItem('theme');
|
||||||
const rootTheme = document.documentElement.dataset.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;
|
const initialTheme = savedTheme || rootTheme || systemTheme;
|
||||||
@ -19,34 +27,64 @@ export function ThemeToggle({ height = 16, width = 16, fill = "currentColor" })
|
|||||||
|
|
||||||
// 确保文档根元素的主题与状态一致
|
// 确保文档根元素的主题与状态一致
|
||||||
document.documentElement.dataset.theme = initialTheme;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!mounted || theme === null) return;
|
if (!mounted || theme === null) return;
|
||||||
|
|
||||||
// 当主题改变时更新 DOM 和 localStorage
|
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
|
|
||||||
if (theme === getSystemTheme()) {
|
// 检查是否是跟随系统的主题
|
||||||
|
const isSystemTheme = theme === getSystemTheme();
|
||||||
|
|
||||||
|
if (isSystemTheme) {
|
||||||
localStorage.removeItem('theme');
|
localStorage.removeItem('theme');
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('theme', theme);
|
||||||
}
|
}
|
||||||
}, [theme, mounted]);
|
}, [theme, mounted, getSystemTheme]);
|
||||||
|
|
||||||
function getSystemTheme() {
|
const toggleTheme = useCallback(() => {
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
if (transitioning) return; // 避免快速连续点击
|
||||||
}
|
|
||||||
|
setTransitioning(true);
|
||||||
function toggleTheme() {
|
|
||||||
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
|
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
|
||||||
}
|
|
||||||
|
// 添加300ms的防抖,避免快速切换
|
||||||
|
transitionTimeoutRef.current = setTimeout(() => {
|
||||||
|
setTransitioning(false);
|
||||||
|
}, 300);
|
||||||
|
}, [transitioning]);
|
||||||
|
|
||||||
// 在客户端挂载前,返回一个空的占位符
|
// 在客户端挂载前,返回一个空的占位符
|
||||||
if (!mounted || theme === null) {
|
if (!mounted || theme === null) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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>
|
<span className="sr-only">加载主题切换按钮...</span>
|
||||||
</div>
|
</div>
|
||||||
@ -55,10 +93,17 @@ export function ThemeToggle({ height = 16, width = 16, fill = "currentColor" })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
onClick={toggleTheme}
|
||||||
role="button"
|
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' ? (
|
{theme === 'dark' ? (
|
||||||
<svg
|
<svg
|
||||||
@ -66,6 +111,7 @@ export function ThemeToggle({ height = 16, width = 16, fill = "currentColor" })
|
|||||||
fill={fill}
|
fill={fill}
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
className="transition-transform duration-200 hover:scale-110"
|
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"/>
|
<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>
|
</svg>
|
||||||
@ -75,6 +121,7 @@ export function ThemeToggle({ height = 16, width = 16, fill = "currentColor" })
|
|||||||
fill={fill}
|
fill={fill}
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
className="transition-transform duration-200 hover:scale-110"
|
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"/>
|
<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>
|
</svg>
|
||||||
|
@ -4,6 +4,11 @@ import { load } from 'cheerio';
|
|||||||
// 添加服务器渲染标记
|
// 添加服务器渲染标记
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
|
// 请求配置常量
|
||||||
|
const MAX_RETRIES = 0; // 最大重试次数
|
||||||
|
const RETRY_DELAY = 1500; // 重试延迟(毫秒)
|
||||||
|
const REQUEST_TIMEOUT = 10000; // 请求超时时间(毫秒)
|
||||||
|
|
||||||
// 生成随机的bid Cookie值
|
// 生成随机的bid Cookie值
|
||||||
function generateBid() {
|
function generateBid() {
|
||||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
@ -14,6 +19,31 @@ function generateBid() {
|
|||||||
return result;
|
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 }) => {
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const type = url.searchParams.get('type') || 'movie';
|
const type = url.searchParams.get('type') || 'movie';
|
||||||
@ -23,162 +53,248 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
if (!doubanId) {
|
if (!doubanId) {
|
||||||
return new Response(JSON.stringify({ error: '缺少豆瓣ID' }), {
|
return new Response(JSON.stringify({ error: '缺少豆瓣ID' }), {
|
||||||
status: 400,
|
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 {
|
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();
|
|
||||||
|
|
||||||
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: '获取豆瓣数据失败' }), {
|
let retries = 0;
|
||||||
status: 500,
|
let lastError: Error | null = null;
|
||||||
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`;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
// 生成随机bid
|
||||||
|
const bid = generateBid();
|
||||||
const html = await response.text();
|
|
||||||
const $ = load(html);
|
|
||||||
|
|
||||||
// 添加类型定义
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$(itemSelector).each((_, element) => {
|
|
||||||
const $element = $(element);
|
|
||||||
|
|
||||||
// 根据选择器调整查找逻辑
|
|
||||||
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++) {
|
const userAgents = [
|
||||||
if ($element.find(`.rating${i}-t`).length > 0) {
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
|
||||||
rating = i;
|
'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',
|
||||||
break;
|
'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': 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 (!response.ok) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 添加类型定义
|
||||||
|
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();
|
$(itemSelector).each((_, element) => {
|
||||||
} else if (itemSelector === '.subject-item') {
|
const $element = $(element);
|
||||||
// 新的图书页面结构
|
|
||||||
imageUrl = $element.find('.pic img').attr('src') || '';
|
try {
|
||||||
title = $element.find('.info h2 a').text().trim();
|
// 根据选择器调整查找逻辑
|
||||||
link = $element.find('.info h2 a').attr('href') || '';
|
let imageUrl = '';
|
||||||
intro = $element.find('.info .pub').text().trim();
|
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);
|
||||||
|
// 继续处理下一个项目,而不是终止整个循环
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 获取评分
|
// 改进分页信息获取逻辑
|
||||||
const ratingClass = $element.find('.rating-star').attr('class') || '';
|
let currentPage = 1;
|
||||||
const ratingMatch = ratingClass.match(/rating(\d)-t/);
|
let totalPages = 1;
|
||||||
if (ratingMatch) {
|
|
||||||
rating = parseInt(ratingMatch[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,
|
console.error('所有尝试都失败了:', lastError);
|
||||||
total: totalPages,
|
return new Response(JSON.stringify({
|
||||||
hasNext: $('.paginator .next a').length > 0,
|
error: '获取豆瓣数据失败',
|
||||||
hasPrev: $('.paginator .prev a').length > 0
|
message: lastError?.message || '未知错误'
|
||||||
};
|
}), {
|
||||||
|
status: 500,
|
||||||
return new Response(JSON.stringify({ items, pagination }), {
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store, max-age=0'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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,
|
status: 500,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store, max-age=0'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user