refactor!: 模块化拆分 src 结构,新增批量同步服务、查询解析器及前端分页/高级检索功能

- src/ 按 clients/services/api 分层,Config 提升至 crate 根
- 新增 batch_sync.rs(双源并行收割)、query_parser.rs(多平台检索式转换)
- build.rs 自动触发前端 npm install & build
- SearchPanel 支持分页/排序/每页条数/高级检索构建器,前端加入搜索缓存
- 新增 SyncPanel 替换 SettingsPanel;新增 live_search 集成测试
This commit is contained in:
fmq
2026-06-09 10:29:24 +08:00
parent 307a1c0cee
commit e13fa2ad40
27 changed files with 4115 additions and 207 deletions
+1
View File
@@ -27,6 +27,7 @@ npm run dev
- `src/features/search/`:统一检索面板,支持跨源搜索与收藏。
- `src/features/library/`:馆藏管理卡片,提供下载状态实时监测及重新下载操作。
- `src/features/reader/`:左右对齐的双分栏阅读器,内置划词高亮笔记及 LLM 重新翻译触发。
- `src/features/sync/`:批量同步面板,支持后台元数据大批量采集、过滤及文献资源批量下载/解析流水线任务管理。
- `src/types.ts`:全局 TypeScript 静态类型定义。
---
+50 -8
View File
@@ -6,11 +6,11 @@ import { SearchPanel } from './features/search/SearchPanel';
import { LibraryPanel } from './features/library/LibraryPanel';
import { ReaderPanel } from './features/reader/ReaderPanel';
import { CitationPanel } from './features/citation/CitationPanel';
import { SettingsPanel } from './features/settings/SettingsPanel';
import { SyncPanel } from './features/sync/SyncPanel';
import type { StandardPaper, CitationNetwork, NoteRecord } from './types';
export default function App() {
const [activeTab, setActiveTab] = useState<'search' | 'library' | 'reader' | 'citation' | 'settings'>('search');
const [activeTab, setActiveTab] = useState<'search' | 'library' | 'reader' | 'citation' | 'sync'>('search');
// 共享数据状态
const [library, setLibrary] = useState<StandardPaper[]>([]);
@@ -24,6 +24,10 @@ export default function App() {
const [exportingList, setExportingList] = useState<string[]>([]);
const [bibtexContent, setBibtexContent] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const [searchRows, setSearchRows] = useState(15);
const [searchStart, setSearchStart] = useState(0);
const [searchSort, setSearchSort] = useState('relevance');
const [searchCache, setSearchCache] = useState<Record<string, StandardPaper[]>>({});
// 读者页状态
const [englishText, setEnglishText] = useState('');
@@ -61,17 +65,26 @@ export default function App() {
}
};
// 2. 检索文献
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
// 2. 检索文献 (统一执行逻辑,包含前端缓存)
const executeSearch = async (start: number, rows: number, sort: string) => {
if (!searchQuery.trim()) return;
// 构造缓存 Key
const cacheKey = `${searchQuery.trim()}_${searchSource}_${start}_${rows}_${sort}`;
if (searchCache[cacheKey]) {
setSearchResults(searchCache[cacheKey]);
return;
}
setSearching(true);
setBibtexContent(null);
try {
const res = await axios.get<StandardPaper[]>('/api/search', {
params: { q: searchQuery, source: searchSource, rows: 15 }
params: { q: searchQuery, source: searchSource, rows, start, sort }
});
setSearchResults(res.data);
// 写入缓存
setSearchCache(prev => ({ ...prev, [cacheKey]: res.data }));
} catch (e) {
console.error('检索文献失败', e);
alert('检索失败,请确认后端连接及 API 密钥配置。');
@@ -80,6 +93,29 @@ export default function App() {
}
};
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
setSearchStart(0);
executeSearch(0, searchRows, searchSort);
};
const handlePageChange = (newStart: number) => {
setSearchStart(newStart);
executeSearch(newStart, searchRows, searchSort);
};
const handleSortChange = (newSort: string) => {
setSearchSort(newSort);
setSearchStart(0);
executeSearch(0, searchRows, newSort);
};
const handleRowsChange = (newRows: number) => {
setSearchRows(newRows);
setSearchStart(0);
executeSearch(0, newRows, searchSort);
};
// 3. 触发文献双格式下载
const handleDownload = async (bibcode: string, force = false) => {
setDownloadingBibcodes(prev => ({ ...prev, [bibcode]: true }));
@@ -300,6 +336,12 @@ export default function App() {
setSearchQuery={setSearchQuery}
searchSource={searchSource}
setSearchSource={setSearchSource}
searchRows={searchRows}
searchStart={searchStart}
searchSort={searchSort}
handlePageChange={handlePageChange}
handleSortChange={handleSortChange}
handleRowsChange={handleRowsChange}
searching={searching}
handleSearch={handleSearch}
searchResults={searchResults}
@@ -367,8 +409,8 @@ export default function App() {
/>
)}
{activeTab === 'settings' && (
<SettingsPanel />
{activeTab === 'sync' && (
<SyncPanel />
)}
</div>
</main>
+4 -4
View File
@@ -1,10 +1,10 @@
// dashboard/src/components/layout/Sidebar.tsx
import { Search, BookOpen, GitFork, Library, Settings } from 'lucide-react';
import { Search, BookOpen, GitFork, Library, RefreshCw } from 'lucide-react';
import type { StandardPaper } from '../../types';
interface SidebarProps {
activeTab: 'search' | 'library' | 'reader' | 'citation' | 'settings';
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'settings') => void;
activeTab: 'search' | 'library' | 'reader' | 'citation' | 'sync';
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync') => void;
selectedPaper: StandardPaper | null;
loadCitations: (bibcode: string) => void;
}
@@ -29,9 +29,9 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations
{[
{ id: 'search', label: '统一检索', icon: Search },
{ id: 'library', label: '馆藏管理', icon: Library },
{ id: 'sync', label: '批量同步', icon: RefreshCw },
{ id: 'reader', label: '双语阅读', icon: BookOpen, disabled: !selectedPaper },
{ id: 'citation', label: '引用星系', icon: GitFork, disabled: !selectedPaper },
{ id: 'settings', label: '系统设置', icon: Settings },
].map(tab => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
@@ -7,7 +7,7 @@ interface LibraryPanelProps {
fetchLibrary: () => void;
openReader: (paper: StandardPaper) => void;
setSelectedPaper: (paper: StandardPaper | null) => void;
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'settings') => void;
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync') => void;
loadCitations: (bibcode: string) => void;
downloadingBibcodes: Record<string, boolean>;
handleDownload: (bibcode: string, force?: boolean) => void;
+321 -108
View File
@@ -1,6 +1,6 @@
// dashboard/src/features/search/SearchPanel.tsx
import React from 'react';
import { Search, Loader, CheckCircle, Copy, Download, RefreshCw } from 'lucide-react';
import { Search, Loader, CheckCircle, Copy, Download, ChevronLeft, ChevronRight } from 'lucide-react';
import type { StandardPaper } from '../../types';
interface SearchPanelProps {
@@ -8,6 +8,12 @@ interface SearchPanelProps {
setSearchQuery: (query: string) => void;
searchSource: 'all' | 'ads' | 'arxiv';
setSearchSource: (src: 'all' | 'ads' | 'arxiv') => void;
searchRows: number;
searchStart: number;
searchSort: string;
handlePageChange: (newStart: number) => void;
handleSortChange: (newSort: string) => void;
handleRowsChange: (newRows: number) => void;
searching: boolean;
handleSearch: (e: React.FormEvent) => void;
searchResults: StandardPaper[];
@@ -21,7 +27,7 @@ interface SearchPanelProps {
selectedPaper: StandardPaper | null;
setSelectedPaper: (paper: StandardPaper | null) => void;
openReader: (paper: StandardPaper) => void;
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'settings') => void;
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync') => void;
loadCitations: (bibcode: string, reset?: boolean) => void;
}
@@ -30,6 +36,12 @@ export function SearchPanel({
setSearchQuery,
searchSource,
setSearchSource,
searchRows,
searchStart,
searchSort,
handlePageChange,
handleSortChange,
handleRowsChange,
searching,
handleSearch,
searchResults,
@@ -46,31 +58,178 @@ export function SearchPanel({
setActiveTab,
loadCitations,
}: SearchPanelProps) {
const currentPage = Math.floor(searchStart / searchRows) + 1;
const hasPreviousPage = searchStart > 0;
const hasNextPage = searchResults.length >= searchRows;
const [showBuilder, setShowBuilder] = React.useState(false);
const [rules, setRules] = React.useState<Array<{ field: string; op: string; val: string }>>([
{ field: 'all', op: 'AND', val: '' }
]);
// 当高级表单规则变化时,自动更新主输入框的检索式
const updateQueryFromRules = (currentRules: typeof rules) => {
let qParts: string[] = [];
currentRules.forEach((rule, idx) => {
if (!rule.val.trim()) return;
let valStr = rule.val.trim();
// 如果包含空格且未加双引号,且不是括号表达式,则自动加上双引号
if (valStr.includes(' ') && !valStr.startsWith('"') && !valStr.startsWith('(')) {
valStr = `"${valStr}"`;
}
let fieldPart = '';
if (rule.field !== 'all') {
fieldPart = `${rule.field}:${valStr}`;
} else {
fieldPart = valStr;
}
if (idx === 0) {
qParts.push(fieldPart);
} else {
qParts.push(`${rule.op} ${fieldPart}`);
}
});
setSearchQuery(qParts.join(' '));
};
const handleAddRule = () => {
setRules(prev => [...prev, { field: 'all', op: 'AND', val: '' }]);
};
const handleRemoveRule = (idx: number) => {
const next = rules.filter((_, i) => i !== idx);
setRules(next);
updateQueryFromRules(next);
};
const handleRuleChange = (idx: number, key: 'field' | 'op' | 'val', value: string) => {
const next = rules.map((r, i) => i === idx ? { ...r, [key]: value } : r);
setRules(next);
updateQueryFromRules(next);
};
return (
<div className="space-y-6 max-w-5xl mx-auto">
{/* 搜索 */}
<div className="glass p-6 rounded-2xl">
{/* 搜索和过滤控制面板 */}
<div className="glass p-6 rounded-2xl space-y-4">
<form onSubmit={handleSearch} className="space-y-4">
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 w-5 h-5" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="检索天文学文献 (支持关键字、作者、年份范围检索,如 'hot subdwarf year:2020-2023')"
className="w-full pl-12 pr-4 py-4 rounded-xl bg-white/60 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition-all text-sm"
/>
<div className="flex gap-3 items-center">
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 w-5 h-5" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="检索天文学文献 (支持关键字、作者、年份范围检索,如 'hot subdwarf year:2020-2023')"
className="w-full pl-12 pr-4 py-4 rounded-xl bg-white/60 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition-all text-sm"
/>
</div>
<button
type="button"
onClick={() => setShowBuilder(!showBuilder)}
className={`px-4 py-4 rounded-xl border text-xs font-semibold transition-all ${
showBuilder
? 'bg-purple-50 border-purple-300 text-purple-600'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{showBuilder ? '隐藏生成器' : '高级检索生成器'}
</button>
<button
type="submit"
disabled={searching}
className="absolute right-3 top-1/2 -translate-y-1/2 px-5 py-2 rounded-lg bg-gradient-to-r from-purple-600 to-indigo-600 text-white text-xs font-semibold hover:from-purple-500 hover:to-indigo-500 transition-all flex items-center gap-2"
className="px-6 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-indigo-600 text-white text-xs font-semibold hover:from-purple-500 hover:to-indigo-500 transition-all flex items-center gap-2 shrink-0 shadow-lg shadow-purple-500/10"
>
{searching ? <Loader className="w-3.5 h-3.5 animate-spin" /> : null}
{searching ? '检索中' : '开始检索'}
</button>
</div>
<div className="flex items-center justify-between">
{/* 动态表单生成器 */}
{showBuilder && (
<div className="p-5 rounded-xl bg-slate-50/70 border border-slate-200/60 space-y-3.5 transition-all">
<div className="text-xs font-bold text-slate-700 flex justify-between items-center">
<span></span>
<button
type="button"
onClick={handleAddRule}
className="text-[10px] text-purple-600 hover:underline"
>
+
</button>
</div>
<div className="space-y-2.5">
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
{idx > 0 ? (
<select
value={rule.op}
onChange={e => handleRuleChange(idx, 'op', e.target.value)}
className="bg-white border border-slate-200 rounded-lg px-2 py-1.5 text-xs text-slate-600 focus:outline-none focus:border-purple-500 w-20"
>
<option value="AND">AND </option>
<option value="OR">OR </option>
<option value="NOT">NOT </option>
</select>
) : (
<div className="w-20 text-center text-xs text-slate-400 font-medium">:</div>
)}
<select
value={rule.field}
onChange={e => handleRuleChange(idx, 'field', e.target.value)}
className="bg-white border border-slate-200 rounded-lg px-2.5 py-1.5 text-xs text-slate-600 focus:outline-none focus:border-purple-500 w-32"
>
<option value="all"> (all)</option>
<option value="title"> (title)</option>
<option value="author"> (author)</option>
<option value="abs"> (abs)</option>
<option value="year"> (year)</option>
</select>
<input
type="text"
value={rule.val}
onChange={e => handleRuleChange(idx, 'val', e.target.value)}
placeholder={
rule.field === 'year'
? '例如: 2020-2023 或 2022'
: rule.field === 'author'
? '例如: Althaus'
: '输入检索词...'
}
className="flex-1 px-3 py-1.5 rounded-lg bg-white border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 text-xs"
/>
{rules.length > 1 && (
<button
type="button"
onClick={() => handleRemoveRule(idx)}
className="text-slate-400 hover:text-rose-500 text-xs px-2 py-1.5"
>
</button>
)}
</div>
))}
</div>
</div>
)}
<div className="text-[10px] text-slate-400 flex flex-wrap gap-x-4 gap-y-1 px-1">
<span>💡 :</span>
<span>: <code className="bg-slate-100 px-1 py-0.5 rounded font-mono text-[9px]">author:"Althaus"</code></span>
<span>: <code className="bg-slate-100 px-1 py-0.5 rounded font-mono text-[9px]">title:"hot subdwarf"</code></span>
<span>: <code className="bg-slate-100 px-1 py-0.5 rounded font-mono text-[9px]">year:2020-2023</code></span>
<span>: <code className="bg-slate-100 px-1 py-0.5 rounded font-mono text-[9px]">(sdOB OR "white dwarf") AND Gaia</code></span>
</div>
<div className="flex flex-wrap items-center justify-between gap-4 pt-2 border-t border-slate-100">
{/* 数据源选择 */}
<div className="flex gap-4">
{[
{ id: 'all', label: '全部数据源' },
@@ -92,16 +251,48 @@ export function SearchPanel({
))}
</div>
{exportingList.length > 0 && (
<button
onClick={handleExportBibtex}
disabled={exporting}
className="px-4 py-1.5 rounded-lg bg-slate-100 border border-slate-200 text-xs text-slate-600 hover:bg-slate-200 hover:text-slate-800 flex items-center gap-1.5"
>
{exporting ? <Loader className="w-3 h-3 animate-spin" /> : null}
({exportingList.length}) BibTeX
</button>
)}
{/* 排序及最大结果数量控制 */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">:</span>
<select
value={searchSort}
onChange={e => handleSortChange(e.target.value)}
className="bg-white/60 border border-slate-200 rounded-lg px-2.5 py-1 text-xs text-slate-600 focus:outline-none focus:border-purple-500"
>
<option value="relevance"></option>
<option value="date_desc"> ()</option>
<option value="date_asc"> ()</option>
<option value="citations_desc"> ()</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">:</span>
<select
value={searchRows}
onChange={e => handleRowsChange(Number(e.target.value))}
className="bg-white/60 border border-slate-200 rounded-lg px-2.5 py-1 text-xs text-slate-600 focus:outline-none focus:border-purple-500"
>
<option value="10">10 </option>
<option value="15">15 </option>
<option value="30">30 </option>
<option value="50">50 </option>
<option value="100">100 </option>
</select>
</div>
{exportingList.length > 0 && (
<button
onClick={handleExportBibtex}
disabled={exporting}
className="px-4 py-1.5 rounded-lg bg-slate-100 border border-slate-200 text-xs text-slate-600 hover:bg-slate-200 hover:text-slate-800 flex items-center gap-1.5"
>
{exporting ? <Loader className="w-3 h-3 animate-spin" /> : null}
({exportingList.length}) BibTeX
</button>
)}
</div>
</div>
</form>
</div>
@@ -128,103 +319,125 @@ export function SearchPanel({
)}
{/* 检索列表 */}
<div className="space-y-4">
{searchResults.map(paper => {
const isDownloading = downloadingBibcodes[paper.bibcode] || false;
const isSelected = selectedPaper?.bibcode === paper.bibcode;
return (
<div
key={paper.bibcode}
className={`glass p-6 rounded-2xl transition-all border ${
isSelected ? 'border-purple-500/50 bg-purple-50/50' : 'border-slate-200/80 hover:border-slate-300'
}`}
>
<div className="flex justify-between items-start gap-4 mb-2">
<h3
className="font-bold text-base text-slate-800 line-clamp-1 leading-snug hover:text-purple-600 cursor-pointer"
onClick={() => openReader(paper)}
>
{paper.title}
</h3>
<div className="flex gap-2 shrink-0">
{paper.is_downloaded ? (
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded bg-emerald-50 text-emerald-600 border border-emerald-200 text-[10px] font-bold uppercase"></span>
<button
onClick={() => { if (confirm('确定要强制重新下载吗?这会覆盖本地文件。')) handleDownload(paper.bibcode, true); }}
disabled={isDownloading}
className="px-3 py-1 rounded bg-slate-100 border border-slate-200 text-xs text-amber-600 hover:bg-slate-200 hover:text-amber-700 flex items-center gap-1 font-semibold"
>
{isDownloading ? <Loader className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
{isDownloading ? '下载中' : '重新下载'}
</button>
</div>
) : (
<button
onClick={() => handleDownload(paper.bibcode)}
disabled={isDownloading}
className="px-3 py-1 rounded bg-slate-100 border border-slate-200 text-xs text-slate-600 hover:bg-slate-200 hover:text-slate-900 flex items-center gap-1"
>
{isDownloading ? <Loader className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
{isDownloading ? '下载中' : '下载 PDF/HTML'}
</button>
)}
<input
type="checkbox"
checked={exportingList.includes(paper.bibcode)}
onChange={() => toggleExportItem(paper.bibcode)}
className="rounded text-purple-600 border-slate-300 bg-white focus:ring-purple-500"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-slate-500 mb-3">
<span className="font-medium text-slate-700">
{paper.authors.slice(0, 3).join(', ')}{paper.authors.length > 3 ? ' et al.' : ''}
</span>
<span className="text-slate-300"></span>
<span>{paper.year}</span>
<span className="text-slate-300"></span>
<span className="text-slate-500">{paper.pub_journal}</span>
{paper.citation_count > 0 && (
<>
<span className="text-slate-300"></span>
<span className="text-amber-600 font-medium">: {paper.citation_count}</span>
</>
)}
</div>
<p className="text-xs text-slate-600 line-clamp-3 mb-4 leading-relaxed">{paper.abstract_text}</p>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<button
<div className="relative min-h-[200px]">
{searching && (
<div className="absolute inset-0 bg-white/40 backdrop-blur-[2px] z-10 flex items-center justify-center rounded-2xl">
<div className="flex flex-col items-center gap-3 bg-white p-6 rounded-2xl shadow-xl border border-slate-200">
<Loader className="w-8 h-8 text-purple-600 animate-spin" />
<span className="text-xs font-semibold text-slate-500">...</span>
</div>
</div>
)}
<div className={`space-y-4 transition-all duration-300 ${searching ? 'opacity-40 pointer-events-none filter blur-[1px]' : ''}`}>
{searchResults.map(paper => {
const isDownloading = downloadingBibcodes[paper.bibcode] || false;
const isSelected = selectedPaper?.bibcode === paper.bibcode;
return (
<div
key={paper.bibcode}
className={`glass p-6 rounded-2xl transition-all border ${
isSelected ? 'border-purple-500/50 bg-purple-50/50' : 'border-slate-200/80 hover:border-slate-300'
}`}
>
<div className="flex justify-between items-start gap-4 mb-2">
<h3
className="font-bold text-base text-slate-800 line-clamp-1 leading-snug hover:text-purple-600 cursor-pointer"
onClick={() => openReader(paper)}
className="px-4 py-1.5 rounded-lg bg-gradient-to-r from-purple-50 to-indigo-50 border border-purple-200 text-xs text-purple-600 hover:from-purple-100 hover:to-indigo-100 hover:border-purple-300"
>
</button>
<button
onClick={() => {
setSelectedPaper(paper);
setActiveTab('citation');
loadCitations(paper.bibcode, true);
}}
className="px-4 py-1.5 rounded-lg bg-slate-100 border border-slate-200 text-xs text-slate-600 hover:bg-slate-200 hover:text-slate-900"
>
{paper.title}
</h3>
<div className="flex gap-2 shrink-0">
{paper.is_downloaded ? (
<span className="px-2.5 py-1 rounded-full bg-emerald-50 text-emerald-600 border border-emerald-200 font-medium text-[10px] flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
</span>
) : (
<button
onClick={() => handleDownload(paper.bibcode)}
disabled={isDownloading}
className="px-3 py-1 rounded-lg bg-amber-50 hover:bg-amber-100 text-amber-600 border border-amber-200 text-[10px] font-semibold flex items-center gap-1 transition-all"
>
{isDownloading ? <Loader className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
</button>
)}
<label className="flex items-center gap-1.5 cursor-pointer text-xs border border-slate-200 px-2 py-1 rounded-lg bg-white hover:bg-slate-50">
<input
type="checkbox"
checked={exportingList.includes(paper.bibcode)}
onChange={() => toggleExportItem(paper.bibcode)}
className="rounded text-purple-600 border-slate-300"
/>
<span className="text-[10px] text-slate-500"></span>
</label>
</div>
</div>
<p className="text-xs text-slate-500 font-medium mb-3">
{paper.authors.join(', ')} {paper.year} <span className="italic">{paper.pub_journal}</span>
</p>
<p className="text-xs text-slate-600 line-clamp-3 leading-relaxed mb-4">
{paper.abstract_text || '暂无摘要'}
</p>
<div className="flex justify-between items-center">
<div className="flex gap-2">
<button
onClick={() => openReader(paper)}
className="px-4 py-1.5 rounded-lg bg-gradient-to-r from-purple-50 to-indigo-50 border border-purple-200 text-xs text-purple-600 hover:from-purple-100 hover:to-indigo-100 hover:border-purple-300"
>
</button>
<button
onClick={() => {
setSelectedPaper(paper);
setActiveTab('citation');
loadCitations(paper.bibcode, true);
}}
className="px-4 py-1.5 rounded-lg bg-slate-100 border border-slate-200 text-xs text-slate-600 hover:bg-slate-200 hover:text-slate-900"
>
</button>
</div>
<div className="text-[10px] text-slate-400 flex gap-4 font-mono">
{paper.doi && <span>DOI: {paper.doi}</span>}
<span>Bibcode: {paper.bibcode}</span>
{paper.citation_count > 0 && <span>: {paper.citation_count}</span>}
</div>
</div>
</div>
);
})}
</div>
</div>
{/* 分页控制栏 */}
{searchResults.length > 0 && (
<div className="flex items-center justify-between p-4 glass rounded-2xl max-w-5xl mx-auto">
<button
onClick={() => handlePageChange(searchStart - searchRows)}
disabled={!hasPreviousPage || searching}
className="px-4 py-2 border border-slate-200 rounded-xl bg-white text-xs text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white flex items-center gap-1 transition-all"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-xs font-semibold text-slate-600">
{currentPage} ( {searchStart + 1} - {searchStart + searchResults.length} )
</span>
<button
onClick={() => handlePageChange(searchStart + searchRows)}
disabled={!hasNextPage || searching}
className="px-4 py-2 border border-slate-200 rounded-xl bg-white text-xs text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white flex items-center gap-1 transition-all"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
);
}
+646
View File
@@ -0,0 +1,646 @@
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import { RefreshCw, Play, Info, AlertTriangle, CheckCircle, Loader, StopCircle, Download, FileText } from 'lucide-react';
interface ProcessStatus {
active: boolean;
total: number;
downloaded: number;
parsed: number;
current_bibcode: string;
logs: string[];
action?: 'all' | 'download' | 'parse';
}
interface HarvestStatus {
active: boolean;
query: string;
source: string;
synced: number;
total: number;
}
export function SyncPanel() {
const [query, setQuery] = useState('');
const [source, setSource] = useState<'all' | 'ads' | 'arxiv'>('all');
const [limit, setLimit] = useState<number>(200);
const [estimating, setEstimating] = useState(false);
const [estimatedCount, setEstimatedCount] = useState<number | null>(null);
const [status, setStatus] = useState<HarvestStatus>({
active: false,
query: '',
source: '',
synced: 0,
total: 0,
});
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const pollIntervalRef = useRef<any>(null);
// 批量下载与解析相关状态
const [processAction, setProcessAction] = useState<'all' | 'download' | 'parse'>('all');
const [processScope, setProcessScope] = useState<'all' | 'undownloaded' | 'unparsed'>('undownloaded');
const [processStatus, setProcessStatus] = useState<ProcessStatus>({
active: false,
total: 0,
downloaded: 0,
parsed: 0,
current_bibcode: '',
logs: [],
});
const [processError, setProcessError] = useState<string | null>(null);
const processPollIntervalRef = useRef<any>(null);
const logsEndRef = useRef<HTMLDivElement | null>(null);
const [showBuilder, setShowBuilder] = useState(false);
const [rules, setRules] = useState<Array<{ field: string; op: string; val: string }>>([
{ field: 'all', op: 'AND', val: '' }
]);
// 当高级表单规则变化时,自动更新同步输入框的检索式
const updateQueryFromRules = (currentRules: typeof rules) => {
let qParts: string[] = [];
currentRules.forEach((rule, idx) => {
if (!rule.val.trim()) return;
let valStr = rule.val.trim();
if (valStr.includes(' ') && !valStr.startsWith('"') && !valStr.startsWith('(')) {
valStr = `"${valStr}"`;
}
let fieldPart = '';
if (rule.field !== 'all') {
fieldPart = `${rule.field}:${valStr}`;
} else {
fieldPart = valStr;
}
if (idx === 0) {
qParts.push(fieldPart);
} else {
qParts.push(`${rule.op} ${fieldPart}`);
}
});
setQuery(qParts.join(' '));
};
const handleAddRule = () => {
setRules(prev => [...prev, { field: 'all', op: 'AND', val: '' }]);
};
const handleRemoveRule = (idx: number) => {
const next = rules.filter((_, i) => i !== idx);
setRules(next);
updateQueryFromRules(next);
};
const handleRuleChange = (idx: number, key: 'field' | 'op' | 'val', value: string) => {
const next = rules.map((r, i) => i === idx ? { ...r, [key]: value } : r);
setRules(next);
updateQueryFromRules(next);
};
// 获取当前的收割状态
const fetchStatus = async () => {
try {
const res = await axios.get<HarvestStatus>('/api/sync/meta/status');
setStatus(res.data);
if (!res.data.active && pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
} catch (e) {
console.error('获取同步状态失败', e);
}
};
// 开始轮询
const startPolling = () => {
if (pollIntervalRef.current) return;
pollIntervalRef.current = setInterval(fetchStatus, 1000);
};
useEffect(() => {
fetchStatus();
// 如果组件加载时已经在运行中,自动启动轮询
axios.get<HarvestStatus>('/api/sync/meta/status').then(res => {
if (res.data.active) {
setStatus(res.data);
startPolling();
}
});
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
// 批量下载与解析相关的网络操作
const fetchProcessStatus = async () => {
try {
const res = await axios.get<ProcessStatus>('/api/sync/asset/status');
setProcessStatus(res.data);
if (!res.data.active && processPollIntervalRef.current) {
clearInterval(processPollIntervalRef.current);
processPollIntervalRef.current = null;
}
} catch (e) {
console.error('获取处理状态失败', e);
}
};
const startProcessPolling = () => {
if (processPollIntervalRef.current) return;
processPollIntervalRef.current = setInterval(fetchProcessStatus, 1000);
};
const handleStartProcess = async () => {
setProcessError(null);
try {
await axios.post('/api/sync/asset/run', {
action: processAction,
scope: processScope,
});
fetchProcessStatus();
startProcessPolling();
} catch (e: any) {
console.error(e);
setProcessError(e.response?.data || '启动下载与解析任务失败。');
}
};
const handleStopProcess = async () => {
try {
await axios.post('/api/sync/asset/stop');
fetchProcessStatus();
} catch (e: any) {
console.error(e);
setProcessError(e.response?.data || '停止任务失败。');
}
};
useEffect(() => {
fetchProcessStatus();
axios.get<ProcessStatus>('/api/sync/asset/status').then(res => {
if (res.data.active) {
setProcessStatus(res.data);
startProcessPolling();
}
});
return () => {
if (processPollIntervalRef.current) {
clearInterval(processPollIntervalRef.current);
}
};
}, []);
// 日志终端自动滚动到底部
useEffect(() => {
if (logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [processStatus.logs]);
// 估算文献总量
const handleEstimate = async () => {
if (!query.trim()) {
setErrorMsg('请输入检索关键词!');
return;
}
setErrorMsg(null);
setEstimating(true);
setEstimatedCount(null);
try {
const res = await axios.get<{ total: number }>('/api/sync/meta/count', {
params: { q: query.trim(), source }
});
setEstimatedCount(res.data.total);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '估算文献总量失败,请检查 API 密钥或网络。');
} finally {
setEstimating(false);
}
};
// 启动收割任务
const handleStartHarvest = async () => {
if (!query.trim()) {
setErrorMsg('请输入检索关键词!');
return;
}
setErrorMsg(null);
try {
await axios.post('/api/sync/meta/run', {
q: query.trim(),
source,
limit: limit,
});
fetchStatus();
startPolling();
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动收割任务失败。');
}
};
const percent = status.total > 0 ? Math.min(100, Math.round((status.synced / status.total) * 100)) : 0;
return (
<div className="space-y-6 max-w-3xl mx-auto">
{/* 标题 */}
<div className="flex flex-col gap-1.5">
<h2 className="text-2xl font-bold tracking-tight text-slate-800 font-outfit"></h2>
<p className="text-slate-500 text-sm"> NASA ADS arXiv </p>
</div>
{errorMsg && (
<div className="p-4 rounded-xl bg-rose-50 border border-rose-200 flex gap-3 text-xs text-rose-600 items-start">
<AlertTriangle className="w-4 h-4 shrink-0" />
<div>{errorMsg}</div>
</div>
)}
{/* 控制面板卡片 */}
<div className="glass p-6 rounded-2xl space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-600 block flex justify-between items-center">
<span> / (Query)</span>
<button
type="button"
onClick={() => setShowBuilder(!showBuilder)}
className="text-[10px] text-purple-600 hover:underline"
>
{showBuilder ? '隐藏构造器' : '高级构造器'}
</button>
</label>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
disabled={status.active}
placeholder="例如: hot subdwarf, Gaia BH1..."
className="w-full px-4 py-2.5 rounded-xl bg-white/60 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition-all text-sm"
/>
<div className="text-[10px] text-slate-400 flex flex-wrap gap-x-2.5 gap-y-0.5 px-0.5">
<span>:</span>
<span><code className="bg-slate-100/80 px-1 py-0.2 rounded font-mono text-[9px]">author:"Althaus" AND year:2020-2023</code></span>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-600 block"> (Source)</label>
<div className="flex gap-2">
{[
{ id: 'all', label: '全部' },
{ id: 'ads', label: 'NASA ADS' },
{ id: 'arxiv', label: 'arXiv 预印本' },
].map(src => (
<button
key={src.id}
type="button"
disabled={status.active}
onClick={() => setSource(src.id as any)}
className={`flex-1 py-2.5 rounded-xl text-xs font-medium border transition-all ${
source === src.id
? 'bg-purple-600/10 text-purple-600 border-purple-500/30'
: 'bg-white/60 text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
>
{src.label}
</button>
))}
</div>
</div>
</div>
{/* 动态表单生成器 */}
{showBuilder && (
<div className="p-4 rounded-xl bg-slate-50/70 border border-slate-200/60 space-y-3.5 transition-all">
<div className="text-xs font-bold text-slate-700 flex justify-between items-center">
<span></span>
<button
type="button"
onClick={handleAddRule}
className="text-[10px] text-purple-600 hover:underline"
>
+
</button>
</div>
<div className="space-y-2.5">
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
{idx > 0 ? (
<select
value={rule.op}
onChange={e => handleRuleChange(idx, 'op', e.target.value)}
className="bg-white border border-slate-200 rounded-lg px-2 py-1.5 text-xs text-slate-600 focus:outline-none focus:border-purple-500 w-20"
>
<option value="AND">AND </option>
<option value="OR">OR </option>
<option value="NOT">NOT </option>
</select>
) : (
<div className="w-20 text-center text-xs text-slate-400 font-medium">:</div>
)}
<select
value={rule.field}
onChange={e => handleRuleChange(idx, 'field', e.target.value)}
className="bg-white border border-slate-200 rounded-lg px-2.5 py-1.5 text-xs text-slate-600 focus:outline-none focus:border-purple-500 w-32"
>
<option value="all"> (all)</option>
<option value="title"> (title)</option>
<option value="author"> (author)</option>
<option value="abs"> (abs)</option>
<option value="year"> (year)</option>
</select>
<input
type="text"
value={rule.val}
onChange={e => handleRuleChange(idx, 'val', e.target.value)}
placeholder={
rule.field === 'year'
? '例如: 2020-2023 或 2022'
: rule.field === 'author'
? '例如: Althaus'
: '输入检索词...'
}
className="flex-1 px-3 py-1.5 rounded-lg bg-white border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 text-xs"
/>
{rules.length > 1 && (
<button
type="button"
onClick={() => handleRemoveRule(idx)}
className="text-slate-400 hover:text-rose-500 text-xs px-2 py-1.5"
>
</button>
)}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-600 block flex items-center justify-between">
<span></span>
<span className="text-[10px] text-slate-400 font-normal"></span>
</label>
<input
type="number"
value={limit}
disabled={status.active}
onChange={e => setLimit(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-4 py-2.5 rounded-xl bg-white/60 border border-slate-200 text-slate-800 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition-all text-sm"
/>
</div>
<div className="flex items-end gap-3">
<button
type="button"
disabled={status.active || estimating}
onClick={handleEstimate}
className="flex-1 py-2.5 rounded-xl bg-white border border-slate-200 hover:bg-slate-50 text-slate-700 text-xs font-semibold flex items-center justify-center gap-2 transition-all disabled:opacity-40"
>
{estimating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
</button>
<button
type="button"
disabled={status.active || !query.trim()}
onClick={handleStartHarvest}
className="flex-1 py-2.5 rounded-xl bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white text-xs font-semibold flex items-center justify-center gap-2 transition-all disabled:opacity-40 shadow-lg shadow-purple-500/20"
>
<Play className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 预估结果展示 */}
{estimatedCount !== null && !status.active && (
<div className="p-4 rounded-xl bg-indigo-50/50 border border-indigo-200/50 flex gap-3 text-xs text-indigo-700 items-center">
<Info className="w-4 h-4 shrink-0" />
<div>
<strong className="text-sm text-indigo-900">{estimatedCount}</strong>
{estimatedCount > limit ? ` 设定的上限为 ${limit} 篇,系统将只拉取前 ${limit} 篇。` : ' 将拉取全部文献。'}
</div>
</div>
)}
</div>
{/* 实时同步进度 */}
{(status.active || status.synced > 0) && (
<div className="glass p-6 rounded-2xl space-y-4">
<div className="flex justify-between items-center">
<div>
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-2">
{status.active ? (
<>
<Loader className="w-4 h-4 text-purple-600 animate-spin" />
<span>...</span>
</>
) : (
<>
<CheckCircle className="w-4 h-4 text-emerald-500" />
<span></span>
</>
)}
</h3>
<p className="text-slate-500 text-xs mt-1">
: <code className="bg-slate-100 px-1 py-0.5 rounded font-mono">{status.query}</code> : {status.source === 'all' ? '全部' : status.source === 'ads' ? 'NASA ADS' : 'arXiv'}
</p>
</div>
<span className="text-sm font-bold text-purple-600">{status.synced} / {status.total}</span>
</div>
<div className="w-full h-3 rounded-full bg-slate-100 overflow-hidden border border-slate-200/50">
<div
className="h-full bg-gradient-to-r from-purple-500 to-indigo-600 transition-all duration-500"
style={{ width: `${percent}%` }}
/>
</div>
{status.active && status.source === 'all' || status.source === 'arxiv' ? (
<div className="p-3 rounded-lg bg-amber-50/50 border border-amber-200/30 text-[10px] text-amber-700">
💡 arXiv ( 3 )
</div>
) : null}
</div>
)}
{/* 批量下载与解析 */}
<div className="glass p-6 rounded-2xl space-y-6">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-2 font-outfit">
<Download className="w-4 h-4 text-purple-600" />
<span> (Bulk Download & Extraction)</span>
</h3>
<p className="text-slate-500 text-xs">
(PDF/HTML) (Markdown)
</p>
</div>
{processError && (
<div className="p-4 rounded-xl bg-rose-50 border border-rose-200 flex gap-3 text-xs text-rose-600 items-start">
<AlertTriangle className="w-4 h-4 shrink-0" />
<div>{processError}</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-600 block"> (Action)</label>
<div className="flex gap-2">
{[
{ id: 'all', label: '下载并解析' },
{ id: 'download', label: '仅下载文献' },
{ id: 'parse', label: '仅解析文献' },
].map(act => (
<button
key={act.id}
type="button"
disabled={processStatus.active}
onClick={() => setProcessAction(act.id as any)}
className={`flex-1 py-2.5 rounded-xl text-xs font-medium border transition-all ${
processAction === act.id
? 'bg-purple-600/10 text-purple-600 border-purple-500/30'
: 'bg-white/60 text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
>
{act.label}
</button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-600 block"> (Scope)</label>
<div className="flex gap-2">
{[
{ id: 'all', label: '全部文献' },
{ id: 'undownloaded', label: '仅未下载' },
{ id: 'unparsed', label: '仅未解析' },
].map(opt => (
<button
key={opt.id}
type="button"
disabled={processStatus.active}
onClick={() => setProcessScope(opt.id as any)}
className={`flex-1 py-2.5 rounded-xl text-xs font-medium border transition-all ${
processScope === opt.id
? 'bg-purple-600/10 text-purple-600 border-purple-500/30'
: 'bg-white/60 text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
<div className="flex justify-end pt-2">
<div className="w-full md:w-1/2 flex">
{processStatus.active ? (
<button
type="button"
onClick={handleStopProcess}
className="w-full py-2.5 rounded-xl bg-rose-600 hover:bg-rose-500 text-white text-xs font-semibold flex items-center justify-center gap-2 transition-all shadow-lg shadow-rose-500/20"
>
<StopCircle className="w-3.5 h-3.5" />
</button>
) : (
<button
type="button"
onClick={handleStartProcess}
className="w-full py-2.5 rounded-xl bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white text-xs font-semibold flex items-center justify-center gap-2 transition-all shadow-lg shadow-purple-500/20"
>
<Play className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* 进度与终端日志展示 */}
{(processStatus.active || processStatus.total > 0) && (
<div className="space-y-4 pt-2 border-t border-slate-200/50">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 下载进度 */}
{(!processStatus.action || processStatus.action === 'all' || processStatus.action === 'download') && (
<div className={`space-y-1.5 ${(!processStatus.action || processStatus.action === 'all') ? '' : 'col-span-2'}`}>
<div className="flex justify-between text-xs">
<span className="font-bold text-slate-700 flex items-center gap-1">
<Download className="w-3.5 h-3.5 text-blue-500" />
</span>
<span className="text-slate-500 font-medium">{processStatus.downloaded} / {processStatus.total}</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-100 overflow-hidden border border-slate-200/30">
<div
className="h-full bg-gradient-to-r from-blue-500 to-indigo-500 transition-all duration-300"
style={{ width: `${processStatus.total > 0 ? Math.min(100, Math.round((processStatus.downloaded / processStatus.total) * 100)) : 0}%` }}
/>
</div>
</div>
)}
{/* 解析进度 */}
{(!processStatus.action || processStatus.action === 'all' || processStatus.action === 'parse') && (
<div className={`space-y-1.5 ${(!processStatus.action || processStatus.action === 'all') ? '' : 'col-span-2'}`}>
<div className="flex justify-between text-xs">
<span className="font-bold text-slate-700 flex items-center gap-1">
<FileText className="w-3.5 h-3.5 text-purple-500" />
</span>
<span className="text-slate-500 font-medium">{processStatus.parsed} / {processStatus.total}</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-100 overflow-hidden border border-slate-200/30">
<div
className="h-full bg-gradient-to-r from-purple-500 to-indigo-500 transition-all duration-300"
style={{ width: `${processStatus.total > 0 ? Math.min(100, Math.round((processStatus.parsed / processStatus.total) * 100)) : 0}%` }}
/>
</div>
</div>
)}
</div>
{processStatus.active && processStatus.current_bibcode && (
<div className="text-[11px] text-slate-500 flex items-center gap-1.5">
<Loader className="w-3 h-3 text-purple-600 animate-spin" />
<span>: <code className="bg-slate-100 px-1 py-0.5 rounded font-mono font-bold text-slate-700">{processStatus.current_bibcode}</code></span>
</div>
)}
{/* 滚动日志终端 */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-slate-600 block"></label>
<div className="bg-slate-950 text-slate-300 font-mono text-[10px] p-4 rounded-xl h-48 overflow-y-auto border border-slate-800 space-y-1 scrollbar-thin scrollbar-thumb-slate-800">
{processStatus.logs.length === 0 ? (
<div className="text-slate-500 italic">...</div>
) : (
processStatus.logs.map((log, idx) => (
<div key={idx} className="whitespace-pre-wrap leading-relaxed">
{log}
</div>
))
)}
<div ref={logsEndRef} />
</div>
</div>
</div>
)}
</div>
</div>
);
}