feat: 手动上传绕防爬、下载错误诊断与健康检查工具;模块化重构 API 与批量同步

后端:
  - 将 handlers.rs (1338行) 拆分为 helpers/papers/notes/sync 四模块
  - 将 batch_sync.rs 拆分为 batch/{mod,meta,asset} 三模块
  - 新增 POST /api/upload 多部件文件上传接口
  - 新增 POST /api/no_resource 标记文献"无全文资源"
  - 新增 GET/POST /api/active_bibcode 追踪活跃文献
  - StandardPaper 结构体扩展 pdf_error / html_error 错误诊断字段
  - download.rs 记录下载失败详情至数据库
  - 新增 health_check 二进制工具,支持只读扫描与 --fix 自动修复
  - 移除 scratch/ 目录、recovered_handlers.rs 及调试日志

  前端:
  - 新建 CustomSelect 可复用组件,替换全部原生 select
  - LibraryPanel:同步按钮反馈动画、下载失败/无资源状态筛选与计数、
    文献类型筛选、状态优先排序、搜索一键清空
  - 详情弹窗:错误诊断展示、手动 PDF/HTML 上传区、无资源标记/恢复
  - SearchPanel:扩展文献类型徽章、下载失败状态提示
  - SyncPanel:同步启动乐观 UI 更新、日志容器内自动滚动
  - Tab 状态 localStorage 持久化、弹窗 z-index 修复
This commit is contained in:
fmq
2026-06-11 22:56:36 +08:00
parent cd6af4f995
commit 8cc2b74abc
43 changed files with 4512 additions and 3879 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dashboard</title>
<title>AstroResearch 天文科研辅助系统</title>
</head>
<body>
<div id="root"></div>
+218 -8
View File
@@ -11,7 +11,14 @@ 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' | 'sync'>('search');
const [activeTab, setActiveTab] = useState<'search' | 'library' | 'reader' | 'citation' | 'sync'>(() => {
const saved = localStorage.getItem('astro_active_tab');
return (saved as any) || 'search';
});
useEffect(() => {
localStorage.setItem('astro_active_tab', activeTab);
}, [activeTab]);
// 全局对话框弹窗状态
const [dialog, setDialog] = useState<{
@@ -83,6 +90,7 @@ export default function App() {
// 下载进度状态
const [downloadingBibcodes, setDownloadingBibcodes] = useState<Record<string, boolean>>({});
const [uploadingBibcode, setUploadingBibcode] = useState<string | null>(null);
// 1. 初始化时加载本地文献
useEffect(() => {
@@ -174,6 +182,83 @@ export default function App() {
}
};
// 3b. 手动上传文献文件以绕过反爬/人机验证
const handleManualUpload = async (bibcode: string, type: 'pdf' | 'html', file: File) => {
setUploadingBibcode(bibcode);
const formData = new FormData();
formData.append('bibcode', bibcode);
formData.append('type', type);
formData.append('file', file);
try {
const res = await axios.post<StandardPaper>('/api/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
// 更新前端库及检索列表状态
setSearchResults(prev => prev.map(p => p.bibcode === bibcode ? res.data : p));
setLibrary(prev => {
if (prev.some(p => p.bibcode === bibcode)) {
return prev.map(p => p.bibcode === bibcode ? res.data : p);
} else {
return [res.data, ...prev];
}
});
if (selectedPaper?.bibcode === bibcode) {
setSelectedPaper(res.data);
}
showAlert('手动文献文件上传导入成功!', '上传成功');
} catch (e: any) {
console.error('手动文件上传失败', e);
const errMsg = e.response?.data || '请确保上传的是合法且完整的文件。';
showAlert(`文件上传失败: ${errMsg}`, '上传出错');
} finally {
setUploadingBibcode(null);
}
};
// 3c. 手动标记文献为“无有效全文资源”
const handleMarkNoResource = async (bibcode: string, clear = false) => {
const performMark = async () => {
try {
const res = await axios.post<StandardPaper>('/api/no_resource', { bibcode, clear });
// 更新前端库及检索列表状态
setSearchResults(prev => prev.map(p => p.bibcode === bibcode ? res.data : p));
setLibrary(prev => {
if (prev.some(p => p.bibcode === bibcode)) {
return prev.map(p => p.bibcode === bibcode ? res.data : p);
} else {
return [res.data, ...prev];
}
});
if (selectedPaper?.bibcode === bibcode) {
setSelectedPaper(res.data);
}
showAlert(
clear
? '已成功清除“无全文资源”标记,该文献已被重新允许重载!'
: '已成功将文献标记为“无全文资源”,后续批量任务将自动跳过!',
'标记更新'
);
} catch (e: any) {
console.error('标记更新失败', e);
const errMsg = e.response?.data || '请稍后重试。';
showAlert(`标记失败: ${errMsg}`, '操作出错');
}
};
if (clear) {
performMark();
} else {
showConfirm(
'确认将此文献标记为“无有效全文资源”吗?\n标记后,未来的批量下载/解析重试任务将自动跳过此文献。',
performMark,
'标记无有效全文资源'
);
}
};
// 4. 文献解析成 Markdown (优先 HTML, 其次 PDF MinerU)
const handleParse = async (bibcode: string, force = false) => {
setParsing(true);
@@ -440,7 +525,7 @@ export default function App() {
if (dialog.onCancel) dialog.onCancel();
setDialog(null);
}}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all"
className="fixed inset-0 z-[60] flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all"
>
<div
onClick={(e) => e.stopPropagation()}
@@ -538,6 +623,7 @@ export default function App() {
</button>
<button
onClick={() => {
axios.post('/api/active_bibcode', { bibcode: uncachedBibcode }).catch(() => {});
window.open(`https://ui.adsabs.harvard.edu/abs/${uncachedBibcode}/abstract`, '_blank');
}}
className="flex-1 bg-white hover:bg-slate-50 text-slate-700 border border-slate-250 py-2 rounded-lg text-[11px] font-bold text-center transition-all shadow-sm cursor-pointer"
@@ -607,7 +693,7 @@ export default function App() {
{/* 摘要 */}
<div className="space-y-1.5">
<span className="text-slate-450 font-bold block"> (Abstract)</span>
<span className="text-slate-450 font-bold block"></span>
<p className="text-slate-700 leading-relaxed font-normal bg-slate-50 p-3.5 rounded-lg border border-slate-200 text-justify max-h-48 overflow-y-auto scrollbar-thin select-text">
{detailPaper.abstract_text || '该文献暂无摘要数据。'}
</p>
@@ -616,7 +702,7 @@ export default function App() {
{/* 关键字 */}
{detailPaper.keywords && detailPaper.keywords.length > 0 && (
<div className="space-y-1.5">
<span className="text-slate-450 font-bold block"> (Keywords)</span>
<span className="text-slate-450 font-bold block"></span>
<div className="flex flex-wrap gap-1.5">
{detailPaper.keywords.map(kw => (
<span key={kw} className="px-2 py-0.5 rounded bg-slate-100 border border-slate-200 text-slate-600 font-bold text-[9px]">
@@ -633,7 +719,13 @@ export default function App() {
<span className="text-slate-400 font-bold block">BIBCODE</span>
<span className="text-slate-700 font-semibold select-all truncate block" title={detailPaper.bibcode === detailPaper.arxiv_id ? '暂无' : detailPaper.bibcode}>
{detailPaper.bibcode === detailPaper.arxiv_id ? '暂无' : (
<a href={`https://ui.adsabs.harvard.edu/abs/${detailPaper.bibcode}/abstract`} target="_blank" rel="noreferrer" className="hover:underline text-sky-600">
<a
href={`https://ui.adsabs.harvard.edu/abs/${detailPaper.bibcode}/abstract`}
target="_blank"
rel="noreferrer"
onClick={() => axios.post('/api/active_bibcode', { bibcode: detailPaper.bibcode }).catch(() => {})}
className="hover:underline text-sky-600"
>
{detailPaper.bibcode}
</a>
)}
@@ -643,23 +735,141 @@ export default function App() {
<span className="text-slate-400 font-bold block">DOI</span>
<span className="text-slate-700 font-semibold select-all truncate block" title={detailPaper.doi || '无'}>
{detailPaper.doi ? (
<a href={`https://doi.org/${detailPaper.doi}`} target="_blank" rel="noreferrer" className="hover:underline text-sky-600">
<a
href={`https://doi.org/${detailPaper.doi}`}
target="_blank"
rel="noreferrer"
onClick={() => axios.post('/api/active_bibcode', { bibcode: detailPaper.bibcode }).catch(() => {})}
className="hover:underline text-sky-600"
>
{detailPaper.doi}
</a>
) : '无'}
</span>
</div>
<div className="bg-slate-50 px-2.5 py-1.5 rounded border border-slate-150">
<span className="text-slate-400 font-bold block">ARXIV ID</span>
<span className="text-slate-450 font-bold block">ARXIV ID</span>
<span className="text-slate-700 font-semibold select-all truncate block" title={detailPaper.arxiv_id || '无'}>
{detailPaper.arxiv_id ? (
<a href={`https://arxiv.org/abs/${detailPaper.arxiv_id}`} target="_blank" rel="noreferrer" className="hover:underline text-sky-600">
<a
href={`https://arxiv.org/abs/${detailPaper.arxiv_id}`}
target="_blank"
rel="noreferrer"
onClick={() => axios.post('/api/active_bibcode', { bibcode: detailPaper.bibcode }).catch(() => {})}
className="hover:underline text-sky-600"
>
{detailPaper.arxiv_id}
</a>
) : '无'}
</span>
</div>
</div>
{/* 自动下载失败诊断 */}
{(detailPaper.pdf_error || detailPaper.html_error) && (
<div className={`rounded-lg p-3 text-[10px] space-y-1 ${
(detailPaper.pdf_error === 'no_resource' && detailPaper.html_error === 'no_resource')
? 'bg-amber-50 border border-amber-150 text-amber-850'
: 'bg-rose-50 border border-rose-150 text-rose-850'
}`}>
{detailPaper.pdf_error === 'no_resource' && detailPaper.html_error === 'no_resource' ? (
<div className="leading-relaxed">
<div className="font-bold flex items-center gap-1.5 text-amber-900 text-xs mb-1">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
</div>
</div>
) : (
<>
<div className="font-bold flex items-center gap-1.5 text-rose-900">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500 animate-pulse" />
</div>
{detailPaper.pdf_error && (
<div className="leading-relaxed">
<span className="font-semibold text-rose-750">PDF : </span>
{detailPaper.pdf_error}
</div>
)}
{detailPaper.html_error && (
<div className="leading-relaxed mt-0.5">
<span className="font-semibold text-rose-750">HTML : </span>
{detailPaper.html_error}
</div>
)}
</>
)}
</div>
)}
{/* 手动上传文件(应对防爬阻断) */}
<div className="border-t border-slate-100 pt-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-slate-450 font-bold block">线</span>
<span className="text-[9px] text-amber-700 font-bold bg-amber-50 px-2 py-0.5 rounded border border-amber-200">/</span>
</div>
<p className="text-[10px] text-slate-450 leading-relaxed">
PDF HTML
</p>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col items-center justify-center border border-dashed border-slate-300 rounded-lg p-2 hover:bg-slate-50 transition-colors relative cursor-pointer group min-h-[50px]">
<input
type="file"
accept="application/pdf"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleManualUpload(detailPaper.bibcode, 'pdf', file);
}}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
disabled={uploadingBibcode === detailPaper.bibcode}
/>
<span className="text-[10px] font-bold text-sky-600 group-hover:underline">
{uploadingBibcode === detailPaper.bibcode ? '上传中...' : '上传 PDF 文献'}
</span>
<span className="text-[8px] text-slate-400"> .pdf </span>
</div>
<div className="flex flex-col items-center justify-center border border-dashed border-slate-300 rounded-lg p-2 hover:bg-slate-50 transition-colors relative cursor-pointer group min-h-[50px]">
<input
type="file"
accept="text/html,.html"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleManualUpload(detailPaper.bibcode, 'html', file);
}}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
disabled={uploadingBibcode === detailPaper.bibcode}
/>
<span className="text-[10px] font-bold text-sky-600 group-hover:underline">
{uploadingBibcode === detailPaper.bibcode ? '上传中...' : '上传 HTML 文献'}
</span>
<span className="text-[8px] text-slate-400"> .html </span>
</div>
</div>
{!detailPaper.is_downloaded && (
<div className="pt-1">
{detailPaper.pdf_error === 'no_resource' && detailPaper.html_error === 'no_resource' ? (
<button
type="button"
onClick={() => handleMarkNoResource(detailPaper.bibcode, true)}
className="w-full py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-[10px] font-bold transition-all border border-slate-250 cursor-pointer flex items-center justify-center gap-1.5"
>
🔄 ()
</button>
) : (
<button
type="button"
onClick={() => handleMarkNoResource(detailPaper.bibcode, false)}
className="w-full py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-[10px] font-bold transition-all border border-slate-250 cursor-pointer flex items-center justify-center gap-1.5"
>
()
</button>
)}
</div>
)}
</div>
</div>
{/* 底部操作:整合所有动作(阅读、图谱、下载) */}
+88
View File
@@ -0,0 +1,88 @@
import { useState, useRef, useEffect } from 'react';
import { ChevronDown } from 'lucide-react';
export interface SelectOption {
value: string | number;
label: React.ReactNode;
}
interface CustomSelectProps {
value: string | number;
onChange: (value: any) => void;
options: SelectOption[];
className?: string;
disabled?: boolean;
}
export function CustomSelect({
value,
onChange,
options,
className = '',
disabled = false,
}: CustomSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Close when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const selectedOption = options.find(opt => opt.value === value) || options[0];
return (
<div ref={containerRef} className={`relative inline-block ${className}`}>
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
className={`w-full flex items-center justify-between gap-2 bg-white border border-slate-250 hover:border-slate-350 rounded-lg px-2.5 py-2 text-xs font-semibold text-slate-700 transition-all text-left outline-none cursor-pointer ${
isOpen ? 'border-sky-500 ring-1 ring-sky-500 bg-white' : ''
} ${disabled ? 'bg-slate-50 text-slate-450 cursor-not-allowed border-slate-200' : ''}`}
>
<span className="truncate">{selectedOption?.label}</span>
<ChevronDown
className={`w-3.5 h-3.5 text-slate-400 transition-transform duration-200 shrink-0 ${
isOpen ? 'rotate-180 text-sky-500' : ''
}`}
/>
</button>
{isOpen && !disabled && (
<div className="absolute left-0 mt-1.5 w-full min-w-[150px] bg-white border border-slate-200 rounded-lg shadow-lg py-1 z-50 max-h-60 overflow-y-auto scrollbar-thin">
{options.map(option => {
const isSelected = option.value === value;
return (
<button
key={option.value}
type="button"
onClick={() => {
onChange(option.value);
setIsOpen(false);
}}
className={`w-full text-left px-3 py-2 text-xs transition-colors block cursor-pointer outline-none ${
isSelected
? 'bg-sky-50 text-sky-700 font-bold'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium'
}`}
>
{option.label}
</button>
);
})}
</div>
)}
</div>
);
}
+214 -51
View File
@@ -1,12 +1,13 @@
// dashboard/src/features/library/LibraryPanel.tsx
import { useState } from 'react';
import { Library, RotateCw, Search, SlidersHorizontal } from 'lucide-react';
import { useState, useCallback } from 'react';
import { Library, RotateCw, Search, SlidersHorizontal, X, CheckCircle, AlertTriangle } from 'lucide-react';
import type { StandardPaper } from '../../types';
import { getDoctypeBadge } from '../search/SearchPanel';
import { CustomSelect } from '../../components/CustomSelect';
interface LibraryPanelProps {
library: StandardPaper[];
fetchLibrary: () => void;
fetchLibrary: () => Promise<void>;
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync') => void;
onShowDetail: (paper: StandardPaper) => void;
}
@@ -18,15 +19,29 @@ export function LibraryPanel({
onShowDetail,
}: LibraryPanelProps) {
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState<'all' | 'downloaded' | 'undownloaded' | 'parsed' | 'translated'>('all');
const [filterStatus, setFilterStatus] = useState<'all' | 'downloaded' | 'undownloaded' | 'download_failed' | 'no_resource' | 'parsed' | 'translated'>('all');
const [filterDoctype, setFilterDoctype] = useState<string>('all');
const [sortBy, setSortBy] = useState<'created' | 'yearDesc' | 'yearAsc' | 'citations' | 'title'>('created');
// 重新同步状态反馈
const [syncing, setSyncing] = useState(false);
const [syncFeedback, setSyncFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
// 高级元数据细化筛选状态
const [showAdvanced, setShowAdvanced] = useState(false);
const [filterAuthor, setFilterAuthor] = useState('');
const [filterYear, setFilterYear] = useState('');
const [filterJournal, setFilterJournal] = useState('');
// 各种状态的文献总数量
const countAll = library.length;
const countDownloaded = library.filter(p => p.is_downloaded).length;
const countUndownloaded = library.filter(p => !p.is_downloaded && !p.pdf_error && !p.html_error).length;
const countDownloadFailed = library.filter(p => !p.is_downloaded && (p.pdf_error || p.html_error) && !(p.pdf_error === 'no_resource' && p.html_error === 'no_resource')).length;
const countNoResource = library.filter(p => !p.is_downloaded && p.pdf_error === 'no_resource' && p.html_error === 'no_resource').length;
const countParsed = library.filter(p => p.has_markdown).length;
const countTranslated = library.filter(p => p.has_translation).length;
// 本地检索与筛选过滤
const filteredLibrary = library.filter(paper => {
// 1. 关键词全局检索 (标题、作者、摘要、Bibcode、arXiv ID、DOI)
@@ -45,11 +60,43 @@ export function LibraryPanel({
// 2. 离线状态筛选
if (filterStatus === 'downloaded' && !paper.is_downloaded) return false;
if (filterStatus === 'undownloaded' && paper.is_downloaded) return false;
if (filterStatus === 'undownloaded' && (paper.is_downloaded || paper.pdf_error || paper.html_error)) return false;
if (filterStatus === 'download_failed') {
if (paper.is_downloaded) return false;
if (!paper.pdf_error && !paper.html_error) return false;
if (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource') return false;
}
if (filterStatus === 'no_resource') {
if (paper.is_downloaded) return false;
if (!(paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource')) return false;
}
if (filterStatus === 'parsed' && !paper.has_markdown) return false;
if (filterStatus === 'translated' && !paper.has_translation) return false;
// 3. 高级元数据细化筛选
// 3. 文献类型筛选
if (filterDoctype !== 'all') {
const docVal = (paper.doctype || 'article').toLowerCase();
if (filterDoctype === 'proceedings') {
if (docVal !== 'proceedings' && docVal !== 'inproceedings') return false;
} else if (filterDoctype === 'thesis') {
if (docVal !== 'phdthesis' && docVal !== 'mastersthesis') return false;
} else if (filterDoctype === 'catalog') {
if (docVal !== 'catalog' && docVal !== 'dataset') return false;
} else if (filterDoctype === 'book') {
if (docVal !== 'book' && docVal !== 'inbook') return false;
} else if (filterDoctype === 'other') {
const standardTypes = [
'article', 'eprint', 'proceedings', 'inproceedings', 'proposal',
'phdthesis', 'mastersthesis', 'abstract', 'catalog', 'dataset',
'software', 'circular', 'book', 'inbook', 'techreport'
];
if (standardTypes.includes(docVal)) return false;
} else {
if (docVal !== filterDoctype) return false;
}
}
// 4. 高级元数据细化筛选
if (filterAuthor.trim()) {
const authorQuery = filterAuthor.toLowerCase().trim();
const matchAuthor = paper.authors.some(a => a.toLowerCase().includes(authorQuery));
@@ -68,8 +115,32 @@ export function LibraryPanel({
return true;
});
// 记录每个 bibcode 在原始 library 数组中的索引,作为“导入时间倒序”的绝对依据
const originalIndices = new Map<string, number>();
library.forEach((paper, index) => {
originalIndices.set(paper.bibcode, index);
});
const getStatusScore = (paper: StandardPaper) => {
if (paper.has_translation) return 4;
if (paper.has_markdown) return 3;
if (paper.is_downloaded) return 2;
return 1;
};
// 本地复合排序
const sortedLibrary = [...filteredLibrary].sort((a, b) => {
if (sortBy === 'created') {
const scoreA = getStatusScore(a);
const scoreB = getStatusScore(b);
if (scoreA !== scoreB) {
return scoreB - scoreA; // 状态高(已翻译 > 已解析 > 已下载 > 其他)的排在前面
}
// 状态相同时,按“导入时间倒序”排序(即原始数组中的索引从小到大)
const idxA = originalIndices.get(a.bibcode) ?? 99999;
const idxB = originalIndices.get(b.bibcode) ?? 99999;
return idxA - idxB;
}
if (sortBy === 'yearDesc') {
return (parseInt(b.year) || 0) - (parseInt(a.year) || 0);
}
@@ -82,10 +153,24 @@ export function LibraryPanel({
if (sortBy === 'title') {
return a.title.localeCompare(b.title);
}
// 'created' (默认): 沿用后端传回的创建时间倒序
return 0;
});
const handleResync = useCallback(async () => {
setSyncing(true);
setSyncFeedback(null);
try {
await fetchLibrary();
const count = library.length;
setSyncFeedback({ type: 'success', message: `馆藏数据已刷新,共 ${count} 篇文献` });
} catch {
setSyncFeedback({ type: 'error', message: '同步失败,请检查后端服务连接' });
} finally {
setSyncing(false);
setTimeout(() => setSyncFeedback(null), 3000);
}
}, [fetchLibrary, library.length]);
return (
<div className="w-full max-w-5xl mx-auto space-y-6">
<div className="flex items-center justify-between mb-4 border-b border-slate-200 pb-4">
@@ -94,13 +179,29 @@ export function LibraryPanel({
<p className="text-xs text-slate-500 mt-1">线</p>
</div>
<button
onClick={fetchLibrary}
className="btn-console px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
onClick={handleResync}
disabled={syncing}
className="btn-console px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2 disabled:opacity-50 transition-all"
>
<RotateCw className="w-3.5 h-3.5" />
<RotateCw className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? '正在同步...' : '重新同步馆藏'}
</button>
</div>
{/* 同步结果反馈条 */}
{syncFeedback && (
<div className={`px-4 py-2.5 rounded-lg text-xs font-bold flex items-center gap-2 transition-all ${
syncFeedback.type === 'success'
? 'bg-emerald-50 border border-emerald-200 text-emerald-700'
: 'bg-red-50 border border-red-200 text-red-700'
}`}>
{syncFeedback.type === 'success'
? <CheckCircle className="w-3.5 h-3.5 shrink-0" />
: <AlertTriangle className="w-3.5 h-3.5 shrink-0" />}
{syncFeedback.message}
</div>
)}
{/* 搜索、筛选与排序工具栏 */}
{library.length > 0 && (
<div className="flex flex-col gap-3.5 bg-white p-4 rounded-xl border border-slate-200 shadow-sm text-xs font-semibold text-slate-700">
@@ -115,41 +216,80 @@ export function LibraryPanel({
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
placeholder="搜索文献标题、作者、摘要、Bibcode、arXiv ID 或 DOI..."
className="w-full pl-9 pr-3 py-2 rounded-lg bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-xs font-medium"
className="w-full pl-9 pr-8 py-2 rounded-lg bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-xs font-medium"
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-all p-0.5 rounded-full hover:bg-slate-100 cursor-pointer flex items-center justify-center"
title="清空检索内容"
>
<X className="w-3 h-3" />
</button>
)}
</div>
</div>
{/* 状态过滤 */}
<div className="w-full sm:w-40 space-y-1.5">
<div className="w-full sm:w-36 space-y-1.5 font-bold flex flex-col">
<label className="block text-slate-500 font-bold"></label>
<select
<CustomSelect
value={filterStatus}
onChange={e => setFilterStatus(e.target.value as any)}
className="w-full px-2.5 py-2 rounded-lg bg-slate-50 border border-slate-250 text-slate-800 focus:outline-none focus:border-sky-500 text-xs cursor-pointer font-medium"
>
<option value="all"></option>
<option value="downloaded"> (PDF/HTML)</option>
<option value="undownloaded"></option>
<option value="parsed"></option>
<option value="translated"></option>
</select>
onChange={val => setFilterStatus(val)}
className="w-full"
options={[
{ value: 'all', label: `全部文献 (${countAll})` },
{ value: 'downloaded', label: `已下载 (${countDownloaded})` },
{ value: 'parsed', label: `已解析 (${countParsed})` },
{ value: 'translated', label: `已翻译 (${countTranslated})` },
{ value: 'undownloaded', label: `未下载 (${countUndownloaded})` },
{ value: 'download_failed', label: `下载失败 (${countDownloadFailed})` },
{ value: 'no_resource', label: `无资源 (${countNoResource})` },
]}
/>
</div>
{/* 文献类型筛选 */}
<div className="w-full sm:w-36 space-y-1.5 font-bold flex flex-col">
<label className="block text-slate-500 font-bold"></label>
<CustomSelect
value={filterDoctype}
onChange={val => setFilterDoctype(val)}
className="w-full"
options={[
{ value: 'all', label: '全部类型' },
{ value: 'article', label: '期刊文章' },
{ value: 'eprint', label: '预印本' },
{ value: 'proceedings', label: '会议论文/集' },
{ value: 'proposal', label: '观测提案' },
{ value: 'thesis', label: '学位论文' },
{ value: 'abstract', label: '会议摘要' },
{ value: 'catalog', label: '星表数据' },
{ value: 'software', label: '软件代码' },
{ value: 'book', label: '专著/图书章节' },
{ value: 'circular', label: '天文电报' },
{ value: 'techreport', label: '技术报告' },
{ value: 'other', label: '其他文献/杂项' },
]}
/>
</div>
{/* 排序方式 */}
<div className="w-full sm:w-40 space-y-1.5">
<div className="w-full sm:w-36 space-y-1.5 font-bold flex flex-col">
<label className="block text-slate-500 font-bold"></label>
<select
<CustomSelect
value={sortBy}
onChange={e => setSortBy(e.target.value as any)}
className="w-full px-2.5 py-2 rounded-lg bg-slate-50 border border-slate-250 text-slate-800 focus:outline-none focus:border-sky-500 text-xs cursor-pointer font-medium"
>
<option value="created"> ()</option>
<option value="yearDesc"> ( )</option>
<option value="yearAsc"> ( )</option>
<option value="citations"> ( )</option>
<option value="title"> (A-Z)</option>
</select>
onChange={val => setSortBy(val)}
className="w-full"
options={[
{ value: 'created', label: '默认 (导入时间)' },
{ value: 'yearDesc', label: '发表年份 (新 → 旧)' },
{ value: 'yearAsc', label: '发表年份 (旧 → 新)' },
{ value: 'citations', label: '被引用数 (高 → 低)' },
{ value: 'title', label: '文献标题 (A-Z)' },
]}
/>
</div>
{/* 高级元数据细化筛选触发器 */}
@@ -233,6 +373,7 @@ export function LibraryPanel({
onClick={() => {
setSearchTerm('');
setFilterStatus('all');
setFilterDoctype('all');
setFilterAuthor('');
setFilterYear('');
setFilterJournal('');
@@ -243,31 +384,52 @@ export function LibraryPanel({
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{sortedLibrary.map(paper => {
return (
<div
key={paper.bibcode}
onClick={() => onShowDetail(paper)}
className="console-panel p-5 rounded-xl border border-slate-200 hover:border-sky-350 bg-white flex flex-col justify-between relative overflow-hidden group transition-all cursor-pointer shadow-sm"
>
<div className="space-y-3">
<div className="flex justify-between items-center text-xs text-slate-500 font-bold px-1">
<span> {sortedLibrary.length} / {library.length} </span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{sortedLibrary.map(paper => {
return (
<div
key={paper.bibcode}
onClick={() => onShowDetail(paper)}
className="console-panel p-5 rounded-xl border border-slate-200 hover:border-sky-350 bg-white flex flex-col justify-between relative overflow-hidden group transition-all cursor-pointer shadow-sm"
>
{/* 状态角标 */}
<div className={`absolute top-0 right-0 px-2 py-0.5 text-[9px] font-bold border-b border-l rounded-bl ${
paper.has_translation
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: paper.has_markdown
? 'bg-sky-50 text-sky-700 border-sky-200'
: paper.is_downloaded
? 'bg-indigo-50 text-indigo-700 border-indigo-200'
: 'bg-amber-50 text-amber-700 border-amber-200'
}`}>
<div
title={
(!paper.is_downloaded && (paper.pdf_error || paper.html_error))
? (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource')
? '已手动标记为【无有效全文资源】,批量下载时自动跳过。'
: `下载失败原因:${[paper.pdf_error, paper.html_error].filter(Boolean).join('; ')}`
: undefined
}
className={`absolute top-0 right-0 px-2 py-0.5 text-[9px] font-bold border-b border-l rounded-bl ${
paper.has_translation
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: paper.has_markdown
? 'bg-sky-50 text-sky-700 border-sky-200'
: paper.is_downloaded
? 'bg-indigo-50 text-indigo-700 border-indigo-200'
: (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource')
? 'bg-slate-100 text-slate-600 border-slate-200 cursor-help'
: (paper.pdf_error || paper.html_error)
? 'bg-rose-50 text-rose-700 border-rose-200 cursor-help'
: 'bg-amber-50 text-amber-700 border-amber-200'
}`}
>
{paper.has_translation
? '已翻译'
: paper.has_markdown
? '已解析'
: paper.is_downloaded
? '已下载'
: '未下载'}
: (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource')
? '无资源'
: (paper.pdf_error || paper.html_error)
? '下载失败'
: '未下载'}
</div>
<div className="pr-10">
@@ -304,6 +466,7 @@ export function LibraryPanel({
</div>
);
})}
</div>
</div>
)}
</div>
+76 -44
View File
@@ -2,6 +2,7 @@
import React from 'react';
import { Search, Loader, CheckCircle, Copy, Download, ChevronLeft, ChevronRight, SlidersHorizontal } from 'lucide-react';
import type { StandardPaper } from '../../types';
import { CustomSelect } from '../../components/CustomSelect';
export const getDoctypeBadge = (doctype: string) => {
const typeMap: Record<string, { label: string; style: string }> = {
@@ -12,9 +13,19 @@ export const getDoctypeBadge = (doctype: string) => {
proposal: { label: '观测提案', style: 'bg-rose-50 text-rose-700 border-rose-200' },
abstract: { label: '会议摘要', style: 'bg-slate-50 text-slate-700 border-slate-200' },
catalog: { label: '星表数据', style: 'bg-indigo-50 text-indigo-700 border-indigo-200' },
dataset: { label: '星表数据', style: 'bg-indigo-50 text-indigo-700 border-indigo-200' },
software: { label: '软件代码', style: 'bg-teal-50 text-teal-700 border-teal-200' },
phdthesis: { label: '博士论文', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
mastersthesis: { label: '硕士论文', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
circular: { label: '天文电报', style: 'bg-orange-50 text-orange-700 border-orange-200' },
inbook: { label: '图书章节', style: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
book: { label: '学术专著', style: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
editorial: { label: '期刊社论', style: 'bg-slate-50 text-slate-700 border-slate-200' },
erratum: { label: '勘误说明', style: 'bg-red-50 text-red-700 border-red-200' },
misc: { label: '其他文献', style: 'bg-slate-50 text-slate-700 border-slate-200' },
newsletter: { label: '简报新闻', style: 'bg-slate-50 text-slate-700 border-slate-200' },
techreport: { label: '技术报告', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
obituary: { label: '讣告', style: 'bg-slate-50 text-slate-700 border-slate-200' },
};
const val = doctype ? doctype.toLowerCase() : 'article';
const match = typeMap[val] || { label: '文献', style: 'bg-slate-50 text-slate-700 border-slate-200' };
@@ -199,30 +210,32 @@ export function SearchPanel({
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
{idx > 0 ? (
<select
<CustomSelect
value={rule.op}
onChange={e => handleRuleChange(idx, 'op', e.target.value)}
className="bg-white border border-slate-350 rounded-lg px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:border-sky-500 w-24"
>
<option value="AND"> (AND)</option>
<option value="OR"> (OR)</option>
<option value="NOT"> (NOT)</option>
</select>
onChange={val => handleRuleChange(idx, 'op', val)}
className="w-24"
options={[
{ value: 'AND', label: '并且 (AND)' },
{ value: 'OR', label: '或者 (OR)' },
{ value: 'NOT', label: '排除 (NOT)' },
]}
/>
) : (
<div className="w-24 text-center text-xs text-slate-500 font-semibold"></div>
)}
<select
<CustomSelect
value={rule.field}
onChange={e => handleRuleChange(idx, 'field', e.target.value)}
className="bg-white border border-slate-350 rounded-lg px-2.5 py-1.5 text-xs text-slate-700 focus:outline-none focus:border-sky-500 w-32"
>
<option value="all"></option>
<option value="title"></option>
<option value="author"></option>
<option value="abs"></option>
<option value="year"></option>
</select>
onChange={val => handleRuleChange(idx, 'field', val)}
className="w-32"
options={[
{ value: 'all', label: '任意字段' },
{ value: 'title', label: '标题名称' },
{ value: 'author', label: '作者名称' },
{ value: 'abs', label: '摘要内容' },
{ value: 'year', label: '年份范围' },
]}
/>
<input
type="text"
@@ -287,30 +300,30 @@ export function SearchPanel({
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-slate-500">:</span>
<select
<CustomSelect
value={searchSort}
onChange={e => handleSortChange(e.target.value)}
className="bg-white border border-slate-300 rounded-lg px-2.5 py-1 text-xs text-slate-750 font-medium focus:outline-none focus:border-sky-500"
>
<option value="relevance"></option>
<option value="date_desc"> ()</option>
<option value="date_asc"> ()</option>
<option value="citations_desc"> ()</option>
</select>
onChange={val => handleSortChange(val)}
options={[
{ value: 'relevance', label: '相关度' },
{ value: 'date_desc', label: '发表日期 (由新到旧)' },
{ value: 'date_asc', label: '发表日期 (由旧到新)' },
{ value: 'citations_desc', label: '被引频次 (从高到低)' },
]}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-slate-500">:</span>
<select
<CustomSelect
value={searchRows}
onChange={e => handleRowsChange(Number(e.target.value))}
className="bg-white border border-slate-300 rounded-lg px-2.5 py-1 text-xs text-slate-750 font-medium focus:outline-none focus:border-sky-500"
>
<option value="10">10 </option>
<option value="15">15 </option>
<option value="30">30 </option>
<option value="50">50 </option>
</select>
onChange={val => handleRowsChange(Number(val))}
options={[
{ value: 10, label: '10 条' },
{ value: 15, label: '15 条' },
{ value: 30, label: '30 条' },
{ value: 50, label: '50 条' },
]}
/>
</div>
{exportingList.length > 0 && (
@@ -401,14 +414,33 @@ export function SearchPanel({
<CheckCircle className="w-3.5 h-3.5" />
</span>
) : (
<button
onClick={() => handleDownload(paper.bibcode)}
disabled={isDownloading}
className="btn-console btn-console-primary px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1 transition-all"
>
{isDownloading ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />}
</button>
<div className="flex items-center gap-2">
{(paper.pdf_error || paper.html_error) && (
paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource' ? (
<span
title="已手动标记为【无有效全文资源】,批量下载时自动跳过"
className="px-2 py-1 bg-slate-50 text-slate-600 border border-slate-200 text-[10px] font-bold rounded-lg cursor-help flex items-center gap-0.5"
>
</span>
) : (
<span
title={`自动下载失败:${[paper.pdf_error, paper.html_error].filter(Boolean).join('; ')}`}
className="px-2 py-1 bg-rose-50 text-rose-700 border border-rose-200 text-[10px] font-bold rounded-lg cursor-help flex items-center gap-0.5"
>
</span>
)
)}
<button
onClick={() => handleDownload(paper.bibcode)}
disabled={isDownloading}
className="btn-console btn-console-primary px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1 transition-all"
>
{isDownloading ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />}
</button>
</div>
)}
<label className="flex items-center gap-1.5 cursor-pointer border border-slate-250 px-2.5 py-1.5 rounded-lg bg-slate-50 hover:bg-slate-100 text-slate-700 transition-all text-xs font-semibold">
+135 -40
View File
@@ -3,6 +3,7 @@ import axios from 'axios';
import { RefreshCw, Play, Info, AlertTriangle, CheckCircle, Loader, StopCircle, Download, FileText, SlidersHorizontal } from 'lucide-react';
import type { SavedSyncQuery } from '../../types';
import { CustomSelect } from '../../components/CustomSelect';
interface ProcessStatus {
active: boolean;
@@ -62,7 +63,7 @@ export function SyncPanel() {
});
const [processError, setProcessError] = useState<string | null>(null);
const processPollIntervalRef = useRef<any>(null);
const logsEndRef = useRef<HTMLDivElement | null>(null);
const logsContainerRef = useRef<HTMLDivElement | null>(null);
const [showBuilder, setShowBuilder] = useState(false);
const [rules, setRules] = useState<Array<{ field: string; op: string; val: string }>>([
@@ -138,6 +139,18 @@ export function SyncPanel() {
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,
@@ -145,15 +158,15 @@ export function SyncPanel() {
limit: sq.limit_count,
});
fetchStatus();
startPolling();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动快速同步失败。');
fetchStatus();
}
};
// 获取当前的收割状态
// 获取当前的元数据同步状态
const fetchStatus = async () => {
try {
const res = await axios.get<HarvestStatus>(`/api/sync/meta/status?t=${Date.now()}`);
@@ -250,10 +263,15 @@ export function SyncPanel() {
};
}, []);
// 日志终端自动滚动到底部
// 日志终端自动滚动到底部 (仅限定滚动容器内部,不影响整个网页的滚动)
useEffect(() => {
if (logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
const container = logsContainerRef.current;
if (container) {
// 阈值设为 80px。如果用户滚动到离底部距离小于 80px,则视为保持跟踪底部的模式
const isCloseToBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 80;
if (isCloseToBottom) {
container.scrollTop = container.scrollHeight;
}
}
}, [processStatus.logs]);
@@ -279,13 +297,25 @@ export function SyncPanel() {
}
};
// 启动收割任务
// 启动任务
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(),
@@ -293,11 +323,11 @@ export function SyncPanel() {
limit: limit,
});
fetchStatus();
startPolling();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动收割任务失败。');
setErrorMsg(e.response?.data || '启动元数据同步任务失败。');
fetchStatus();
}
};
@@ -391,30 +421,32 @@ export function SyncPanel() {
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
{idx > 0 ? (
<select
<CustomSelect
value={rule.op}
onChange={e => handleRuleChange(idx, 'op', e.target.value)}
className="bg-white border border-slate-300 rounded-lg px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:border-sky-500 w-24"
>
<option value="AND"> (AND)</option>
<option value="OR"> (OR)</option>
<option value="NOT"> (NOT)</option>
</select>
onChange={val => handleRuleChange(idx, 'op', val)}
className="w-24"
options={[
{ value: 'AND', label: '并且 (AND)' },
{ value: 'OR', label: '或者 (OR)' },
{ value: 'NOT', label: '排除 (NOT)' },
]}
/>
) : (
<div className="w-24 text-center text-xs text-slate-500 font-semibold"></div>
)}
<select
<CustomSelect
value={rule.field}
onChange={e => handleRuleChange(idx, 'field', e.target.value)}
className="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs text-slate-700 focus:outline-none focus:border-sky-500 w-32"
>
<option value="all"></option>
<option value="title"></option>
<option value="author"></option>
<option value="abs"></option>
<option value="year"></option>
</select>
onChange={val => handleRuleChange(idx, 'field', val)}
className="w-32"
options={[
{ value: 'all', label: '任意字段' },
{ value: 'title', label: '标题' },
{ value: 'author', label: '作者' },
{ value: 'abs', label: '摘要' },
{ value: 'year', label: '年份' },
]}
/>
<input
type="text"
@@ -578,28 +610,29 @@ export function SyncPanel() {
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"> (DOCS)</label>
<label className="text-xs font-bold text-slate-700 block"></label>
<input
type="number"
value={batchLimitCount}
disabled={processStatus.active}
onChange={e => setBatchLimitCount(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-3 py-1.5 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"
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"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<select
<CustomSelect
value={sortOrder}
disabled={processStatus.active}
onChange={e => setSortOrder(e.target.value as any)}
className="w-full px-3 py-1.5 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"
>
<option value="default"></option>
<option value="pub_year_desc"></option>
<option value="created_at_desc"></option>
</select>
onChange={val => setSortOrder(val)}
className="w-full"
options={[
{ value: 'default', label: '默认(不指定)' },
{ value: 'pub_year_desc', label: '按出版年份降序' },
{ value: 'created_at_desc', label: '按入库时间降序' },
]}
/>
</div>
</div>
@@ -732,7 +765,7 @@ export function SyncPanel() {
{/* 滚动日志终端 */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<div className="bg-slate-50 text-slate-800 font-mono text-xs p-4 rounded-lg h-48 overflow-y-auto border border-slate-250 space-y-1 scrollbar-thin scrollbar-thumb-slate-300 relative">
<div ref={logsContainerRef} className="bg-slate-50 text-slate-800 font-mono text-xs p-4 rounded-lg h-48 overflow-y-auto border border-slate-250 space-y-1 scrollbar-thin scrollbar-thumb-slate-300 relative">
{processStatus.logs.length === 0 ? (
<div className="text-slate-400 italic">...</div>
) : (
@@ -742,7 +775,6 @@ export function SyncPanel() {
</div>
))
)}
<div ref={logsEndRef} />
</div>
</div>
</div>
@@ -757,7 +789,7 @@ export function SyncPanel() {
<span></span>
</h3>
<p className="text-slate-500 text-xs">
</p>
</div>
@@ -808,6 +840,69 @@ export function SyncPanel() {
</div>
)}
</div>
{/* 浏览器快捷直推书签工具 */}
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4">
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-sky-500 animate-pulse" />
<span></span>
</h3>
<p className="text-slate-500 text-xs mt-1">
Cloudflare/WAF PDF/HTML AstroResearch
</p>
</div>
<div className="space-y-3.5 text-xs text-slate-700">
<div className="space-y-1.5 font-bold">
<span className="text-slate-500"></span>
<p className="text-[11px] text-slate-400 font-normal mt-1">
URL JavaScript
</p>
<div className="flex flex-wrap items-center gap-3 mt-2">
{/* 可直接拖拽的链接按钮 */}
<a
ref={(el) => {
if (el) {
el.setAttribute(
'href',
`javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode');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});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()}
>
{/* 浏览器拖拽时读取全部 textContentsr-only span 的文字成为书签名 */}
<span style={{position:'absolute',width:'1px',height:'1px',overflow:'hidden',opacity:0,pointerEvents:'none'}}>AstroResearch</span>
<span aria-hidden="true"></span>
</a>
<button
onClick={() => {
const bookmarkletCode = `javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode');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});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);`;
navigator.clipboard.writeText(bookmarkletCode);
alert('书签代码已成功复制到剪贴板!');
}}
className="px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-[11px] font-bold transition-all cursor-pointer"
>
JavaScript
</button>
</div>
</div>
<div className="space-y-1.5 font-bold mt-4">
<span className="text-slate-500">使</span>
<ol className="list-decimal list-inside text-[11px] text-slate-500 font-normal pl-1 space-y-1.5 mt-1">
<li> <span className="font-bold">BIBCODE / DOI / arXiv ID </span></li>
<li><span className="font-bold"></span></li>
<li><span className="font-bold"> Bibcode</span></li>
</ol>
</div>
</div>
</div>
</div>
);
}
+38
View File
@@ -94,3 +94,41 @@ body {
color: #0f172a;
}
/* Premium clean console select dropdown styling */
.select-console {
display: inline-block;
background-color: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 0.5rem; /* 8px */
padding: 0.5rem 1.75rem 0.5rem 0.625rem; /* padding-right leaves space for custom arrow */
color: #334155;
font-size: 0.75rem; /* text-xs */
font-weight: 500;
cursor: pointer;
outline: none;
transition: all 0.2s ease;
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%2364748b' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.25rem;
}
.select-console:hover:not(:disabled) {
border-color: #94a3b8;
color: #0f172a;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.select-console:focus {
border-color: #0284c7;
box-shadow: 0 0 0 1px #0284c7;
}
.select-console:disabled {
background-color: #f1f5f9;
color: #94a3b8;
cursor: not-allowed;
}
+2
View File
@@ -16,6 +16,8 @@ export interface StandardPaper {
has_markdown: boolean;
has_translation: boolean;
doctype: string;
pdf_error?: string;
html_error?: string;
}
export interface CitationNetwork {