feat: 重构 PDF/文献检索同步机制、升级引力图交互与控制台 UI 样式

- [后端/PDF解析] 重构 MinerU PDF 解析流程:引入预签名两阶段直传机制,解决大文件 API 传输限制问题;支持轮询机制与本地 images 备用目录存储。
- [后端/同步与下载] 新增经典 ADS SCAN 扫描件 PDF 和 ADS_PDF 直接通道的下载逻辑;新增常用同步检索配置的持久化存储与去重管理 API。
- [后端/日志] 重构日志系统,支持控制台 pretty 输出与每日滚动文件日志(使用上海 +08:00 时区),引入 HTTP 路由请求链路追踪。
- [前端/引力图] 升级引用星系图 canvas 交互:支持平移拖拽与滚轮缩放,添加引力圈轨道装饰及未导入文献的半透明视觉区分。
- [前端/控制台] 统一重构为扁平高对比度浅色纯中文控制台样式;重新设计文献详情弹窗与状态进度条。
- [数据库] 新增 papers 表的 doctype 字段及 sync_queries 检索配置表。
This commit is contained in:
fmq
2026-06-10 17:29:07 +08:00
parent e13fa2ad40
commit cd6af4f995
43 changed files with 4029 additions and 958 deletions
+276
View File
@@ -17,6 +17,7 @@
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tailwind-merge": "^3.6.0"
},
@@ -3047,6 +3048,15 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3055,6 +3065,32 @@
"node": ">= 0.4"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
"dependencies": {
"@types/mdast": "^4.0.0",
"escape-string-regexp": "^5.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mdast-util-from-markdown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
@@ -3078,6 +3114,101 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
"dependencies": {
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-gfm-autolink-literal": "^2.0.0",
"mdast-util-gfm-footnote": "^2.0.0",
"mdast-util-gfm-strikethrough": "^2.0.0",
"mdast-util-gfm-table": "^2.0.0",
"mdast-util-gfm-task-list-item": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-autolink-literal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
"dependencies": {
"@types/mdast": "^4.0.0",
"ccount": "^2.0.0",
"devlop": "^1.0.0",
"mdast-util-find-and-replace": "^3.0.0",
"micromark-util-character": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.1.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-strikethrough": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-table": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"markdown-table": "^3.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-task-list-item": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-math": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz",
@@ -3285,6 +3416,120 @@
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-extension-gfm": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
"dependencies": {
"micromark-extension-gfm-autolink-literal": "^2.0.0",
"micromark-extension-gfm-footnote": "^2.0.0",
"micromark-extension-gfm-strikethrough": "^2.0.0",
"micromark-extension-gfm-table": "^2.0.0",
"micromark-extension-gfm-tagfilter": "^2.0.0",
"micromark-extension-gfm-task-list-item": "^2.0.0",
"micromark-util-combine-extensions": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-autolink-literal": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
"dependencies": {
"devlop": "^1.0.0",
"micromark-core-commonmark": "^2.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-strikethrough": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
"dependencies": {
"devlop": "^1.0.0",
"micromark-util-chunked": "^2.0.0",
"micromark-util-classify-character": "^2.0.0",
"micromark-util-resolve-all": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-table": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-tagfilter": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
"dependencies": {
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-task-list-item": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-math": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz",
@@ -4028,6 +4273,23 @@
"katex": "cli.js"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-gfm": "^3.0.0",
"micromark-extension-gfm": "^3.0.0",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-math": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz",
@@ -4074,6 +4336,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-stringify": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-to-markdown": "^2.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/rolldown": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
+1
View File
@@ -19,6 +19,7 @@
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tailwind-merge": "^3.6.0"
},
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

+426 -104
View File
@@ -1,8 +1,9 @@
// dashboard/src/App.tsx
import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import { Loader, Download } from 'lucide-react';
import { Sidebar } from './components/layout/Sidebar';
import { SearchPanel } from './features/search/SearchPanel';
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';
@@ -11,15 +12,46 @@ import type { StandardPaper, CitationNetwork, NoteRecord } from './types';
export default function App() {
const [activeTab, setActiveTab] = useState<'search' | 'library' | 'reader' | 'citation' | 'sync'>('search');
// 全局对话框弹窗状态
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 [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);
@@ -39,6 +71,7 @@ export default function App() {
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[]>([]);
@@ -87,7 +120,7 @@ export default function App() {
setSearchCache(prev => ({ ...prev, [cacheKey]: res.data }));
} catch (e) {
console.error('检索文献失败', e);
alert('检索失败,请确认后端连接及 API 密钥配置。');
showAlert('检索失败,请确认后端连接及 API 密钥配置。', '检索出错');
} finally {
setSearching(false);
}
@@ -135,7 +168,7 @@ export default function App() {
}
} catch (e) {
console.error('下载文献失败', e);
alert('文献下载失败,请检查 ADS 网络限制与网络代理!');
showAlert('文献下载失败,请检查 ADS 网络限制与网络代理!', '下载失败');
} finally {
setDownloadingBibcodes(prev => ({ ...prev, [bibcode]: false }));
}
@@ -155,7 +188,7 @@ export default function App() {
}
} catch (e) {
console.error('文献解析失败', e);
alert('文献排版解析失败,请检查是否已完成 HTML/PDF 下载,并配置了 MinerU API 节点。');
showAlert('文献排版解析失败,请检查是否已完成 HTML/PDF 下载,并配置了 MinerU API 节点。', '解析失败');
} finally {
setParsing(false);
}
@@ -174,7 +207,7 @@ export default function App() {
}
} catch (e) {
console.error('文献翻译失败', e);
alert('翻译失败,请检查 .env 中的大模型 API 密钥与端点配置。');
showAlert('翻译失败,请检查 .env 中的大模型 API 密钥与端点配置。', '翻译失败');
} finally {
setTranslating(false);
}
@@ -246,7 +279,7 @@ export default function App() {
setBibtexContent(res.data.bibtex);
} catch (e) {
console.error('导出 BibTeX 失败', e);
alert('导出 BibTeX 失败,请检查 ADS Token。');
showAlert('导出 BibTeX 失败,请检查 ADS Token。', '导出失败');
} finally {
setExporting(false);
}
@@ -299,14 +332,8 @@ export default function App() {
};
return (
<div className="flex h-screen overflow-hidden text-slate-800">
<div className="flex h-screen overflow-hidden text-slate-800 bg-slate-100 select-text">
{/* 炫酷淡雅背景装饰 */}
<div className="absolute inset-0 bg-[#f8fafc] z-[-10]">
<div className="absolute top-[10%] left-[20%] w-[300px] h-[300px] bg-purple-500/5 rounded-full blur-[120px] animate-pulse-slow" />
<div className="absolute bottom-[10%] right-[20%] w-[400px] h-[400px] bg-blue-500/5 rounded-full blur-[150px] animate-pulse-slow" style={{ animationDelay: '1.5s' }} />
</div>
{/* 导航左侧栏 */}
<Sidebar
activeTab={activeTab}
@@ -316,104 +343,399 @@ export default function App() {
/>
{/* 主工作区 */}
<main className="flex-1 flex flex-col overflow-hidden">
{/* 顶部状态条 */}
<header className="h-16 border-b border-slate-200/60 px-8 flex items-center justify-between bg-white/40 backdrop-blur-md">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-xs text-slate-600 font-medium"></span>
</div>
<div className="text-xs text-slate-600">
: {library.length}
</div>
</header>
<main className="flex-1 flex flex-col overflow-hidden relative">
{/* 选项卡容器 */}
<div className="flex-1 overflow-y-auto p-8">
{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}
/>
)}
<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 max-w-7xl mx-auto flex-1 flex flex-col min-h-0">
{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}
openReader={openReader}
setSelectedPaper={setSelectedPaper}
setActiveTab={setActiveTab}
loadCitations={loadCitations}
downloadingBibcodes={downloadingBibcodes}
handleDownload={handleDownload}
/>
)}
{activeTab === 'library' && (
<LibraryPanel
library={library}
fetchLibrary={fetchLibrary}
setActiveTab={setActiveTab}
onShowDetail={(paper) => setDetailBibcode(paper.bibcode)}
/>
)}
{activeTab === 'reader' && selectedPaper && (
<ReaderPanel
selectedPaper={selectedPaper}
parsing={parsing}
handleParse={handleParse}
translating={translating}
handleTranslate={handleTranslate}
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}
/>
)}
{activeTab === 'reader' && selectedPaper && (
<ReaderPanel
selectedPaper={selectedPaper}
parsing={parsing}
handleParse={handleParse}
translating={translating}
handleTranslate={handleTranslate}
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}
/>
)}
{activeTab === 'citation' && (
<CitationPanel
selectedPaper={selectedPaper}
loadingCitations={loadingCitations}
citationNetwork={citationNetwork}
citationHistory={citationHistory}
loadCitations={loadCitations}
/>
)}
{activeTab === 'citation' && (
<CitationPanel
selectedPaper={selectedPaper}
loadingCitations={loadingCitations}
citationNetwork={citationNetwork}
citationHistory={citationHistory}
loadCitations={loadCitations}
onUncachedClick={setUncachedBibcode}
/>
)}
{activeTab === 'sync' && (
<SyncPanel />
)}
{activeTab === 'sync' && (
<SyncPanel />
)}
</div>
</div>
</main>
{/* 全局统一样式弹窗 */}
{dialog && (
<div
onClick={() => {
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"
>
<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={() => {
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 max-h-[60vh] overflow-y-auto pr-1 text-xs scrollbar-thin">
{/* 作者 */}
<div className="space-y-1">
<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-2 gap-4 border-y border-slate-100 py-3">
<div className="space-y-1">
<span className="text-slate-450 font-bold block"></span>
<span className="text-slate-800 font-bold italic">{detailPaper.pub_journal || '未标注'}</span>
</div>
<div className="space-y-1">
<span className="text-slate-450 font-bold block"></span>
<span className="text-slate-850 font-extrabold">{detailPaper.year}</span>
</div>
</div>
{/* 摘要 */}
<div className="space-y-1.5">
<span className="text-slate-450 font-bold block"> (Abstract)</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>
</div>
{/* 关键字 */}
{detailPaper.keywords && detailPaper.keywords.length > 0 && (
<div className="space-y-1.5">
<span className="text-slate-450 font-bold block"> (Keywords)</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]">
{kw}
</span>
))}
</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">
<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" 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" 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-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">
{detailPaper.arxiv_id}
</a>
) : '无'}
</span>
</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>
);
}
+172 -38
View File
@@ -5,6 +5,7 @@ import type { CitationNetwork } from '../types';
interface CanvasProps {
networks: CitationNetwork[];
activeNetwork: CitationNetwork;
nodeLimit: number;
onNodeClick: (bibcode: string) => void;
}
@@ -18,6 +19,7 @@ interface Node {
radius: number;
color: string;
type: 'center' | 'reference' | 'citation';
inDb: boolean;
}
interface Link {
@@ -25,7 +27,7 @@ interface Link {
target: string;
}
export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: CanvasProps) {
export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNodeClick }: CanvasProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
@@ -34,24 +36,26 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 适配高清屏幕像素比
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
// 合并所有 networks 的节点,去重,最多 50 个
const MAX_NODES = 50;
const MAX_NODES = nodeLimit;
const allIds = new Set<string>();
const nodes: Node[] = [];
const links: Link[] = [];
networks.forEach((net, netIdx) => {
const isActive = net.bibcode === activeNetwork.bibcode;
// 添加中心节点
if (!allIds.has(net.bibcode) && nodes.length < MAX_NODES) {
allIds.add(net.bibcode);
// 根据被引用数量决定中心节点大小 (起步半径 16,按被引量上限 32 比例缩放)
const citeCount = net.citation_count || 0;
const radius = isActive
? Math.min(32, Math.max(18, 16 + citeCount / 50))
: Math.min(24, Math.max(12, 11 + citeCount / 100));
nodes.push({
id: net.bibcode,
label: net.bibcode,
@@ -59,20 +63,21 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
y: rect.height / 2 + (netIdx === 0 ? 0 : (Math.random() - 0.5) * 200),
vx: 0,
vy: 0,
radius: isActive ? 24 : 16,
color: isActive ? '#a855f7' : '#6366f1',
radius,
color: isActive ? '#0284c7' : '#475569',
type: 'center',
inDb: true,
});
}
// 添加参考文献节点
net.references.forEach((ref, idx) => {
if (nodes.length >= MAX_NODES) return;
if (!allIds.has(ref)) {
allIds.add(ref);
const angle = (idx / Math.max(1, net.references.length)) * Math.PI * 2;
const dist = 140 + Math.random() * 30;
const dist = 120 + Math.random() * 30;
const centerNode = nodes.find(n => n.id === net.bibcode);
const inDb = activeNetwork.citation_counts ? Object.prototype.hasOwnProperty.call(activeNetwork.citation_counts, ref) : false;
nodes.push({
id: ref,
label: ref,
@@ -80,9 +85,10 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
y: (centerNode?.y ?? rect.height / 2) + Math.sin(angle) * dist,
vx: 0,
vy: 0,
radius: 12,
radius: 8, // 初始值,稍后按连线数重算
color: '#d97706',
type: 'reference',
inDb,
});
}
if (allIds.has(ref)) {
@@ -90,14 +96,14 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
}
});
// 添加被引文献节点
net.citations.forEach((cit, idx) => {
if (nodes.length >= MAX_NODES) return;
if (!allIds.has(cit)) {
allIds.add(cit);
const angle = (idx / Math.max(1, net.citations.length)) * Math.PI * 2 + Math.PI;
const dist = 160 + Math.random() * 40;
const dist = 140 + Math.random() * 40;
const centerNode = nodes.find(n => n.id === net.bibcode);
const inDb = activeNetwork.citation_counts ? Object.prototype.hasOwnProperty.call(activeNetwork.citation_counts, cit) : false;
nodes.push({
id: cit,
label: cit,
@@ -105,9 +111,10 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
y: (centerNode?.y ?? rect.height / 2) + Math.sin(angle) * dist,
vx: 0,
vy: 0,
radius: 12,
color: '#4f46e5',
radius: 8, // 初始值,稍后按连线数重算
color: '#0891b2',
type: 'citation',
inDb,
});
}
if (allIds.has(cit)) {
@@ -116,24 +123,46 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
});
});
// 计算外围节点在当前渲染网络中的度数(连线数),动态微调其半径大小,凸显网络枢纽节点
const degrees: Record<string, number> = {};
links.forEach(l => {
degrees[l.source] = (degrees[l.source] || 0) + 1;
degrees[l.target] = (degrees[l.target] || 0) + 1;
});
nodes.forEach(n => {
if (n.type !== 'center') {
const deg = degrees[n.id] || 0;
const citeCount = activeNetwork.citation_counts?.[n.id] || 0;
// 融合数据库中该文献的被引数量 (起步+citeCount/40) 与当前渲染网格连线度数,动态决定半径 (最大限制 18)
n.radius = Math.min(18, Math.max(6, 6 + citeCount / 40 + Math.min(6, deg * 1.5)));
}
});
let offsetX = 0;
let offsetY = 0;
let scale = 1.0;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let hasDragged = false;
let animationFrameId: number;
let hoveredNode: Node | null = null;
let frameCount = 0;
// 经典力导向算法迭代
const updatePhysics = () => {
// 1. 斥力:任何两个节点之间均产生反向推力
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
let dx = nodes[j].x - nodes[i].x;
let dy = nodes[j].y - nodes[i].y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
let minDist = nodes[i].radius + nodes[j].radius + 50;
let minDist = nodes[i].radius + nodes[j].radius + 60;
if (dist < minDist) {
let force = (minDist - dist) * 0.08;
let fx = (dx / dist) * force;
let fy = (dy / dist) * force;
// 节点不强行推动中心大节点
if (nodes[i].type !== 'center' || nodes[i].id !== activeNetwork.bibcode) {
nodes[i].vx -= fx;
nodes[i].vy -= fy;
@@ -146,7 +175,6 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
}
}
// 2. 引力与向心力:被连线连接的节点之间产生向中心靠拢力
links.forEach(link => {
const sourceNode = nodes.find(n => n.id === link.source);
const targetNode = nodes.find(n => n.id === link.target);
@@ -154,7 +182,7 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
let dx = targetNode.x - sourceNode.x;
let dy = targetNode.y - sourceNode.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
let force = dist * 0.003; // 弹性系数
let force = dist * 0.003;
let fx = (dx / dist) * force;
let fy = (dy / dist) * force;
@@ -169,23 +197,54 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
}
});
// 3. 应用阻尼阻力,限制极限加速
nodes.forEach(node => {
if (node.id !== activeNetwork.bibcode) {
node.x += node.vx;
node.y += node.vy;
node.vx *= 0.85; // 阻尼
node.vx *= 0.85;
node.vy *= 0.85;
}
});
};
// 画布渲染渲染循环
const render = () => {
frameCount++;
updatePhysics();
ctx.clearRect(0, 0, rect.width, rect.height);
ctx.save();
// 应用拖拽和缩放的坐标变换
const cx = rect.width / 2;
const cy = rect.height / 2;
ctx.translate(cx + offsetX, cy + offsetY);
ctx.scale(scale, scale);
ctx.translate(-cx, -cy);
// 绘制背景宇宙引力线 & 刻度圈
const centerNode = nodes.find(n => n.id === activeNetwork.bibcode);
if (centerNode) {
ctx.strokeStyle = 'rgba(148, 163, 184, 0.15)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 6]);
ctx.beginPath();
ctx.arc(centerNode.x, centerNode.y, 130, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.arc(centerNode.x, centerNode.y, 180, 0, Math.PI * 2);
ctx.stroke();
// 动态圈
ctx.strokeStyle = 'rgba(2, 132, 199, 0.06)';
ctx.setLineDash([]);
const pulseRadius = 130 + (frameCount % 120) * 0.4;
ctx.beginPath();
ctx.arc(centerNode.x, centerNode.y, pulseRadius, 0, Math.PI * 2);
ctx.stroke();
}
// 绘制连线
ctx.lineWidth = 1;
links.forEach(link => {
@@ -195,7 +254,7 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
ctx.beginPath();
ctx.moveTo(sourceNode.x, sourceNode.y);
ctx.lineTo(targetNode.x, targetNode.y);
ctx.strokeStyle = sourceNode.type === 'reference' ? 'rgba(245, 158, 11, 0.25)' : 'rgba(129, 140, 248, 0.25)';
ctx.strokeStyle = 'rgba(148, 163, 184, 0.25)';
ctx.stroke();
}
});
@@ -203,62 +262,137 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
// 绘制节点
nodes.forEach(node => {
const isHovered = hoveredNode?.id === node.id;
ctx.save();
ctx.globalAlpha = node.inDb ? 1.0 : 0.35; // 未入库文献透明度降为 0.35
ctx.beginPath();
ctx.arc(node.x, node.y, node.radius + (isHovered ? 4 : 0), 0, Math.PI * 2);
ctx.arc(node.x, node.y, node.radius + (isHovered ? 6 : 3), 0, Math.PI * 2);
ctx.fillStyle = node.color + (isHovered ? '25' : '0f');
ctx.fill();
ctx.beginPath();
ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
ctx.fillStyle = node.color;
ctx.fill();
// 绘制光晕环绕
ctx.beginPath();
ctx.arc(node.x, node.y, node.radius + (isHovered ? 8 : 4), 0, Math.PI * 2);
ctx.strokeStyle = node.color + '40'; // 附加透明度光晕
ctx.lineWidth = 2;
ctx.arc(node.x, node.y, node.radius + (isHovered ? 4 : 2), 0, Math.PI * 2);
ctx.strokeStyle = node.color + '40';
ctx.lineWidth = 1;
ctx.stroke();
// 绘制 bibcode 文本说明
ctx.fillStyle = isHovered ? '#0f172a' : '#64748b';
ctx.font = isHovered ? 'bold 10px monospace' : '9px monospace';
ctx.fillStyle = isHovered ? '#0284c7' : '#334155';
ctx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(node.label, node.x, node.y + node.radius + (isHovered ? 18 : 14));
ctx.fillText(node.label, node.x, node.y + node.radius + (isHovered ? 16 : 12));
ctx.restore();
});
ctx.restore();
animationFrameId = requestAnimationFrame(render);
};
render();
// 交互鼠标监听
const handleMouseDown = (e: MouseEvent) => {
isDragging = true;
dragStartX = e.clientX - offsetX;
dragStartY = e.clientY - offsetY;
hasDragged = false;
};
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
if (isDragging) {
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
if (Math.sqrt((dx - offsetX) ** 2 + (dy - offsetY) ** 2) > 3) {
hasDragged = true;
}
offsetX = dx;
offsetY = dy;
}
const cx = rect.width / 2;
const cy = rect.height / 2;
const gx = (mouseX - cx - offsetX) / scale + cx;
const gy = (mouseY - cy - offsetY) / scale + cy;
let found: Node | null = null;
for (const node of nodes) {
let dx = node.x - mouseX;
let dy = node.y - mouseY;
let dx = node.x - gx;
let dy = node.y - gy;
let dist = Math.sqrt(dx * dx + dy * dy);
if (dist < node.radius + 5) {
if (dist < node.radius + 6) {
found = node;
break;
}
}
hoveredNode = found;
canvas.style.cursor = found ? 'pointer' : 'default';
if (isDragging) {
canvas.style.cursor = 'grabbing';
} else {
canvas.style.cursor = found ? 'pointer' : 'grab';
}
};
const handleMouseUp = () => {
isDragging = false;
};
const handleMouseLeave = () => {
isDragging = false;
};
const handleCanvasClick = () => {
if (hasDragged) return;
if (hoveredNode && hoveredNode.id !== activeNetwork.bibcode) {
onNodeClick(hoveredNode.id);
}
};
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const cx = rect.width / 2;
const cy = rect.height / 2;
const gx = (mouseX - cx - offsetX) / scale + cx;
const gy = (mouseY - cy - offsetY) / scale + cy;
if (e.deltaY < 0) {
scale = Math.min(5.0, scale * 1.1);
} else {
scale = Math.max(0.15, scale / 1.1);
}
offsetX = mouseX - cx - (gx - cx) * scale;
offsetY = mouseY - cy - (gy - cy) * scale;
};
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('mouseleave', handleMouseLeave);
canvas.addEventListener('click', handleCanvasClick);
canvas.addEventListener('wheel', handleWheel, { passive: false });
return () => {
cancelAnimationFrame(animationFrameId);
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseup', handleMouseUp);
canvas.removeEventListener('mouseleave', handleMouseLeave);
canvas.removeEventListener('click', handleCanvasClick);
canvas.removeEventListener('wheel', handleWheel);
};
}, [networks, activeNetwork, onNodeClick]);
+41 -24
View File
@@ -11,25 +11,38 @@ interface SidebarProps {
export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations }: SidebarProps) {
return (
<aside className="w-64 glass border-r border-slate-200/80 flex flex-col justify-between py-6">
<aside className="w-64 bg-slate-50 border-r border-slate-200 flex flex-col justify-between py-6 px-4 z-10 select-none">
<div>
{/* Logo */}
<div className="px-6 mb-8 flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg shadow-purple-500/20">
<span className="font-extrabold text-white text-lg tracking-wider">A</span>
{/* 系统LOGO区 */}
<div className="px-3 mb-8 flex items-center gap-3">
<div className="w-9 h-9 flex items-center justify-center">
<svg className="w-9 h-9" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="18" stroke="#bae6fd" strokeWidth="1.5" />
<circle cx="24" cy="24" r="21" stroke="#0284c7" strokeWidth="1.5" strokeDasharray="2 3" />
<path d="M24 9C24 18 24 18 33 24C24 24 24 24 24 33C24 24 24 24 15 24C24 18 24 18 24 9Z" fill="url(#sidebarStarGrad)" />
<ellipse cx="24" cy="24" rx="20" ry="7" transform="rotate(-28 24 24)" stroke="#0284c7" strokeWidth="2" />
<circle cx="38" cy="16" r="4.5" fill="#0284c7" stroke="#ffffff" strokeWidth="1.5" />
<circle cx="10" cy="32" r="2.5" fill="#38bdf8" />
<defs>
<linearGradient id="sidebarStarGrad" x1="15" y1="9" x2="33" y2="33" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#0284c7" />
<stop offset="100%" stopColor="#0369a1" />
</linearGradient>
</defs>
</svg>
</div>
<div>
<h1 className="text-lg font-bold text-slate-800 leading-none font-outfit">AstroResearch</h1>
<span className="text-xs text-slate-500"></span>
<h1 className="text-sm font-bold text-slate-800 tracking-wider">AstroResearch</h1>
<span className="text-[11px] text-slate-500 block font-medium"></span>
</div>
</div>
{/* 选项卡导航 */}
<nav className="px-4 space-y-1.5">
{/* 导航菜单列表 */}
<nav className="space-y-1">
{[
{ id: 'search', label: '统一检索', icon: Search },
{ id: 'library', label: '馆藏管理', icon: Library },
{ id: 'sync', label: '批量同步', icon: RefreshCw },
{ id: 'sync', label: '批量任务', icon: RefreshCw },
{ id: 'reader', label: '双语阅读', icon: BookOpen, disabled: !selectedPaper },
{ id: 'citation', label: '引用星系', icon: GitFork, disabled: !selectedPaper },
].map(tab => {
@@ -45,32 +58,36 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations
loadCitations(selectedPaper.bibcode);
}
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all ${
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-xs font-semibold tracking-wider transition-all border ${
isActive
? 'bg-gradient-to-r from-purple-600/10 to-indigo-600/10 text-purple-600 border border-purple-500/20'
? 'bg-sky-50 border-sky-200 text-sky-700 shadow-sm'
: tab.disabled
? 'opacity-40 cursor-not-allowed text-slate-400'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
? 'opacity-30 cursor-not-allowed border-transparent text-slate-400'
: 'border-transparent text-slate-650 hover:bg-slate-100 hover:text-slate-800'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
<Icon className={`w-4 h-4 ${isActive ? 'text-sky-600' : 'text-slate-500'}`} />
<span>{tab.label}</span>
</button>
);
})}
</nav>
</div>
{/* 底部当前文献卡片 */}
{selectedPaper && (
<div className="mx-4 p-4 rounded-xl bg-slate-100/50 border border-slate-200/80">
<span className="text-[10px] text-purple-600 font-bold uppercase tracking-wider block mb-1"></span>
<h4 className="text-xs text-slate-800 font-medium line-clamp-2 mb-2">{selectedPaper.title}</h4>
<div className="flex items-center justify-between text-[10px] text-slate-500">
<span>{selectedPaper.year}</span>
<span className="truncate max-w-[100px] text-slate-400">{selectedPaper.bibcode}</span>
{/* 底部当前选定文献提示 */}
{selectedPaper ? (
<div className="p-3.5 rounded-lg border border-sky-100 bg-sky-50/50">
<span className="text-[9px] font-bold text-sky-600 tracking-widest block mb-1"></span>
<h4 className="text-xs text-slate-800 font-bold line-clamp-2 mb-2 leading-relaxed">{selectedPaper.title}</h4>
<div className="flex items-center justify-between text-[10px] font-medium text-slate-500">
<span>: {selectedPaper.year}</span>
<span className="truncate max-w-[90px] font-mono">{selectedPaper.bibcode}</span>
</div>
</div>
) : (
<div className="p-3 rounded-lg border border-slate-200 bg-slate-100/30 text-center">
<span className="text-[10px] text-slate-400 font-medium tracking-wide"></span>
</div>
)}
</aside>
);
@@ -1,4 +1,5 @@
// dashboard/src/features/citation/CitationPanel.tsx
import { useState } from 'react';
import { Loader, GitFork, RotateCcw } from 'lucide-react';
import { CitationGalaxyCanvas } from '../../components/CitationGalaxyCanvas';
import type { StandardPaper, CitationNetwork } from '../../types';
@@ -9,6 +10,7 @@ interface CitationPanelProps {
citationNetwork: CitationNetwork | null;
citationHistory: CitationNetwork[];
loadCitations: (bibcode: string, reset?: boolean) => void;
onUncachedClick: (bibcode: string) => void;
}
export function CitationPanel({
@@ -17,62 +19,90 @@ export function CitationPanel({
citationNetwork,
citationHistory,
loadCitations,
onUncachedClick,
}: CitationPanelProps) {
const [nodeLimit, setNodeLimit] = useState(50);
// 统一节点点击事件:已入库直接拉取,未入库弹窗选择
const handleNodeClick = (bibcode: string) => {
if (!citationNetwork) return;
const inDb = bibcode === citationNetwork.bibcode || (citationNetwork.citation_counts && Object.prototype.hasOwnProperty.call(citationNetwork.citation_counts, bibcode));
if (inDb) {
loadCitations(bibcode, false);
} else {
onUncachedClick(bibcode);
}
};
return (
<div className="max-w-7xl mx-auto h-[calc(100vh-140px)] flex flex-col space-y-4 w-full">
<div className="flex items-center justify-between">
<div className="w-full flex-1 flex flex-col space-y-4 min-h-0">
<div className="flex items-center justify-between border-b border-slate-200 pb-3">
<div>
<h2 className="text-lg font-bold text-slate-800"></h2>
<p className="text-xs text-slate-500"> (References) - - (Citations)</p>
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase"></h2>
<p className="text-xs text-slate-500 mt-1"> visual - </p>
</div>
<div className="flex gap-4 items-center">
<div className="flex items-center gap-2 border border-slate-200 bg-white px-3 py-1.5 rounded-lg shadow-sm">
<span className="text-[11px] text-slate-500 font-bold">:</span>
<input
type="range"
min="20"
max="200"
step="5"
value={nodeLimit}
onChange={(e) => setNodeLimit(Number(e.target.value))}
className="w-20 h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-sky-600 focus:outline-none"
/>
<span className="text-xs font-bold text-sky-600 w-6 text-right">{nodeLimit}</span>
</div>
{selectedPaper && (
<button
onClick={() => loadCitations(selectedPaper.bibcode, true)}
className="btn-console px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
{selectedPaper && (
<button
onClick={() => loadCitations(selectedPaper.bibcode, true)}
className="px-4 py-2 rounded-xl bg-slate-100 border border-slate-200 text-xs text-slate-650 hover:bg-slate-200 flex items-center gap-2"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
{loadingCitations ? (
<div className="glass rounded-2xl flex-1 flex flex-col items-center justify-center text-slate-500">
<Loader className="w-8 h-8 animate-spin text-purple-500 mb-2" />
<p className="text-xs"> SQLite ...</p>
<div className="console-panel rounded-xl flex-1 flex flex-col items-center justify-center text-slate-500 bg-white">
<Loader className="w-8 h-8 animate-spin text-sky-600 mb-2" />
<p className="text-xs font-bold text-slate-600">...</p>
</div>
) : citationNetwork ? (
<div className="flex-1 glass rounded-2xl border border-slate-200/80 overflow-hidden relative flex">
<div className="flex-1 console-panel rounded-xl border border-slate-200 overflow-hidden relative flex flex-col md:flex-row bg-white min-h-0">
{/* 可视化画布 */}
<div className="flex-1 relative">
<div className="flex-1 relative bg-slate-50/30 min-h-[350px] md:min-h-0">
<CitationGalaxyCanvas
networks={citationHistory}
activeNetwork={citationNetwork}
onNodeClick={(bibcode) => {
loadCitations(bibcode, false);
}}
nodeLimit={nodeLimit}
onNodeClick={handleNodeClick}
/>
</div>
{/* 右侧关系详情面板 */}
<div className="w-80 border-l border-slate-200 bg-white/50 p-6 space-y-6 overflow-y-auto">
<div className="w-full md:w-80 h-64 md:h-auto border-t md:border-t-0 md:border-l border-slate-200 bg-slate-50 p-6 space-y-6 overflow-y-auto scrollbar-thin">
<div>
<span className="text-[10px] text-purple-600 font-bold uppercase tracking-wider block mb-1"></span>
<h4 className="text-sm font-semibold text-slate-800 leading-snug">{citationNetwork.title}</h4>
<p className="text-xs text-slate-500 font-mono mt-1">{citationNetwork.bibcode}</p>
<span className="text-[10px] font-bold text-sky-700 tracking-wider block mb-2"> </span>
<h4 className="text-xs font-bold text-slate-900 leading-relaxed font-sans">{citationNetwork.title}</h4>
<p className="text-[10px] text-slate-600 font-mono mt-2 bg-slate-200/50 border border-slate-300/60 px-2 py-1.5 rounded select-all truncate">{citationNetwork.bibcode}</p>
</div>
<div>
<span className="text-[10px] text-amber-700 font-bold uppercase tracking-wider block mb-2">
(References: {citationNetwork.references.length})
<div className="border-t border-slate-200 pt-4">
<span className="text-xs font-bold text-slate-700 block mb-2">
( // 共 {citationNetwork.references.length} 篇)
</span>
<div className="space-y-1 max-h-48 overflow-y-auto">
<div className="space-y-1.5 max-h-48 overflow-y-auto scrollbar-thin pr-1">
{citationNetwork.references.length === 0 ? (
<span className="text-xs text-slate-400 italic"> bibcode </span>
<span className="text-xs text-slate-400 italic"></span>
) : (
citationNetwork.references.map(bib => (
<div
key={bib}
onClick={() => loadCitations(bib)}
className="text-xs text-slate-600 hover:text-purple-600 cursor-pointer font-mono truncate py-1 hover:bg-slate-100 px-2 rounded"
onClick={() => handleNodeClick(bib)}
className="text-[10px] text-slate-700 hover:text-sky-700 font-mono py-1.5 px-2.5 rounded bg-white border border-slate-200 hover:border-sky-300 cursor-pointer transition-all truncate"
>
{bib}
</div>
@@ -81,19 +111,19 @@ export function CitationPanel({
</div>
</div>
<div>
<span className="text-[10px] text-indigo-700 font-bold uppercase tracking-wider block mb-2">
(Citations: {citationNetwork.citations.length})
<div className="border-t border-slate-200 pt-4">
<span className="text-xs font-bold text-slate-700 block mb-2">
( // 共 {citationNetwork.citations.length} 篇)
</span>
<div className="space-y-1 max-h-48 overflow-y-auto">
<div className="space-y-1.5 max-h-48 overflow-y-auto scrollbar-thin pr-1">
{citationNetwork.citations.length === 0 ? (
<span className="text-xs text-slate-400 italic"> bibcode </span>
<span className="text-xs text-slate-400 italic"></span>
) : (
citationNetwork.citations.map(bib => (
<div
key={bib}
onClick={() => loadCitations(bib)}
className="text-xs text-slate-600 hover:text-purple-600 cursor-pointer font-mono truncate py-1 hover:bg-slate-100 px-2 rounded"
onClick={() => handleNodeClick(bib)}
className="text-[10px] text-slate-700 hover:text-sky-700 font-mono py-1.5 px-2.5 rounded bg-white border border-slate-200 hover:border-sky-300 cursor-pointer transition-all truncate"
>
{bib}
</div>
@@ -104,9 +134,9 @@ export function CitationPanel({
</div>
</div>
) : (
<div className="glass rounded-2xl flex-1 flex flex-col items-center justify-center text-slate-400">
<GitFork className="w-12 h-12 mb-3" />
<p className="text-xs"></p>
<div className="console-panel rounded-xl flex-1 flex flex-col items-center justify-center text-slate-500 bg-white">
<GitFork className="w-12 h-12 mb-3 text-slate-300" />
<p className="text-xs font-bold text-slate-500"></p>
</div>
)}
</div>
+264 -84
View File
@@ -1,124 +1,304 @@
// dashboard/src/features/library/LibraryPanel.tsx
import { Library, RotateCw, Download, Loader, RefreshCw } from 'lucide-react';
import { useState } from 'react';
import { Library, RotateCw, Search, SlidersHorizontal } from 'lucide-react';
import type { StandardPaper } from '../../types';
import { getDoctypeBadge } from '../search/SearchPanel';
interface LibraryPanelProps {
library: StandardPaper[];
fetchLibrary: () => void;
openReader: (paper: StandardPaper) => void;
setSelectedPaper: (paper: StandardPaper | null) => void;
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync') => void;
loadCitations: (bibcode: string) => void;
downloadingBibcodes: Record<string, boolean>;
handleDownload: (bibcode: string, force?: boolean) => void;
onShowDetail: (paper: StandardPaper) => void;
}
export function LibraryPanel({
library,
fetchLibrary,
openReader,
setSelectedPaper,
setActiveTab,
loadCitations,
downloadingBibcodes,
handleDownload,
onShowDetail,
}: LibraryPanelProps) {
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState<'all' | 'downloaded' | 'undownloaded' | 'parsed' | 'translated'>('all');
const [sortBy, setSortBy] = useState<'created' | 'yearDesc' | 'yearAsc' | 'citations' | 'title'>('created');
// 高级元数据细化筛选状态
const [showAdvanced, setShowAdvanced] = useState(false);
const [filterAuthor, setFilterAuthor] = useState('');
const [filterYear, setFilterYear] = useState('');
const [filterJournal, setFilterJournal] = useState('');
// 本地检索与筛选过滤
const filteredLibrary = library.filter(paper => {
// 1. 关键词全局检索 (标题、作者、摘要、Bibcode、arXiv ID、DOI)
const query = searchTerm.toLowerCase().trim();
if (query) {
const matchTitle = paper.title.toLowerCase().includes(query);
const matchAuthors = paper.authors.some(a => a.toLowerCase().includes(query));
const matchAbstract = paper.abstract_text.toLowerCase().includes(query);
const matchBibcode = paper.bibcode.toLowerCase().includes(query);
const matchArxivId = (paper.arxiv_id || '').toLowerCase().includes(query);
const matchDoi = (paper.doi || '').toLowerCase().includes(query);
if (!matchTitle && !matchAuthors && !matchAbstract && !matchBibcode && !matchArxivId && !matchDoi) {
return false;
}
}
// 2. 离线状态筛选
if (filterStatus === 'downloaded' && !paper.is_downloaded) return false;
if (filterStatus === 'undownloaded' && paper.is_downloaded) return false;
if (filterStatus === 'parsed' && !paper.has_markdown) return false;
if (filterStatus === 'translated' && !paper.has_translation) return false;
// 3. 高级元数据细化筛选
if (filterAuthor.trim()) {
const authorQuery = filterAuthor.toLowerCase().trim();
const matchAuthor = paper.authors.some(a => a.toLowerCase().includes(authorQuery));
if (!matchAuthor) return false;
}
if (filterYear.trim()) {
const yearQuery = filterYear.toLowerCase().trim();
if (!paper.year.toLowerCase().includes(yearQuery)) return false;
}
if (filterJournal.trim()) {
const journalQuery = filterJournal.toLowerCase().trim();
const matchJournal = (paper.pub_journal || '').toLowerCase().includes(journalQuery);
if (!matchJournal) return false;
}
return true;
});
// 本地复合排序
const sortedLibrary = [...filteredLibrary].sort((a, b) => {
if (sortBy === 'yearDesc') {
return (parseInt(b.year) || 0) - (parseInt(a.year) || 0);
}
if (sortBy === 'yearAsc') {
return (parseInt(a.year) || 0) - (parseInt(b.year) || 0);
}
if (sortBy === 'citations') {
return (b.citation_count || 0) - (a.citation_count || 0);
}
if (sortBy === 'title') {
return a.title.localeCompare(b.title);
}
// 'created' (默认): 沿用后端传回的创建时间倒序
return 0;
});
return (
<div className="max-w-5xl mx-auto space-y-6">
<div className="flex items-center justify-between mb-4">
<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">
<div>
<h2 className="text-lg font-bold text-slate-800"></h2>
<p className="text-xs text-slate-500"></p>
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase"></h2>
<p className="text-xs text-slate-500 mt-1">线</p>
</div>
<button
onClick={fetchLibrary}
className="px-4 py-2 rounded-xl bg-slate-100 border border-slate-200 text-xs text-slate-600 hover:bg-slate-200 flex items-center gap-2"
className="btn-console px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
>
<RotateCw className="w-3.5 h-3.5" />
<RotateCw className="w-3.5 h-3.5" />
</button>
</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">
<div className="flex flex-col sm:flex-row gap-3">
{/* 本地检索 */}
<div className="flex-1 space-y-1.5">
<label className="block text-slate-500 font-bold"></label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 w-3.5 h-3.5" />
<input
type="text"
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"
/>
</div>
</div>
{/* 状态过滤 */}
<div className="w-full sm:w-40 space-y-1.5">
<label className="block text-slate-500 font-bold"></label>
<select
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>
</div>
{/* 排序方式 */}
<div className="w-full sm:w-40 space-y-1.5">
<label className="block text-slate-500 font-bold"></label>
<select
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>
</div>
{/* 高级元数据细化筛选触发器 */}
<div className="flex items-end">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className={`w-full sm:w-auto px-4 py-2 rounded-lg border text-xs font-bold transition-all shrink-0 flex items-center justify-center gap-1.5 cursor-pointer ${
showAdvanced
? 'bg-sky-50 border-sky-300 text-sky-700'
: 'btn-console btn-console-secondary'
}`}
>
<SlidersHorizontal className="w-3.5 h-3.5" />
{showAdvanced ? '精简筛选' : '高级元数据'}
</button>
</div>
</div>
{/* 展开的元数据细化筛选字段 */}
{showAdvanced && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-3.5 border-t border-slate-100 w-full transition-all">
{/* 作者过滤 */}
<div className="space-y-1.5">
<label className="block text-slate-500 font-bold"></label>
<input
type="text"
value={filterAuthor}
onChange={e => setFilterAuthor(e.target.value)}
placeholder="如: Althaus..."
className="w-full px-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"
/>
</div>
{/* 年份过滤 */}
<div className="space-y-1.5">
<label className="block text-slate-500 font-bold"></label>
<input
type="text"
value={filterYear}
onChange={e => setFilterYear(e.target.value)}
placeholder="如: 2023..."
className="w-full px-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"
/>
</div>
{/* 期刊过滤 */}
<div className="space-y-1.5">
<label className="block text-slate-500 font-bold"></label>
<input
type="text"
value={filterJournal}
onChange={e => setFilterJournal(e.target.value)}
placeholder="如: ApJ 或 MNRAS..."
className="w-full px-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"
/>
</div>
</div>
)}
</div>
)}
{library.length === 0 ? (
<div className="glass p-12 rounded-2xl text-center">
<div className="console-panel p-16 rounded-xl text-center border-2 border-dashed border-slate-300">
<Library className="w-12 h-12 text-slate-400 mx-auto mb-4" />
<h3 className="font-bold text-slate-800 mb-1"></h3>
<p className="text-xs text-slate-500 mb-6">线</p>
<h3 className="font-bold text-slate-800 text-sm mb-2"></h3>
<p className="text-xs text-slate-500 mb-6 max-w-md mx-auto">线便</p>
<button
onClick={() => setActiveTab('search')}
className="px-6 py-2.5 rounded-xl bg-purple-600 text-white font-semibold hover:bg-purple-500 text-xs"
className="btn-console btn-console-primary px-6 py-2.5 rounded-lg text-xs font-bold"
>
</button>
</div>
) : sortedLibrary.length === 0 ? (
<div className="console-panel p-12 rounded-xl text-center border border-slate-200 bg-white">
<SlidersHorizontal className="w-10 h-10 text-slate-350 mx-auto mb-3" />
<h3 className="font-bold text-slate-700 text-xs mb-1.5"></h3>
<p className="text-xs text-slate-450 max-w-sm mx-auto mb-4">线</p>
<button
onClick={() => {
setSearchTerm('');
setFilterStatus('all');
setFilterAuthor('');
setFilterYear('');
setFilterJournal('');
}}
className="px-4 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-650 rounded-lg text-xs font-bold transition-all cursor-pointer"
>
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{library.map(paper => {
const isDownloading = downloadingBibcodes[paper.bibcode] || false;
{sortedLibrary.map(paper => {
return (
<div key={paper.bibcode} className="glass p-6 rounded-2xl border border-slate-200/80 hover:border-slate-300 flex flex-col justify-between">
<div>
<div className="flex justify-between items-start gap-4 mb-2">
<h3
className="font-bold text-sm text-slate-800 line-clamp-2 hover:text-purple-600 cursor-pointer"
onClick={() => openReader(paper)}
>
{paper.title}
</h3>
{paper.is_downloaded ? (
<span className="px-2 py-0.5 rounded bg-emerald-50 text-emerald-600 border border-emerald-200 text-[9px] font-bold uppercase shrink-0"></span>
) : (
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-600 border border-amber-200 text-[9px] font-bold uppercase shrink-0"></span>
)}
</div>
<div className="text-xs text-slate-500 mb-3">
{paper.authors.slice(0, 2).join(', ')}{paper.authors.length > 2 ? ' et al.' : ''} | {paper.year}
</div>
<p className="text-xs text-slate-600 line-clamp-3 leading-relaxed mb-4">{paper.abstract_text}</p>
<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'
}`}>
{paper.has_translation
? '已翻译'
: paper.has_markdown
? '已解析'
: paper.is_downloaded
? '已下载'
: '未下载'}
</div>
<div className="flex items-center justify-between border-t border-slate-200/60 pt-4 mt-auto">
<div className="flex flex-wrap gap-2">
<button
onClick={() => openReader(paper)}
className="px-3.5 py-1.5 rounded-lg bg-purple-600 text-white text-xs hover:bg-purple-500 transition-all font-semibold"
>
</button>
<button
onClick={() => {
setSelectedPaper(paper);
setActiveTab('citation');
loadCitations(paper.bibcode);
}}
className="px-3.5 py-1.5 rounded-lg bg-slate-100 border border-slate-200 text-slate-600 hover:bg-slate-200 text-xs font-semibold"
>
</button>
{paper.is_downloaded ? (
<button
onClick={() => { if (confirm('确定要强制重新下载吗?这会覆盖本地文件。')) handleDownload(paper.bibcode, true); }}
disabled={isDownloading}
className="px-3.5 py-1.5 rounded-lg bg-slate-100 border border-slate-200 text-amber-600 hover:bg-slate-200 text-xs font-semibold flex items-center gap-1"
>
{isDownloading ? <Loader className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
{isDownloading ? '下载中' : '重新下载'}
</button>
) : (
<button
onClick={() => handleDownload(paper.bibcode)}
disabled={isDownloading}
className="px-3.5 py-1.5 rounded-lg bg-blue-600 text-white text-xs hover:bg-blue-500 transition-all font-semibold flex items-center gap-1 shadow-lg shadow-blue-500/10"
>
{isDownloading ? <Loader className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
{isDownloading ? '下载中' : '下载 PDF/HTML'}
</button>
)}
<div className="pr-10">
<div>
<h3 className="font-bold text-xs text-slate-900 line-clamp-2 hover:text-sky-700 transition-all leading-relaxed">
{getDoctypeBadge(paper.doctype)}
<span className="align-middle">{paper.title}</span>
</h3>
</div>
<div className="text-[10px] text-slate-500 font-semibold mt-1.5 uppercase">
: {paper.authors.slice(0, 2).join(', ')}{paper.authors.length > 2 ? ' 等' : ''} | : {paper.year}
</div>
</div>
<div className="flex items-center gap-1.5">
<span className={`w-1.5 h-1.5 rounded-full ${paper.has_markdown ? 'bg-purple-400' : 'bg-slate-350'}`} title="解析状态" />
<span className={`w-1.5 h-1.5 rounded-full ${paper.has_translation ? 'bg-emerald-400' : 'bg-slate-350'}`} title="翻译状态" />
<span className="text-[10px] font-mono text-slate-400">{paper.bibcode}</span>
<div className="flex items-center justify-between border-t border-slate-100 pt-3 mt-4 text-[10px]">
<span className="font-mono text-slate-400 select-all" onClick={(e) => e.stopPropagation()}>
{paper.bibcode === paper.arxiv_id ? `arXiv:${paper.arxiv_id}` : paper.bibcode}
</span>
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-1">
<span
className={`w-1.5 h-1.5 rounded-full ${paper.has_markdown ? 'bg-sky-500' : 'bg-slate-200'}`}
/>
<span className="text-slate-400 text-[9px]">{paper.has_markdown ? '正文' : '未解析'}</span>
</div>
<div className="flex items-center gap-1">
<span
className={`w-1.5 h-1.5 rounded-full ${paper.has_translation ? 'bg-emerald-500' : 'bg-slate-200'}`}
/>
<span className="text-slate-400 text-[9px]">{paper.has_translation ? '翻译' : '未翻译'}</span>
</div>
</div>
</div>
</div>
+275 -139
View File
@@ -1,6 +1,8 @@
// dashboard/src/features/reader/ReaderPanel.tsx
import { useState, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import remarkGfm from 'remark-gfm';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
import { FileText, Loader, Languages, RotateCw, Pencil, X, PlusCircle, Trash2 } from 'lucide-react';
@@ -28,13 +30,14 @@ interface ReaderPanelProps {
setNewNoteText: (text: string) => void;
handleCreateNote: () => void;
handleDeleteNote: (id: number) => void;
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
}
const NOTE_COLORS: Record<string, { bg: string; border: string; label: string }> = {
yellow: { bg: 'bg-yellow-500/20', border: 'border-yellow-500/40', label: '黄色' },
green: { bg: 'bg-emerald-500/20', border: 'border-emerald-500/40', label: '绿色' },
blue: { bg: 'bg-blue-500/20', border: 'border-blue-500/40', label: '蓝色' },
pink: { bg: 'bg-pink-500/20', border: 'border-pink-500/40', label: '粉色' },
const NOTE_COLORS: Record<string, { bg: string; border: string; label: string; text: string }> = {
cyan: { bg: 'bg-cyan-50 border-cyan-200', border: 'border-cyan-300', label: '天蓝色', text: 'text-cyan-800' },
amber: { bg: 'bg-amber-50 border-amber-200', border: 'border-amber-300', label: '星光金', text: 'text-amber-800' },
purple: { bg: 'bg-purple-50 border-purple-200', border: 'border-purple-300', label: '淡紫色', text: 'text-purple-800' },
green: { bg: 'bg-emerald-50 border-emerald-200', border: 'border-emerald-300', label: '荧光绿', text: 'text-emerald-800' },
};
export function ReaderPanel({
@@ -59,34 +62,131 @@ export function ReaderPanel({
setNewNoteText,
handleCreateNote,
handleDeleteNote,
showConfirm,
}: ReaderPanelProps) {
const [viewMode, setViewMode] = useState<'bilingual' | 'english' | 'chinese'>(() => {
if (typeof window !== 'undefined' && window.innerWidth < 768) {
return 'english';
}
return 'bilingual';
});
const englishRef = useRef<HTMLDivElement>(null);
const chineseRef = useRef<HTMLDivElement>(null);
const [syncScroll, setSyncScroll] = useState(true);
const scrollLock = useRef(false);
const handleEnglishScroll = () => {
if (!syncScroll) return;
if (scrollLock.current) return;
const eng = englishRef.current;
const chn = chineseRef.current;
if (!eng || !chn) return;
scrollLock.current = true;
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
requestAnimationFrame(() => {
scrollLock.current = false;
});
};
const handleChineseScroll = () => {
if (!syncScroll) return;
if (scrollLock.current) return;
const eng = englishRef.current;
const chn = chineseRef.current;
if (!eng || !chn) return;
scrollLock.current = true;
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
requestAnimationFrame(() => {
scrollLock.current = false;
});
};
return (
<div className="max-w-7xl mx-auto h-[calc(100vh-140px)] flex flex-col space-y-4 w-full">
<div className="w-full flex-1 flex flex-col space-y-4 min-h-0">
{/* 控制头部 */}
<div className="flex items-center justify-between">
<div className="flex items-center justify-between border-b border-slate-200 pb-3">
<div>
<h2 className="text-base font-bold text-slate-800 line-clamp-1 leading-snug">{selectedPaper.title}</h2>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span>: {selectedPaper.pub_journal}</span>
<h2 className="text-sm font-bold text-slate-900 line-clamp-1 leading-snug">{selectedPaper.title}</h2>
<div className="flex items-center gap-2 text-xs text-slate-500 mt-1 font-semibold">
<span>: {selectedPaper.pub_journal || '未标注'}</span>
<span></span>
<span>Bibcode: {selectedPaper.bibcode}</span>
<span>: {selectedPaper.bibcode}</span>
</div>
</div>
<div className="flex gap-2">
<div className="flex gap-2 items-center">
{(englishText || chineseText) && (
<div className="flex gap-1 bg-slate-100 p-0.5 rounded-lg border border-slate-200">
<button
type="button"
onClick={() => setViewMode('english')}
className={`px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
viewMode === 'english'
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
type="button"
onClick={() => setViewMode('chinese')}
className={`px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
viewMode === 'chinese'
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
type="button"
onClick={() => setViewMode('bilingual')}
className={`hidden md:block px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
viewMode === 'bilingual'
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
</div>
)}
{(englishText || chineseText) && viewMode === 'bilingual' && (
<button
onClick={() => setSyncScroll(!syncScroll)}
className={`px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5 border transition-all cursor-pointer ${
syncScroll
? 'bg-sky-50 text-sky-700 border-sky-200 shadow-sm'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${syncScroll ? 'bg-sky-500 animate-pulse' : 'bg-slate-300'}`} />
<span>{syncScroll ? '开启' : '关闭'}</span>
</button>
)}
{!selectedPaper.has_markdown ? (
<button
onClick={() => handleParse(selectedPaper.bibcode)}
disabled={parsing}
className="px-4 py-2 rounded-xl bg-purple-600 text-white text-xs hover:bg-purple-500 font-semibold flex items-center gap-2 shadow-lg shadow-purple-500/10"
className="btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
>
{parsing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <FileText className="w-3.5 h-3.5" />}
{parsing ? '提取英文正文中...' : '第一步:解析 PDF/HTML 正文'}
{parsing ? '正文结构解析中...' : '解析源文正文'}
</button>
) : (
<button
onClick={() => { if (confirm('确定要重新解析正文吗?这会覆盖本地已解析的 Markdown。')) handleParse(selectedPaper.bibcode, true); }}
onClick={() => {
showConfirm('确定要重新解析正文吗?这会覆盖本地已解析的 Markdown。', () => {
handleParse(selectedPaper.bibcode, true);
}, '确认重新解析');
}}
disabled={parsing}
className="px-4 py-2 rounded-xl bg-slate-100 border border-slate-200 text-slate-600 text-xs hover:bg-slate-200 font-semibold flex items-center gap-2"
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
title="覆盖本地解析结果,重新从 HTML/PDF 转换为 Markdown"
>
{parsing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />}
@@ -94,14 +194,14 @@ export function ReaderPanel({
</button>
)}
{selectedPaper.has_markdown && (
{selectedPaper.has_markdown && !selectedPaper.has_translation && (
<button
onClick={() => handleTranslate(selectedPaper.bibcode)}
disabled={translating}
className="px-4 py-2 rounded-xl bg-emerald-600 text-white text-xs hover:bg-emerald-500 font-semibold flex items-center gap-2 shadow-lg shadow-emerald-500/10"
className="btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
>
{translating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Languages className="w-3.5 h-3.5" />}
{translating ? 'LLM 学术术语修正翻译中...' : '第二步:大模型对比翻译'}
{translating ? '天文学术语对比翻译中...' : '生成智能翻译'}
</button>
)}
@@ -109,7 +209,7 @@ export function ReaderPanel({
<button
onClick={() => handleTranslate(selectedPaper.bibcode, true)}
disabled={translating}
className="px-4 py-2 rounded-xl bg-slate-100 border border-slate-200 text-slate-600 text-xs hover:bg-slate-200 font-semibold flex items-center gap-2"
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
title="清除翻译缓存并重新生成大模型对照翻译"
>
{translating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />}
@@ -120,129 +220,155 @@ export function ReaderPanel({
</div>
{/* 并排对比阅读器 */}
<div className="flex-1 grid gap-6 overflow-hidden w-full" style={{ gridTemplateColumns: showNotesPanel ? '1fr 1fr 320px' : '1fr 1fr' }}>
{/* 英文正文面板 */}
<div className="glass rounded-2xl p-6 overflow-y-auto border border-slate-200 relative flex flex-col">
<div className="flex items-center justify-between mb-3 border-b border-slate-200/60 pb-2">
<span className="text-[10px] text-purple-650 font-bold uppercase tracking-wider"> (Markdown/LaTeX)</span>
<button
onClick={() => setShowNotesPanel(!showNotesPanel)}
className={`flex items-center gap-1 text-[10px] px-2 py-1 rounded-lg transition-all ${
showNotesPanel ? 'bg-amber-100 text-amber-800 border border-amber-300' : 'text-slate-600 hover:text-slate-900 bg-slate-100 border border-slate-200'
}`}
>
<Pencil className="w-3 h-3" />
({notes.length})
</button>
</div>
{parsing ? (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-2">
<Loader className="w-8 h-8 animate-spin text-purple-500" />
<p className="text-xs"> HTML MD退 MinerU PDF ...</p>
</div>
) : englishText ? (
<div className="prose prose-sm max-w-none leading-relaxed text-slate-700 prose-headings:text-purple-700 prose-strong:text-slate-900 prose-code:text-emerald-700 prose-blockquote:border-purple-500/50 prose-blockquote:text-slate-500 prose-img:max-w-full prose-img:rounded-lg">
{englishText.split('\n\n').map((para, idx) => (
<div
key={idx}
onMouseUp={() => handleTextSelection(idx)}
className={`cursor-text relative rounded px-1 -mx-1 transition-colors ${
notes.some(n => n.paragraph_index === idx)
? `${NOTE_COLORS[notes.find(n => n.paragraph_index === idx)!.highlight_color]?.bg || ''} ${NOTE_COLORS[notes.find(n => n.paragraph_index === idx)!.highlight_color]?.border || ''} border`
: 'hover:bg-slate-100'
}`}
>
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]}
>
{para}
</ReactMarkdown>
</div>
))}
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400">
<FileText className="w-12 h-12 mb-3" />
<p className="text-xs"> Markdown </p>
<p className="text-[11px] text-slate-500 mt-1"></p>
</div>
)}
</div>
{/* 中文翻译面板 */}
<div className="glass rounded-2xl p-6 overflow-y-auto border border-slate-200 relative flex flex-col">
<span className="text-[10px] text-emerald-700 font-bold uppercase tracking-wider block mb-3 border-b border-slate-200/60 pb-2"> ()</span>
{translating ? (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-2">
<Loader className="w-8 h-8 animate-spin text-emerald-500" />
<p className="text-xs"> prompt ...</p>
</div>
) : chineseText ? (
<div className="prose prose-sm max-w-none leading-relaxed text-slate-700 prose-headings:text-emerald-700 prose-strong:text-slate-900 prose-code:text-blue-700 prose-blockquote:border-emerald-500/50 prose-img:max-w-full prose-img:rounded-lg">
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]}
<div
className="flex-1 grid gap-6 overflow-hidden w-full"
style={{
gridTemplateColumns: viewMode === 'bilingual'
? (showNotesPanel ? '1fr 1fr 340px' : '1fr 1fr')
: (showNotesPanel ? '1fr 340px' : '1fr')
}}
>
{(viewMode === 'english' || viewMode === 'bilingual') && (
<div
ref={englishRef}
onScroll={handleEnglishScroll}
className="console-panel rounded-xl p-6 overflow-y-auto bg-white border border-slate-200 relative flex flex-col"
>
<div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2.5">
<span className="text-xs font-bold text-slate-800"></span>
<button
onClick={() => setShowNotesPanel(!showNotesPanel)}
className={`flex items-center gap-1 text-xs font-bold px-2.5 py-1 rounded-lg border transition-all ${
showNotesPanel ? 'bg-sky-50 text-sky-700 border-sky-200' : 'btn-console btn-console-secondary'
}`}
>
{chineseText}
</ReactMarkdown>
<Pencil className="w-3.5 h-3.5" />
({notes.length})
</button>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400">
<Languages className="w-12 h-12 mb-3" />
<p className="text-xs"></p>
<p className="text-[11px] text-slate-500 mt-1"></p>
{parsing ? (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-3">
<Loader className="w-8 h-8 animate-spin text-sky-600" />
<p className="text-xs font-bold"> Markdown ...</p>
</div>
) : englishText ? (
<div className="prose prose-sm max-w-none leading-relaxed text-slate-800 prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-blockquote:border-slate-300 prose-blockquote:text-slate-500 prose-img:max-w-full prose-img:rounded-lg">
{englishText.split('\n\n').map((para, idx) => (
<div
key={idx}
onMouseUp={() => handleTextSelection(idx)}
className={`cursor-text relative rounded px-1.5 -mx-1.5 py-1 transition-colors duration-200 ${
notes.some(n => n.paragraph_index === idx)
? `${NOTE_COLORS[notes.find(n => n.paragraph_index === idx)!.highlight_color]?.bg || ''} ${NOTE_COLORS[notes.find(n => n.paragraph_index === idx)!.highlight_color]?.border || ''} border`
: 'hover:bg-slate-100'
}`}
>
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex]}
>
{para}
</ReactMarkdown>
</div>
))}
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400">
<FileText className="w-12 h-12 mb-3 text-slate-300" />
<p className="text-xs font-bold text-slate-500"></p>
<p className="text-xs text-slate-400 mt-1"></p>
</div>
)}
</div>
)}
{(viewMode === 'chinese' || viewMode === 'bilingual') && (
<div
ref={chineseRef}
onScroll={handleChineseScroll}
className="console-panel rounded-xl p-6 overflow-y-auto bg-white border border-slate-200 relative flex flex-col"
>
<div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2.5">
<span className="text-xs font-bold text-slate-800"></span>
</div>
)}
</div>
{translating ? (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-3">
<Loader className="w-8 h-8 animate-spin text-sky-600" />
<p className="text-xs font-bold">...</p>
</div>
) : chineseText ? (
<div className="prose prose-sm max-w-none leading-relaxed text-slate-800 prose-headings:text-slate-900 prose-strong:text-slate-900 prose-code:text-sky-700 prose-blockquote:border-slate-350 prose-img:max-w-full prose-img:rounded-lg">
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex]}
>
{chineseText}
</ReactMarkdown>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400">
<Languages className="w-12 h-12 mb-3 text-slate-300" />
<p className="text-xs font-bold text-slate-500"></p>
<p className="text-xs text-slate-400 mt-1"></p>
</div>
)}
</div>
)}
{/* 笔记侧边栏 */}
{showNotesPanel && (
<div className="glass rounded-2xl border border-amber-200 bg-amber-50/20 flex flex-col overflow-hidden">
<div className="px-5 py-4 border-b border-amber-200 flex items-center justify-between">
<span className="text-xs text-amber-700 font-bold"> ({notes.length})</span>
<button onClick={() => setShowNotesPanel(false)} className="text-slate-500 hover:text-slate-800">
<div className="console-panel rounded-xl border border-slate-200 bg-slate-50 flex flex-col overflow-hidden relative shadow-sm">
<div className="px-4 py-3.5 border-b border-slate-200 flex items-center justify-between bg-white">
<span className="text-xs font-bold text-slate-800"> ({notes.length})</span>
<button onClick={() => setShowNotesPanel(false)} className="text-slate-400 hover:text-slate-655 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* 新建笔记输入区 */}
{selectedParagraphIdx !== null && (
<div className="px-4 py-3 border-b border-slate-200 bg-slate-50">
<div className="text-[10px] text-slate-500 mb-2"> #{selectedParagraphIdx + 1}</div>
{selectedText && <div className="text-[10px] text-slate-650 italic line-clamp-2 mb-2 bg-white border border-slate-200 px-2 py-1 rounded">"{selectedText}"</div>}
{/* 高亮颜色选择 */}
<div className="flex gap-1.5 mb-2">
{Object.entries(NOTE_COLORS).map(([color, style]) => (
<button
key={color}
onClick={() => setNewNoteColor(color)}
className={`w-5 h-5 rounded-full border-2 transition-transform ${
newNoteColor === color ? 'border-slate-700 scale-110' : 'border-transparent'
} ${style.bg.replace('/20', '')}`}
title={style.label}
/>
))}
<div className="px-4 py-4 border-b border-slate-200 bg-white space-y-3">
<div className="text-xs font-bold text-slate-700"> #{selectedParagraphIdx + 1}</div>
{selectedText && (
<div className="text-xs text-slate-600 italic line-clamp-2 bg-slate-50 px-2.5 py-1.5 rounded border border-slate-200">
"{selectedText}"
</div>
)}
{/* 颜色标记 */}
<div className="flex gap-2 items-center">
<span className="text-xs text-slate-500 font-semibold">:</span>
<div className="flex gap-1.5">
{Object.entries(NOTE_COLORS).map(([color, style]) => (
<button
key={color}
onClick={() => setNewNoteColor(color)}
className={`w-4 h-4 rounded-full border transition-transform ${
newNoteColor === color ? 'border-slate-800 scale-120 shadow-sm' : 'border-transparent'
} ${style.bg.replace('border-cyan-200', '')}`}
title={style.label}
/>
))}
</div>
</div>
<textarea
value={newNoteText}
onChange={e => setNewNoteText(e.target.value)}
placeholder="输入笔记内容..."
placeholder="记录段落心得或重点..."
rows={3}
className="w-full bg-white border border-slate-350 rounded-lg text-xs text-slate-800 placeholder-slate-400 px-3 py-2 resize-none focus:outline-none focus:border-amber-500 mb-2"
className="w-full bg-white border border-slate-300 rounded-lg text-xs text-slate-900 placeholder-slate-400 px-3 py-2 resize-none focus:outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-500/10 leading-relaxed"
/>
<div className="flex gap-2">
<button
onClick={handleCreateNote}
className="flex-1 px-3 py-1.5 rounded-lg bg-amber-600 text-white text-xs hover:bg-amber-500 font-semibold flex items-center justify-center gap-1"
className="btn-console btn-console-primary flex-1 px-3 py-1.5 rounded-lg text-xs font-bold flex items-center justify-center gap-1"
>
<PlusCircle className="w-3 h-3" />
<PlusCircle className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { setSelectedParagraphIdx(null); setSelectedText(''); setNewNoteText(''); }}
className="px-3 py-1.5 rounded-lg bg-slate-150 text-slate-600 text-xs hover:bg-slate-200"
className="btn-console btn-console-secondary px-3 py-1.5 rounded-lg text-xs font-bold"
>
</button>
@@ -253,30 +379,40 @@ export function ReaderPanel({
{/* 笔记列表 */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{notes.length === 0 ? (
<div className="text-center text-slate-400 text-xs py-8">
<Pencil className="w-8 h-8 mx-auto mb-2 opacity-40" />
<div className="text-center text-slate-400 text-xs py-12 space-y-2">
<Pencil className="w-8 h-8 mx-auto opacity-30 text-slate-500" />
<div></div>
</div>
) : (
notes.map(note => (
<div
key={note.id}
className={`p-3 rounded-xl border text-xs ${NOTE_COLORS[note.highlight_color]?.bg || 'bg-slate-50'} ${NOTE_COLORS[note.highlight_color]?.border || 'border-slate-200'}`}
>
<div className="text-slate-500 text-[10px] mb-1"> #{note.paragraph_index + 1}</div>
{note.selected_text && <div className="text-slate-600 italic line-clamp-1 mb-1 text-[10px]">"{note.selected_text}"</div>}
<p className="text-slate-800 leading-relaxed">{note.note_text}</p>
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-slate-400">{note.created_at.split('T')[0]}</span>
<button
onClick={() => handleDeleteNote(note.id)}
className="text-slate-400 hover:text-red-600 transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
notes.map(note => {
const style = NOTE_COLORS[note.highlight_color] || NOTE_COLORS.cyan;
return (
<div
key={note.id}
className={`p-4 rounded-lg border text-xs bg-white border-slate-200 relative group overflow-hidden`}
>
{/* 彩色左标记条 */}
<div className={`absolute left-0 top-0 w-1 h-full ${style.bg.split(' ')[0]}`} />
<div className="text-slate-500 text-[10px] font-bold mb-1"> #{note.paragraph_index + 1}</div>
{note.selected_text && (
<div className="text-slate-500 italic line-clamp-2 mb-2 border-l-2 border-slate-200 pl-2">
"{note.selected_text}"
</div>
)}
<p className="text-slate-800 leading-relaxed font-medium">{note.note_text}</p>
<div className="flex items-center justify-between mt-3 border-t border-slate-100 pt-2 text-[10px]">
<span className="text-slate-400 font-semibold">{note.created_at.split('T')[0]}</span>
<button
onClick={() => handleDeleteNote(note.id)}
className="text-slate-400 hover:text-red-655 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
))
);
})
)}
</div>
</div>
+167 -122
View File
@@ -1,8 +1,30 @@
// dashboard/src/features/search/SearchPanel.tsx
import React from 'react';
import { Search, Loader, CheckCircle, Copy, Download, ChevronLeft, ChevronRight } from 'lucide-react';
import { Search, Loader, CheckCircle, Copy, Download, ChevronLeft, ChevronRight, SlidersHorizontal } from 'lucide-react';
import type { StandardPaper } from '../../types';
export const getDoctypeBadge = (doctype: string) => {
const typeMap: Record<string, { label: string; style: string }> = {
article: { label: '期刊文章', style: 'bg-blue-50 text-blue-700 border-blue-200' },
eprint: { label: '预印本', style: 'bg-purple-50 text-purple-700 border-purple-200' },
inproceedings: { label: '会议论文', style: 'bg-amber-50 text-amber-700 border-amber-200' },
proceedings: { label: '会议集', style: 'bg-amber-50 text-amber-700 border-amber-200' },
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' },
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' },
};
const val = doctype ? doctype.toLowerCase() : 'article';
const match = typeMap[val] || { label: '文献', style: 'bg-slate-50 text-slate-700 border-slate-200' };
return (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold border ${match.style} mr-2 align-middle`}>
{match.label}
</span>
);
};
interface SearchPanelProps {
searchQuery: string;
setSearchQuery: (query: string) => void;
@@ -29,6 +51,8 @@ interface SearchPanelProps {
openReader: (paper: StandardPaper) => void;
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync') => void;
loadCitations: (bibcode: string, reset?: boolean) => void;
showAlert: (msg: string, title?: string) => void;
onShowDetail: (paper: StandardPaper) => void;
}
export function SearchPanel({
@@ -57,6 +81,8 @@ export function SearchPanel({
openReader,
setActiveTab,
loadCitations,
showAlert,
onShowDetail,
}: SearchPanelProps) {
const currentPage = Math.floor(searchStart / searchRows) + 1;
@@ -68,13 +94,11 @@ export function SearchPanel({
{ 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}"`;
}
@@ -112,51 +136,60 @@ export function SearchPanel({
};
return (
<div className="space-y-6 max-w-5xl mx-auto">
{/* 搜索和过滤控制面板 */}
<div className="glass p-6 rounded-2xl space-y-4">
<div className="space-y-6 w-full max-w-5xl mx-auto">
{/* 标题 */}
<div className="flex flex-col gap-1.5 border-b border-slate-200 pb-3">
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase"></h2>
<p className="text-slate-500 text-xs"> NASA ADS arXiv </p>
</div>
{/* 检索控制面板 */}
<div className="console-panel p-6 rounded-xl space-y-4">
<form onSubmit={handleSearch} className="space-y-4">
<div className="flex gap-3 items-center">
<div className="flex flex-col md:flex-row gap-3 items-stretch">
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 w-5 h-5" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="检索天文学文献 (支持关键字、作者、年份范围检索,如 'hot subdwarf year:2020-2023')"
className="w-full pl-12 pr-4 py-4 rounded-xl bg-white/60 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition-all text-sm"
placeholder="检索天文学文献 (支持关键字、作者、发表年份,如 'hot subdwarf year:2020-2023')"
className="w-full pl-12 pr-4 py-3 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-sm font-medium"
/>
</div>
<button
type="button"
onClick={() => setShowBuilder(!showBuilder)}
className={`px-4 py-4 rounded-xl border text-xs font-semibold transition-all ${
showBuilder
? 'bg-purple-50 border-purple-300 text-purple-600'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{showBuilder ? '隐藏生成器' : '高级检索生成器'}
</button>
<button
type="submit"
disabled={searching}
className="px-6 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-indigo-600 text-white text-xs font-semibold hover:from-purple-500 hover:to-indigo-500 transition-all flex items-center gap-2 shrink-0 shadow-lg shadow-purple-500/10"
>
{searching ? <Loader className="w-3.5 h-3.5 animate-spin" /> : null}
{searching ? '检索中' : '开始检索'}
</button>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowBuilder(!showBuilder)}
className={`px-4 py-3 rounded-lg border text-xs font-bold transition-all shrink-0 flex items-center gap-1.5 ${
showBuilder
? 'bg-sky-50 border-sky-300 text-sky-700'
: 'btn-console'
}`}
>
<SlidersHorizontal className="w-3.5 h-3.5" />
{showBuilder ? '隐藏构造器' : '条件构造器'}
</button>
<button
type="submit"
disabled={searching}
className="btn-console btn-console-primary px-6 py-3 rounded-lg text-xs font-bold tracking-wider flex items-center gap-2 shrink-0"
>
{searching ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
{searching ? '检索中...' : '检索文献'}
</button>
</div>
</div>
{/* 动态表单生成器 */}
{/* 条件构造器 */}
{showBuilder && (
<div className="p-5 rounded-xl bg-slate-50/70 border border-slate-200/60 space-y-3.5 transition-all">
<div className="p-4 rounded-lg bg-slate-50 border border-slate-200 space-y-3 transition-all">
<div className="text-xs font-bold text-slate-700 flex justify-between items-center">
<span></span>
<button
type="button"
onClick={handleAddRule}
className="text-[10px] text-purple-600 hover:underline"
className="text-xs text-sky-600 hover:text-sky-700 font-bold"
>
+
</button>
@@ -169,26 +202,26 @@ export function SearchPanel({
<select
value={rule.op}
onChange={e => handleRuleChange(idx, 'op', e.target.value)}
className="bg-white border border-slate-200 rounded-lg px-2 py-1.5 text-xs text-slate-600 focus:outline-none focus:border-purple-500 w-20"
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>
<option value="AND"> (AND)</option>
<option value="OR"> (OR)</option>
<option value="NOT"> (NOT)</option>
</select>
) : (
<div className="w-20 text-center text-xs text-slate-400 font-medium">:</div>
<div className="w-24 text-center text-xs text-slate-500 font-semibold"></div>
)}
<select
value={rule.field}
onChange={e => handleRuleChange(idx, 'field', e.target.value)}
className="bg-white border border-slate-200 rounded-lg px-2.5 py-1.5 text-xs text-slate-600 focus:outline-none focus:border-purple-500 w-32"
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"> (all)</option>
<option value="title"> (title)</option>
<option value="author"> (author)</option>
<option value="abs"> (abs)</option>
<option value="year"> (year)</option>
<option value="all"></option>
<option value="title"></option>
<option value="author"></option>
<option value="abs"></option>
<option value="year"></option>
</select>
<input
@@ -200,16 +233,16 @@ export function SearchPanel({
? '例如: 2020-2023 或 2022'
: rule.field === 'author'
? '例如: Althaus'
: '输入检索词...'
: '输入检索词...'
}
className="flex-1 px-3 py-1.5 rounded-lg bg-white border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 text-xs"
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 && (
<button
type="button"
onClick={() => handleRemoveRule(idx)}
className="text-slate-400 hover:text-rose-500 text-xs px-2 py-1.5"
className="text-red-655 hover:text-red-700 text-xs font-bold px-2 py-1.5"
>
</button>
@@ -220,76 +253,75 @@ export function SearchPanel({
</div>
)}
<div className="text-[10px] text-slate-400 flex flex-wrap gap-x-4 gap-y-1 px-1">
<span>💡 :</span>
<span>: <code className="bg-slate-100 px-1 py-0.5 rounded font-mono text-[9px]">author:"Althaus"</code></span>
<span>: <code className="bg-slate-100 px-1 py-0.5 rounded font-mono text-[9px]">title:"hot subdwarf"</code></span>
<span>: <code className="bg-slate-100 px-1 py-0.5 rounded font-mono text-[9px]">year:2020-2023</code></span>
<span>: <code className="bg-slate-100 px-1 py-0.5 rounded font-mono text-[9px]">(sdOB OR "white dwarf") AND Gaia</code></span>
<div className="text-[11px] text-slate-500 flex flex-wrap gap-x-4 gap-y-1 px-1">
<span className="font-semibold text-slate-650">💡 :</span>
<span>: <code className="bg-slate-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">author:"Althaus"</code></span>
<span>: <code className="bg-slate-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">title:"hot subdwarf"</code></span>
<span>: <code className="bg-slate-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">year:2020-2023</code></span>
</div>
<div className="flex flex-wrap items-center justify-between gap-4 pt-2 border-t border-slate-100">
{/* 数据源选择 */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-slate-200">
{/* 数据源平台 */}
<div className="flex gap-4">
{[
{ id: 'all', label: '全部数据源' },
{ id: 'ads', label: 'NASA ADS' },
{ id: 'arxiv', label: 'arXiv 预印本' },
].map(src => (
<label key={src.id} className="flex items-center gap-2 cursor-pointer text-xs">
<label key={src.id} className="flex items-center gap-2 cursor-pointer text-xs font-bold text-slate-700">
<input
type="radio"
name="searchSource"
checked={searchSource === src.id}
onChange={() => setSearchSource(src.id as any)}
className="text-purple-600 focus:ring-purple-500 border-slate-300 bg-white"
className="accent-sky-600"
/>
<span className={searchSource === src.id ? 'text-purple-600 font-medium' : 'text-slate-500'}>
<span className={searchSource === src.id ? 'text-sky-750 font-bold' : 'text-slate-500 hover:text-slate-700'}>
{src.label}
</span>
</label>
))}
</div>
{/* 排序及最大结果数量控制 */}
<div className="flex items-center gap-4">
{/* 排序及每页条数 */}
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">:</span>
<span className="text-xs font-semibold text-slate-500">:</span>
<select
value={searchSort}
onChange={e => handleSortChange(e.target.value)}
className="bg-white/60 border border-slate-200 rounded-lg px-2.5 py-1 text-xs text-slate-600 focus:outline-none focus:border-purple-500"
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="date_desc"> ()</option>
<option value="date_asc"> ()</option>
<option value="citations_desc"> ()</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">:</span>
<span className="text-xs font-semibold text-slate-500">:</span>
<select
value={searchRows}
onChange={e => handleRowsChange(Number(e.target.value))}
className="bg-white/60 border border-slate-200 rounded-lg px-2.5 py-1 text-xs text-slate-600 focus:outline-none focus:border-purple-500"
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>
<option value="100">100 </option>
</select>
</div>
{exportingList.length > 0 && (
<button
type="button"
onClick={handleExportBibtex}
disabled={exporting}
className="px-4 py-1.5 rounded-lg bg-slate-100 border border-slate-200 text-xs text-slate-600 hover:bg-slate-200 hover:text-slate-800 flex items-center gap-1.5"
className="btn-console btn-console-secondary px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5"
>
{exporting ? <Loader className="w-3 h-3 animate-spin" /> : null}
({exportingList.length}) BibTeX
{exporting ? <Loader className="w-3.5 h-3.5 animate-spin" /> : null}
({exportingList.length}) BibTeX
</button>
)}
</div>
@@ -297,23 +329,23 @@ export function SearchPanel({
</form>
</div>
{/* BibTeX 导出结果显示 */}
{/* BibTeX 导出显示 */}
{bibtexContent && (
<div className="glass p-6 rounded-2xl relative">
<h3 className="text-sm font-bold text-purple-600 mb-3 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-500" /> BibTeX
<div className="console-panel p-5 rounded-xl border border-sky-200 relative overflow-hidden bg-sky-50/20">
<h3 className="text-xs font-bold text-sky-700 mb-3 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-600" /> BibTeX
</h3>
<pre className="bg-slate-50 p-4 rounded-xl border border-slate-200 text-xs text-slate-700 font-mono overflow-x-auto whitespace-pre-wrap max-h-60">
<pre className="bg-white p-4 rounded-lg border border-slate-200 text-xs text-slate-800 font-mono overflow-x-auto whitespace-pre-wrap max-h-60 leading-relaxed shadow-inner">
{bibtexContent}
</pre>
<button
onClick={() => {
navigator.clipboard.writeText(bibtexContent);
alert('已复制至剪贴板');
showAlert('BibTeX 数据已成功复制至剪贴板。', '复制成功');
}}
className="absolute top-6 right-6 text-slate-600 hover:text-slate-900 flex items-center gap-1 text-xs bg-slate-100 px-3 py-1 rounded-lg border border-slate-200"
className="absolute top-5 right-5 btn-console btn-console-secondary px-3 py-1 rounded-lg text-xs font-bold flex items-center gap-1"
>
<Copy className="w-3 h-3" />
<Copy className="w-3.5 h-3.5" />
</button>
</div>
)}
@@ -321,74 +353,87 @@ export function SearchPanel({
{/* 检索列表 */}
<div className="relative min-h-[200px]">
{searching && (
<div className="absolute inset-0 bg-white/40 backdrop-blur-[2px] z-10 flex items-center justify-center rounded-2xl">
<div className="flex flex-col items-center gap-3 bg-white p-6 rounded-2xl shadow-xl border border-slate-200">
<Loader className="w-8 h-8 text-purple-600 animate-spin" />
<span className="text-xs font-semibold text-slate-500">...</span>
<div className="absolute inset-0 bg-white/60 backdrop-blur-[1px] z-10 flex items-center justify-center rounded-xl">
<div className="flex flex-col items-center gap-3 bg-white p-6 rounded-xl shadow-lg border border-slate-200 text-center">
<Loader className="w-8 h-8 text-sky-650 animate-spin" />
<span className="text-xs font-bold text-slate-600">...</span>
</div>
</div>
)}
<div className={`space-y-4 transition-all duration-300 ${searching ? 'opacity-40 pointer-events-none filter blur-[1px]' : ''}`}>
<div className={`space-y-4 transition-all duration-300 ${searching ? 'opacity-45 pointer-events-none filter blur-[1px]' : ''}`}>
{searchResults.map(paper => {
const isDownloading = downloadingBibcodes[paper.bibcode] || false;
const isSelected = selectedPaper?.bibcode === paper.bibcode;
return (
<div
key={paper.bibcode}
className={`glass p-6 rounded-2xl transition-all border ${
isSelected ? 'border-purple-500/50 bg-purple-50/50' : 'border-slate-200/80 hover:border-slate-300'
className={`console-panel p-6 rounded-xl border transition-all relative ${
isSelected ? 'border-sky-500 bg-sky-50/10' : 'border-slate-200 hover:border-slate-350 hover:bg-white'
}`}
>
<div className="flex justify-between items-start gap-4 mb-2">
<h3
className="font-bold text-base text-slate-800 line-clamp-1 leading-snug hover:text-purple-600 cursor-pointer"
onClick={() => openReader(paper)}
>
{paper.title}
</h3>
<div className="flex gap-2 shrink-0">
{/* 选定指示侧条 */}
{isSelected && <div className="absolute top-0 left-0 w-1.5 h-full bg-sky-600" />}
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-4 mb-3">
<div className="flex-1">
<h3
className="font-bold text-sm text-slate-900 hover:text-sky-700 cursor-pointer transition-all line-clamp-2 leading-relaxed"
onClick={() => openReader(paper)}
>
{getDoctypeBadge(paper.doctype)}
<span className="align-middle">{paper.title}</span>
</h3>
<p className="text-xs text-slate-500 font-medium mt-2 leading-snug">
: <span className="text-slate-800">{paper.authors.slice(0, 5).join(', ')}{paper.authors.length > 5 ? ' 等' : ''}</span> : <span className="text-slate-850 font-bold">{paper.year}</span> : <span className="italic text-slate-700">{paper.pub_journal || '未标注'}</span>
</p>
</div>
<div className="flex gap-2 shrink-0 items-center">
<button
type="button"
onClick={() => onShowDetail(paper)}
className="px-3 py-1.5 rounded-lg bg-white border border-slate-250 text-slate-650 hover:bg-slate-50 hover:border-slate-350 transition-all text-xs font-bold flex items-center cursor-pointer shadow-sm"
>
</button>
{paper.is_downloaded ? (
<span className="px-2.5 py-1 rounded-full bg-emerald-50 text-emerald-600 border border-emerald-200 font-medium text-[10px] flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
<span className="px-2.5 py-1 rounded-lg bg-emerald-50 text-emerald-700 border border-emerald-200 text-xs font-bold flex items-center gap-1">
<CheckCircle className="w-3.5 h-3.5" />
</span>
) : (
<button
onClick={() => handleDownload(paper.bibcode)}
disabled={isDownloading}
className="px-3 py-1 rounded-lg bg-amber-50 hover:bg-amber-100 text-amber-600 border border-amber-200 text-[10px] font-semibold flex items-center gap-1 transition-all"
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 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
{isDownloading ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />}
</button>
)}
<label className="flex items-center gap-1.5 cursor-pointer text-xs border border-slate-200 px-2 py-1 rounded-lg bg-white hover:bg-slate-50">
<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">
<input
type="checkbox"
checked={exportingList.includes(paper.bibcode)}
onChange={() => toggleExportItem(paper.bibcode)}
className="rounded text-purple-600 border-slate-300"
className="rounded text-sky-600 border-slate-300"
/>
<span className="text-[10px] text-slate-500"></span>
<span></span>
</label>
</div>
</div>
<p className="text-xs text-slate-500 font-medium mb-3">
{paper.authors.join(', ')} {paper.year} <span className="italic">{paper.pub_journal}</span>
<p className="text-xs text-slate-600 line-clamp-3 leading-relaxed mb-4 font-normal">
{paper.abstract_text || '暂无文献摘要数据。'}
</p>
<p className="text-xs text-slate-600 line-clamp-3 leading-relaxed mb-4">
{paper.abstract_text || '暂无摘要'}
</p>
<div className="flex justify-between items-center">
<div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center border-t border-slate-100 pt-4 gap-3">
<div className="flex gap-2">
<button
onClick={() => openReader(paper)}
className="px-4 py-1.5 rounded-lg bg-gradient-to-r from-purple-50 to-indigo-50 border border-purple-200 text-xs text-purple-600 hover:from-purple-100 hover:to-indigo-100 hover:border-purple-300"
className="px-4 py-1.5 rounded-lg bg-sky-50 border border-sky-200 text-xs font-bold text-sky-700 hover:bg-sky-100 transition-all"
>
</button>
<button
onClick={() => {
@@ -396,43 +441,43 @@ export function SearchPanel({
setActiveTab('citation');
loadCitations(paper.bibcode, true);
}}
className="px-4 py-1.5 rounded-lg bg-slate-100 border border-slate-200 text-xs text-slate-600 hover:bg-slate-200 hover:text-slate-900"
className="px-4 py-1.5 rounded-lg bg-slate-50 border border-slate-250 text-xs font-bold text-slate-600 hover:border-slate-400 hover:text-slate-800 transition-all"
>
</button>
</div>
</button>
</div>
<div className="text-[10px] text-slate-400 flex gap-4 font-mono">
{paper.doi && <span>DOI: {paper.doi}</span>}
<span>Bibcode: {paper.bibcode}</span>
{paper.citation_count > 0 && <span>: {paper.citation_count}</span>}
<div className="text-xs text-slate-450 flex flex-wrap gap-x-4 gap-y-1 font-mono">
{paper.doi && <span>DOI: {paper.doi}</span>}
<span>Bibcode: <span className="font-semibold text-slate-700">{paper.bibcode}</span></span>
{paper.citation_count > 0 && <span>: <span className="text-sky-750 font-bold">{paper.citation_count}</span></span>}
</div>
</div>
</div>
</div>
);
})}
);
})}
</div>
</div>
{/* 分页控制栏 */}
{searchResults.length > 0 && (
<div className="flex items-center justify-between p-4 glass rounded-2xl max-w-5xl mx-auto">
<div className="flex items-center justify-between p-4 bg-white border border-slate-200 rounded-xl max-w-5xl mx-auto shadow-sm">
<button
onClick={() => handlePageChange(searchStart - searchRows)}
disabled={!hasPreviousPage || searching}
className="px-4 py-2 border border-slate-200 rounded-xl bg-white text-xs text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white flex items-center gap-1 transition-all"
className="px-4 py-2 border border-slate-250 rounded-lg bg-white text-xs font-bold text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white flex items-center gap-1 transition-all"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-xs font-semibold text-slate-600">
<span className="text-xs font-bold text-slate-600">
{currentPage} ( {searchStart + 1} - {searchStart + searchResults.length} )
</span>
<button
onClick={() => handlePageChange(searchStart + searchRows)}
disabled={!hasNextPage || searching}
className="px-4 py-2 border border-slate-200 rounded-xl bg-white text-xs text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white flex items-center gap-1 transition-all"
className="px-4 py-2 border border-slate-250 rounded-lg bg-white text-xs font-bold text-slate-700 hover:bg-slate-50 disabled:opacity-40 disabled:hover:bg-white flex items-center gap-1 transition-all"
>
<ChevronRight className="w-4 h-4" />
</button>
+360 -193
View File
@@ -1,15 +1,19 @@
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import { RefreshCw, Play, Info, AlertTriangle, CheckCircle, Loader, StopCircle, Download, FileText } from 'lucide-react';
import { RefreshCw, Play, Info, AlertTriangle, CheckCircle, Loader, StopCircle, Download, FileText, SlidersHorizontal } from 'lucide-react';
import type { SavedSyncQuery } from '../../types';
interface ProcessStatus {
active: boolean;
total: number;
downloaded: number;
parsed: number;
download_failed: number;
parse_failed: number;
current_bibcode: string;
logs: string[];
action?: 'all' | 'download' | 'parse';
action?: 'download' | 'parse' | 'translate';
}
interface HarvestStatus {
@@ -34,16 +38,25 @@ export function SyncPanel() {
total: 0,
});
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [syncQueries, setSyncQueries] = useState<SavedSyncQuery[]>([]);
const pollIntervalRef = useRef<any>(null);
// 批量下载与解析相关状态
const [processAction, setProcessAction] = useState<'all' | 'download' | 'parse'>('all');
const [processScope, setProcessScope] = useState<'all' | 'undownloaded' | 'unparsed'>('undownloaded');
const [targetPhase, setTargetPhase] = useState<'download' | 'parse' | 'translate'>('download');
const [batchLimitCount, setBatchLimitCount] = useState<number>(100);
const [sortOrder, setSortOrder] = useState<'default' | 'pub_year_desc' | 'created_at_desc'>('default');
const [skipCompleted, setSkipCompleted] = useState<boolean>(true);
const [skipFailed, setSkipFailed] = useState<boolean>(false);
const [skipPrecedingFailed, setSkipPrecedingFailed] = useState<boolean>(false);
const [skipPrecedingUncompleted, setSkipPrecedingUncompleted] = useState<boolean>(false);
const [processStatus, setProcessStatus] = useState<ProcessStatus>({
active: false,
total: 0,
downloaded: 0,
parsed: 0,
download_failed: 0,
parse_failed: 0,
current_bibcode: '',
logs: [],
});
@@ -98,14 +111,61 @@ export function SyncPanel() {
updateQueryFromRules(next);
};
// 获取历史检索配置
const fetchSyncQueries = async () => {
try {
const res = await axios.get<SavedSyncQuery[]>(`/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);
try {
await axios.post('/api/sync/meta/run', {
q: sq.query,
source: sq.source,
limit: sq.limit_count,
});
fetchStatus();
startPolling();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动快速同步失败。');
}
};
// 获取当前的收割状态
const fetchStatus = async () => {
try {
const res = await axios.get<HarvestStatus>('/api/sync/meta/status');
const res = await axios.get<HarvestStatus>(`/api/sync/meta/status?t=${Date.now()}`);
setStatus(res.data);
if (!res.data.active && pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
if (res.data.active) {
startPolling();
} else {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
fetchSyncQueries();
}
}
} catch (e) {
console.error('获取同步状态失败', e);
@@ -120,13 +180,7 @@ export function SyncPanel() {
useEffect(() => {
fetchStatus();
// 如果组件加载时已经在运行中,自动启动轮询
axios.get<HarvestStatus>('/api/sync/meta/status').then(res => {
if (res.data.active) {
setStatus(res.data);
startPolling();
}
});
fetchSyncQueries();
return () => {
if (pollIntervalRef.current) {
@@ -138,9 +192,11 @@ export function SyncPanel() {
// 批量下载与解析相关的网络操作
const fetchProcessStatus = async () => {
try {
const res = await axios.get<ProcessStatus>('/api/sync/asset/status');
const res = await axios.get<ProcessStatus>(`/api/sync/asset/status?t=${Date.now()}`);
setProcessStatus(res.data);
if (!res.data.active && processPollIntervalRef.current) {
if (res.data.active) {
startProcessPolling();
} else if (processPollIntervalRef.current) {
clearInterval(processPollIntervalRef.current);
processPollIntervalRef.current = null;
}
@@ -158,14 +214,19 @@ export function SyncPanel() {
setProcessError(null);
try {
await axios.post('/api/sync/asset/run', {
action: processAction,
scope: processScope,
target_phase: targetPhase,
limit_count: batchLimitCount,
sort_order: sortOrder,
skip_completed: skipCompleted,
skip_failed: skipFailed,
skip_preceding_failed: skipPrecedingFailed,
skip_preceding_uncompleted: skipPrecedingUncompleted,
});
fetchProcessStatus();
startProcessPolling();
} catch (e: any) {
console.error(e);
setProcessError(e.response?.data || '启动下载与解析任务失败。');
setProcessError(e.response?.data || '启动批量任务失败。');
}
};
@@ -181,12 +242,6 @@ export function SyncPanel() {
useEffect(() => {
fetchProcessStatus();
axios.get<ProcessStatus>('/api/sync/asset/status').then(res => {
if (res.data.active) {
setProcessStatus(res.data);
startProcessPolling();
}
});
return () => {
if (processPollIntervalRef.current) {
@@ -239,6 +294,7 @@ export function SyncPanel() {
});
fetchStatus();
startPolling();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动收割任务失败。');
@@ -248,32 +304,33 @@ export function SyncPanel() {
const percent = status.total > 0 ? Math.min(100, Math.round((status.synced / status.total) * 100)) : 0;
return (
<div className="space-y-6 max-w-3xl mx-auto">
<div className="space-y-6 w-full max-w-3xl mx-auto">
{/* 标题 */}
<div className="flex flex-col gap-1.5">
<h2 className="text-2xl font-bold tracking-tight text-slate-800 font-outfit"></h2>
<p className="text-slate-500 text-sm"> NASA ADS arXiv </p>
<div className="flex flex-col gap-1.5 border-b border-slate-200 pb-3">
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase"></h2>
<p className="text-slate-500 text-xs"> NASA ADS arXiv 线</p>
</div>
{errorMsg && (
<div className="p-4 rounded-xl bg-rose-50 border border-rose-200 flex gap-3 text-xs text-rose-600 items-start">
<AlertTriangle className="w-4 h-4 shrink-0" />
<div>{errorMsg}</div>
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div className="font-semibold">{errorMsg}</div>
</div>
)}
{/* 控制面板卡片 */}
<div className="glass p-6 rounded-2xl space-y-6">
<div className="console-panel p-6 rounded-xl space-y-6 relative overflow-hidden">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-600 block flex justify-between items-center">
<span> / (Query)</span>
<label className="text-xs font-bold text-slate-700 block flex justify-between items-center">
<span> (Query)</span>
<button
type="button"
onClick={() => setShowBuilder(!showBuilder)}
className="text-[10px] text-purple-600 hover:underline"
className="text-xs text-sky-600 hover:text-sky-750 font-bold flex items-center gap-1"
>
{showBuilder ? '隐藏构造器' : '高级构造器'}
<SlidersHorizontal className="w-3.5 h-3.5" />
{showBuilder ? '隐藏高级构造' : '高级检索构造'}
</button>
</label>
<input
@@ -282,16 +339,16 @@ export function SyncPanel() {
onChange={e => setQuery(e.target.value)}
disabled={status.active}
placeholder="例如: hot subdwarf, Gaia BH1..."
className="w-full px-4 py-2.5 rounded-xl bg-white/60 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition-all text-sm"
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"
/>
<div className="text-[10px] text-slate-400 flex flex-wrap gap-x-2.5 gap-y-0.5 px-0.5">
<span>:</span>
<span><code className="bg-slate-100/80 px-1 py-0.2 rounded font-mono text-[9px]">author:"Althaus" AND year:2020-2023</code></span>
<div className="text-[11px] text-slate-450 flex flex-wrap gap-x-2.5 px-0.5 mt-1">
<span>:</span>
<span><code className="text-slate-700 bg-slate-100 px-1 py-0.2 rounded font-mono">author:"Althaus" AND year:2020-2023</code></span>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-600 block"> (Source)</label>
<label className="text-xs font-bold text-slate-700 block"></label>
<div className="flex gap-2">
{[
{ id: 'all', label: '全部' },
@@ -303,10 +360,10 @@ export function SyncPanel() {
type="button"
disabled={status.active}
onClick={() => setSource(src.id as any)}
className={`flex-1 py-2.5 rounded-xl text-xs font-medium border transition-all ${
className={`flex-1 py-2 rounded-lg text-xs font-bold border transition-all ${
source === src.id
? 'bg-purple-600/10 text-purple-600 border-purple-500/30'
: 'bg-white/60 text-slate-600 border-slate-200 hover:bg-slate-50'
? 'bg-sky-50 border-sky-300 text-sky-700 shadow-sm'
: 'btn-console btn-console-secondary'
}`}
>
{src.label}
@@ -318,15 +375,15 @@ export function SyncPanel() {
{/* 动态表单生成器 */}
{showBuilder && (
<div className="p-4 rounded-xl bg-slate-50/70 border border-slate-200/60 space-y-3.5 transition-all">
<div className="p-4 rounded-lg bg-slate-50 border border-slate-200 space-y-3.5 transition-all">
<div className="text-xs font-bold text-slate-700 flex justify-between items-center">
<span></span>
<span></span>
<button
type="button"
onClick={handleAddRule}
className="text-[10px] text-purple-600 hover:underline"
className="text-xs text-sky-600 hover:text-sky-700 font-bold"
>
+
+
</button>
</div>
@@ -337,26 +394,26 @@ export function SyncPanel() {
<select
value={rule.op}
onChange={e => handleRuleChange(idx, 'op', e.target.value)}
className="bg-white border border-slate-200 rounded-lg px-2 py-1.5 text-xs text-slate-600 focus:outline-none focus:border-purple-500 w-20"
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>
<option value="AND"> (AND)</option>
<option value="OR"> (OR)</option>
<option value="NOT"> (NOT)</option>
</select>
) : (
<div className="w-20 text-center text-xs text-slate-400 font-medium">:</div>
<div className="w-24 text-center text-xs text-slate-500 font-semibold"></div>
)}
<select
value={rule.field}
onChange={e => handleRuleChange(idx, 'field', e.target.value)}
className="bg-white border border-slate-200 rounded-lg px-2.5 py-1.5 text-xs text-slate-600 focus:outline-none focus:border-purple-500 w-32"
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"> (all)</option>
<option value="title"> (title)</option>
<option value="author"> (author)</option>
<option value="abs"> (abs)</option>
<option value="year"> (year)</option>
<option value="all"></option>
<option value="title"></option>
<option value="author"></option>
<option value="abs"></option>
<option value="year"></option>
</select>
<input
@@ -368,16 +425,16 @@ export function SyncPanel() {
? '例如: 2020-2023 或 2022'
: rule.field === 'author'
? '例如: Althaus'
: '输入检索词...'
: '输入检索词...'
}
className="flex-1 px-3 py-1.5 rounded-lg bg-white border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 text-xs"
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 && (
<button
type="button"
onClick={() => handleRemoveRule(idx)}
className="text-slate-400 hover:text-rose-500 text-xs px-2 py-1.5"
className="text-red-655 hover:text-red-700 text-xs font-bold px-2 py-1.5"
>
</button>
@@ -388,18 +445,18 @@ export function SyncPanel() {
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-2 border-t border-slate-200">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-600 block flex items-center justify-between">
<span></span>
<span className="text-[10px] text-slate-400 font-normal"></span>
<label className="text-xs font-bold text-slate-700 block flex items-center justify-between">
<span></span>
<span className="text-[11px] text-slate-450 font-normal"> API</span>
</label>
<input
type="number"
value={limit}
disabled={status.active}
onChange={e => setLimit(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-4 py-2.5 rounded-xl bg-white/60 border border-slate-200 text-slate-800 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition-all text-sm"
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"
/>
</div>
@@ -408,30 +465,30 @@ export function SyncPanel() {
type="button"
disabled={status.active || estimating}
onClick={handleEstimate}
className="flex-1 py-2.5 rounded-xl bg-white border border-slate-200 hover:bg-slate-50 text-slate-700 text-xs font-semibold flex items-center justify-center gap-2 transition-all disabled:opacity-40"
className="flex-1 py-2 rounded-lg bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 text-xs font-bold flex items-center justify-center gap-2 transition-all disabled:opacity-40"
>
{estimating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
</button>
<button
type="button"
disabled={status.active || !query.trim()}
onClick={handleStartHarvest}
className="flex-1 py-2.5 rounded-xl bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white text-xs font-semibold flex items-center justify-center gap-2 transition-all disabled:opacity-40 shadow-lg shadow-purple-500/20"
className="btn-console btn-console-primary flex-1 py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 disabled:opacity-40"
>
<Play className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 预估结果展示 */}
{/* 预估结果 */}
{estimatedCount !== null && !status.active && (
<div className="p-4 rounded-xl bg-indigo-50/50 border border-indigo-200/50 flex gap-3 text-xs text-indigo-700 items-center">
<Info className="w-4 h-4 shrink-0" />
<div className="p-4 rounded-lg bg-sky-50 border border-sky-200 flex gap-3 text-xs text-sky-850 items-center">
<Info className="w-4 h-4 shrink-0 text-sky-600" />
<div>
<strong className="text-sm text-indigo-900">{estimatedCount}</strong>
{estimatedCount > limit ? ` 设定的上限为 ${limit} 篇,系统将只拉取${limit} 篇。` : ' 将拉取全部文献。'}
<strong className="text-sky-900 font-bold">{estimatedCount}</strong>
{estimatedCount > limit ? ` 限制最大同步${limit}元数据` : ' 将全部进行同步。'}
</div>
</div>
)}
@@ -439,134 +496,181 @@ export function SyncPanel() {
{/* 实时同步进度 */}
{(status.active || status.synced > 0) && (
<div className="glass p-6 rounded-2xl space-y-4">
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4">
<div className="flex justify-between items-center">
<div>
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-2">
<h3 className="text-xs font-bold text-slate-800 flex items-center gap-2">
{status.active ? (
<>
<Loader className="w-4 h-4 text-purple-600 animate-spin" />
<span>...</span>
<Loader className="w-4 h-4 text-sky-600 animate-spin" />
<span>...</span>
</>
) : (
<>
<CheckCircle className="w-4 h-4 text-emerald-500" />
<span></span>
<CheckCircle className="w-4 h-4 text-emerald-600" />
<span></span>
</>
)}
</h3>
<p className="text-slate-500 text-xs mt-1">
: <code className="bg-slate-100 px-1 py-0.5 rounded font-mono">{status.query}</code> : {status.source === 'all' ? '全部' : status.source === 'ads' ? 'NASA ADS' : 'arXiv'}
<p className="text-slate-500 text-[11px] mt-1.5 font-semibold">
: <code className="bg-slate-100 px-1 py-0.5 rounded text-slate-700">{status.query}</code> : {status.source === 'all' ? '全部' : status.source === 'ads' ? 'NASA ADS' : 'arXiv'}
</p>
</div>
<span className="text-sm font-bold text-purple-600">{status.synced} / {status.total}</span>
<span className="text-xs font-bold text-sky-700">{status.synced} / {status.total} </span>
</div>
<div className="w-full h-3 rounded-full bg-slate-100 overflow-hidden border border-slate-200/50">
<div className="w-full h-3 rounded-full bg-slate-100 overflow-hidden border border-slate-200">
<div
className="h-full bg-gradient-to-r from-purple-500 to-indigo-600 transition-all duration-500"
className="h-full bg-sky-600 transition-all duration-500 shadow-sm"
style={{ width: `${percent}%` }}
/>
</div>
{status.active && status.source === 'all' || status.source === 'arxiv' ? (
<div className="p-3 rounded-lg bg-amber-50/50 border border-amber-200/30 text-[10px] text-amber-700">
💡 arXiv ( 3 )
{status.active && (status.source === 'all' || status.source === 'arxiv') ? (
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-700">
💡 arXiv 3000
</div>
) : null}
</div>
)}
{/* 批量下载与解析 */}
<div className="glass p-6 rounded-2xl space-y-6">
{/* 批量任务 */}
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-6 relative overflow-hidden">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-2 font-outfit">
<Download className="w-4 h-4 text-purple-600" />
<span> (Bulk Download & Extraction)</span>
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<Download className="w-4 h-4 text-sky-600" />
<span>线</span>
</h3>
<p className="text-slate-500 text-xs">
(PDF/HTML) (Markdown)
PDF/HTML Markdown
</p>
</div>
{processError && (
<div className="p-4 rounded-xl bg-rose-50 border border-rose-200 flex gap-3 text-xs text-rose-600 items-start">
<AlertTriangle className="w-4 h-4 shrink-0" />
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>{processError}</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-600 block"> (Action)</label>
<div className="flex gap-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<div className="flex flex-col gap-1.5">
{[
{ id: 'all', label: '下载并解析' },
{ id: 'download', label: '仅下载文献' },
{ id: 'parse', label: '仅解析文献' },
].map(act => (
<button
key={act.id}
type="button"
disabled={processStatus.active}
onClick={() => setProcessAction(act.id as any)}
className={`flex-1 py-2.5 rounded-xl text-xs font-medium border transition-all ${
processAction === act.id
? 'bg-purple-600/10 text-purple-600 border-purple-500/30'
: 'bg-white/60 text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
>
{act.label}
</button>
{ id: 'download', label: '下载' },
{ id: 'parse', label: '解析' },
{ id: 'translate', label: '翻译' },
].map(phase => (
<label key={phase.id} className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="radio"
name="targetPhase"
checked={targetPhase === phase.id}
disabled={processStatus.active}
onChange={() => setTargetPhase(phase.id as any)}
className="text-sky-650 focus:ring-sky-500"
/>
<span>{phase.label}</span>
</label>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-600 block"> (Scope)</label>
<div className="flex gap-2">
{[
{ id: 'all', label: '全部文献' },
{ id: 'undownloaded', label: '仅未下载' },
{ id: 'unparsed', label: '仅未解析' },
].map(opt => (
<button
key={opt.id}
type="button"
disabled={processStatus.active}
onClick={() => setProcessScope(opt.id as any)}
className={`flex-1 py-2.5 rounded-xl text-xs font-medium border transition-all ${
processScope === opt.id
? 'bg-purple-600/10 text-purple-600 border-purple-500/30'
: 'bg-white/60 text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
</div>
<label className="text-xs font-bold text-slate-700 block"> (DOCS)</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"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<select
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>
</div>
</div>
<div className="flex justify-end pt-2">
<div className="w-full md:w-1/2 flex">
<div className="space-y-2 border-t border-slate-100 pt-4">
<label className="text-xs font-bold text-slate-700 block"></label>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipCompleted}
disabled={processStatus.active}
onChange={e => setSkipCompleted(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipFailed}
disabled={processStatus.active}
onChange={e => setSkipFailed(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipPrecedingFailed}
disabled={processStatus.active}
onChange={e => setSkipPrecedingFailed(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipPrecedingUncompleted}
disabled={processStatus.active}
onChange={e => setSkipPrecedingUncompleted(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
</div>
</div>
<div className="flex justify-end pt-2 border-t border-slate-100">
<div className="w-full md:w-1/3 flex">
{processStatus.active ? (
<button
type="button"
onClick={handleStopProcess}
className="w-full py-2.5 rounded-xl bg-rose-600 hover:bg-rose-500 text-white text-xs font-semibold flex items-center justify-center gap-2 transition-all shadow-lg shadow-rose-500/20"
className="w-full py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-xs font-bold flex items-center justify-center gap-2 transition-all shadow-sm"
>
<StopCircle className="w-3.5 h-3.5" />
</button>
) : (
<button
type="button"
onClick={handleStartProcess}
className="w-full py-2.5 rounded-xl bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white text-xs font-semibold flex items-center justify-center gap-2 transition-all shadow-lg shadow-purple-500/20"
className="btn-console btn-console-primary w-full py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2"
>
<Play className="w-3.5 h-3.5" />
</button>
)}
</div>
@@ -574,60 +678,63 @@ export function SyncPanel() {
{/* 进度与终端日志展示 */}
{(processStatus.active || processStatus.total > 0) && (
<div className="space-y-4 pt-2 border-t border-slate-200/50">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 下载进度 */}
{(!processStatus.action || processStatus.action === 'all' || processStatus.action === 'download') && (
<div className={`space-y-1.5 ${(!processStatus.action || processStatus.action === 'all') ? '' : 'col-span-2'}`}>
<div className="flex justify-between text-xs">
<span className="font-bold text-slate-700 flex items-center gap-1">
<Download className="w-3.5 h-3.5 text-blue-500" />
<div className="space-y-4 pt-4 border-t border-slate-200">
<div className="space-y-1.5">
<div className="flex justify-between text-xs font-bold text-slate-600">
<span className="flex items-center gap-1">
{processStatus.action === 'download' && <Download className="w-3.5 h-3.5 text-sky-600" />}
{processStatus.action === 'parse' && <FileText className="w-3.5 h-3.5 text-emerald-600" />}
{processStatus.action === 'translate' && <RefreshCw className="w-3.5 h-3.5 text-indigo-600" />}
{processStatus.action === 'download' ? '正文离线下载进度' : processStatus.action === 'parse' ? '结构化排版解析进度' : '中英双语对照翻译进度'}
</span>
<span>
{processStatus.action === 'download' ? processStatus.downloaded : processStatus.parsed} / {processStatus.total}
{((processStatus.action === 'download' && processStatus.download_failed > 0) ||
(processStatus.action !== 'download' && processStatus.parse_failed > 0)) && (
<span className="text-red-500 ml-2 font-bold">
( {processStatus.action === 'download' ? processStatus.download_failed : processStatus.parse_failed} )
</span>
<span className="text-slate-500 font-medium">{processStatus.downloaded} / {processStatus.total}</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-100 overflow-hidden border border-slate-200/30">
<div
className="h-full bg-gradient-to-r from-blue-500 to-indigo-500 transition-all duration-300"
style={{ width: `${processStatus.total > 0 ? Math.min(100, Math.round((processStatus.downloaded / processStatus.total) * 100)) : 0}%` }}
/>
</div>
</div>
)}
{/* 解析进度 */}
{(!processStatus.action || processStatus.action === 'all' || processStatus.action === 'parse') && (
<div className={`space-y-1.5 ${(!processStatus.action || processStatus.action === 'all') ? '' : 'col-span-2'}`}>
<div className="flex justify-between text-xs">
<span className="font-bold text-slate-700 flex items-center gap-1">
<FileText className="w-3.5 h-3.5 text-purple-500" />
</span>
<span className="text-slate-500 font-medium">{processStatus.parsed} / {processStatus.total}</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-100 overflow-hidden border border-slate-200/30">
<div
className="h-full bg-gradient-to-r from-purple-500 to-indigo-500 transition-all duration-300"
style={{ width: `${processStatus.total > 0 ? Math.min(100, Math.round((processStatus.parsed / processStatus.total) * 100)) : 0}%` }}
/>
</div>
</div>
)}
)}
</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-100 overflow-hidden border border-slate-200">
<div
className={`h-full transition-all duration-300 ${
processStatus.action === 'download' ? 'bg-sky-600' : processStatus.action === 'parse' ? 'bg-emerald-600' : 'bg-indigo-600'
}`}
style={{
width: `${
processStatus.total > 0
? Math.min(
100,
Math.round(
((processStatus.action === 'download'
? processStatus.downloaded + processStatus.download_failed
: processStatus.parsed + processStatus.parse_failed) /
processStatus.total) *
100
)
)
: 0
}%`,
}}
/>
</div>
</div>
{processStatus.active && processStatus.current_bibcode && (
<div className="text-[11px] text-slate-500 flex items-center gap-1.5">
<Loader className="w-3 h-3 text-purple-600 animate-spin" />
<span>: <code className="bg-slate-100 px-1 py-0.5 rounded font-mono font-bold text-slate-700">{processStatus.current_bibcode}</code></span>
<div className="text-xs font-bold text-slate-600 flex items-center gap-2">
<Loader className="w-3.5 h-3.5 text-sky-600 animate-spin" />
<span>: <code className="bg-slate-100 px-2 py-0.5 rounded font-mono font-bold text-slate-800 border border-slate-200">{processStatus.current_bibcode}</code></span>
</div>
)}
{/* 滚动日志终端 */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-slate-600 block"></label>
<div className="bg-slate-950 text-slate-300 font-mono text-[10px] p-4 rounded-xl h-48 overflow-y-auto border border-slate-800 space-y-1 scrollbar-thin scrollbar-thumb-slate-800">
<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">
{processStatus.logs.length === 0 ? (
<div className="text-slate-500 italic">...</div>
<div className="text-slate-400 italic">...</div>
) : (
processStatus.logs.map((log, idx) => (
<div key={idx} className="whitespace-pre-wrap leading-relaxed">
@@ -641,6 +748,66 @@ 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">
<SlidersHorizontal className="w-4 h-4 text-sky-600 animate-pulse" />
<span></span>
</h3>
<p className="text-slate-500 text-xs">
</p>
</div>
{syncQueries.length === 0 ? (
<div className="text-center py-6 text-xs text-slate-400 italic">
</div>
) : (
<div className="divide-y divide-slate-100 max-h-80 overflow-y-auto pr-1 scrollbar-thin">
{syncQueries.map(sq => (
<div key={sq.id} className="py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs">
<div className="space-y-1">
<div className="font-bold text-slate-800 break-all select-all font-mono">
{sq.query}
</div>
<div className="text-[10px] text-slate-500 flex items-center gap-2.5 font-semibold">
<span>: <strong className="text-slate-700">{sq.source === 'all' ? '全部' : sq.source === 'ads' ? 'NASA ADS' : 'arXiv'}</strong></span>
<span>: <strong className="text-slate-700">{sq.limit_count}</strong></span>
<span>: <strong className="text-slate-700">{sq.last_run}</strong></span>
</div>
</div>
<div className="flex gap-2 shrink-0 self-end sm:self-center">
<button
type="button"
onClick={() => handleReuseQuery(sq)}
className="px-2.5 py-1.5 rounded-lg bg-slate-50 border border-slate-250 text-slate-650 hover:bg-slate-100 hover:text-slate-800 transition-all text-xs font-bold cursor-pointer"
>
</button>
<button
type="button"
disabled={status.active}
onClick={() => handleQuickSync(sq)}
className="px-2.5 py-1.5 rounded-lg bg-sky-50 border border-sky-200 text-sky-700 hover:bg-sky-100 transition-all text-xs font-bold cursor-pointer disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => handleDeleteQuery(sq.id)}
className="px-2.5 py-1.5 rounded-lg bg-red-50 border border-red-200 text-red-750 hover:bg-red-100 transition-all text-xs font-bold cursor-pointer"
>
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+61 -19
View File
@@ -1,20 +1,32 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
color-scheme: light;
--bg-main: #f1f5f9;
--bg-card: #ffffff;
--bg-sidebar: #f8fafc;
--text-main: #0f172a;
--text-muted: #475569;
--accent-blue: #0284c7;
--accent-navy: #1e3a8a;
--border-clean: #e2e8f0;
}
body {
margin: 0;
background-color: #f8fafc;
color: #0f172a;
background-color: var(--bg-main);
color: var(--text-main);
min-height: 100vh;
overflow: hidden;
position: relative;
}
/* Custom premium scrollbar styling */
/* Custom premium scrollbar styling for light mode */
::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -30,25 +42,55 @@ body {
background: #94a3b8;
}
/* Glassmorphism utility for light mode */
.glass {
background: rgba(255, 255, 255, 0.45);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.03);
/* Premium clean panel cards */
.console-panel {
background: var(--bg-card);
border: 1px solid var(--border-clean);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}
.glass-accent {
border: 1px solid rgba(192, 132, 252, 0.2);
.console-panel-active {
border-color: var(--accent-blue);
box-shadow: 0 0 0 1px var(--accent-blue), 0 4px 6px -1px rgba(0, 0, 0, 0.05);
}
/* Animations */
@keyframes pulse-slow {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
/* High contrast clean console button */
.btn-console {
background: #ffffff;
border: 1px solid #cbd5e1;
color: #334155;
font-weight: 500;
transition: all 0.2s ease;
}
.animate-pulse-slow {
animation: pulse-slow 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
.btn-console:hover:not(:disabled) {
background: #f8fafc;
border-color: #94a3b8;
color: #0f172a;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.btn-console-primary {
background: var(--accent-blue);
border: 1px solid var(--accent-blue);
color: #ffffff;
}
.btn-console-primary:hover:not(:disabled) {
background: #0369a1;
border-color: #0369a1;
color: #ffffff;
box-shadow: 0 2px 4px 0 rgba(2, 132, 199, 0.2);
}
.btn-console-secondary {
background: #f1f5f9;
border: 1px solid #e2e8f0;
color: #334155;
}
.btn-console-secondary:hover:not(:disabled) {
background: #e2e8f0;
color: #0f172a;
}
+10
View File
@@ -15,6 +15,7 @@ export interface StandardPaper {
is_downloaded: boolean;
has_markdown: boolean;
has_translation: boolean;
doctype: string;
}
export interface CitationNetwork {
@@ -24,6 +25,7 @@ export interface CitationNetwork {
reference_count: number;
references: string[];
citations: string[];
citation_counts?: Record<string, number>;
}
export interface NoteRecord {
@@ -35,3 +37,11 @@ export interface NoteRecord {
selected_text: string;
created_at: string;
}
export interface SavedSyncQuery {
id: number;
query: string;
source: string;
limit_count: number;
last_run: string;
}