AstroResearch/dashboard/src/App.tsx
Asfmq 22e7e1dcee feat: RAG 文献问答、天体目标识别、解析器模块化与批量管线扩展
核心新增:
  - RAG 问答系统:Markdown 安全切片器 (LaTeX 保护) + 向量化 + sqlite-vec 检索 + LLM 生成
  - 天体目标识别:IAU 标准正则提取 15+ 星表标识符,CDS SIMBAD/Sesame 查询与本地缓存
  - 多模态 LLM:chat_completion_with_image 支持图表视觉分析
  - CLI Skills Agent (cli.rs):对外暴露 rag/target/ingest 等 5 个子命令
  - 解析器模块化重构:单体 778 行 → 按期刊拆分 (A&A/ar5iv/IOP/Generic/PDF) +
    common.rs 静态正则工具库

  管线与 Schema:
  - AssetSync→AssetBatch 重命名,批量管线新增 embed/target 两个处理阶段
  - 新增 paper_chunks_content (RAG 切片) 和 paper_targets (天体缓存) 两张表
  - StandardPaper 新增 has_vector 字段,所有查询同步更新

  前端:
  - 新增 AI 助手侧边栏 (RAG 问答 + 来源跳转高亮)
  - 最近浏览文献列表 (localStorage 持久化)、跨面板无缝导航
  - SyncPanel 批量阶段扩展为下拉选项,支持向量化/天体识别

  测试与清理:
  - 集成测试合并至 ads.rs 和 llm.rs,新增 chunker + target 单元测试 15 个
  - 删除旧单体 parser.rs、独立测试文件及过期 scratch 脚本
2026-06-15 13:25:18 +08:00

1132 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// dashboard/src/App.tsx
import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import { Loader, Download, BookOpen, GitFork, RefreshCw, AlertTriangle } from 'lucide-react';
import { Sidebar } from './components/layout/Sidebar';
import { SearchPanel, getDoctypeBadge } from './features/search/SearchPanel';
import { LibraryPanel } from './features/library/LibraryPanel';
import { ReaderPanel } from './features/reader/ReaderPanel';
import { CitationPanel } from './features/citation/CitationPanel';
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'>(() => {
const saved = localStorage.getItem('astro_active_tab');
return (saved as any) || 'search';
});
useEffect(() => {
localStorage.setItem('astro_active_tab', activeTab);
}, [activeTab]);
// 全局对话框弹窗状态
const [dialog, setDialog] = useState<{
type: 'alert' | 'confirm';
title: string;
message: string;
onConfirm: () => void;
onCancel?: () => void;
} | null>(null);
const showAlert = useCallback((message: string, title = '系统提示') => {
setDialog({
type: 'alert',
title,
message,
onConfirm: () => {},
});
}, []);
const showConfirm = useCallback((message: string, onConfirm: () => void, title = '确认操作') => {
setDialog({
type: 'confirm',
title,
message,
onConfirm,
});
}, []);
// 共享数据状态
const [library, setLibrary] = useState<StandardPaper[]>([]);
const [selectedPaper, setSelectedPaper] = useState<StandardPaper | null>(null);
const [detailBibcode, setDetailBibcode] = useState<string | null>(null);
const [recentlySelected, setRecentlySelected] = useState<StandardPaper[]>(() => {
try {
const saved = localStorage.getItem('astro_recently_selected');
return saved ? JSON.parse(saved) : [];
} catch (e) {
console.error('Failed to parse recently selected papers', e);
return [];
}
});
useEffect(() => {
localStorage.setItem('astro_recently_selected', JSON.stringify(recentlySelected));
}, [recentlySelected]);
const addPaperToRecent = (paper: StandardPaper) => {
setRecentlySelected(prev => {
const filtered = prev.filter(p => p.bibcode !== paper.bibcode);
return [paper, ...filtered].slice(0, 5);
});
};
const openCitation = (paper: StandardPaper) => {
setSelectedPaper(paper);
setActiveTab('citation');
loadCitations(paper.bibcode, true);
addPaperToRecent(paper);
};
// 检索页状态
const [searchQuery, setSearchQuery] = useState('');
const [searchSource, setSearchSource] = useState<'all' | 'ads' | 'arxiv'>('all');
const [searchResults, setSearchResults] = useState<StandardPaper[]>([]);
// 衍生状态计算
const detailPaper = library.find(p => p.bibcode === detailBibcode) || searchResults.find(p => p.bibcode === detailBibcode);
const [searching, setSearching] = useState(false);
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('');
const [chineseText, setChineseText] = useState('');
const [parsing, setParsing] = useState(false);
const [translating, setTranslating] = useState(false);
const [vectorizing, setVectorizing] = useState(false);
// 引用星系数据状态
const [citationNetwork, setCitationNetwork] = useState<CitationNetwork | null>(null);
const [loadingCitations, setLoadingCitations] = useState(false);
const [citationHistory, setCitationHistory] = useState<CitationNetwork[]>([]); // 多跳历史
const [uncachedBibcode, setUncachedBibcode] = useState<string | null>(null);
// 笔记系统状态
const [notes, setNotes] = useState<NoteRecord[]>([]);
const [showNotesPanel, setShowNotesPanel] = useState(false);
const [newNoteText, setNewNoteText] = useState('');
const [newNoteColor, setNewNoteColor] = useState('yellow');
const [selectedParagraphIdx, setSelectedParagraphIdx] = useState<number | null>(null);
const [selectedText, setSelectedText] = useState('');
// 下载进度状态
const [downloadingBibcodes, setDownloadingBibcodes] = useState<Record<string, boolean>>({});
const [uploadingBibcode, setUploadingBibcode] = useState<string | null>(null);
// 1. 初始化时加载本地文献
useEffect(() => {
fetchLibrary();
}, []);
const fetchLibrary = async () => {
try {
const res = await axios.get<StandardPaper[]>('/api/library');
setLibrary(res.data);
const lastReadBibcode = localStorage.getItem('last_read_bibcode');
if (lastReadBibcode) {
const lastReadPaper = res.data.find(p => p.bibcode === lastReadBibcode);
if (lastReadPaper) {
const initialTab = localStorage.getItem('astro_active_tab') || 'search';
openReader(lastReadPaper, initialTab !== 'reader');
}
}
} catch (e) {
console.error('加载本地文献库失败', e);
}
};
// 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, start, sort }
});
setSearchResults(res.data);
// 写入缓存
setSearchCache(prev => ({ ...prev, [cacheKey]: res.data }));
} catch (e) {
console.error('检索文献失败', e);
showAlert('检索失败,请确认后端连接及 API 密钥配置。', '检索出错');
} finally {
setSearching(false);
}
};
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 }));
try {
const res = await axios.post<StandardPaper>('/api/download', { bibcode, force });
// 更新库及检索列表中的状态
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);
}
} catch (e) {
console.error('下载文献失败', e);
showAlert('文献下载失败,请检查 ADS 网络限制与网络代理!', '下载失败');
} finally {
setDownloadingBibcodes(prev => ({ ...prev, [bibcode]: false }));
}
};
// 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);
try {
const res = await axios.post<{ markdown: string }>('/api/parse', { bibcode, force });
setEnglishText(res.data.markdown);
// 更新文献状态
setLibrary(prev => prev.map(p => p.bibcode === bibcode ? { ...p, has_markdown: true } : p));
if (selectedPaper?.bibcode === bibcode) {
setSelectedPaper(prev => prev ? { ...prev, has_markdown: true } : null);
}
} catch (e) {
console.error('文献解析失败', e);
showAlert('文献排版解析失败,请检查是否已完成 HTML/PDF 下载,并配置了 MinerU API 节点。', '解析失败');
} finally {
setParsing(false);
}
};
// 5. 对比翻译文本 (带天文学术语修正)
const handleTranslate = async (bibcode: string, force = false) => {
setTranslating(true);
try {
const res = await axios.post<{ translation: string }>('/api/translate', { bibcode, force });
setChineseText(res.data.translation);
setLibrary(prev => prev.map(p => p.bibcode === bibcode ? { ...p, has_translation: true } : p));
if (selectedPaper?.bibcode === bibcode) {
setSelectedPaper(prev => prev ? { ...prev, has_translation: true } : null);
}
} catch (e) {
console.error('文献翻译失败', e);
showAlert('翻译失败,请检查 .env 中的大模型 API 密钥与端点配置。', '翻译失败');
} finally {
setTranslating(false);
}
};
// 5.5. 对文献进行向量化分块入库 (独立任务)
const handleVectorize = async (bibcode: string) => {
setVectorizing(true);
try {
const res = await axios.post<{ chunk_count: number }>('/api/embed', { bibcode });
setLibrary(prev => prev.map(p => p.bibcode === bibcode ? { ...p, has_vector: true } : p));
if (selectedPaper?.bibcode === bibcode) {
setSelectedPaper(prev => prev ? { ...prev, has_vector: true } : null);
}
showAlert(`文献向量化分块入库成功,共切片并录入 ${res.data.chunk_count} 个向量块。`, '向量化成功');
} catch (e) {
console.error('文献向量化失败', e);
showAlert('向量化失败,请检查 .env 中的 Embedding API 配置。', '向量化失败');
} finally {
setVectorizing(false);
}
};
// 6. 加载文献引用关系网络
const loadCitations = useCallback(async (bibcode: string, reset = false) => {
setLoadingCitations(true);
try {
const res = await axios.get<CitationNetwork>('/api/citations', {
params: { bibcode }
});
setCitationNetwork(res.data);
setCitationHistory(prev => {
if (reset) {
return [res.data];
}
if (prev.some(net => net.bibcode === res.data.bibcode)) {
return prev;
}
return [...prev, res.data];
});
} catch (e) {
console.error('加载引用拓扑失败', e);
} finally {
setLoadingCitations(false);
}
}, []);
// 7. 进入阅读器
const openReader = async (paper: StandardPaper, skipTabSwitch = false) => {
setSelectedPaper(paper);
localStorage.setItem('last_read_bibcode', paper.bibcode);
addPaperToRecent(paper);
setEnglishText('');
setChineseText('');
setNotes([]);
setShowNotesPanel(false);
if (!skipTabSwitch) {
setActiveTab('reader');
}
// 获取详情 (包含已有原文及翻译)
try {
const res = await axios.get<{ paper: StandardPaper, english_content?: string, translation_content?: string }>('/api/paper', {
params: { bibcode: paper.bibcode }
});
if (res.data.english_content) {
setEnglishText(res.data.english_content);
}
if (res.data.translation_content) {
setChineseText(res.data.translation_content);
}
} catch (e) {
console.error('加载文献详情失败', e);
}
// 加载该文献的所有笔记
try {
const nRes = await axios.get<NoteRecord[]>('/api/notes', { params: { bibcode: paper.bibcode } });
setNotes(nRes.data);
} catch (e) {
console.error('加载笔记失败', e);
}
};
// 7.5. 从 RAG 问答结果跳转至目标文献段落并高亮
const handleJumpToSource = async (bibcode: string, paragraphIndex: number) => {
setActiveTab('reader');
// 如果当前选中的不是目标文献,则先加载它
if (!selectedPaper || selectedPaper.bibcode !== bibcode) {
setEnglishText('');
setChineseText('');
setNotes([]);
setShowNotesPanel(false);
try {
// 获取 StandardPaper 基础信息与正文
const paperRes = await axios.get<{ paper: StandardPaper, english_content?: string, translation_content?: string }>('/api/paper', {
params: { bibcode }
});
setSelectedPaper(paperRes.data.paper);
if (paperRes.data.english_content) {
setEnglishText(paperRes.data.english_content);
}
if (paperRes.data.translation_content) {
setChineseText(paperRes.data.translation_content);
}
// 加载该文献的所有笔记
const nRes = await axios.get<NoteRecord[]>('/api/notes', { params: { bibcode } });
setNotes(nRes.data);
} catch (e) {
console.error('加载跳转文献失败:', e);
return;
}
}
// 强制开启侧边栏
setShowNotesPanel(true);
// 延迟等 DOM 渲染完成进行滚动高亮
setTimeout(() => {
const element = document.getElementById(`paragraph-block-${paragraphIndex}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.add('bg-amber-100', 'transition-all');
setTimeout(() => {
element.classList.remove('bg-amber-100');
}, 2500);
}
}, 400);
};
// 8. 批量导出 BibTeX
const handleExportBibtex = async () => {
if (exportingList.length === 0) return;
setExporting(true);
try {
const res = await axios.post<{ bibtex: string }>('/api/export', { bibcodes: exportingList });
setBibtexContent(res.data.bibtex);
} catch (e) {
console.error('导出 BibTeX 失败', e);
showAlert('导出 BibTeX 失败,请检查 ADS Token。', '导出失败');
} finally {
setExporting(false);
}
};
// 批量选择引文
const toggleExportItem = (bibcode: string) => {
setExportingList(prev =>
prev.includes(bibcode) ? prev.filter(b => b !== bibcode) : [...prev, bibcode]
);
};
// 笔记相关操作
const handleCreateNote = async () => {
if (!selectedPaper || selectedParagraphIdx === null || !newNoteText.trim()) return;
try {
const res = await axios.post<NoteRecord>('/api/notes', {
bibcode: selectedPaper.bibcode,
paragraph_index: selectedParagraphIdx,
note_text: newNoteText.trim(),
highlight_color: newNoteColor,
selected_text: selectedText,
});
setNotes(prev => [...prev, res.data]);
setNewNoteText('');
setSelectedParagraphIdx(null);
setSelectedText('');
} catch (e) {
console.error('保存笔记失败', e);
}
};
const handleDeleteNote = async (id: number) => {
try {
await axios.delete('/api/notes', { params: { id } });
setNotes(prev => prev.filter(n => n.id !== id));
} catch (e) {
console.error('删除笔记失败', e);
}
};
// 选中文本时弹出笔记添加面板
const handleTextSelection = (paragraphIdx: number) => {
const sel = window.getSelection();
if (sel && sel.toString().trim().length > 3) {
setSelectedText(sel.toString().trim());
setSelectedParagraphIdx(paragraphIdx);
setShowNotesPanel(true);
}
};
return (
<div className="flex h-screen overflow-hidden text-slate-800 bg-slate-100 select-text">
{/* 导航左侧栏 */}
<Sidebar
activeTab={activeTab}
setActiveTab={setActiveTab}
selectedPaper={selectedPaper}
loadCitations={loadCitations}
/>
{/* 主工作区 */}
<main className="flex-1 flex flex-col overflow-hidden relative">
{/* 选项卡容器 */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 md:p-8 relative z-10 w-full flex flex-col">
<div className={`w-full flex-1 flex flex-col min-h-0 ${
(activeTab === 'reader' || activeTab === 'citation') ? 'max-w-none' : 'max-w-7xl mx-auto'
}`}>
{activeTab === 'search' && (
<SearchPanel
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchSource={searchSource}
setSearchSource={setSearchSource}
searchRows={searchRows}
searchStart={searchStart}
searchSort={searchSort}
handlePageChange={handlePageChange}
handleSortChange={handleSortChange}
handleRowsChange={handleRowsChange}
searching={searching}
handleSearch={handleSearch}
searchResults={searchResults}
exportingList={exportingList}
toggleExportItem={toggleExportItem}
handleExportBibtex={handleExportBibtex}
exporting={exporting}
bibtexContent={bibtexContent}
downloadingBibcodes={downloadingBibcodes}
handleDownload={handleDownload}
selectedPaper={selectedPaper}
setSelectedPaper={setSelectedPaper}
openReader={openReader}
setActiveTab={setActiveTab}
loadCitations={loadCitations}
showAlert={showAlert}
onShowDetail={(paper) => setDetailBibcode(paper.bibcode)}
/>
)}
{activeTab === 'library' && (
<LibraryPanel
library={library}
fetchLibrary={fetchLibrary}
setActiveTab={setActiveTab}
onShowDetail={(paper) => setDetailBibcode(paper.bibcode)}
onOpenReader={openReader}
onOpenCitation={openCitation}
/>
)}
{activeTab === 'reader' && (
selectedPaper ? (
<ReaderPanel
selectedPaper={selectedPaper}
library={library}
recentlySelected={recentlySelected}
onSwitchPaper={openReader}
parsing={parsing}
handleParse={handleParse}
translating={translating}
handleTranslate={handleTranslate}
vectorizing={vectorizing}
handleVectorize={handleVectorize}
showNotesPanel={showNotesPanel}
setShowNotesPanel={setShowNotesPanel}
notes={notes}
englishText={englishText}
chineseText={chineseText}
handleTextSelection={handleTextSelection}
selectedParagraphIdx={selectedParagraphIdx}
setSelectedParagraphIdx={setSelectedParagraphIdx}
selectedText={selectedText}
setSelectedText={setSelectedText}
newNoteColor={newNoteColor}
setNewNoteColor={setNewNoteColor}
newNoteText={newNoteText}
setNewNoteText={setNewNoteText}
handleCreateNote={handleCreateNote}
handleDeleteNote={handleDeleteNote}
showConfirm={showConfirm}
onJumpToSource={handleJumpToSource}
/>
) : (
<div className="w-full flex-1 flex flex-col items-center justify-center p-12 bg-white rounded-2xl border border-slate-200 shadow-xs min-h-[450px]">
<div className="w-16 h-16 rounded-2xl bg-sky-50 border border-sky-100 flex items-center justify-center text-sky-600 mb-5 shadow-2xs">
<BookOpen className="w-8 h-8" />
</div>
<h3 className="text-base font-bold text-slate-800 tracking-wide mb-2"></h3>
<p className="text-xs text-slate-500 max-w-sm text-center leading-relaxed mb-6">
</p>
<button
onClick={() => setActiveTab('library')}
className="px-6 py-2.5 bg-sky-600 hover:bg-sky-700 text-white rounded-lg text-xs font-bold transition-all shadow-xs hover:shadow-md cursor-pointer hover:scale-102 active:scale-98"
>
</button>
</div>
)
)}
{activeTab === 'citation' && (
selectedPaper ? (
<CitationPanel
selectedPaper={selectedPaper}
library={library}
recentlySelected={recentlySelected}
onSwitchPaper={openCitation}
loadingCitations={loadingCitations}
citationNetwork={citationNetwork}
citationHistory={citationHistory}
loadCitations={loadCitations}
onUncachedClick={setUncachedBibcode}
/>
) : (
<div className="w-full flex-1 flex flex-col items-center justify-center p-12 bg-white rounded-2xl border border-slate-200 shadow-xs min-h-[450px]">
<div className="w-16 h-16 rounded-2xl bg-sky-50 border border-sky-100 flex items-center justify-center text-sky-600 mb-5 shadow-2xs">
<GitFork className="w-8 h-8" />
</div>
<h3 className="text-base font-bold text-slate-800 tracking-wide mb-2"></h3>
<p className="text-xs text-slate-500 max-w-sm text-center leading-relaxed mb-6">
-
</p>
<button
onClick={() => setActiveTab('library')}
className="px-6 py-2.5 bg-sky-600 hover:bg-sky-700 text-white rounded-lg text-xs font-bold transition-all shadow-xs hover:shadow-md cursor-pointer hover:scale-102 active:scale-98"
>
</button>
</div>
)
)}
{activeTab === 'sync' && (
<SyncPanel />
)}
</div>
</div>
</main>
{/* 全局统一样式弹窗 */}
{dialog && (
<div
onClick={() => {
if (dialog.onCancel) dialog.onCancel();
setDialog(null);
}}
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()}
className="bg-white rounded-xl border border-slate-200 shadow-xl max-w-sm w-full p-6 space-y-4"
>
<div className="flex items-center justify-between border-b border-slate-100 pb-2.5">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${dialog.type === 'confirm' ? 'bg-sky-500' : 'bg-red-500'} animate-pulse`} />
<span>{dialog.title}</span>
</h3>
<button
onClick={() => {
if (dialog.onCancel) dialog.onCancel();
setDialog(null);
}}
className="text-slate-400 hover:text-slate-600 p-1 rounded-lg hover:bg-slate-100 transition-colors cursor-pointer"
title="关闭"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-2">
<p className="text-xs text-slate-705 leading-relaxed">{dialog.message}</p>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => {
dialog.onConfirm();
setDialog(null);
}}
className="flex-1 btn-console btn-console-primary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer"
>
</button>
{dialog.type === 'confirm' && (
<button
onClick={() => {
if (dialog.onCancel) dialog.onCancel();
setDialog(null);
}}
className="px-4 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-[11px] font-bold text-center transition-all cursor-pointer"
>
</button>
)}
</div>
</div>
</div>
)}
{/* 未入库文献操作弹窗 */}
{uncachedBibcode && (
<div
onClick={() => setUncachedBibcode(null)}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all animate-fade-in"
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-xl border border-slate-200 shadow-xl max-w-sm w-full p-6 space-y-4"
>
<div className="flex items-center justify-between border-b border-slate-100 pb-2.5">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
<span></span>
</h3>
<button
onClick={() => setUncachedBibcode(null)}
className="text-slate-400 hover:text-slate-600 p-1 rounded-lg hover:bg-slate-100 transition-colors cursor-pointer"
title="关闭"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-2">
<p className="text-xs text-slate-700 leading-relaxed">
<span className="font-mono bg-slate-100 px-1.5 py-0.5 rounded text-sky-700 font-bold select-all">{uncachedBibcode}</span>
</p>
<p className="text-[11px] text-slate-400 leading-relaxed">
线 NASA ADS
</p>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => {
loadCitations(uncachedBibcode, false);
setUncachedBibcode(null);
}}
className="flex-1 btn-console btn-console-primary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer"
>
</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"
>
ADS
</button>
<button
onClick={() => setUncachedBibcode(null)}
className="px-3 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-xs font-medium text-center transition-all cursor-pointer"
>
</button>
</div>
</div>
</div>
)}
{/* 文献详情弹窗 */}
{detailPaper && (
<div
onClick={() => setDetailBibcode(null)}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all animate-fade-in cursor-pointer"
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-xl border border-slate-200 shadow-xl max-w-lg w-full p-6 space-y-4 cursor-default animate-fade-in"
>
{/* 标题 & 关闭 */}
<div className="flex items-start justify-between border-b border-slate-100 pb-3">
<div className="space-y-1 pr-4">
<span className="text-[10px] font-bold text-sky-700 uppercase tracking-wider"></span>
<h3 className="text-xs font-bold text-slate-900 leading-snug">
{getDoctypeBadge(detailPaper.doctype)}
<span className="align-middle">{detailPaper.title}</span>
</h3>
</div>
<button
onClick={() => setDetailBibcode(null)}
className="text-slate-400 hover:text-slate-655 p-1 rounded-lg hover:bg-slate-100 transition-colors shrink-0 cursor-pointer"
title="关闭"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 详情内容 */}
<div className="space-y-4 h-[460px] flex flex-col overflow-y-auto pr-1 text-xs scrollbar-thin">
{/* 作者 */}
<div className="space-y-1 h-12 overflow-y-auto scrollbar-thin shrink-0">
<span className="text-slate-450 font-bold"></span>
<p className="text-slate-800 leading-relaxed font-semibold">{detailPaper.authors.join(', ')}</p>
</div>
{/* 期刊 & 年份 */}
<div className="grid grid-cols-5 gap-4 border-y border-slate-100 py-2.5 shrink-0 h-14">
<div className="space-y-0.5 flex flex-col justify-center min-w-0 col-span-4">
<span className="text-slate-450 font-bold block text-[10px] leading-tight"></span>
<span
className="text-slate-800 font-bold italic truncate block text-[11px] leading-tight"
title={detailPaper.pub_journal || '未标注'}
>
{detailPaper.pub_journal || '未标注'}
</span>
</div>
<div className="space-y-0.5 flex flex-col justify-center col-span-1">
<span className="text-slate-450 font-bold block text-[10px] leading-tight"></span>
<span className="text-slate-850 font-extrabold block text-[11px] leading-tight">{detailPaper.year}</span>
</div>
</div>
{/* 摘要 */}
<div className="space-y-1.5 flex flex-col h-40 shrink-0">
<span className="text-slate-450 font-bold block"></span>
<p className="text-slate-700 leading-relaxed font-normal bg-slate-50 p-3 flex-1 rounded-lg border border-slate-200 text-justify overflow-y-auto scrollbar-thin select-text">
{detailPaper.abstract_text || '该文献暂无摘要数据。'}
</p>
</div>
{/* 关键字 */}
<div className="space-y-1.5 flex flex-col h-16 shrink-0">
<span className="text-slate-450 font-bold block"></span>
{detailPaper.keywords && detailPaper.keywords.length > 0 ? (
<div className="flex flex-wrap gap-1.5 flex-1 content-start items-start overflow-y-auto scrollbar-thin pr-1">
{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]">
{kw}
</span>
))}
</div>
) : (
<div className="flex items-center justify-center flex-1 bg-slate-50 border border-slate-200 rounded-lg text-[10px] text-slate-400 font-bold select-none">
</div>
)}
</div>
{/* 标识符 */}
<div className="border-t border-slate-100 pt-3 grid grid-cols-1 sm:grid-cols-3 gap-2 text-[10px] font-mono mt-auto shrink-0">
<div className="bg-slate-50 px-2.5 py-1.5 rounded border border-slate-150">
<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"
onClick={() => axios.post('/api/active_bibcode', { bibcode: detailPaper.bibcode }).catch(() => {})}
className="hover:underline text-sky-600"
>
{detailPaper.bibcode}
</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">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"
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-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"
onClick={() => axios.post('/api/active_bibcode', { bibcode: detailPaper.bibcode }).catch(() => {})}
className="hover:underline text-sky-600"
>
{detailPaper.arxiv_id}
</a>
) : '无'}
</span>
</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}
/>
{detailPaper.has_pdf && (
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-600 bg-emerald-50 border border-emerald-200 px-1 py-0.2 rounded select-none pointer-events-none z-10">
</span>
)}
<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}
/>
{detailPaper.has_html && (
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-600 bg-emerald-50 border border-emerald-200 px-1 py-0.2 rounded select-none pointer-events-none z-10">
</span>
)}
<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"
>
<RefreshCw className="w-3 h-3" /> ()
</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"
>
<AlertTriangle className="w-3 h-3 text-amber-500" /> ()
</button>
)}
</div>
)}
</div>
{/* 自动下载失败诊断 */}
{!detailPaper.is_downloaded && (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>
{/* 底部操作:整合所有动作(阅读、图谱、下载) */}
<div className="flex flex-wrap gap-2 pt-3 border-t border-slate-100">
{detailPaper.is_downloaded ? (
<>
<button
onClick={() => {
setDetailBibcode(null);
openReader(detailPaper);
}}
className="flex-1 btn-console btn-console-primary py-2.5 rounded-lg text-xs font-bold text-center cursor-pointer"
>
</button>
<button
onClick={() => {
setDetailBibcode(null);
setSelectedPaper(detailPaper);
setActiveTab('citation');
loadCitations(detailPaper.bibcode);
}}
className="flex-1 bg-white hover:bg-slate-50 text-slate-700 border border-slate-250 py-2.5 rounded-lg text-xs font-bold text-center transition-all cursor-pointer shadow-sm"
>
</button>
<button
onClick={() => {
showConfirm('确定要强制重新下载吗?这会覆盖本地文件。', () => {
handleDownload(detailPaper.bibcode, true);
}, '确认重新下载');
}}
disabled={downloadingBibcodes[detailPaper.bibcode]}
className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-amber-700 rounded-lg text-xs font-bold transition-all cursor-pointer disabled:opacity-50"
>
{downloadingBibcodes[detailPaper.bibcode] ? '重下中...' : '重新下载'}
</button>
</>
) : (
<>
<button
onClick={() => {
handleDownload(detailPaper.bibcode);
}}
disabled={downloadingBibcodes[detailPaper.bibcode]}
className="flex-1 btn-console btn-console-primary py-2.5 rounded-lg text-xs font-bold flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-50"
>
{downloadingBibcodes[detailPaper.bibcode] ? (
<>
<Loader className="w-3.5 h-3.5 animate-spin" />
<span>...</span>
</>
) : (
<>
<Download className="w-3.5 h-3.5" />
<span></span>
</>
)}
</button>
<button
onClick={() => {
setDetailBibcode(null);
setSelectedPaper(detailPaper);
setActiveTab('citation');
loadCitations(detailPaper.bibcode);
}}
className="px-6 bg-white hover:bg-slate-50 text-slate-700 border border-slate-250 py-2.5 rounded-lg text-xs font-bold text-center transition-all cursor-pointer shadow-sm"
>
</button>
</>
)}
</div>
</div>
</div>
)}
</div>
);
}