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:
Generated
+276
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user