import { useState, useEffect, useRef } from 'react'; import axios from 'axios'; import { RefreshCw, Play, Info, AlertTriangle, CheckCircle, Loader, StopCircle, Download, FileText, SlidersHorizontal, Lightbulb } from 'lucide-react'; import type { SavedSyncQuery } from '../../types'; import { CustomSelect } from '../../components/CustomSelect'; interface BatchStatus { active: boolean; total: number; downloaded: number; parsed: number; download_failed: number; parse_failed: number; current_bibcode: string; logs: string[]; action?: 'download' | 'parse' | 'translate' | 'embed' | 'target'; } 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(200); const [estimating, setEstimating] = useState(false); const [estimatedCount, setEstimatedCount] = useState(null); const [status, setStatus] = useState({ active: false, query: '', source: '', synced: 0, total: 0, }); const [errorMsg, setErrorMsg] = useState(null); const [syncQueries, setSyncQueries] = useState([]); const pollIntervalRef = useRef(null); // 批量下载与解析相关状态 const [targetPhase, setTargetPhase] = useState<'download' | 'parse' | 'translate' | 'embed' | 'target'>('download'); const [batchLimitCount, setBatchLimitCount] = useState(100); const [sortOrder, setSortOrder] = useState<'default' | 'pub_year_desc' | 'created_at_desc'>('default'); const [skipCompleted, setSkipCompleted] = useState(true); const [skipFailed, setSkipFailed] = useState(false); const [skipPrecedingFailed, setSkipPrecedingFailed] = useState(false); const [skipPrecedingUncompleted, setSkipPrecedingUncompleted] = useState(false); const [batchStatus, setBatchStatus] = useState({ active: false, total: 0, downloaded: 0, parsed: 0, download_failed: 0, parse_failed: 0, current_bibcode: '', logs: [], }); const [batchError, setBatchError] = useState(null); const batchPollIntervalRef = useRef(null); const logsContainerRef = useRef(null); const [showBuilder, setShowBuilder] = useState(false); const [rules, setRules] = useState>([ { 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 fetchSyncQueries = async () => { try { const res = await axios.get(`/api/sync/queries?t=${Date.now()}`); setSyncQueries(res.data); } catch (e) { console.error('获取检索配置列表失败', e); } }; const handleDeleteQuery = async (id: number) => { try { await axios.delete(`/api/sync/queries/${id}`); fetchSyncQueries(); } catch (e) { console.error('删除配置失败', e); } }; const handleReuseQuery = (sq: SavedSyncQuery) => { setQuery(sq.query); setSource(sq.source as any); setLimit(sq.limit_count); }; const handleQuickSync = async (sq: SavedSyncQuery) => { setErrorMsg(null); // 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态 setStatus(prev => ({ ...prev, active: true, query: sq.query, source: sq.source as any, synced: 0, total: sq.limit_count, })); startPolling(); try { await axios.post('/api/sync/meta/run', { q: sq.query, source: sq.source, limit: sq.limit_count, }); fetchStatus(); setTimeout(fetchSyncQueries, 500); } catch (e: any) { console.error(e); setErrorMsg(e.response?.data || '启动快速同步失败。'); fetchStatus(); } }; // 获取当前的元数据同步状态 const fetchStatus = async () => { try { const res = await axios.get(`/api/sync/meta/status?t=${Date.now()}`); setStatus(res.data); if (res.data.active) { startPolling(); } else { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; fetchSyncQueries(); } } } catch (e) { console.error('获取同步状态失败', e); } }; // 开始轮询 const startPolling = () => { if (pollIntervalRef.current) return; pollIntervalRef.current = setInterval(fetchStatus, 1000); }; useEffect(() => { fetchStatus(); fetchSyncQueries(); return () => { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); } }; }, []); // 批量下载与解析相关的网络操作 const fetchBatchStatus = async () => { try { const res = await axios.get(`/api/batch/asset/status?t=${Date.now()}`); setBatchStatus(res.data); if (res.data.active) { startBatchPolling(); } else if (batchPollIntervalRef.current) { clearInterval(batchPollIntervalRef.current); batchPollIntervalRef.current = null; } } catch (e) { console.error('获取处理状态失败', e); } }; const startBatchPolling = () => { if (batchPollIntervalRef.current) return; batchPollIntervalRef.current = setInterval(fetchBatchStatus, 1000); }; const handleStartBatch = async () => { setBatchError(null); try { await axios.post('/api/batch/asset/run', { target_phase: targetPhase, limit_count: batchLimitCount, sort_order: sortOrder, skip_completed: skipCompleted, skip_failed: skipFailed, skip_preceding_failed: skipPrecedingFailed, skip_preceding_uncompleted: skipPrecedingUncompleted, }); fetchBatchStatus(); startBatchPolling(); } catch (e: any) { console.error(e); setBatchError(e.response?.data || '启动批量任务失败。'); } }; const handleStopBatch = async () => { try { await axios.post('/api/batch/asset/stop'); fetchBatchStatus(); } catch (e: any) { console.error(e); setBatchError(e.response?.data || '停止任务失败。'); } }; useEffect(() => { fetchBatchStatus(); return () => { if (batchPollIntervalRef.current) { clearInterval(batchPollIntervalRef.current); } }; }, []); // 日志终端自动滚动到底部 (仅限定滚动容器内部,不影响整个网页的滚动) useEffect(() => { const container = logsContainerRef.current; if (container) { // 阈值设为 80px。如果用户滚动到离底部距离小于 80px,则视为保持跟踪底部的模式 const isCloseToBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 80; if (isCloseToBottom) { container.scrollTop = container.scrollHeight; } } }, [batchStatus.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); // 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态 setStatus(prev => ({ ...prev, active: true, query: query.trim(), source, synced: 0, total: 0, })); startPolling(); try { await axios.post('/api/sync/meta/run', { q: query.trim(), source, limit: limit, }); fetchStatus(); setTimeout(fetchSyncQueries, 500); } catch (e: any) { console.error(e); setErrorMsg(e.response?.data || '启动元数据同步任务失败。'); fetchStatus(); } }; const percent = status.total > 0 ? Math.min(100, Math.round((status.synced / status.total) * 100)) : 0; return (
{/* 标题 */}

批量任务管理器

设定检索关键词,在 NASA ADS 和 arXiv 平台大批量同步学术文献索引,或针对本地文献馆藏批量执行下载、解析、翻译等学术流水线任务。

{errorMsg && (
{errorMsg}
)} {/* 控制面板卡片 */}
setQuery(e.target.value)} disabled={status.active} placeholder="例如: hot subdwarf, Gaia BH1..." className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-xs font-medium" />
高级格式: author:"Althaus" AND year:2020-2023
{[ { id: 'all', label: '全部' }, { id: 'ads', label: 'NASA ADS' }, { id: 'arxiv', label: 'arXiv 预印本' }, ].map(src => ( ))}
{/* 动态表单生成器 */} {showBuilder && (
高级条件生成器
{rules.map((rule, idx) => (
{idx > 0 ? ( handleRuleChange(idx, 'op', val)} className="w-24" options={[ { value: 'AND', label: '并且 (AND)' }, { value: 'OR', label: '或者 (OR)' }, { value: 'NOT', label: '排除 (NOT)' }, ]} /> ) : (
筛选条件
)} handleRuleChange(idx, 'field', val)} className="w-32" options={[ { value: 'all', label: '任意字段' }, { value: 'title', label: '标题' }, { value: 'author', label: '作者' }, { value: 'abs', label: '摘要' }, { value: 'year', label: '年份' }, ]} /> 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-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 text-xs font-medium" /> {rules.length > 1 && ( )}
))}
)}
setLimit(Math.max(1, parseInt(e.target.value) || 0))} className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium" />
{/* 预估结果 */} {estimatedCount !== null && !status.active && (
检索库中估计有约 {estimatedCount} 篇文献记录。 {estimatedCount > limit ? ` 限制最大同步前 ${limit} 篇元数据。` : ' 将全部进行同步。'}
)}
{/* 实时同步进度 */} {(status.active || status.synced > 0) && (

{status.active ? ( <> 正在同步学术元数据... ) : ( <> 元数据目录同步完成 )}

检索条件: {status.query} • 平台: {status.source === 'all' ? '全部' : status.source === 'ads' ? 'NASA ADS' : 'arXiv'}

{status.synced} / {status.total} 篇
{status.active && (status.source === 'all' || status.source === 'arxiv') ? (
同步 arXiv 文献时系统会自动加设 3000 毫秒的安全限流延迟以规避服务器封锁。
) : null}
)} {/* 批量任务 */}

馆藏文献批量学术流水线任务

针对本地馆藏中的文献,批量发起正文 PDF/HTML 下载、Markdown 结构化排版解析以及中英双语对照翻译。

{batchError && (
{batchError}
)}
setTargetPhase(val as any)} className="w-full" options={[ { value: 'download', label: '下载' }, { value: 'parse', label: '解析' }, { value: 'translate', label: '翻译' }, { value: 'embed', label: '向量化' }, { value: 'target', label: '天体识别' }, ]} />
setBatchLimitCount(Math.max(1, parseInt(e.target.value) || 0))} className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium" />
setSortOrder(val)} className="w-full" options={[ { value: 'default', label: '默认(不指定)' }, { value: 'pub_year_desc', label: '按出版年份降序' }, { value: 'created_at_desc', label: '按入库时间降序' }, ]} />
{batchStatus.active ? ( ) : ( )}
{/* 进度与终端日志展示 */} {(batchStatus.active || batchStatus.total > 0) && (
{batchStatus.action === 'download' && } {batchStatus.action === 'parse' && } {batchStatus.action === 'translate' && } {batchStatus.action === 'embed' && } {batchStatus.action === 'target' && } {batchStatus.action === 'download' ? '正文离线下载进度' : batchStatus.action === 'parse' ? '结构化排版解析进度' : batchStatus.action === 'translate' ? '中英双语对照翻译进度' : batchStatus.action === 'embed' ? '段落向量分块入库进度' : '天体识别与缓存进度'} {batchStatus.action === 'download' ? batchStatus.downloaded : batchStatus.parsed} / {batchStatus.total} 篇 {((batchStatus.action === 'download' && batchStatus.download_failed > 0) || (batchStatus.action !== 'download' && batchStatus.parse_failed > 0)) && ( (失败 {batchStatus.action === 'download' ? batchStatus.download_failed : batchStatus.parse_failed} 篇) )}
0 ? Math.min( 100, Math.round( ((batchStatus.action === 'download' ? batchStatus.downloaded + batchStatus.download_failed : batchStatus.parsed + batchStatus.parse_failed) / batchStatus.total) * 100 ) ) : 0 }%`, }} />
{batchStatus.active && batchStatus.current_bibcode && (
当前正在处理: {batchStatus.current_bibcode}
)} {/* 滚动日志终端 */}
{batchStatus.logs.length === 0 ? (
等待数据流任务启动,暂无日志输出...
) : ( batchStatus.logs.map((log, idx) => (
{log}
)) )}
)}
{/* 已存同步检索配置 */}

常用批量同步检索配置

保存的历史批量元数据同步检索规则。可在此快速一键再次启动同步或加载检索参数。

{syncQueries.length === 0 ? (
暂无已存检索配置。执行一次批量元数据同步后将自动去重记录在此。
) : (
{syncQueries.map(sq => (
{sq.query}
数据源: {sq.source === 'all' ? '全部' : sq.source === 'ads' ? 'NASA ADS' : 'arXiv'} 数量限制: {sq.limit_count} 最近同步: {sq.last_run}
))}
)}
{/* 浏览器快捷直推书签工具 */}

浏览器快捷直推书签导入工具

无需手动保存和拖拽上传!在您自己真实的浏览器上突破 Cloudflare/WAF 人机验证或在学校内网成功打开 PDF/HTML 页面后,点击此书签即可将文献直接推送同步到本地 AstroResearch 数据库与文献馆中。

第一步:安装书签到您的浏览器

将下方的按钮直接拖拽到浏览器的书签栏中;或者新建一个浏览器书签,将其 URL 设为复制的 JavaScript 代码(点击右侧按钮复制):

{/* 可直接拖拽的链接按钮 */} { if (el) { el.setAttribute( 'href', `javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode',{credentials:'include'});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);` ); } }} className="px-4 py-2 bg-gradient-to-r from-sky-500 to-indigo-500 hover:from-sky-600 hover:to-indigo-600 text-white rounded-lg text-xs font-bold transition-all shadow-sm cursor-move flex items-center gap-1.5" title="拖拽我到您的书签栏中" onClick={(e) => e.preventDefault()} > {/* 浏览器拖拽时读取全部 textContent,sr-only span 的文字成为书签名 */} 导入AstroResearch
第二步:如何使用书签直推?
  1. 点击详情弹窗内的 BIBCODE / DOI / arXiv ID 链接
  2. 页面跳转至文献原始网页后,直接点击该浏览器书签
  3. 书签脚本会自动读取并自动填充好 Bibcode,您只需点击确认,文件即可秒级自动上传录入至系统!
); }