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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user