links infinite scroll
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import CreateLinkModal from '../components/ui/CreateLinkModal';
|
import CreateLinkModal from '../components/ui/CreateLinkModal';
|
||||||
import { Link, StatsOverview, Tag } from '../api/types';
|
import { Link, StatsOverview, Tag } from '../api/types';
|
||||||
|
|
||||||
@@ -48,6 +48,13 @@ export default function LinksPage() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 无限加载相关状态
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
const observer = useRef<IntersectionObserver | null>(null);
|
||||||
|
const lastLinkElementRef = useRef<HTMLTableRowElement | null>(null);
|
||||||
|
|
||||||
// 映射API数据到UI所需格式的函数
|
// 映射API数据到UI所需格式的函数
|
||||||
const mapApiLinkToUiLink = (apiLink: Link): UILink => {
|
const mapApiLinkToUiLink = (apiLink: Link): UILink => {
|
||||||
// 生成短URL显示 - 因为数据库中没有short_url字段
|
// 生成短URL显示 - 因为数据库中没有short_url字段
|
||||||
@@ -91,49 +98,116 @@ export default function LinksPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 获取链接数据
|
// 获取链接数据
|
||||||
useEffect(() => {
|
const fetchLinks = useCallback(async (pageNum: number, isInitialLoad: boolean = false) => {
|
||||||
const fetchLinks = async () => {
|
try {
|
||||||
try {
|
if (isInitialLoad) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
} else {
|
||||||
|
setIsLoadingMore(true);
|
||||||
// 获取链接列表
|
}
|
||||||
const linksResponse = await fetch('/api/links');
|
setError(null);
|
||||||
if (!linksResponse.ok) {
|
|
||||||
throw new Error(`Failed to fetch links: ${linksResponse.statusText}`);
|
// 获取链接列表
|
||||||
}
|
const linksResponse = await fetch(`/api/links?page=${pageNum}&limit=20${searchQuery ? `&search=${encodeURIComponent(searchQuery)}` : ''}`);
|
||||||
const linksData = await linksResponse.json();
|
if (!linksResponse.ok) {
|
||||||
|
throw new Error(`Failed to fetch links: ${linksResponse.statusText}`);
|
||||||
// 获取标签列表
|
}
|
||||||
|
const linksData = await linksResponse.json();
|
||||||
|
|
||||||
|
const uiLinks = linksData.data.map(mapApiLinkToUiLink);
|
||||||
|
|
||||||
|
if (isInitialLoad) {
|
||||||
|
setLinks(uiLinks);
|
||||||
|
} else {
|
||||||
|
setLinks(prevLinks => [...prevLinks, ...uiLinks]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否还有更多数据可加载
|
||||||
|
const { pagination } = linksData;
|
||||||
|
setHasMore(pagination.page < pagination.totalPages);
|
||||||
|
|
||||||
|
if (isInitialLoad) {
|
||||||
|
// 只在初始加载时获取标签和统计数据
|
||||||
const tagsResponse = await fetch('/api/tags');
|
const tagsResponse = await fetch('/api/tags');
|
||||||
if (!tagsResponse.ok) {
|
if (!tagsResponse.ok) {
|
||||||
throw new Error(`Failed to fetch tags: ${tagsResponse.statusText}`);
|
throw new Error(`Failed to fetch tags: ${tagsResponse.statusText}`);
|
||||||
}
|
}
|
||||||
const tagsData = await tagsResponse.json();
|
const tagsData = await tagsResponse.json();
|
||||||
|
|
||||||
// 获取统计数据
|
|
||||||
const statsResponse = await fetch('/api/stats');
|
const statsResponse = await fetch('/api/stats');
|
||||||
if (!statsResponse.ok) {
|
if (!statsResponse.ok) {
|
||||||
throw new Error(`Failed to fetch stats: ${statsResponse.statusText}`);
|
throw new Error(`Failed to fetch stats: ${statsResponse.statusText}`);
|
||||||
}
|
}
|
||||||
const statsData = await statsResponse.json();
|
const statsData = await statsResponse.json();
|
||||||
|
|
||||||
// 处理并设置数据
|
|
||||||
const uiLinks = linksData.data.map(mapApiLinkToUiLink);
|
|
||||||
setLinks(uiLinks);
|
|
||||||
setAllTags(tagsData);
|
setAllTags(tagsData);
|
||||||
setStats(statsData);
|
setStats(statsData);
|
||||||
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('Data loading failed:', err);
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
console.error('Data loading failed:', err);
|
||||||
} finally {
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
if (isInitialLoad) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
} else {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// 初始加载
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
fetchLinks(1, true);
|
||||||
|
}, [fetchLinks]);
|
||||||
|
|
||||||
|
// 搜索过滤变化时重新加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
// 当搜索关键词变化时,重置页码和链接列表,然后重新获取数据
|
||||||
|
setLinks([]);
|
||||||
|
setPage(1);
|
||||||
|
fetchLinks(1, true);
|
||||||
|
}, [searchQuery, fetchLinks]);
|
||||||
|
|
||||||
|
// 设置Intersection Observer来检测滚动并加载更多数据
|
||||||
|
useEffect(() => {
|
||||||
|
// 如果正在加载或没有更多数据,则不设置observer
|
||||||
|
if (isLoading || isLoadingMore || !hasMore) return;
|
||||||
|
|
||||||
|
// 断开之前的observer连接
|
||||||
|
if (observer.current) {
|
||||||
|
observer.current.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
observer.current = new IntersectionObserver(entries => {
|
||||||
|
if (entries[0].isIntersecting && hasMore) {
|
||||||
|
// 当最后一个元素可见且有更多数据时,加载下一页
|
||||||
|
setPage(prevPage => prevPage + 1);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
root: null,
|
||||||
|
rootMargin: '0px',
|
||||||
|
threshold: 0.5
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lastLinkElementRef.current) {
|
||||||
|
observer.current.observe(lastLinkElementRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (observer.current) {
|
||||||
|
observer.current.disconnect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}, [isLoading, isLoadingMore, hasMore, links]);
|
||||||
fetchLinks();
|
|
||||||
}, []);
|
// 当页码变化时加载更多数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > 1) {
|
||||||
|
fetchLinks(page, false);
|
||||||
|
}
|
||||||
|
}, [page, fetchLinks]);
|
||||||
|
|
||||||
const filteredLinks = links.filter(link =>
|
const filteredLinks = links.filter(link =>
|
||||||
link.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
link.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
@@ -152,14 +226,8 @@ export default function LinksPage() {
|
|||||||
console.log('创建链接:', linkData);
|
console.log('创建链接:', linkData);
|
||||||
|
|
||||||
// 刷新链接列表
|
// 刷新链接列表
|
||||||
const response = await fetch('/api/links');
|
setPage(1);
|
||||||
if (!response.ok) {
|
fetchLinks(1, true);
|
||||||
throw new Error(`刷新链接列表失败: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newData = await response.json();
|
|
||||||
const uiLinks = newData.data.map(mapApiLinkToUiLink);
|
|
||||||
setLinks(uiLinks);
|
|
||||||
|
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -309,8 +377,8 @@ export default function LinksPage() {
|
|||||||
{/* Links Table */}
|
{/* Links Table */}
|
||||||
<div className="overflow-hidden border rounded-lg shadow bg-card-bg border-card-border">
|
<div className="overflow-hidden border rounded-lg shadow bg-card-bg border-card-border">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm text-left text-text-secondary">
|
<table className="min-w-full divide-y divide-card-border">
|
||||||
<thead className="text-xs uppercase border-b bg-card-bg/60 text-text-secondary border-card-border">
|
<thead className="bg-card-bg-secondary">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" className="px-6 py-3">Link Info</th>
|
<th scope="col" className="px-6 py-3">Link Info</th>
|
||||||
<th scope="col" className="px-6 py-3">Visits</th>
|
<th scope="col" className="px-6 py-3">Visits</th>
|
||||||
@@ -323,28 +391,20 @@ export default function LinksPage() {
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="divide-y divide-card-border">
|
||||||
{isLoading && links.length === 0 ? (
|
{filteredLinks.length === 0 ? (
|
||||||
<tr className="border-b bg-card-bg border-card-border">
|
<tr>
|
||||||
<td colSpan={7} className="px-6 py-4 text-center text-text-secondary">
|
<td colSpan={7} className="px-6 py-12 text-center text-text-secondary">
|
||||||
<div className="flex items-center justify-center">
|
No links found. Create one to get started.
|
||||||
<div className="w-6 h-6 border-2 rounded-full border-accent-blue border-t-transparent animate-spin"></div>
|
|
||||||
<span className="ml-2">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : filteredLinks.length === 0 ? (
|
|
||||||
<tr className="border-b bg-card-bg border-card-border">
|
|
||||||
<td colSpan={7} className="px-6 py-4 text-center text-text-secondary">
|
|
||||||
No links found matching your search criteria
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredLinks.map((link) => (
|
filteredLinks.map((link, index) => (
|
||||||
<tr
|
<tr
|
||||||
key={link.id}
|
key={link.id}
|
||||||
className="border-b cursor-pointer bg-card-bg border-card-border hover:bg-card-bg/80"
|
|
||||||
onClick={() => handleOpenLinkDetails(link.id)}
|
onClick={() => handleOpenLinkDetails(link.id)}
|
||||||
|
className="transition-colors cursor-pointer hover:bg-card-bg-secondary"
|
||||||
|
ref={index === filteredLinks.length - 1 ? lastLinkElementRef : null}
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="font-medium text-foreground">{link.name}</div>
|
<div className="font-medium text-foreground">{link.name}</div>
|
||||||
@@ -436,6 +496,21 @@ export default function LinksPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Loading more indicator */}
|
||||||
|
{isLoadingMore && (
|
||||||
|
<div className="p-4 text-center">
|
||||||
|
<div className="inline-block w-6 h-6 border-2 rounded-full border-accent-blue border-t-transparent animate-spin"></div>
|
||||||
|
<p className="mt-2 text-sm text-text-secondary">Loading more links...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* End of results message */}
|
||||||
|
{!hasMore && links.length > 0 && (
|
||||||
|
<div className="p-4 text-center text-sm text-text-secondary">
|
||||||
|
No more links to load.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags Section */}
|
{/* Tags Section */}
|
||||||
|
|||||||
Reference in New Issue
Block a user