Spaces:
Running
Running
| import { useState, useCallback } from 'react'; | |
| import { Paper, SectionType } from '../types'; | |
| import { searchArxiv, fetchPaperSections } from '../utils/api'; | |
| import { isFavorited, addFavorite, removeFavorite, getCachedSections, setCachedSections } from '../utils/storage'; | |
| interface Props { | |
| onViewDetail: (paper: Paper) => void; | |
| onRefreshFavorites: () => void; | |
| } | |
| const SECTION_OPTIONS: { key: SectionType; label: string; labelZh: string }[] = [ | |
| { key: 'abstract', label: 'Abstract', labelZh: '摘要' }, | |
| { key: 'introduction', label: 'Introduction', labelZh: '引言' }, | |
| { key: 'relatedWork', label: 'Related Work', labelZh: '相关工作' }, | |
| { key: 'methods', label: 'Methods', labelZh: '方法' }, | |
| ]; | |
| export default function SearchView({ onViewDetail, onRefreshFavorites }: Props) { | |
| const [query, setQuery] = useState(''); | |
| const [papers, setPapers] = useState<Paper[]>([]); | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState(''); | |
| const [total, setTotal] = useState(0); | |
| const [page, setPage] = useState(0); | |
| const [selectedSections, setSelectedSections] = useState<SectionType[]>(['abstract']); | |
| const [batchLoading, setBatchLoading] = useState(false); | |
| const [batchProgress, setBatchProgress] = useState({ done: 0, total: 0 }); | |
| const pageSize = 10; | |
| const doSearch = useCallback( | |
| async (startPage: number = 0) => { | |
| if (!query.trim()) return; | |
| setLoading(true); | |
| setError(''); | |
| try { | |
| const { papers: results, total: t } = await searchArxiv( | |
| query.trim(), | |
| startPage * pageSize, | |
| pageSize | |
| ); | |
| if (startPage === 0) { | |
| setPapers(results); | |
| } else { | |
| setPapers((prev) => [...prev, ...results]); | |
| } | |
| setTotal(t); | |
| setPage(startPage); | |
| } catch (e) { | |
| setError(e instanceof Error ? e.message : 'Search failed'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, | |
| [query] | |
| ); | |
| const loadMore = () => { | |
| doSearch(page + 1); | |
| }; | |
| const loadSectionsForPaper = async (paper: Paper): Promise<Paper> => { | |
| const needsFullText = selectedSections.some((s) => s !== 'abstract'); | |
| if (!needsFullText) return paper; | |
| if (paper.sectionsLoaded) return paper; | |
| // Check cache | |
| const cached = getCachedSections(paper.id); | |
| if (cached) { | |
| return { | |
| ...paper, | |
| introduction: cached.introduction, | |
| relatedWork: cached.relatedWork, | |
| methods: cached.methods, | |
| references: cached.references, | |
| sectionsLoaded: true, | |
| }; | |
| } | |
| try { | |
| const sections = await fetchPaperSections(paper.id); | |
| setCachedSections(paper.id, sections); | |
| return { | |
| ...paper, | |
| ...sections, | |
| sectionsLoaded: true, | |
| }; | |
| } catch (e) { | |
| return { | |
| ...paper, | |
| sectionsError: e instanceof Error ? e.message : 'Failed to load', | |
| sectionsLoaded: false, | |
| }; | |
| } | |
| }; | |
| const batchLoadSections = async () => { | |
| const needsFullText = selectedSections.some((s) => s !== 'abstract'); | |
| if (!needsFullText) return; | |
| setBatchLoading(true); | |
| const toLoad = papers.filter((p) => !p.sectionsLoaded); | |
| setBatchProgress({ done: 0, total: toLoad.length }); | |
| for (let i = 0; i < toLoad.length; i++) { | |
| const updated = await loadSectionsForPaper(toLoad[i]); | |
| setPapers((prev) => prev.map((p) => (p.id === updated.id ? updated : p))); | |
| setBatchProgress({ done: i + 1, total: toLoad.length }); | |
| } | |
| setBatchLoading(false); | |
| }; | |
| const toggleSection = (section: SectionType) => { | |
| setSelectedSections((prev) => | |
| prev.includes(section) ? prev.filter((s) => s !== section) : [...prev, section] | |
| ); | |
| }; | |
| const toggleFavorite = (paper: Paper) => { | |
| if (isFavorited(paper.id)) { | |
| removeFavorite(paper.id); | |
| } else { | |
| addFavorite(paper.id, paper.title, paper.authors); | |
| } | |
| onRefreshFavorites(); | |
| setPapers((prev) => [...prev]); // trigger re-render | |
| }; | |
| const viewDetail = async (paper: Paper) => { | |
| let p = paper; | |
| if (!p.sectionsLoaded && selectedSections.some((s) => s !== 'abstract')) { | |
| p = { ...p, sectionsLoading: true }; | |
| setPapers((prev) => prev.map((x) => (x.id === p.id ? p : x))); | |
| p = await loadSectionsForPaper(p); | |
| p.sectionsLoading = false; | |
| setPapers((prev) => prev.map((x) => (x.id === p.id ? p : x))); | |
| } | |
| onViewDetail(p); | |
| }; | |
| return ( | |
| <div className="max-w-6xl mx-auto"> | |
| {/* Search Bar */} | |
| <div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6"> | |
| <div className="flex gap-3 mb-4"> | |
| <input | |
| type="text" | |
| value={query} | |
| onChange={(e) => setQuery(e.target.value)} | |
| onKeyDown={(e) => e.key === 'Enter' && doSearch(0)} | |
| placeholder="Search ArXiv papers by keyword..." | |
| className="flex-1 px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-800 placeholder-gray-400" | |
| /> | |
| <button | |
| onClick={() => doSearch(0)} | |
| disabled={loading || !query.trim()} | |
| className="px-8 py-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors" | |
| > | |
| {loading ? ( | |
| <span className="flex items-center gap-2"> | |
| <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24"> | |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /> | |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> | |
| </svg> | |
| 搜索中... | |
| </span> | |
| ) : ( | |
| '🔍 搜索 Search' | |
| )} | |
| </button> | |
| </div> | |
| {/* Section Filters */} | |
| <div className="flex flex-wrap items-center gap-3"> | |
| <span className="text-sm text-gray-500 font-medium">获取章节 Sections:</span> | |
| {SECTION_OPTIONS.map((opt) => ( | |
| <label | |
| key={opt.key} | |
| className={`flex items-center gap-2 px-3 py-1.5 rounded-lg cursor-pointer text-sm transition-colors ${ | |
| selectedSections.includes(opt.key) | |
| ? 'bg-indigo-50 text-indigo-700 border border-indigo-200' | |
| : 'bg-gray-50 text-gray-500 border border-gray-100 hover:bg-gray-100' | |
| }`} | |
| > | |
| <input | |
| type="checkbox" | |
| checked={selectedSections.includes(opt.key)} | |
| onChange={() => toggleSection(opt.key)} | |
| className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" | |
| /> | |
| {opt.label} {opt.labelZh} | |
| </label> | |
| ))} | |
| {papers.length > 0 && selectedSections.some((s) => s !== 'abstract') && ( | |
| <button | |
| onClick={batchLoadSections} | |
| disabled={batchLoading} | |
| className="ml-auto px-4 py-1.5 text-sm bg-amber-50 text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-100 disabled:opacity-50 transition-colors" | |
| > | |
| {batchLoading | |
| ? `加载中 Loading... (${batchProgress.done}/${batchProgress.total})` | |
| : '📥 批量加载全文 Batch Load Sections'} | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Results Info */} | |
| {total > 0 && ( | |
| <div className="mb-4 text-sm text-gray-500"> | |
| 共找到 Found <span className="font-semibold text-gray-700">{total.toLocaleString()}</span> 篇文章, | |
| 已加载 loaded <span className="font-semibold text-gray-700">{papers.length}</span> 篇 | |
| </div> | |
| )} | |
| {error && ( | |
| <div className="mb-4 p-4 bg-red-50 text-red-700 rounded-xl border border-red-100"> | |
| ⚠️ {error} | |
| </div> | |
| )} | |
| {/* Paper List */} | |
| <div className="space-y-4"> | |
| {papers.map((paper) => ( | |
| <PaperCard | |
| key={paper.id} | |
| paper={paper} | |
| selectedSections={selectedSections} | |
| onViewDetail={() => viewDetail(paper)} | |
| onToggleFavorite={() => toggleFavorite(paper)} | |
| isFav={isFavorited(paper.id)} | |
| /> | |
| ))} | |
| </div> | |
| {/* Load More */} | |
| {papers.length < total && ( | |
| <div className="mt-6 text-center"> | |
| <button | |
| onClick={loadMore} | |
| disabled={loading} | |
| className="px-8 py-3 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 text-gray-700 font-medium transition-colors disabled:opacity-50" | |
| > | |
| {loading ? '加载中 Loading...' : `加载更多 Load More (${papers.length}/${total})`} | |
| </button> | |
| </div> | |
| )} | |
| {!loading && papers.length === 0 && query && ( | |
| <div className="text-center py-20 text-gray-400"> | |
| <div className="text-5xl mb-4">📄</div> | |
| <p>未找到结果 No results found</p> | |
| <p className="text-sm mt-1">请尝试其他关键词 Try different keywords</p> | |
| </div> | |
| )} | |
| {!query && papers.length === 0 && ( | |
| <div className="text-center py-20 text-gray-400"> | |
| <div className="text-5xl mb-4">🔬</div> | |
| <p className="text-lg font-medium">ArXiv Research Explorer</p> | |
| <p className="text-sm mt-2">输入关键词搜索学术论文</p> | |
| <p className="text-sm">Enter keywords to search academic papers</p> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function PaperCard({ | |
| paper, | |
| selectedSections, | |
| onViewDetail, | |
| onToggleFavorite, | |
| isFav, | |
| }: { | |
| paper: Paper; | |
| selectedSections: SectionType[]; | |
| onViewDetail: () => void; | |
| onToggleFavorite: () => void; | |
| isFav: boolean; | |
| }) { | |
| const date = paper.published ? new Date(paper.published).toLocaleDateString() : ''; | |
| const previewText = | |
| paper.abstract.length > 300 ? paper.abstract.substring(0, 300) + '...' : paper.abstract; | |
| return ( | |
| <div className="bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-shadow p-5"> | |
| <div className="flex items-start justify-between gap-4"> | |
| <div className="flex-1 min-w-0"> | |
| <h3 | |
| className="text-lg font-semibold text-gray-800 hover:text-indigo-600 cursor-pointer leading-snug" | |
| onClick={onViewDetail} | |
| > | |
| {paper.title} | |
| </h3> | |
| <div className="mt-1.5 flex flex-wrap items-center gap-2 text-sm text-gray-500"> | |
| <span>{paper.authors.slice(0, 3).join(', ')}{paper.authors.length > 3 ? ` +${paper.authors.length - 3}` : ''}</span> | |
| <span>·</span> | |
| <span>{date}</span> | |
| <span>·</span> | |
| <span className="text-xs bg-gray-100 px-2 py-0.5 rounded">{paper.id}</span> | |
| {paper.categories.slice(0, 2).map((c) => ( | |
| <span key={c} className="text-xs bg-blue-50 text-blue-600 px-2 py-0.5 rounded"> | |
| {c} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2 shrink-0"> | |
| <button | |
| onClick={onToggleFavorite} | |
| className={`p-2 rounded-lg transition-colors ${ | |
| isFav | |
| ? 'bg-amber-50 text-amber-500 hover:bg-amber-100' | |
| : 'bg-gray-50 text-gray-400 hover:bg-gray-100 hover:text-amber-500' | |
| }`} | |
| title={isFav ? '取消收藏 Unfavorite' : '收藏 Favorite'} | |
| > | |
| {isFav ? '★' : '☆'} | |
| </button> | |
| </div> | |
| </div> | |
| {selectedSections.includes('abstract') && ( | |
| <p className="mt-3 text-sm text-gray-600 leading-relaxed">{previewText}</p> | |
| )} | |
| <div className="mt-3 flex items-center gap-3"> | |
| <button | |
| onClick={onViewDetail} | |
| className="px-4 py-1.5 text-sm bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-100 transition-colors font-medium" | |
| > | |
| 📖 查看详情 View Details | |
| </button> | |
| <a | |
| href={paper.pdfLink} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="px-4 py-1.5 text-sm bg-emerald-50 text-emerald-600 rounded-lg hover:bg-emerald-100 transition-colors font-medium" | |
| > | |
| </a> | |
| <a | |
| href={`https://arxiv.org/abs/${paper.id}`} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="px-4 py-1.5 text-sm bg-gray-50 text-gray-600 rounded-lg hover:bg-gray-100 transition-colors font-medium" | |
| > | |
| 🔗 ArXiv | |
| </a> | |
| {paper.sectionsLoading && ( | |
| <span className="text-sm text-gray-400 flex items-center gap-1"> | |
| <svg className="animate-spin h-3 w-3" viewBox="0 0 24 24"> | |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /> | |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> | |
| </svg> | |
| 加载章节中... | |
| </span> | |
| )} | |
| {paper.sectionsLoaded && ( | |
| <span className="text-sm text-emerald-500">✓ 已加载 Loaded</span> | |
| )} | |
| {paper.sectionsError && ( | |
| <span className="text-sm text-red-400">⚠ {paper.sectionsError}</span> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |