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:
parent
e13fa2ad40
commit
cd6af4f995
@ -24,3 +24,9 @@ MINERU_API_KEY=your_mineru_api_key
|
||||
LIBRARY_DIR=./library
|
||||
PORT=8000
|
||||
DATABASE_URL=sqlite://astro_research.db
|
||||
|
||||
# Logging Configuration (Pretty console and rolling file logging)
|
||||
LOG_LEVEL=info,astroresearch=debug
|
||||
LOG_FORMAT=pretty
|
||||
LOG_OUTPUTS=stdout,file
|
||||
LOG_DIR=./logs
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -12,6 +12,7 @@
|
||||
|
||||
# Local literature storage (contains downloaded PDFs, HTML, Markdowns and Translations)
|
||||
/library/
|
||||
/logs/
|
||||
|
||||
# IDEs and OS files
|
||||
.vscode/
|
||||
|
||||
462
Cargo.lock
generated
462
Cargo.lock
generated
@ -2,6 +2,23 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@ -54,8 +71,9 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"html2md",
|
||||
"quick-xml",
|
||||
"rand",
|
||||
@ -63,15 +81,18 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"sqlx",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tower-http 0.5.2",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -209,6 +230,16 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@ -227,6 +258,15 @@ version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
|
||||
dependencies = [
|
||||
"libbz2-rs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.63"
|
||||
@ -234,6 +274,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@ -263,6 +305,22 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@ -279,6 +337,18 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@ -334,6 +404,12 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@ -343,6 +419,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
@ -358,6 +443,24 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.12"
|
||||
@ -383,13 +486,37 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deflate64"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
@ -409,12 +536,25 @@ version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
@ -504,6 +644,17 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
@ -684,10 +835,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -770,7 +923,7 @@ version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -779,7 +932,16 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -870,6 +1032,15 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
@ -1093,6 +1264,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
@ -1115,7 +1295,7 @@ dependencies = [
|
||||
"combine",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@ -1147,6 +1327,16 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.99"
|
||||
@ -1174,6 +1364,12 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libbz2-rs-sys"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
@ -1242,6 +1438,15 @@ version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rust2"
|
||||
version = "0.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce716bf1a316f47a280fc76295f6495b5bea4752bca01c3b3885e101b1c23c02"
|
||||
dependencies = [
|
||||
"sha2 0.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@ -1296,7 +1501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1327,6 +1532,16 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
@ -1510,6 +1725,16 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
"hmac 0.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@ -1617,6 +1842,12 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppmd-rust"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@ -1841,8 +2072,8 @@ version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"digest",
|
||||
"const-oid 0.9.6",
|
||||
"digest 0.10.7",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
@ -2079,8 +2310,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2090,8 +2332,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2125,10 +2378,16 @@ version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@ -2231,10 +2490,10 @@ dependencies = [
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"sqlformat",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@ -2270,7 +2529,7 @@ dependencies = [
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"sqlx-core",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
@ -2294,7 +2553,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"dotenvy",
|
||||
"either",
|
||||
"futures-channel",
|
||||
@ -2304,7 +2563,7 @@ dependencies = [
|
||||
"generic-array",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"itoa",
|
||||
"log",
|
||||
"md-5",
|
||||
@ -2314,12 +2573,12 @@ dependencies = [
|
||||
"rand",
|
||||
"rsa",
|
||||
"serde",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"sha1 0.10.6",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
"whoami",
|
||||
]
|
||||
@ -2344,7 +2603,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"home",
|
||||
"itoa",
|
||||
"log",
|
||||
@ -2354,11 +2613,11 @@ dependencies = [
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
"whoami",
|
||||
]
|
||||
@ -2435,6 +2694,12 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "symlink"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@ -2528,7 +2793,16 @@ version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2542,6 +2816,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
@ -2559,6 +2844,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"js-sys",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
@ -2762,6 +3048,19 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-appender"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"symlink",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
@ -2794,6 +3093,16 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-serde"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
@ -2804,12 +3113,15 @@ dependencies = [
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
"tracing-serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2818,6 +3130,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@ -2911,6 +3229,17 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@ -3547,8 +3876,81 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"bzip2",
|
||||
"constant_time_eq",
|
||||
"crc32fast",
|
||||
"deflate64",
|
||||
"flate2",
|
||||
"getrandom 0.4.2",
|
||||
"hmac 0.13.0",
|
||||
"indexmap",
|
||||
"lzma-rust2",
|
||||
"memchr",
|
||||
"pbkdf2",
|
||||
"ppmd-rust",
|
||||
"sha1 0.11.0",
|
||||
"time",
|
||||
"typed-path",
|
||||
"zeroize",
|
||||
"zopfli",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.0.16+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
12
Cargo.toml
12
Cargo.toml
@ -10,10 +10,14 @@ path = "src/lib.rs"
|
||||
name = "astroresearch"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "test_qiniu"
|
||||
path = "scratch/test_qiniu.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
tower-http = { version = "0.5", features = ["cors", "fs", "trace"] }
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono", "json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
@ -23,7 +27,7 @@ quick-xml = { version = "0.31", features = ["serialize"] }
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
futures-util = { version = "0.3", features = ["io"] }
|
||||
rand = "0.8"
|
||||
regex = "1.10"
|
||||
@ -34,3 +38,7 @@ base64 = "0.22"
|
||||
urlencoding = "2.1"
|
||||
url = "2.5"
|
||||
html2md = "0.2"
|
||||
flate2 = "1.1.9"
|
||||
zip = "8.6.0"
|
||||
uuid = { version = "1.23.2", features = ["v4"] }
|
||||
tracing-appender = "0.2.5"
|
||||
|
||||
@ -68,7 +68,7 @@ cp .env.example .env
|
||||
- 🏗️ **[架构设计](docs/architecture.md)**:包含系统宏观流程图与序列图。
|
||||
- 🌐 **[API 接口规范](docs/api.md)**:后端 Axum 路由及 HTTP 接口格式。
|
||||
- 🗄️ **[数据库设计](docs/database.md)**:SQLite 表结构、ER 图与索引优化。
|
||||
- 🎨 **[视觉与交互设计](docs/design.md)**:浅色/深色磨砂玻璃、自研 Canvas 图谱引擎说明。
|
||||
- 🎨 **[视觉与交互设计](docs/design.md)**:高对比度浅色中文控制台、自研 Canvas 图谱引擎说明。
|
||||
- 🛠️ **[排障指南](docs/troubleshooting.md)**:人机校验、解析失败等常见问题解法。
|
||||
- 🚀 **[编译与部署指南](docs/deployment.md)**:单执行文件打包与发布流程。
|
||||
- 🤝 **[参与贡献指南](docs/contributing.md)**:开发规范及单元测试。
|
||||
|
||||
276
dashboard/package-lock.json
generated
276
dashboard/package-lock.json
generated
@ -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 |
@ -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';
|
||||
@ -12,14 +13,45 @@ 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,13 +332,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden text-slate-800">
|
||||
|
||||
{/* 炫酷淡雅背景装饰 */}
|
||||
<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>
|
||||
<div className="flex h-screen overflow-hidden text-slate-800 bg-slate-100 select-text">
|
||||
|
||||
{/* 导航左侧栏 */}
|
||||
<Sidebar
|
||||
@ -316,20 +343,10 @@ 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">
|
||||
<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}
|
||||
@ -357,6 +374,8 @@ export default function App() {
|
||||
openReader={openReader}
|
||||
setActiveTab={setActiveTab}
|
||||
loadCitations={loadCitations}
|
||||
showAlert={showAlert}
|
||||
onShowDetail={(paper) => setDetailBibcode(paper.bibcode)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -364,12 +383,8 @@ export default function App() {
|
||||
<LibraryPanel
|
||||
library={library}
|
||||
fetchLibrary={fetchLibrary}
|
||||
openReader={openReader}
|
||||
setSelectedPaper={setSelectedPaper}
|
||||
setActiveTab={setActiveTab}
|
||||
loadCitations={loadCitations}
|
||||
downloadingBibcodes={downloadingBibcodes}
|
||||
handleDownload={handleDownload}
|
||||
onShowDetail={(paper) => setDetailBibcode(paper.bibcode)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -396,6 +411,7 @@ export default function App() {
|
||||
setNewNoteText={setNewNoteText}
|
||||
handleCreateNote={handleCreateNote}
|
||||
handleDeleteNote={handleDeleteNote}
|
||||
showConfirm={showConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -406,6 +422,7 @@ export default function App() {
|
||||
citationNetwork={citationNetwork}
|
||||
citationHistory={citationHistory}
|
||||
loadCitations={loadCitations}
|
||||
onUncachedClick={setUncachedBibcode}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -413,7 +430,312 @@ export default function App() {
|
||||
<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="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"
|
||||
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" /> 重置星系图
|
||||
<RotateCcw className="w-3.5 h-3.5" /> 重置引用星系图
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</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
|
||||
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="pr-10">
|
||||
<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 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>
|
||||
{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 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>
|
||||
<p className="text-xs text-slate-600 line-clamp-3 leading-relaxed mb-4">{paper.abstract_text}</p>
|
||||
</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="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 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>
|
||||
</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,41 +220,52 @@ 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>
|
||||
<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-[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'
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
笔记 ({notes.length})
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
阅读笔记 ({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 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-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">
|
||||
<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 -mx-1 transition-colors ${
|
||||
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]}
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
>
|
||||
{para}
|
||||
@ -164,26 +275,33 @@ export function ReaderPanel({
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 中文翻译面板 */}
|
||||
<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>
|
||||
{(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>
|
||||
|
||||
{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 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-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">
|
||||
<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]}
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
>
|
||||
{chineseText}
|
||||
@ -191,58 +309,66 @@ export function ReaderPanel({
|
||||
</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>
|
||||
<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">
|
||||
<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-5 h-5 rounded-full border-2 transition-transform ${
|
||||
newNoteColor === color ? 'border-slate-700 scale-110' : 'border-transparent'
|
||||
} ${style.bg.replace('/20', '')}`}
|
||||
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 => (
|
||||
notes.map(note => {
|
||||
const style = NOTE_COLORS[note.highlight_color] || NOTE_COLORS.cyan;
|
||||
return (
|
||||
<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'}`}
|
||||
className={`p-4 rounded-lg border text-xs bg-white border-slate-200 relative group overflow-hidden`}
|
||||
>
|
||||
<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>
|
||||
{/* 彩色左标记条 */}
|
||||
<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-600 transition-colors"
|
||||
className="text-slate-400 hover:text-red-655 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</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>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBuilder(!showBuilder)}
|
||||
className={`px-4 py-4 rounded-xl border text-xs font-semibold transition-all ${
|
||||
className={`px-4 py-3 rounded-lg border text-xs font-bold transition-all shrink-0 flex items-center gap-1.5 ${
|
||||
showBuilder
|
||||
? 'bg-purple-50 border-purple-300 text-purple-600'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
? 'bg-sky-50 border-sky-300 text-sky-700'
|
||||
: 'btn-console'
|
||||
}`}
|
||||
>
|
||||
{showBuilder ? '隐藏生成器' : '高级检索生成器'}
|
||||
<SlidersHorizontal className="w-3.5 h-3.5" />
|
||||
{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"
|
||||
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" /> : null}
|
||||
{searching ? '检索中' : '开始检索'}
|
||||
{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">
|
||||
{/* 选定指示侧条 */}
|
||||
{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-base text-slate-800 line-clamp-1 leading-snug hover:text-purple-600 cursor-pointer"
|
||||
className="font-bold text-sm text-slate-900 hover:text-sky-700 cursor-pointer transition-all line-clamp-2 leading-relaxed"
|
||||
onClick={() => openReader(paper)}
|
||||
>
|
||||
{paper.title}
|
||||
{getDoctypeBadge(paper.doctype)}
|
||||
<span className="align-middle">{paper.title}</span>
|
||||
</h3>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<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,16 +441,16 @@ 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>
|
||||
|
||||
<div className="text-[10px] text-slate-400 flex gap-4 font-mono">
|
||||
<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: {paper.bibcode}</span>
|
||||
{paper.citation_count > 0 && <span>被引: {paper.citation_count}</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>
|
||||
@ -416,23 +461,23 @@ export function SearchPanel({
|
||||
|
||||
{/* 分页控制栏 */}
|
||||
{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) {
|
||||
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"
|
||||
{ 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}
|
||||
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>
|
||||
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"
|
||||
<label className="text-xs font-bold text-slate-700 block">批量处理上限 (DOCS)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={batchLimitCount}
|
||||
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>
|
||||
))}
|
||||
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>
|
||||
<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="w-full h-2 rounded-full bg-slate-100 overflow-hidden border border-slate-200">
|
||||
<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}%` }}
|
||||
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.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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ graph TD
|
||||
Downloader[下载器 services/download.rs]
|
||||
Translator[翻译器 services/translation.rs]
|
||||
Qiniu[七牛云客户端 clients/qiniu.rs]
|
||||
Logging[日志记录器 services/logging.rs]
|
||||
DB[("SQLite / astro_research.db")]
|
||||
end
|
||||
|
||||
@ -143,12 +144,18 @@ sequenceDiagram
|
||||
P->>P: 6e. 转换 GFM Markdown 并恢复 LaTeX 公式
|
||||
P->>P: 6f. 后处理:清除冗余的 margin 空白与前导缩进
|
||||
else 仅有 PDF 文件 (PDF 降级解析)
|
||||
P->>M: 7a. Multipart 格式上传 PDF 至 MinerU 服务
|
||||
M-->>P: 7b. 返回大模型解析出的 Markdown 文本及插图包
|
||||
loop 遍历每一个提取的插图
|
||||
P->>Q: 7c. 上传插图文件并获取七牛云 CDN 域名外链
|
||||
P->>M: 7a. 获取批量预签名上传 URL (POST /file-urls/batch/)
|
||||
M-->>P: 7b. 返回预签名上传 URL 与 Batch ID
|
||||
P->>M: 7c. 上传 PDF 二进制字节流 (PUT 至预签名 URL)
|
||||
loop 轮询任务状态 (每 10s 一次,最多 45 次)
|
||||
P->>M: 7d. 查询提取进度与结果 (GET /extract-results/batch/{id})
|
||||
M-->>P: 7e. 返回处理状态 ("done"/"error"等)
|
||||
end
|
||||
P->>P: 7d. 在 Markdown 中重写插图链接为七牛云 CDN 绝对路径
|
||||
P->>P: 7f. 下载解析结果的 ZIP 压缩包并解压提取
|
||||
loop 遍历每一个提取的插图
|
||||
P->>Q: 7g. 上传插图文件并获取七牛云 CDN 域名外链
|
||||
end
|
||||
P->>P: 7h. 在 Markdown 中重写插图链接为七牛云 CDN 绝对路径
|
||||
end
|
||||
|
||||
P-->>H: 8. 返回清洗转换出的标准英文 Markdown 文本
|
||||
@ -159,7 +166,7 @@ sequenceDiagram
|
||||
|
||||
#### 详细解析说明:
|
||||
1. **HTML 转换为 Markdown 保护公式**:由于 MathJax/LaTeX 在 Markdown 转换中极易被当成普通字符进行转义(例如 `_` 倾斜或 `\` 换行失效),解析器在 HTML 解析前,通过正则将 `$` / `$$` 或 `\(` / `\[` 中的内容全部替换为特定的 UUID 占位符,转换为标准 Markdown 之后,再反向替换恢复公式,确保 LaTeX 渲染无损。
|
||||
2. **PDF 复杂排版降级**:遇到无法直接提取 HTML 的老文献时,调用 MinerU 进行布局分析与公式提取,配合七牛云对象存储实现插图的自动提取、自动图床托管与正文自动替换回写。
|
||||
2. **PDF 复杂排版降级与大文件直传**:遇到无法直接提取 HTML 的老文献时,调用 MinerU 进行布局分析与公式提取。为避免在上传大型 PDF 时触发 API 网关的 `413 Payload Too Large` 错误,系统弃用了传统的 Multipart 表单直接 POST 请求,转而采用**两阶段直传机制**:先请求预签名上传 URL,随后使用 HTTP `PUT` 直接流式传输二进制数据至存储服务,最后通过后台任务轮询 `extract-results` 获取转换完毕的 ZIP 并自动托管插图至七牛云。
|
||||
|
||||
---
|
||||
|
||||
@ -233,6 +240,8 @@ sequenceDiagram
|
||||
- 统一相对图表链接,并集成 MinerU PDF 解析。
|
||||
- **[src/services/translation.rs](../src/services/translation.rs)**:
|
||||
- 利用本地千万字级别的天文学双语词典对原文进行分词匹配,注入系统提示词让 LLM 实现学术级精细翻译。
|
||||
- **[src/services/logging.rs](../src/services/logging.rs)**:
|
||||
- 全局日志记录系统,基于 `tracing-subscriber` 实现了控制台美化日志输出与基于时间的每日滚动日志文件写出,使用上海时区 (+08:00) 格式化时间。
|
||||
- **[dashboard/src/components/CitationGalaxyCanvas.tsx](../dashboard/src/components/CitationGalaxyCanvas.tsx)**:
|
||||
- 基于原生 HTML5 Canvas 开发的轻量级、高性能力导向图星系物理引擎,用于文献引文网络拓扑结构的可视化渲染。
|
||||
|
||||
|
||||
@ -34,13 +34,13 @@
|
||||
|
||||
### Rust 规范 (Backend)
|
||||
- 遵循 Rust 官方标准样式,提交前必须执行 `cargo fmt` 与 `cargo clippy`。
|
||||
- 注释和系统日志建议统一使用中文,便于开发者追踪和阅读。
|
||||
- API handers 中的异常信息请使用 `anyhow` 或 `thiserror` 进行结构化抛出。
|
||||
- 注释和系统日志建议统一使用中文,便于开发者追踪 and 阅读。
|
||||
- API handlers 中的异常信息请使用 `anyhow` 或 `thiserror` 进行结构化抛出。
|
||||
|
||||
### React & TypeScript 规范 (Frontend)
|
||||
- 严格遵循 `React 18/19` 函数式组件写法,使用 React Hooks 维护状态。
|
||||
- 为保证生产编译成功,务必开启类型安全限制(如在导入纯类型时显式使用 `import type { ... }`)。
|
||||
- CSS 层面使用 Tailwind CSS 统一的磨砂玻璃体 (Glassmorphism) 及响应式布局,所有间距、颜色严格使用 CSS 变量控制以支持主题切换。
|
||||
- CSS 层面使用 Tailwind CSS 统一的高对比度浅色纯中文控制台风格,所有布局、间距、颜色需遵循实边框、高对比度黑白字及高雅按钮样式(`.btn-console` 等),以保障学术沉浸与阅读的高保真性。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -6,14 +6,14 @@ AstroResearch 的前端界面设计坚持“未来科技感与学术沉浸”的
|
||||
|
||||
## 1. 视觉系统 (Visual Palette)
|
||||
|
||||
### 1.1 精致双色主题
|
||||
### 1.1 高对比度浅色纯中文控制台
|
||||
|
||||
AstroResearch 完美适配了深色与浅色模式。使用精挑细选的 HSL 柔和色彩代替刺眼的饱和色:
|
||||
AstroResearch 前端目前重构并统一为**高对比度浅色纯中文学术控制台**,以确保长久学术检索与对照阅读的沉浸体验:
|
||||
|
||||
| 模式 | 背景色 | 主文本色 | 卡片容器 | 毛玻璃效果 (Glassmorphism) |
|
||||
| 元素 | 背景色 / 边框色 | 主文本色 | 交互按钮与状态指示 | 设计风格 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **深色模式** | 深夜极光黑 (`#090d16`) | 纯净雪白 (`#f8fafc`) | 磨砂深灰 (`bg-slate-900/60`) | 边框: `border-slate-800/80`, 模糊: `backdrop-blur-md` |
|
||||
| **浅色模式** | 雅致灰石色 (`#f8fafc`) | 深石板色 (`#0f172a`) | 磨砂亮白 (`bg-white/60`) | 边框: `border-slate-200/80`, 模糊: `backdrop-blur-md` |
|
||||
| **主背景** | 纯净冷灰白 (`#f1f5f9`) | 深石板灰/接近纯黑 (`#0f172a`) | 控制台按钮 (`.btn-console` / `.btn-console-primary`) | 扁平极简实边框设计 |
|
||||
| **卡片/容器** | 纯白背景 (`#ffffff`),实线灰色边框 (`#e2e8f0`) | 辅助灰 (`#64748b`) | 指示灯:深宝石绿 (就绪) / 灰石色 (未解析) | 微卡片投影效果 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -35,9 +35,21 @@
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据库与运行环境问题 (Runtime & DB Issues)
|
||||
## 3. 检索与显示问题 (Search & Display Issues)
|
||||
|
||||
### 3.1 启动提示 "Database Migration Failed"
|
||||
### 3.1 无法通过特定的 arXiv ID 或 DOI 检索到已导入的文献
|
||||
- **原因**:历史版本前端本地检索仅匹配了文献的标题、作者、摘要和 `bibcode`,未对 `arxiv_id` 和 `doi` 进行全局检测过滤。
|
||||
- **排障/解决**:现已在馆藏过滤逻辑中追加了 `arxiv_id` 和 `doi` 的字段检索。如果遇到由于升级导致的缓存错乱,可点击顶部的 “重新同步馆藏” 刷新本地缓存状态。
|
||||
|
||||
### 3.2 文献详情页的 BIBCODE 与 ARXIV ID 显示完全相同的值(如均显示 '0710.0600')
|
||||
- **原因**:当文献通过 arXiv 单独直接导入时,后端处理器无法预知其关联的 ADS Bibcode。为确保数据一致,系统在 SQLite 中临时将 `bibcode` 和 `arxiv_id` 均用 arXiv ID 填充,直到后续 ADS 元数据同步匹配成功将其“升级”。
|
||||
- **解决机制**:前端已实现了防重与标识规整机制。如果检测到 `bibcode === arxiv_id`,卡片页将前缀格式化为 `arXiv:xxxx.xxxx` 形式,而文献元数据详情弹窗中 `BIBCODE` 则会直接优雅呈现为 **“暂无”** 状态,避免视觉歧义。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据库与运行环境问题 (Runtime & DB Issues)
|
||||
|
||||
### 4.1 启动提示 "Database Migration Failed"
|
||||
- **原因**:本地 SQLite 数据库文件 `astro_research.db` 出现并发锁死或版本 schema 冲突。
|
||||
- **解决方法**:
|
||||
1. 备份并临时删除根目录下的 `astro_research.db` 数据库文件。
|
||||
|
||||
3
migrations/20260608000002_add_doctype.sql
Normal file
3
migrations/20260608000002_add_doctype.sql
Normal file
@ -0,0 +1,3 @@
|
||||
-- migrations/20260608000002_add_doctype.sql
|
||||
|
||||
ALTER TABLE papers ADD COLUMN doctype TEXT;
|
||||
12
migrations/20260608000003_sync_features.sql
Normal file
12
migrations/20260608000003_sync_features.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- migrations/20260608000003_sync_features.sql
|
||||
|
||||
-- 批量同步检索配置表 (支持唯一去重机制)
|
||||
CREATE TABLE IF NOT EXISTS sync_queries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
query TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
limit_count INTEGER NOT NULL,
|
||||
last_run DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(query, source, limit_count) -- 去重约束
|
||||
);
|
||||
BIN
scratch/__pycache__/analyze_failures.cpython-312.pyc
Normal file
BIN
scratch/__pycache__/analyze_failures.cpython-312.pyc
Normal file
Binary file not shown.
321
scratch/failed_results.txt
Normal file
321
scratch/failed_results.txt
Normal file
@ -0,0 +1,321 @@
|
||||
=== 403 FORBIDDEN ===
|
||||
### Monthly Notices of the Royal Astronomical Society (共 26 篇)
|
||||
- `2010MNRAS.401.1080A`
|
||||
- `2010MNRAS.401.1850K`
|
||||
- `2010MNRAS.406.2701K`
|
||||
- `2011MNRAS.412..487K`
|
||||
- `2011MNRAS.415.3042K`
|
||||
- `2012MNRAS.419..452Z`
|
||||
- `2012MNRAS.421.3238K`
|
||||
- `2013MNRAS.431..240O`
|
||||
- `2013MNRAS.436.1408Q`
|
||||
- `2014MNRAS.445.4247K`
|
||||
- `2015MNRAS.451.3986K`
|
||||
- `2015MNRAS.453.1879K`
|
||||
- `2016MNRAS.457..723K`
|
||||
- `2016MNRAS.459.4343K`
|
||||
- `2017MNRAS.467.3963K`
|
||||
- `2018MNRAS.481.2721B`
|
||||
- `2019MNRAS.482..758S`
|
||||
- `2019MNRAS.485.4330K`
|
||||
- `2019MNRAS.489.1556B`
|
||||
- `2019MNRAS.490.1283K`
|
||||
- `2020MNRAS.493.5162R`
|
||||
- `2021MNRAS.508..560K`
|
||||
- `2022MNRAS.516.1509K`
|
||||
- `2023MNRAS.525..183S`
|
||||
- `2023MNRAS.525.1342R`
|
||||
- `2026MNRAS.548ag689.`
|
||||
### American Astronomical Society Meeting Abstracts #242 (共 7 篇)
|
||||
- `2023AAS...24220105B`
|
||||
- `2023AAS...24230301D`
|
||||
- `2023AAS...24230501K`
|
||||
- `2023AAS...24230502S`
|
||||
- `2023AAS...24230503L`
|
||||
- `2023AAS...24233706D`
|
||||
- `2023AAS...24240003S`
|
||||
### International Conference on Binaries: in celebration of Ron Webbink's 65th Birthday (共 4 篇)
|
||||
- `2010AIPC.1314...67G`
|
||||
- `2010AIPC.1314...73W`
|
||||
- `2010AIPC.1314...85H`
|
||||
- `2010AIPC.1314...91S`
|
||||
### 17th European White Dwarf Workshop (共 4 篇)
|
||||
- `2010AIPC.1273..243S`
|
||||
- `2010AIPC.1273..255L`
|
||||
- `2010AIPC.1273..259B`
|
||||
- `2010AIPC.1273..263G`
|
||||
### American Astronomical Society Meeting Abstracts #245 (共 3 篇)
|
||||
- `2025AAS...24540303B`
|
||||
- `2025AAS...24540312T`
|
||||
- `2025AAS...24540313K`
|
||||
### American Astronomical Society Meeting Abstracts #237 (共 3 篇)
|
||||
- `2021AAS...23714004P`
|
||||
- `2021AAS...23734904W`
|
||||
- `2021AAS...23755001C`
|
||||
### Journal of Physics Conference Series (共 3 篇)
|
||||
- `2009JPhCS.172a2015H`
|
||||
- `2016JPhCS.728g2023Z`
|
||||
- `2019JPhCS1380a2095S`
|
||||
### The Astrophysical Journal (共 2 篇)
|
||||
- `2011ApJ...737L..27R`
|
||||
- `2026ApJ...997...58W`
|
||||
### Binary Systems, their Evolution and Environments (共 2 篇)
|
||||
- `2014bsee.confE..25H`
|
||||
- `2014bsee.confP..25S`
|
||||
### Publications of the Astronomical Society of the Pacific (共 2 篇)
|
||||
- `1998PASP..110..906H`
|
||||
- `2001PASP..113..490W`
|
||||
### IUE Proposal (共 2 篇)
|
||||
- `1987iue..prop.2806L`
|
||||
- `1993iue..prop.4613J`
|
||||
### Annual Review of Astronomy and Astrophysics (共 1 篇)
|
||||
- `2009ARA&A..47..211H`
|
||||
### Stellar Pulsation: Challenges for Theory and Observation (共 1 篇)
|
||||
- `2009AIPC.1170..585C`
|
||||
### Ph.D. Thesis (共 1 篇)
|
||||
- `2015PhDT.......315A`
|
||||
### Ultraviolet observations of Quasars (共 1 篇)
|
||||
- `1980ESASP.157..323R`
|
||||
### American Astronomical Society Meeting Abstracts #244 (共 1 篇)
|
||||
- `2024AAS...24430302K`
|
||||
### EAS2023, European Astronomical Society Annual Meeting (共 1 篇)
|
||||
- `2023eas..conf..491U`
|
||||
### Future Directions in Ultraviolet Spectroscopy: A Conference Inspired by the Accomplishments of the Far Ultraviolet Spectroscopic Explorer Mission (共 1 篇)
|
||||
- `2009AIPC.1135..148C`
|
||||
### American Astronomical Society Meeting Abstracts #234 (共 1 篇)
|
||||
- `2019AAS...23432204B`
|
||||
### Astronomicheskii Zhurnal (共 1 篇)
|
||||
- `1995AZh....72..879E`
|
||||
### American Astronomical Society Meeting Abstracts #233 (共 1 篇)
|
||||
- `2019AAS...23346403W`
|
||||
### American Astronomical Society Meeting Abstracts #241 (共 1 篇)
|
||||
- `2023AAS...24130225Z`
|
||||
### The Astronomical Journal (共 1 篇)
|
||||
- `2021AJ....161..193L`
|
||||
### American Astronomical Society Meeting Abstracts #227 (共 1 篇)
|
||||
- `2016AAS...22740404B`
|
||||
### Astronomische Nachrichten (共 1 篇)
|
||||
- `2001AN....322..271S`
|
||||
### Thirteenth Marcel Grossmann Meeting: On Recent Developments in Theoretical and Experimental General Relativity, Astrophysics and Relativistic Field Theories (共 1 篇)
|
||||
- `2015mgm..conf.2459M`
|
||||
|
||||
=== 404 OR MISSING PDF MAGIC ===
|
||||
### VizieR Online Data Catalog (共 16 篇)
|
||||
- `1996yCat.3137....0K`
|
||||
- `2016yCat..74573396P`
|
||||
- `2023yCat..74910874N`
|
||||
- `2023yCat..74952844S`
|
||||
- `2024yCat..19280020B`
|
||||
- `2024yCat..19420109L`
|
||||
- `2024yCat..22710021L`
|
||||
- `2024yCat..22710057X`
|
||||
- `2024yCat..36840118U`
|
||||
- `2024yCat..36910223V`
|
||||
- `2024yCat..36930121H`
|
||||
- `2024yCat..36930245W`
|
||||
- `2024yCat..36930268R`
|
||||
- `2025yCat..36900368G`
|
||||
- `2025yCat..36970098B`
|
||||
- `2025yCat..37050248L`
|
||||
### American Astronomical Society Meeting Abstracts (共 12 篇)
|
||||
- `1992AAS...181.5003H`
|
||||
- `1994AAS...185.8005L`
|
||||
- `1995AAS...187.8202M`
|
||||
- `2000AAS...197.8302C`
|
||||
- `2001AAS...199.0615S`
|
||||
- `2004AAS...20517003S`
|
||||
- `2006AAS...20915101W`
|
||||
- `2007AAS...211.0320P`
|
||||
- `2007AAS...211.0333W`
|
||||
- `2007AAS...211.6006C`
|
||||
- `2007AAS...21110422K`
|
||||
- `2026AAS...24730801S`
|
||||
### The Astrophysical Journal (共 9 篇)
|
||||
- `1997ApJ...485..843L`
|
||||
- `1997ApJ...487L..81B`
|
||||
- `1997ApJ...491..172S`
|
||||
- `1998ApJ...493..440G`
|
||||
- `1998ApJ...494L..75B`
|
||||
- `2000ApJ...530..441B`
|
||||
- `2011ApJ...733..100L`
|
||||
- `2011ApJ...734...59G`
|
||||
- `2025ApJ...989..177C`
|
||||
### HST Proposal (共 8 篇)
|
||||
- `1994hst..prop.5305L`
|
||||
- `2012hst..prop12954B`
|
||||
- `2013hst..prop13290G`
|
||||
- `2014hst..prop13800J`
|
||||
- `2017hst..prop15284N`
|
||||
- `2022hst..prop17072D`
|
||||
- `2024hst..prop17697D`
|
||||
- `2024hst..prop17799N`
|
||||
### XMM-Newton Proposal (共 7 篇)
|
||||
- `2009xmm..prop..182M`
|
||||
- `2010xmm..prop...51M`
|
||||
- `2010xmm..prop...57L`
|
||||
- `2011xmm..prop..162L`
|
||||
- `2019xmm..prop...66M`
|
||||
- `2020xmm..prop..117M`
|
||||
- `2021xmm..prop..123M`
|
||||
### IUE Proposal (共 7 篇)
|
||||
- `1980iue..prop..596D`
|
||||
- `1981iue..prop..889W`
|
||||
- `1981iue..prop..907D`
|
||||
- `1986iue..prop.2435D`
|
||||
- `1988iue..prop.3231H`
|
||||
- `1991iue..prop.4189D`
|
||||
- `1994iue..prop.4925T`
|
||||
### Ph.D. Thesis (共 6 篇)
|
||||
- `1991PhDT........10S`
|
||||
- `1991PhDT.......346M`
|
||||
- `1994PhDT.......261M`
|
||||
- `2007PhDT.......230R`
|
||||
- `2013PhDT.......509A`
|
||||
- `2022PhDT........21F`
|
||||
### The Astronomical Journal (共 6 篇)
|
||||
- `2008AJ....136..946M`
|
||||
- `2023AJ....165..142K`
|
||||
- `2023AJ....165..148H`
|
||||
- `2025AJ....170..199O`
|
||||
- `2026AJ....171..165C`
|
||||
- `2026AJ....171..217B`
|
||||
### NOAO Proposal (共 5 篇)
|
||||
- `1999noao.prop...31W`
|
||||
- `2002noao.prop..318S`
|
||||
- `2010noao.prop..372W`
|
||||
- `2011noao.prop..191V`
|
||||
- `2012noao.prop..214B`
|
||||
### White Dwarfs (共 4 篇)
|
||||
- `1995LNP...443..221S`
|
||||
- `1995LNP...443..271T`
|
||||
- `1995LNP...443..272U`
|
||||
- `2003ASIB..105...99G`
|
||||
### American Astronomical Society Meeting Abstracts #233 (共 4 篇)
|
||||
- `2019AAS...23331402W`
|
||||
- `2019AAS...23336016C`
|
||||
- `2019AAS...23342201R`
|
||||
- `2019AAS...23346406D`
|
||||
### IAU General Assembly (共 4 篇)
|
||||
- `2015IAUGA..2224007H`
|
||||
- `2015IAUGA..2233490G`
|
||||
- `2015IAUGA..2235533G`
|
||||
- `2015IAUGA..2254919C`
|
||||
### Research Notes of the American Astronomical Society (共 3 篇)
|
||||
- `2019RNAAS...3...81B`
|
||||
- `2023RNAAS...7..255K`
|
||||
- `2025RNAAS...9..227Z`
|
||||
### EAS2023, European Astronomical Society Annual Meeting (共 3 篇)
|
||||
- `2023eas..conf..202T`
|
||||
- `2023eas..conf..553G`
|
||||
- `2023eas..conf.2257V`
|
||||
### American Astronomical Society Meeting Abstracts #221 (共 3 篇)
|
||||
- `2013AAS...22111605P`
|
||||
- `2013AAS...22114217B`
|
||||
- `2013AAS...22144305B`
|
||||
### American Astronomical Society Meeting Abstracts #227 (共 3 篇)
|
||||
- `2016AAS...22714405V`
|
||||
- `2016AAS...22734412B`
|
||||
- `2016AAS...22734514C`
|
||||
### Odessa Astronomical Publications (共 3 篇)
|
||||
- `2001OAP....14...82P`
|
||||
- `2001OAP....14...87P`
|
||||
- `2005OAP....18..135V`
|
||||
### EAS2024, European Astronomical Society Annual Meeting (共 2 篇)
|
||||
- `2024eas..conf.1492A`
|
||||
- `2024eas..conf.2481P`
|
||||
### Nature (共 2 篇)
|
||||
- `1978Natur.275..385H`
|
||||
- `1979Natur.279..305H`
|
||||
### Publications of the Astronomical Society of the Pacific (共 2 篇)
|
||||
- `1998PASP..110.1315G`
|
||||
- `2001PASP..113..944W`
|
||||
### American Astronomical Society Meeting Abstracts #215 (共 2 篇)
|
||||
- `2010AAS...21541929W`
|
||||
- `2010AAS...21545206C`
|
||||
### New Quests in Stellar Astrophysics. II. Ultraviolet Properties of Evolved Stellar Populations (共 2 篇)
|
||||
- `2009ASSP....7...59H`
|
||||
- `2009ASSP....7..191N`
|
||||
### American Astronomical Society Meeting Abstracts #223 (共 2 篇)
|
||||
- `2014AAS...22315615V`
|
||||
- `2014AAS...22315625R`
|
||||
### Astronomy Reports (共 2 篇)
|
||||
- `1995ARep...39..785E`
|
||||
- `1997ARep...41..802M`
|
||||
### The Atmospheres of Early-Type Stars (共 2 篇)
|
||||
- `1992LNP...401..257D`
|
||||
- `1992LNP...401..264T`
|
||||
### American Astronomical Society Meeting Abstracts #198 (共 2 篇)
|
||||
- `2001AAS...198.4906W`
|
||||
- `2001AAS...198.4907S`
|
||||
### Hot Stars in the Galactic Halo (共 2 篇)
|
||||
- `1994hsgh.conf..182D`
|
||||
- `1994hsgh.conf..341D`
|
||||
### American Astronomical Society Meeting Abstracts #219 (共 2 篇)
|
||||
- `2012AAS...21915325L`
|
||||
- `2012AAS...21940803B`
|
||||
### Magnetic Stars (共 1 篇)
|
||||
- `2011mast.conf..415H`
|
||||
### The First Year of IUE (共 1 篇)
|
||||
- `1979IUE1.symp..363D`
|
||||
### Stellar Atmospheres - Beyond Classical Models (共 1 篇)
|
||||
- `1991ASIC..341..341W`
|
||||
### Fourth European IUE Conference (共 1 篇)
|
||||
- `1984ESASP.218..273H`
|
||||
### Exploring the Universe with the IUE Satellite (共 1 篇)
|
||||
- `1987ASSL..129..355V`
|
||||
### Planetary and Proto-Planetary Nebulae: From IRAS to ISO (共 1 篇)
|
||||
- `1987ASSL..135..137H`
|
||||
### Acta Astronomica Sinica (共 1 篇)
|
||||
- `2020AcASn..61...19M`
|
||||
### American Astronomical Society Meeting Abstracts #231 (共 1 篇)
|
||||
- `2018AAS...23114603H`
|
||||
### Swift and the Surprising Sky: The First Seven Years of Swift. Online at: <A href="http://www.brera.inaf.it/docM/OAB/Research/SWIFT/Swift7/?p=program">http://www.brera.inaf.it/docM/OAB/Research/SWIFT/Swift7/?p=program</A> (共 1 篇)
|
||||
- `2011sssf.confE..42M`
|
||||
### Stellar Magnetic Fields (共 1 篇)
|
||||
- `1997smf..proc..122E`
|
||||
### FUSE Proposal (共 1 篇)
|
||||
- `2003fuse.prop.D165L`
|
||||
### Optical Complex Systems: OCS11 (共 1 篇)
|
||||
- `2011SPIE.8172E..0UV`
|
||||
### American Astronomical Society Meeting Abstracts #224 (共 1 篇)
|
||||
- `2014AAS...22421903B`
|
||||
### The Impact of Asteroseismology across Stellar Astrophysics (共 1 篇)
|
||||
- `2011iasa.confE...2H`
|
||||
### Progress in Astronomy (共 1 篇)
|
||||
- `2008PABei..26..126Y`
|
||||
### American Astronomical Society Meeting Abstracts #218 (共 1 篇)
|
||||
- `2011AAS...21812207C`
|
||||
### NASA Conference Publication (共 1 篇)
|
||||
- `1981NASCP2171..349K`
|
||||
### American Astronomical Society Meeting Abstracts #194 (共 1 篇)
|
||||
- `1999AAS...194.6702H`
|
||||
### Cataclysmic Variables and Low-Mass X-ray Binaries (共 1 篇)
|
||||
- `1985ASSL..113...15B`
|
||||
### Magellanic Clouds and Other Dwarf Galaxies (共 1 篇)
|
||||
- `1998mcdg.proc..229A`
|
||||
### Memoires of the Societe Royale des Sciences de Liege (共 1 篇)
|
||||
- `1975MSRSL...9..247G`
|
||||
### IAU Colloquium 53: White Dwarfs and Variable Degenerate Stars (共 1 篇)
|
||||
- `1979wdvd.coll..107O`
|
||||
### Astrofizika (共 1 篇)
|
||||
- `1990Afz....33..199S`
|
||||
### Structure and Evolution of Active Galactic Nuclei (共 1 篇)
|
||||
- `1986ASSL..121..317K`
|
||||
### Keck Observatory Archive ESI (共 1 篇)
|
||||
- `2013koa..prop...89F`
|
||||
### Astronomy Letters (共 1 篇)
|
||||
- `2020AstL...46..601A`
|
||||
### Astronomische Nachrichten (共 1 篇)
|
||||
- `2007AN....328..708G`
|
||||
### Pisma v Astronomicheskii Zhurnal (共 1 篇)
|
||||
- `1990PAZh...16.1095T`
|
||||
### NASA Special Publication (共 1 篇)
|
||||
- `1982NASSP.456..147L`
|
||||
### Variable Stars, the Galactic halo and Galaxy Formation (共 1 篇)
|
||||
- `2010vsgh.conf..161G`
|
||||
### GALEX Proposal (共 1 篇)
|
||||
- `2004galx.prop...60W`
|
||||
### Visual Double Stars : Formation, Dynamics and Evolutionary Tracks (共 1 篇)
|
||||
- `1997ASSL..223..209U`
|
||||
### Peremennye Zvezdy (共 1 篇)
|
||||
- `2018PZ.....38....2D`
|
||||
67
scratch/format_failed.py
Normal file
67
scratch/format_failed.py
Normal file
@ -0,0 +1,67 @@
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
log_path = "/home/fmq/program/AstroResearch/logs/astro_research.log.2026-06-10"
|
||||
db_path = "/home/fmq/program/AstroResearch/library/astro_research.db"
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
bibcode_to_pub = {}
|
||||
cursor.execute("SELECT bibcode, pub FROM papers")
|
||||
for row in cursor.fetchall():
|
||||
bibcode_to_pub[row[0]] = row[1]
|
||||
|
||||
bibcode_logs = {}
|
||||
current_bibcode = None
|
||||
|
||||
with open(log_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
m = re.search(r"开始处理文献:\s*(\S+)", line)
|
||||
if m:
|
||||
current_bibcode = m.group(1)
|
||||
bibcode_logs.setdefault(current_bibcode, [])
|
||||
bibcode_logs[current_bibcode].append(line)
|
||||
continue
|
||||
m = re.search(r"\[下载\] 开始 (PDF|HTML) 下载:\s*(\S+)", line)
|
||||
if m:
|
||||
current_bibcode = m.group(2)
|
||||
bibcode_logs.setdefault(current_bibcode, [])
|
||||
bibcode_logs[current_bibcode].append(line)
|
||||
continue
|
||||
if current_bibcode:
|
||||
bibcode_logs[current_bibcode].append(line)
|
||||
|
||||
failed_papers = {}
|
||||
for bibcode, logs in bibcode_logs.items():
|
||||
log_text = "".join(logs)
|
||||
if "下载失败(PDF 和 HTML 均下载失败)" in log_text:
|
||||
failed_papers[bibcode] = log_text
|
||||
|
||||
err_403 = {}
|
||||
err_404_magic = {}
|
||||
|
||||
for bibcode, log_text in failed_papers.items():
|
||||
pub = bibcode_to_pub.get(bibcode, "Unknown")
|
||||
has_403 = "403" in log_text or "Forbidden" in log_text or "Cloudflare" in log_text or "验证码" in log_text
|
||||
has_404 = "404" in log_text or "not found" in log_text.lower() or "魔数" in log_text or "不是有效的 PDF" in log_text or "过小" in log_text or "损坏或不完整" in log_text
|
||||
|
||||
if has_403:
|
||||
err_403[bibcode] = pub
|
||||
elif has_404:
|
||||
err_404_magic[bibcode] = pub
|
||||
|
||||
def format_group(err_dict):
|
||||
grouped = {}
|
||||
for b, p in err_dict.items():
|
||||
grouped.setdefault(p, []).append(b)
|
||||
output = []
|
||||
for pub, bibs in sorted(grouped.items(), key=lambda x: len(x[1]), reverse=True):
|
||||
output.append(f"### {pub} (共 {len(bibs)} 篇)")
|
||||
for bib in sorted(bibs):
|
||||
output.append(f"- `{bib}`")
|
||||
return "\n".join(output)
|
||||
|
||||
print("=== 403 FORBIDDEN ===")
|
||||
print(format_group(err_403))
|
||||
print("\n=== 404 OR MISSING PDF MAGIC ===")
|
||||
print(format_group(err_404_magic))
|
||||
15
scratch/list_buckets.py
Normal file
15
scratch/list_buckets.py
Normal file
@ -0,0 +1,15 @@
|
||||
import qiniu
|
||||
|
||||
ak = "vf63aPF-QIFbyzULtHaSx9JgiVSS3zRuy0zmBACE"
|
||||
sk = "JlQvHevHSAgilNYaH0UxQoX68rb4m9VflpaXtYL1"
|
||||
|
||||
auth = qiniu.Auth(ak, sk)
|
||||
bucket_manager = qiniu.BucketManager(auth)
|
||||
|
||||
print("Listing buckets...")
|
||||
try:
|
||||
buckets, info = bucket_manager.buckets()
|
||||
print("Buckets:", buckets)
|
||||
print("Info:", info)
|
||||
except Exception as e:
|
||||
print("Error listing buckets:", e)
|
||||
76
scratch/parse_errors.py
Normal file
76
scratch/parse_errors.py
Normal file
@ -0,0 +1,76 @@
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
log_path = "/home/fmq/program/AstroResearch/logs/astro_research.log.2026-06-10"
|
||||
db_path = "/home/fmq/program/AstroResearch/library/astro_research.db"
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
bibcode_to_pub = {}
|
||||
cursor.execute("SELECT bibcode, pub FROM papers")
|
||||
for row in cursor.fetchall():
|
||||
bibcode_to_pub[row[0]] = row[1]
|
||||
|
||||
bibcode_logs = {}
|
||||
current_bibcode = None
|
||||
|
||||
with open(log_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
m = re.search(r"开始处理文献:\s*(\S+)", line)
|
||||
if m:
|
||||
current_bibcode = m.group(1)
|
||||
bibcode_logs.setdefault(current_bibcode, [])
|
||||
bibcode_logs[current_bibcode].append(line)
|
||||
continue
|
||||
m = re.search(r"\[下载\] 开始 (PDF|HTML) 下载:\s*(\S+)", line)
|
||||
if m:
|
||||
current_bibcode = m.group(2)
|
||||
bibcode_logs.setdefault(current_bibcode, [])
|
||||
bibcode_logs[current_bibcode].append(line)
|
||||
continue
|
||||
if current_bibcode:
|
||||
bibcode_logs[current_bibcode].append(line)
|
||||
|
||||
failed_papers = {}
|
||||
for bibcode, logs in bibcode_logs.items():
|
||||
log_text = "".join(logs)
|
||||
if "下载失败(PDF 和 HTML 均下载失败)" in log_text:
|
||||
failed_papers[bibcode] = log_text
|
||||
|
||||
print("Total failed papers:", len(failed_papers))
|
||||
|
||||
err_403 = {}
|
||||
err_404_magic = {}
|
||||
|
||||
for bibcode, log_text in failed_papers.items():
|
||||
pub = bibcode_to_pub.get(bibcode, "Unknown")
|
||||
|
||||
# We want to identify the primary reason for failure.
|
||||
# If the log text contains "403 Forbidden" or "Cloudflare", then it's a 403 block.
|
||||
# Otherwise, if it has "404 Not Found" or "缺少 %PDF 魔数" or "响应不是有效的 PDF", it's 404/Magic.
|
||||
# Let's check:
|
||||
has_403 = "403" in log_text or "Forbidden" in log_text or "Cloudflare" in log_text or "验证码" in log_text
|
||||
has_404 = "404" in log_text or "not found" in log_text.lower() or "魔数" in log_text or "不是有效的 PDF" in log_text or "过小" in log_text or "损坏或不完整" in log_text
|
||||
|
||||
if has_403:
|
||||
err_403[bibcode] = pub
|
||||
elif has_404:
|
||||
err_404_magic[bibcode] = pub
|
||||
|
||||
print(f"Failed via 403 count: {len(err_403)}")
|
||||
print(f"Failed via 404/Magic count: {len(err_404_magic)}")
|
||||
|
||||
# Print them grouped
|
||||
print("\n--- 403 Grouped ---")
|
||||
grouped_403 = {}
|
||||
for b, p in err_403.items():
|
||||
grouped_403.setdefault(p, []).append(b)
|
||||
for pub, bibs in sorted(grouped_403.items(), key=lambda x: len(x[1]), reverse=True):
|
||||
print(f"出版社/期刊: {pub} (共 {len(bibs)} 篇): {', '.join(bibs)}")
|
||||
|
||||
print("\n--- 404/Magic Grouped ---")
|
||||
grouped_404_magic = {}
|
||||
for b, p in err_404_magic.items():
|
||||
grouped_404_magic.setdefault(p, []).append(b)
|
||||
for pub, bibs in sorted(grouped_404_magic.items(), key=lambda x: len(x[1]), reverse=True):
|
||||
print(f"出版社/期刊: {pub} (共 {len(bibs)} 篇): {', '.join(bibs)}")
|
||||
44
scratch/test_qiniu.py
Normal file
44
scratch/test_qiniu.py
Normal file
@ -0,0 +1,44 @@
|
||||
import base64
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import requests
|
||||
|
||||
def urlsafe_base64_encode(data):
|
||||
if isinstance(data, str):
|
||||
data = data.encode('utf-8')
|
||||
ret = base64.urlsafe_b64encode(data)
|
||||
# base64url standard replaces padding '=' with nothing
|
||||
return ret.decode('utf-8').rstrip('=')
|
||||
|
||||
def generate_token(ak, sk, bucket, key, scope_key=True):
|
||||
deadline = int(time.time()) + 3600
|
||||
scope = f"{bucket}:{key}" if scope_key else bucket
|
||||
policy = {
|
||||
"scope": scope,
|
||||
"deadline": deadline
|
||||
}
|
||||
policy_str = json.dumps(policy, separators=(',', ':'))
|
||||
encoded_policy = urlsafe_base64_encode(policy_str)
|
||||
|
||||
# hmac-sha1
|
||||
hashed = hmac.new(sk.encode('utf-8'), encoded_policy.encode('utf-8'), hashlib.sha1)
|
||||
encoded_signature = urlsafe_base64_encode(hashed.digest())
|
||||
|
||||
return f"{ak}:{encoded_signature}:{encoded_policy}"
|
||||
|
||||
ak = "vf63aPF-QIFbyzULtHaSx9JgiVSS3zRuy0zmBACE"
|
||||
sk = "JlQvHevHSAgilNYaH0UxQoX68rb4m9VflpaXtYL1"
|
||||
bucket = "fmqi-img"
|
||||
key = "astroresearch/test_hello.txt"
|
||||
|
||||
token = generate_token(ak, sk, bucket, key, scope_key=True)
|
||||
print(f"Generated python token: {token}")
|
||||
|
||||
# Let's try uploading
|
||||
files = {'file': ('test.txt', b'hello python')}
|
||||
data = {'token': token, 'key': key}
|
||||
res = requests.post("https://up-z1.qiniup.com", data=data, files=files)
|
||||
print(f"Python upload status: {res.status_code}")
|
||||
print(f"Python upload response: {res.text}")
|
||||
68
scratch/test_qiniu.rs
Normal file
68
scratch/test_qiniu.rs
Normal file
@ -0,0 +1,68 @@
|
||||
use sha1::Sha1;
|
||||
use hmac::{Hmac, Mac};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use reqwest::multipart;
|
||||
|
||||
type HmacSha1 = Hmac<Sha1>;
|
||||
|
||||
fn generate_token(ak: &str, sk: &str, bucket: &str, key: &str, scope_key: bool) -> String {
|
||||
let deadline = chrono::Utc::now().timestamp() + 3600;
|
||||
|
||||
let scope = if scope_key {
|
||||
format!("{}:{}", bucket, key)
|
||||
} else {
|
||||
bucket.to_string()
|
||||
};
|
||||
|
||||
let policy = serde_json::json!({
|
||||
"scope": scope,
|
||||
"deadline": deadline
|
||||
});
|
||||
|
||||
let policy_str = policy.to_string();
|
||||
let encoded_policy = URL_SAFE_NO_PAD.encode(policy_str.as_bytes());
|
||||
|
||||
let mut mac = HmacSha1::new_from_slice(sk.as_bytes()).unwrap();
|
||||
mac.update(encoded_policy.as_bytes());
|
||||
let signature = mac.finalize().into_bytes();
|
||||
let encoded_signature = URL_SAFE_NO_PAD.encode(&signature);
|
||||
|
||||
format!("{}:{}:{}", ak, encoded_signature, encoded_policy)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let ak = "vf63aPF-QIFbyzULtHaSx9JgiVSS3zRuy0zmBACE".to_string();
|
||||
let sk = "JlQvHevHSAgilNYaH0UxQoX68rb4m9VflpaXtYL1".to_string();
|
||||
let bucket = "fmqi-img".to_string();
|
||||
let domain = "http://qnimg.asfmq.cn".to_string();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let dummy_data = b"hello world qiniu test".to_vec();
|
||||
let filename = "test_hello.txt";
|
||||
let key = "astroresearch/test_hello.txt";
|
||||
|
||||
println!("Testing with scoped key token (bucket:key)...");
|
||||
let token1 = generate_token(&ak, &sk, &bucket, key, true);
|
||||
let form1 = multipart::Form::new()
|
||||
.text("token", token1)
|
||||
.text("key", key)
|
||||
.part("file", multipart::Part::bytes(dummy_data.clone()).file_name(filename));
|
||||
|
||||
let res1 = client.post("https://up-z1.qiniup.com").multipart(form1).send().await?;
|
||||
println!("Status (scoped key): {}", res1.status());
|
||||
println!("Response: {}", res1.text().await?);
|
||||
|
||||
println!("\nTesting with bucket-only scoped token...");
|
||||
let token2 = generate_token(&ak, &sk, &bucket, key, false);
|
||||
let form2 = multipart::Form::new()
|
||||
.text("token", token2)
|
||||
.text("key", key)
|
||||
.part("file", multipart::Part::bytes(dummy_data).file_name(filename));
|
||||
|
||||
let res2 = client.post("https://up-z1.qiniup.com").multipart(form2).send().await?;
|
||||
println!("Status (bucket-only): {}", res2.status());
|
||||
println!("Response: {}", res2.text().await?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
15
scratch/test_qiniu_sdk.py
Normal file
15
scratch/test_qiniu_sdk.py
Normal file
@ -0,0 +1,15 @@
|
||||
import qiniu
|
||||
|
||||
ak = "vf63aPF-QIFbyzULtHaSx9JgiVSS3zRuy0zmBACE"
|
||||
sk = "JlQvHevHSAgilNYaH0UxQoX68rb4m9VflpaXtYL1"
|
||||
bucket = "fmqi-img"
|
||||
key = "astroresearch/test_hello.txt"
|
||||
|
||||
auth = qiniu.Auth(ak, sk)
|
||||
token = auth.upload_token(bucket, key, 3600)
|
||||
print("SDK Generated Token:", token)
|
||||
|
||||
# Upload using Qiniu SDK put_data
|
||||
ret, info = qiniu.put_data(token, key, b"hello SDK")
|
||||
print("Ret:", ret)
|
||||
print("Info:", info)
|
||||
@ -20,6 +20,7 @@
|
||||
* **[parser.rs](services/parser.rs)**:文献排版转换与清洗器,支持 MathJax LaTeX 占位符防护及 MinerU 图文 PDF 降级解析。
|
||||
* **[translation.rs](services/translation.rs)**:大模型对比翻译流水线。支持基于天文学对照词表的分词过滤,通过 Trie 树最长匹配机制生成 Glossary 专有名词注入 Prompt。
|
||||
* **[query_parser.rs](services/query_parser.rs)**:解析并标准化学术检索式,为 ADS 和 arXiv 分别生成合规的专有检索语法。
|
||||
* **[logging.rs](services/logging.rs)**:系统日志服务,支持控制台彩色日志输出、每日滚动写入磁盘日志文件,采用自定义上海时区 (+08:00) 格式化时间。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -57,6 +57,7 @@ pub struct StandardPaper {
|
||||
pub is_downloaded: bool,
|
||||
pub has_markdown: bool,
|
||||
pub has_translation: bool,
|
||||
pub doctype: String,
|
||||
}
|
||||
|
||||
// ── GET /api/search ──
|
||||
@ -197,6 +198,7 @@ pub async fn download_paper(
|
||||
};
|
||||
|
||||
if pdf_path.is_none() && html_path.is_none() {
|
||||
error!("文献 {} PDF 和 HTML 均下载失败,无可用物理文件格式", req.bibcode);
|
||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, "PDF 和 HTML 均下载失败,请检查网络".to_string()));
|
||||
}
|
||||
|
||||
@ -324,9 +326,11 @@ pub async fn parse_paper(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("文献 {} 解析失败:本地 PDF 文件 {:?} 丢失", req.bibcode, pdf_abs);
|
||||
return Err((StatusCode::NOT_FOUND, "本地 PDF 文件未找到".to_string()));
|
||||
}
|
||||
} else {
|
||||
error!("文献 {} 解析失败:请先下载该文献的 HTML 或 PDF 文件", req.bibcode);
|
||||
return Err((StatusCode::BAD_REQUEST, "请先下载该文献的 HTML 或 PDF 文件".to_string()));
|
||||
}
|
||||
}
|
||||
@ -381,19 +385,32 @@ pub async fn translate_paper(
|
||||
}
|
||||
|
||||
// 检查英文解析文件是否存在
|
||||
let md_rel = md_opt.ok_or((StatusCode::BAD_REQUEST, "文献必须先完成解析方可翻译".to_string()))?;
|
||||
let md_rel = match md_opt {
|
||||
Some(rel) => rel,
|
||||
None => {
|
||||
error!("文献 {} 翻译失败:文献未完成解析,缺少英文 Markdown 路径", req.bibcode);
|
||||
return Err((StatusCode::BAD_REQUEST, "文献必须先完成解析方可翻译".to_string()));
|
||||
}
|
||||
};
|
||||
let md_abs = state.config.library_dir.join(&md_rel);
|
||||
if !md_abs.exists() {
|
||||
error!("文献 {} 翻译失败:解析的英文 Markdown 文件 {:?} 不存在", req.bibcode, md_abs);
|
||||
return Err((StatusCode::BAD_REQUEST, "解析 Markdown 文件丢失".to_string()));
|
||||
}
|
||||
|
||||
let english_markdown = fs::read_to_string(&md_abs)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("读取解析内容失败: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
error!("文献 {} 翻译失败:读取解析内容失败: {}", req.bibcode, e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("读取解析内容失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 调用 LLM 翻译服务并注入对照词表
|
||||
let translated_markdown = crate::services::translation::translate_markdown(&english_markdown, &state.dict, &state.config)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("调用 LLM 翻译失败: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
error!("文献 {} 翻译失败:调用 LLM 翻译发生错误: {}", req.bibcode, e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("调用 LLM 翻译失败: {}", e))
|
||||
})?;
|
||||
|
||||
// 翻译结果物理写入本地
|
||||
let tr_filename = format!("{}_zh.md", req.bibcode);
|
||||
@ -425,6 +442,7 @@ pub struct CitationsResponse {
|
||||
pub reference_count: i32,
|
||||
pub references: Vec<String>, // 该文献参考文献 bibcode 数组
|
||||
pub citations: Vec<String>, // 引用该文献的 bibcode 数组
|
||||
pub citation_counts: std::collections::HashMap<String, i32>, // 相关文献与被引数映射
|
||||
}
|
||||
|
||||
// 从 SQLite 查询引用关联,生成引用星系关系树
|
||||
@ -432,9 +450,49 @@ pub async fn get_citation_network(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<DownloadRequest>,
|
||||
) -> Result<Json<CitationsResponse>, (StatusCode, String)> {
|
||||
let paper = get_paper_from_db(&state.db, &state.config.library_dir, ¶ms.bibcode)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, format!("未找到文献数据: {}", e)))?;
|
||||
let paper = match get_paper_from_db(&state.db, &state.config.library_dir, ¶ms.bibcode).await {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
// 如果本地数据库查不到,尝试从 ADS 在线 API 动态获取
|
||||
if !state.config.ads_api_key.is_empty() {
|
||||
match state.ads.search(&format!("bibcode:{}", params.bibcode), 0, 1, "relevance").await {
|
||||
Ok(docs) => {
|
||||
if let Some(doc) = docs.first() {
|
||||
let standard_paper = convert_ads_doc_to_standard(doc);
|
||||
// 保存至数据库缓存,并保存引用关联
|
||||
let _ = save_paper_to_db(&state.db, &standard_paper).await;
|
||||
if let Some(refs) = &doc.reference {
|
||||
for ref_bib in refs {
|
||||
let _ = sqlx::query("INSERT OR IGNORE INTO citations_references (source_bibcode, target_bibcode) VALUES (?, ?)")
|
||||
.bind(&standard_paper.bibcode)
|
||||
.bind(ref_bib)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if let Some(cits) = &doc.citation {
|
||||
for cit_bib in cits {
|
||||
let _ = sqlx::query("INSERT OR IGNORE INTO citations_references (source_bibcode, target_bibcode) VALUES (?, ?)")
|
||||
.bind(cit_bib)
|
||||
.bind(&standard_paper.bibcode)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
standard_paper
|
||||
} else {
|
||||
return Err((StatusCode::NOT_FOUND, format!("在本地库及 ADS 中均未找到该文献: {}", params.bibcode)));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("在线检索文献元数据失败: {}", e)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err((StatusCode::NOT_FOUND, format!("本地数据库未收录该文献,且未配置 ADS_API_KEY,无法在线加载: {}", params.bibcode)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 加载引用的文献
|
||||
let refs_rows = sqlx::query("SELECT target_bibcode FROM citations_references WHERE source_bibcode = ?")
|
||||
@ -452,6 +510,21 @@ pub async fn get_citation_network(
|
||||
.unwrap_or_default();
|
||||
let citations: Vec<String> = cits_rows.iter().map(|row| row.get(0)).collect();
|
||||
|
||||
// 加载关联文献的被引数量 (从 SQLite papers 表获取)
|
||||
let mut citation_counts = std::collections::HashMap::new();
|
||||
let mut all_related = references.clone();
|
||||
all_related.extend(citations.clone());
|
||||
for bib in all_related {
|
||||
let count_opt: Option<i32> = sqlx::query_scalar("SELECT citation_count FROM papers WHERE bibcode = ?")
|
||||
.bind(&bib)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if let Some(c) = count_opt {
|
||||
citation_counts.insert(bib, c);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(CitationsResponse {
|
||||
bibcode: paper.bibcode,
|
||||
title: paper.title,
|
||||
@ -459,6 +532,7 @@ pub async fn get_citation_network(
|
||||
reference_count: paper.reference_count,
|
||||
references,
|
||||
citations,
|
||||
citation_counts,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -499,7 +573,7 @@ pub async fn get_paper_detail(
|
||||
pub async fn get_library(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<Vec<StandardPaper>>, (StatusCode, String)> {
|
||||
let rows = sqlx::query("SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path FROM papers ORDER BY created_at DESC")
|
||||
let rows = sqlx::query("SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path, doctype FROM papers ORDER BY created_at DESC")
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("访问本地数据库失败: {}", e)))?;
|
||||
@ -510,6 +584,7 @@ pub async fn get_library(
|
||||
let html_path: Option<String> = r.get(12);
|
||||
let markdown_path: Option<String> = r.get(13);
|
||||
let translation_path: Option<String> = r.get(14);
|
||||
let doctype_val: Option<String> = r.get(15);
|
||||
|
||||
let authors_str: Option<String> = r.get(2);
|
||||
let authors: Vec<String> = authors_str.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
|
||||
@ -532,6 +607,7 @@ pub async fn get_library(
|
||||
|| html_path.as_ref().map(|p| state.config.library_dir.join(p).exists()).unwrap_or(false),
|
||||
has_markdown: markdown_path.as_ref().map(|p| state.config.library_dir.join(p).exists()).unwrap_or(false),
|
||||
has_translation: translation_path.as_ref().map(|p| state.config.library_dir.join(p).exists()).unwrap_or(false),
|
||||
doctype: doctype_val.unwrap_or_else(|| "article".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
@ -714,6 +790,7 @@ pub(crate) fn convert_ads_doc_to_standard(doc: &AdsPaperDoc) -> StandardPaper {
|
||||
is_downloaded: false,
|
||||
has_markdown: false,
|
||||
has_translation: false,
|
||||
doctype: doc.doctype.clone().unwrap_or_else(|| "article".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@ -733,6 +810,7 @@ pub(crate) fn convert_arxiv_to_standard(doc: &ArxivPaper) -> StandardPaper {
|
||||
is_downloaded: false,
|
||||
has_markdown: false,
|
||||
has_translation: false,
|
||||
doctype: "eprint".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -759,7 +837,7 @@ pub(crate) async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyh
|
||||
if is_existing_temp && is_new_formal {
|
||||
info!("发现相同 arXiv ID 的文献,将临时主键 {} 升级为正式 ADS Bibcode: {}", existing_bibcode, p.bibcode);
|
||||
sqlx::query(
|
||||
"UPDATE papers SET bibcode = ?, title = ?, authors = ?, year = ?, pub = ?, keywords = ?, abstract = ?, doi = ?, citation_count = ?, reference_count = ? WHERE bibcode = ?"
|
||||
"UPDATE papers SET bibcode = ?, title = ?, authors = ?, year = ?, pub = ?, keywords = ?, abstract = ?, doi = ?, citation_count = ?, reference_count = ?, doctype = ? WHERE bibcode = ?"
|
||||
)
|
||||
.bind(&p.bibcode)
|
||||
.bind(&p.title)
|
||||
@ -771,6 +849,7 @@ pub(crate) async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyh
|
||||
.bind(&p.doi)
|
||||
.bind(p.citation_count)
|
||||
.bind(p.reference_count)
|
||||
.bind(&p.doctype)
|
||||
.bind(&existing_bibcode)
|
||||
.execute(db)
|
||||
.await?;
|
||||
@ -787,8 +866,8 @@ pub(crate) async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyh
|
||||
|
||||
// 2. 正常插入/冲突更新
|
||||
sqlx::query(
|
||||
"INSERT INTO papers (bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
|
||||
"INSERT INTO papers (bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, doctype) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
|
||||
ON CONFLICT(bibcode) DO UPDATE SET \
|
||||
title=excluded.title, \
|
||||
authors=excluded.authors, \
|
||||
@ -798,7 +877,8 @@ pub(crate) async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyh
|
||||
doi=excluded.doi, \
|
||||
arxiv_id=excluded.arxiv_id, \
|
||||
citation_count=excluded.citation_count, \
|
||||
reference_count=excluded.reference_count"
|
||||
reference_count=excluded.reference_count, \
|
||||
doctype=excluded.doctype"
|
||||
)
|
||||
.bind(&p.bibcode)
|
||||
.bind(&p.title)
|
||||
@ -811,6 +891,7 @@ pub(crate) async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyh
|
||||
.bind(&p.arxiv_id)
|
||||
.bind(p.citation_count)
|
||||
.bind(p.reference_count)
|
||||
.bind(&p.doctype)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
@ -818,7 +899,7 @@ pub(crate) async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyh
|
||||
}
|
||||
|
||||
async fn get_paper_from_db(db: &SqlitePool, library_dir: &std::path::Path, bibcode: &str) -> anyhow::Result<StandardPaper> {
|
||||
let r = sqlx::query("SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path FROM papers WHERE bibcode = ?")
|
||||
let r = sqlx::query("SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path, doctype FROM papers WHERE bibcode = ?")
|
||||
.bind(bibcode)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
@ -827,6 +908,7 @@ async fn get_paper_from_db(db: &SqlitePool, library_dir: &std::path::Path, bibco
|
||||
let html_path: Option<String> = r.get(12);
|
||||
let markdown_path: Option<String> = r.get(13);
|
||||
let translation_path: Option<String> = r.get(14);
|
||||
let doctype_val: Option<String> = r.get(15);
|
||||
|
||||
let authors_str: Option<String> = r.get(2);
|
||||
let authors: Vec<String> = authors_str.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
|
||||
@ -853,6 +935,7 @@ async fn get_paper_from_db(db: &SqlitePool, library_dir: &std::path::Path, bibco
|
||||
is_downloaded: is_pdf_exist || is_html_exist,
|
||||
has_markdown: is_md_exist,
|
||||
has_translation: is_tr_exist,
|
||||
doctype: doctype_val.unwrap_or_else(|| "article".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1011,8 +1094,8 @@ pub async fn run_asset_sync(
|
||||
}
|
||||
}
|
||||
"unparsed" | "all_unparsed" => {
|
||||
// 查询所有本地无 Markdown 文件的文献
|
||||
let rows = sqlx::query("SELECT bibcode FROM papers WHERE markdown_path IS NULL")
|
||||
// 查询所有本地无 Markdown 文件的文献 (或者处于 mineru_batch: 状态的任务)
|
||||
let rows = sqlx::query("SELECT bibcode FROM papers WHERE markdown_path IS NULL OR markdown_path LIKE 'mineru_batch:%'")
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("读取数据库失败: {}", e)))?;
|
||||
@ -1055,6 +1138,58 @@ pub async fn stop_asset_sync(
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
// ── GET /api/sync/queries ──
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SavedSyncQuery {
|
||||
pub id: i64,
|
||||
pub query: String,
|
||||
pub source: String,
|
||||
pub limit_count: i32,
|
||||
pub last_run: String,
|
||||
}
|
||||
|
||||
pub async fn get_sync_queries(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<Vec<SavedSyncQuery>>, (StatusCode, String)> {
|
||||
let rows = sqlx::query("SELECT id, query, source, limit_count, datetime(last_run, 'localtime') FROM sync_queries ORDER BY last_run DESC")
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("获取已存同步检索配置失败: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("获取已存同步检索配置失败: {}", e))
|
||||
})?;
|
||||
|
||||
let mut list = Vec::new();
|
||||
for r in rows {
|
||||
list.push(SavedSyncQuery {
|
||||
id: r.get(0),
|
||||
query: r.get(1),
|
||||
source: r.get(2),
|
||||
limit_count: r.get(3),
|
||||
last_run: r.get(4),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
// ── DELETE /api/sync/queries/:id ──
|
||||
pub async fn delete_sync_query(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::extract::Path(id): axum::extract::Path<i64>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
sqlx::query("DELETE FROM sync_queries WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("删除同步检索配置失败: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("删除同步检索配置失败: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
// ── GET /api/sync/asset/status ──
|
||||
pub async fn get_asset_sync_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
@ -1086,6 +1221,7 @@ mod tests {
|
||||
reference: None,
|
||||
citation: None,
|
||||
identifier: None,
|
||||
doctype: Some("article".to_string()),
|
||||
};
|
||||
|
||||
let paper = convert_ads_doc_to_standard(&doc);
|
||||
@ -1118,6 +1254,7 @@ mod tests {
|
||||
reference: None,
|
||||
citation: None,
|
||||
identifier: Some(vec!["2026MNRAS.530.1234A".to_string(), "arXiv:2606.12345".to_string()]),
|
||||
doctype: Some("article".to_string()),
|
||||
};
|
||||
|
||||
let paper = convert_ads_doc_to_standard(&doc);
|
||||
@ -1174,6 +1311,7 @@ mod tests {
|
||||
is_downloaded: false,
|
||||
has_markdown: false,
|
||||
has_translation: false,
|
||||
doctype: "article".to_string(),
|
||||
};
|
||||
|
||||
// 保存
|
||||
|
||||
@ -20,6 +20,7 @@ pub struct AdsPaperDoc {
|
||||
pub reference: Option<Vec<String>>,
|
||||
pub citation: Option<Vec<String>>,
|
||||
pub identifier: Option<Vec<String>>,
|
||||
pub doctype: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@ -69,8 +70,8 @@ impl AdsClient {
|
||||
|
||||
let translated = crate::services::query_parser::to_ads_query(query);
|
||||
|
||||
// fl 声明返回字段,包括 reference 和 citation 引用关系数组及 identifier
|
||||
let fl = "bibcode,title,author,year,pub,keyword,abstract,doi,citation_count,reference_count,reference,citation,identifier";
|
||||
// fl 声明返回字段,包括 reference 和 citation 引用关系数组及 identifier 和 doctype
|
||||
let fl = "bibcode,title,author,year,pub,keyword,abstract,doi,citation_count,reference_count,reference,citation,identifier,doctype";
|
||||
|
||||
let ads_sort = match sort {
|
||||
"date_desc" => "date desc",
|
||||
@ -120,6 +121,7 @@ impl AdsClient {
|
||||
reference: d.reference,
|
||||
citation: d.citation,
|
||||
identifier: d.identifier,
|
||||
doctype: d.doctype,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
@ -204,6 +206,7 @@ struct RawDoc {
|
||||
reference: Option<Vec<String>>,
|
||||
citation: Option<Vec<String>>,
|
||||
identifier: Option<Vec<String>>,
|
||||
doctype: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use sha1::Sha1;
|
||||
use hmac::{Hmac, Mac};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE};
|
||||
use reqwest::multipart;
|
||||
use tracing::{info, error};
|
||||
|
||||
@ -43,7 +43,7 @@ impl QiniuClient {
|
||||
});
|
||||
|
||||
let policy_str = policy.to_string();
|
||||
let encoded_policy = URL_SAFE_NO_PAD.encode(policy_str.as_bytes());
|
||||
let encoded_policy = URL_SAFE.encode(policy_str.as_bytes());
|
||||
|
||||
let mut mac = HmacSha1::new_from_slice(self.secret_key.as_bytes())
|
||||
.expect("HMAC 密钥可接收任意大小");
|
||||
@ -51,7 +51,7 @@ impl QiniuClient {
|
||||
let result = mac.finalize();
|
||||
let signature = result.into_bytes();
|
||||
|
||||
let encoded_signature = URL_SAFE_NO_PAD.encode(&signature);
|
||||
let encoded_signature = URL_SAFE.encode(&signature);
|
||||
|
||||
format!("{}:{}:{}", self.access_key, encoded_signature, encoded_policy)
|
||||
}
|
||||
@ -62,9 +62,9 @@ impl QiniuClient {
|
||||
return Err(anyhow::anyhow!("本地 .env 文件中未正确配置七牛云参数"));
|
||||
}
|
||||
|
||||
// 使用毫秒级时间戳防重名覆盖
|
||||
// 使用毫秒级时间戳防重名覆盖,并放置在 astroresearch 虚拟文件夹下
|
||||
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||
let key = format!("astroresearch_{}_{}", timestamp, filename);
|
||||
let key = format!("astroresearch/{}_{}", timestamp, filename);
|
||||
|
||||
let token = self.generate_upload_token(&key);
|
||||
info!("正在上传文献提取图片到七牛云: key='{}'", key);
|
||||
@ -74,7 +74,7 @@ impl QiniuClient {
|
||||
.text("key", key.clone())
|
||||
.part("file", multipart::Part::bytes(buffer).file_name(filename.to_string()));
|
||||
|
||||
let upload_url = "https://up.qiniu.com";
|
||||
let upload_url = "https://up-z1.qiniup.com";
|
||||
|
||||
let response = self.client.post(upload_url)
|
||||
.multipart(form)
|
||||
|
||||
14
src/main.rs
14
src/main.rs
@ -21,13 +21,8 @@ use astroresearch::api::handlers::{AppState, self};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// 1. 初始化日志记录器
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,astroresearch=debug")),
|
||||
)
|
||||
.init();
|
||||
// 1. 初始化日志记录器并保留异步写保护 Guard
|
||||
let _logging_guards = astroresearch::services::logging::init_logging()?;
|
||||
|
||||
info!("正在启动 AstroResearch 天文学文献辅助系统后端服务...");
|
||||
|
||||
@ -124,7 +119,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/sync/meta/status", get(handlers::get_meta_sync_status))
|
||||
.route("/sync/asset/run", post(handlers::run_asset_sync))
|
||||
.route("/sync/asset/stop", post(handlers::stop_asset_sync))
|
||||
.route("/sync/asset/status", get(handlers::get_asset_sync_status));
|
||||
.route("/sync/asset/status", get(handlers::get_asset_sync_status))
|
||||
.route("/sync/queries", get(handlers::get_sync_queries))
|
||||
.route("/sync/queries/:id", axum::routing::delete(handlers::delete_sync_query));
|
||||
|
||||
// 静态文件资源代理托管(当前端打包至 dashboard/dist 后,直接挂载到主域名根路由)
|
||||
let serve_dir = ServeDir::new("dashboard/dist")
|
||||
@ -134,6 +131,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.nest("/api", api_routes)
|
||||
.fallback_service(serve_dir)
|
||||
.layer(cors)
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
.with_state(app_state);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
|
||||
|
||||
@ -87,6 +87,18 @@ impl MetaSync {
|
||||
tokio::spawn(async move {
|
||||
info!("启动后台批量收割任务: 查询词='{}', 源='{}', 上限={}", query_clone, source_clone, limit);
|
||||
|
||||
// 自动将检索配置存入/更新至 sync_queries 数据库表中进行去重和时间更新
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO sync_queries (query, source, limit_count, last_run) \
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP) \
|
||||
ON CONFLICT(query, source, limit_count) DO UPDATE SET last_run=excluded.last_run"
|
||||
)
|
||||
.bind(&query_clone)
|
||||
.bind(&source_clone)
|
||||
.bind(limit)
|
||||
.execute(&db)
|
||||
.await;
|
||||
|
||||
// 1. 并行获取两端预估总量
|
||||
let ads_count_fut = {
|
||||
let ads = ads.clone();
|
||||
@ -282,6 +294,8 @@ pub struct AssetSyncStatus {
|
||||
pub total: i32,
|
||||
pub downloaded: i32,
|
||||
pub parsed: i32,
|
||||
pub download_failed: i32,
|
||||
pub parse_failed: i32,
|
||||
pub current_bibcode: String,
|
||||
pub logs: Vec<String>,
|
||||
pub action: Option<SyncAction>,
|
||||
@ -294,6 +308,8 @@ impl AssetSyncStatus {
|
||||
total: 0,
|
||||
downloaded: 0,
|
||||
parsed: 0,
|
||||
download_failed: 0,
|
||||
parse_failed: 0,
|
||||
current_bibcode: String::new(),
|
||||
logs: Vec::new(),
|
||||
action: None,
|
||||
@ -331,6 +347,8 @@ impl AssetSync {
|
||||
s.total = total;
|
||||
s.downloaded = 0;
|
||||
s.parsed = 0;
|
||||
s.download_failed = 0;
|
||||
s.parse_failed = 0;
|
||||
s.current_bibcode = String::new();
|
||||
s.logs.clear();
|
||||
s.action = Some(action);
|
||||
@ -344,7 +362,8 @@ impl AssetSync {
|
||||
}
|
||||
|
||||
let mut dl_count = 0;
|
||||
let mut parse_count = 0;
|
||||
let mut dl_failed_count = 0;
|
||||
let mut join_handles = Vec::new();
|
||||
|
||||
for bibcode in bibcodes {
|
||||
// 每次循环前,检查是否被外部停止了(active 设为 false)
|
||||
@ -364,20 +383,21 @@ impl AssetSync {
|
||||
|
||||
// 1. 获取文献元数据与当前路径状态
|
||||
let paper_res = sqlx::query(
|
||||
"SELECT arxiv_id, doi, pdf_path, html_path, markdown_path FROM papers WHERE bibcode = ?"
|
||||
"SELECT arxiv_id, doi, pdf_path, html_path, markdown_path, doctype FROM papers WHERE bibcode = ?"
|
||||
)
|
||||
.bind(&bibcode)
|
||||
.fetch_optional(&db)
|
||||
.await;
|
||||
|
||||
let (arxiv_id, doi, mut pdf_path, mut html_path, markdown_path) = match paper_res {
|
||||
let (arxiv_id, doi, mut pdf_path, mut html_path, markdown_path, doctype) = match paper_res {
|
||||
Ok(Some(row)) => {
|
||||
let arxiv_id: String = row.get(0);
|
||||
let doi: String = row.get(1);
|
||||
let pdf_path: Option<String> = row.get(2);
|
||||
let html_path: Option<String> = row.get(3);
|
||||
let markdown_path: Option<String> = row.get(4);
|
||||
(arxiv_id, doi, pdf_path, html_path, markdown_path)
|
||||
let doctype: Option<String> = row.get(5);
|
||||
(arxiv_id, doi, pdf_path, html_path, markdown_path, doctype)
|
||||
}
|
||||
_ => {
|
||||
let mut s = status.lock().await;
|
||||
@ -386,6 +406,22 @@ impl AssetSync {
|
||||
}
|
||||
};
|
||||
|
||||
// 1b. 检查 doctype,如果是 proposal, abstract, catalog, software 等无数字全文的文件,直接跳过处理
|
||||
let doctype_str = doctype.unwrap_or_else(|| "article".to_string()).to_lowercase();
|
||||
if doctype_str == "proposal" || doctype_str == "abstract" || doctype_str == "catalog" || doctype_str == "software" {
|
||||
let mut s = status.lock().await;
|
||||
s.add_log(format!("文献 {} 的类型为 {} (无数字版全文),跳过下载与解析。", bibcode, doctype_str));
|
||||
// 同样更新处理进度,防止任务进度条卡住
|
||||
if action == SyncAction::Download || action == SyncAction::All {
|
||||
dl_count += 1;
|
||||
s.downloaded = dl_count;
|
||||
}
|
||||
if action == SyncAction::Parse || action == SyncAction::All {
|
||||
s.parsed += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 检查并执行下载
|
||||
if action == SyncAction::Download || action == SyncAction::All {
|
||||
let is_pdf_exist = pdf_path.as_ref().map(|p| config.library_dir.join(p).exists()).unwrap_or(false);
|
||||
@ -427,7 +463,9 @@ impl AssetSync {
|
||||
s.add_log(format!("文献 {} 下载成功!", bibcode));
|
||||
}
|
||||
} else {
|
||||
dl_failed_count += 1;
|
||||
let mut s = status.lock().await;
|
||||
s.download_failed = dl_failed_count;
|
||||
s.add_log(format!("文献 {} 下载失败(PDF 和 HTML 均下载失败)", bibcode));
|
||||
}
|
||||
|
||||
@ -457,7 +495,6 @@ impl AssetSync {
|
||||
s.add_log(format!("文献 {} 开始进行排版提取与 Markdown 转换...", bibcode));
|
||||
}
|
||||
|
||||
let mut parsed_markdown = String::new();
|
||||
let mut relative_md_path = String::new();
|
||||
|
||||
// 确定源链接
|
||||
@ -499,7 +536,7 @@ impl AssetSync {
|
||||
year,
|
||||
keywords.join(",")
|
||||
);
|
||||
parsed_markdown = format!("{}{}", front_matter, md);
|
||||
let parsed_markdown = format!("{}{}", front_matter, md);
|
||||
let md_filename = format!("{}.md", bibcode);
|
||||
let md_dest = config.library_dir.join("Markdown").join(&md_filename);
|
||||
let _ = fs::create_dir_all(md_dest.parent().unwrap());
|
||||
@ -511,18 +548,85 @@ impl AssetSync {
|
||||
}
|
||||
}
|
||||
|
||||
// 策略 2:PDF 回退(远程 MinerU)
|
||||
if parsed_markdown.is_empty() {
|
||||
if !relative_md_path.is_empty() {
|
||||
// HTML 解析成功,直接写入数据库并记录成功
|
||||
let _ = sqlx::query("UPDATE papers SET markdown_path = ? WHERE bibcode = ?")
|
||||
.bind(&relative_md_path)
|
||||
.bind(&bibcode)
|
||||
.execute(&db)
|
||||
.await;
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.parsed += 1;
|
||||
s.add_log(format!("文献 {} HTML 本地解析成功!", bibcode));
|
||||
}
|
||||
} else {
|
||||
// HTML 解析失败或无 HTML,执行 PDF 回退(异步非阻塞提交 MinerU)
|
||||
if let Some(pdf_rel) = &pdf_path {
|
||||
let pdf_abs = config.library_dir.join(pdf_rel);
|
||||
if pdf_abs.exists() {
|
||||
match crate::services::parser::parse_pdf_via_mineru(&pdf_abs, &qiniu, &config).await {
|
||||
// 检查是否已经是 mineru_batch: 状态
|
||||
let existing_batch_id = markdown_path.as_ref()
|
||||
.and_then(|p| p.strip_prefix("mineru_batch:"))
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
let db_clone = db.clone();
|
||||
let config_clone = config.clone();
|
||||
let qiniu_clone = qiniu.clone();
|
||||
let status_clone = status.clone();
|
||||
let bibcode_clone = bibcode.clone();
|
||||
let source_url_clone = source_url.clone();
|
||||
|
||||
let mut submitted_ok = true;
|
||||
let batch_id = if let Some(id) = existing_batch_id {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.add_log(format!("文献 {} 检测到未完成的 MinerU 任务,正在恢复轮询 (Batch ID: {})...", bibcode, id));
|
||||
}
|
||||
id
|
||||
} else {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.add_log(format!("文献 {} PDF 提交后台解析 (MinerU)...", bibcode));
|
||||
}
|
||||
match crate::services::parser::submit_pdf_to_mineru(&pdf_abs, &config).await {
|
||||
Ok(id) => {
|
||||
// 提交成功,立刻把 batch_id 存入数据库以备断点续跑
|
||||
let marker = format!("mineru_batch:{}", id);
|
||||
let _ = sqlx::query("UPDATE papers SET markdown_path = ? WHERE bibcode = ?")
|
||||
.bind(&marker)
|
||||
.bind(&bibcode)
|
||||
.execute(&db)
|
||||
.await;
|
||||
id
|
||||
}
|
||||
Err(e) => {
|
||||
let mut s = status.lock().await;
|
||||
s.parse_failed += 1;
|
||||
s.add_log(format!("文献 {} PDF 提交 MinerU 失败: {}", bibcode, e));
|
||||
let err_reason = format!("error: {}", e);
|
||||
let _ = sqlx::query("UPDATE papers SET markdown_path = ? WHERE bibcode = ?")
|
||||
.bind(&err_reason)
|
||||
.bind(&bibcode)
|
||||
.execute(&db)
|
||||
.await;
|
||||
submitted_ok = false;
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if submitted_ok {
|
||||
let handle = tokio::spawn(async move {
|
||||
match crate::services::parser::poll_and_extract_mineru(&batch_id, &bibcode_clone, &qiniu_clone, &config_clone).await {
|
||||
Ok(md) => {
|
||||
let paper_meta_res = sqlx::query("SELECT title, authors, pub, year, keywords FROM papers WHERE bibcode = ?")
|
||||
.bind(&bibcode)
|
||||
.fetch_optional(&db)
|
||||
.bind(&bibcode_clone)
|
||||
.fetch_optional(&db_clone)
|
||||
.await;
|
||||
|
||||
let mut rel_md = String::new();
|
||||
if let Ok(Some(meta_row)) = paper_meta_res {
|
||||
let title: String = meta_row.get(0);
|
||||
let authors_json: String = meta_row.get(1);
|
||||
@ -538,47 +642,76 @@ impl AssetSync {
|
||||
serde_json::to_string(&title).unwrap_or_else(|_| format!("\"{}\"", title)),
|
||||
authors.iter().map(|a| format!("\"{}\"", a)).collect::<Vec<_>>().join(", "),
|
||||
serde_json::to_string(&pub_journal).unwrap_or_else(|_| format!("\"{}\"", pub_journal)),
|
||||
source_url,
|
||||
source_url_clone,
|
||||
year,
|
||||
keywords.join(",")
|
||||
);
|
||||
parsed_markdown = format!("{}{}", front_matter, md);
|
||||
let md_filename = format!("{}.md", bibcode);
|
||||
let md_dest = config.library_dir.join("Markdown").join(&md_filename);
|
||||
let parsed_markdown = format!("{}{}", front_matter, md);
|
||||
let md_filename = format!("{}.md", bibcode_clone);
|
||||
let md_dest = config_clone.library_dir.join("Markdown").join(&md_filename);
|
||||
let _ = fs::create_dir_all(md_dest.parent().unwrap());
|
||||
if fs::write(&md_dest, &parsed_markdown).is_ok() {
|
||||
relative_md_path = format!("Markdown/{}", md_filename);
|
||||
rel_md = format!("Markdown/{}", md_filename);
|
||||
}
|
||||
}
|
||||
|
||||
if !rel_md.is_empty() {
|
||||
let _ = sqlx::query("UPDATE papers SET markdown_path = ? WHERE bibcode = ?")
|
||||
.bind(&rel_md)
|
||||
.bind(&bibcode_clone)
|
||||
.execute(&db_clone)
|
||||
.await;
|
||||
|
||||
let mut s = status_clone.lock().await;
|
||||
s.parsed += 1;
|
||||
s.add_log(format!("文献 {} PDF (MinerU) 解析成功!", bibcode_clone));
|
||||
} else {
|
||||
let mut s = status_clone.lock().await;
|
||||
s.parse_failed += 1;
|
||||
s.add_log(format!("文献 {} PDF 写入 Markdown 失败。", bibcode_clone));
|
||||
let _ = sqlx::query("UPDATE papers SET markdown_path = 'error: PDF 写入 Markdown 失败' WHERE bibcode = ?")
|
||||
.bind(&bibcode_clone)
|
||||
.execute(&db_clone)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let mut s = status.lock().await;
|
||||
s.add_log(format!("PDF 结构解析失败 (MinerU): {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !relative_md_path.is_empty() {
|
||||
let mut s = status_clone.lock().await;
|
||||
s.parse_failed += 1;
|
||||
s.add_log(format!("文献 {} PDF 结构解析失败 (MinerU): {}", bibcode_clone, e));
|
||||
let err_reason = format!("error: {}", e);
|
||||
let _ = sqlx::query("UPDATE papers SET markdown_path = ? WHERE bibcode = ?")
|
||||
.bind(&relative_md_path)
|
||||
.bind(&err_reason)
|
||||
.bind(&bibcode_clone)
|
||||
.execute(&db_clone)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
join_handles.push(handle);
|
||||
}
|
||||
} else {
|
||||
let mut s = status.lock().await;
|
||||
s.parse_failed += 1;
|
||||
s.add_log(format!("文献 {} 本地 PDF 文件不存在,无法解析。", bibcode));
|
||||
let _ = sqlx::query("UPDATE papers SET markdown_path = 'error: 本地 PDF 文件不存在' WHERE bibcode = ?")
|
||||
.bind(&bibcode)
|
||||
.execute(&db)
|
||||
.await;
|
||||
|
||||
parse_count += 1;
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.parsed = parse_count;
|
||||
s.add_log(format!("文献 {} Markdown 解析成功!", bibcode));
|
||||
}
|
||||
} else {
|
||||
let mut s = status.lock().await;
|
||||
s.add_log(format!("文献 {} 转换为 Markdown 失败。", bibcode));
|
||||
s.parse_failed += 1;
|
||||
s.add_log(format!("文献 {} HTML 转换失败,且无本地 PDF,无法解析。", bibcode));
|
||||
let _ = sqlx::query("UPDATE papers SET markdown_path = 'error: HTML 转换失败且无本地 PDF' WHERE bibcode = ?")
|
||||
.bind(&bibcode)
|
||||
.execute(&db)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut s = status.lock().await;
|
||||
s.parse_failed += 1;
|
||||
s.add_log(format!("文献 {} 无本地 PDF/HTML,无法解析,跳过。", bibcode));
|
||||
}
|
||||
} else {
|
||||
@ -586,12 +719,19 @@ impl AssetSync {
|
||||
let mut s = status.lock().await;
|
||||
s.add_log(format!("文献 {} 已存在解析后的 Markdown,跳过。", bibcode));
|
||||
}
|
||||
parse_count += 1;
|
||||
let mut s = status.lock().await;
|
||||
s.parsed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !join_handles.is_empty() {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.parsed = parse_count;
|
||||
}
|
||||
s.add_log(format!("本地下载与快速解析已完成,正在等待后台共 {} 个 MinerU 异步解析任务结束...", join_handles.len()));
|
||||
}
|
||||
for handle in join_handles {
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use url::Url;
|
||||
use tracing::{info, warn};
|
||||
use tracing::{info, warn, debug};
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
// ─── 浏览器伪装辅助 ────────────────────────────────────────────
|
||||
@ -43,7 +43,6 @@ fn build_browser_headers() -> HeaderMap {
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
));
|
||||
h.insert("Accept-Language", HeaderValue::from_static("en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7"));
|
||||
h.insert("Accept-Encoding", HeaderValue::from_static("gzip, deflate, br"));
|
||||
h.insert("DNT", HeaderValue::from_static("1"));
|
||||
h.insert("Connection", HeaderValue::from_static("keep-alive"));
|
||||
h.insert("Upgrade-Insecure-Requests", HeaderValue::from_static("1"));
|
||||
@ -64,7 +63,6 @@ fn build_chrome_headers(referer: Option<&str>) -> HeaderMap {
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
));
|
||||
h.insert("Accept-Language", HeaderValue::from_static("en-US,en;q=0.9"));
|
||||
h.insert("Accept-Encoding", HeaderValue::from_static("gzip, deflate, br, zstd"));
|
||||
h.insert("Sec-Ch-Ua", HeaderValue::from_static(
|
||||
"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"",
|
||||
));
|
||||
@ -224,7 +222,7 @@ impl Downloader {
|
||||
|
||||
/// 解析 ADS Link Gateway 路由,若遇 perfdrive 防护则提取 ssc 参数绕过
|
||||
async fn resolve_ads_gateway(&self, gateway_url: &str) -> Result<String> {
|
||||
info!("解析 ADS 网关: {}", gateway_url);
|
||||
debug!("解析 ADS 网关: {}", gateway_url);
|
||||
|
||||
// HEAD 请求跟踪重定向(部分出版商阻断 HEAD,自动降级 GET)
|
||||
let response = match self.client.head(gateway_url).send().await {
|
||||
@ -234,7 +232,7 @@ impl Downloader {
|
||||
};
|
||||
|
||||
let final_url = response.url().as_str().to_string();
|
||||
info!("网关解析结果: {}", final_url);
|
||||
debug!("网关解析结果: {}", final_url);
|
||||
|
||||
// 如重定向至 validate.perfdrive.com,提取 ssc 参数中的真实 URL
|
||||
if final_url.contains("validate.perfdrive.com") {
|
||||
@ -242,7 +240,7 @@ impl Downloader {
|
||||
if let Some(ssc) = parsed.query_pairs().find(|(k, _)| k == "ssc").map(|(_, v)| v.into_owned()) {
|
||||
if let Ok(decoded) = urlencoding::decode(&ssc) {
|
||||
let real_url = decoded.into_owned();
|
||||
info!("检测到 perfdrive 拦截,解码真实地址: {}", real_url);
|
||||
debug!("检测到 perfdrive 拦截,解码真实地址: {}", real_url);
|
||||
return Ok(real_url);
|
||||
}
|
||||
}
|
||||
@ -276,18 +274,18 @@ impl Downloader {
|
||||
let pdf_url = format!("https://iopscience.iop.org/article/{}/pdf", doi);
|
||||
|
||||
// 步骤 1:访问文章主页,建立 Cookie 会话
|
||||
info!("[IOP] 预热主页: {}", main_url);
|
||||
debug!("[IOP] 预热主页: {}", main_url);
|
||||
Self::maybe_delay().await;
|
||||
match self.client.get(&main_url)
|
||||
.headers(build_chrome_headers(None))
|
||||
.send().await
|
||||
{
|
||||
Ok(r) => info!("[IOP] 主页响应: {}", r.status()),
|
||||
Ok(r) => debug!("[IOP] 主页响应: {}", r.status()),
|
||||
Err(e) => warn!("[IOP] 主页访问失败(继续尝试): {:?}", e),
|
||||
}
|
||||
|
||||
// 步骤 2:携带 Referer 下载 PDF
|
||||
info!("[IOP] 下载 PDF: {}", pdf_url);
|
||||
debug!("[IOP] 下载 PDF: {}", pdf_url);
|
||||
Self::maybe_delay().await;
|
||||
let response = self.client.get(&pdf_url)
|
||||
.headers(build_chrome_headers(Some(&main_url)))
|
||||
@ -516,7 +514,19 @@ impl Downloader {
|
||||
Err(e) => warn!("[PUB_PDF] 网关解析失败: {:?}", e),
|
||||
}
|
||||
|
||||
// 1b. ADS EPRINT_PDF 网关
|
||||
// 1b. ADS_PDF 网关 (经典 ADS 整合 PDF 直接通道)
|
||||
let gw = format!("{}/{}/ADS_PDF", base, bibcode);
|
||||
match self.resolve_ads_gateway(&gw).await {
|
||||
Ok(resolved) => {
|
||||
match self.download_pdf_direct(&resolved, &pdf_dest, "ADS_PDF").await {
|
||||
Ok(_) => { pdf_ok = Some(pdf_dest.clone()); break 'pdf; }
|
||||
Err(e) => warn!("[ADS_PDF] 下载失败: {:?}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("[ADS_PDF] 网关解析失败: {:?}", e),
|
||||
}
|
||||
|
||||
// 1c. ADS EPRINT_PDF 网关
|
||||
let gw = format!("{}/{}/EPRINT_PDF", base, bibcode);
|
||||
match self.resolve_ads_gateway(&gw).await {
|
||||
Ok(resolved) => {
|
||||
@ -531,10 +541,17 @@ impl Downloader {
|
||||
// 1c. CrossRef API 回退(需要 DOI)
|
||||
if let Some(doi_str) = doi {
|
||||
match self.download_crossref_pdf(doi_str, &pdf_dest).await {
|
||||
Ok(_) => { pdf_ok = Some(pdf_dest.clone()); }
|
||||
Ok(_) => { pdf_ok = Some(pdf_dest.clone()); break 'pdf; }
|
||||
Err(e) => warn!("[CrossRef] PDF 下载失败: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// 1d. ADS SCAN 扫描版文献直接合并下载 PDF(主要针对早期/不可下载直接 PDF 的文献)
|
||||
let scan_url = format!("https://articles.adsabs.harvard.edu/cgi-bin/nph-iarticle_query?bibcode={}&db_key=AST&data_type=PDF_HIGH", bibcode);
|
||||
match self.download_pdf_direct(&scan_url, &pdf_dest, "ADS_SCAN").await {
|
||||
Ok(_) => { pdf_ok = Some(pdf_dest.clone()); }
|
||||
Err(e) => warn!("[ADS_SCAN] 下载失败: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTML 下载 ──────────────────────────────────────────
|
||||
@ -710,5 +727,25 @@ mod tests {
|
||||
Some("2101.00001".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_download_scan_pdf() -> anyhow::Result<()> {
|
||||
let downloader = Downloader::new();
|
||||
let bibcode = "2005MNRAS.359..315E";
|
||||
let temp_dir = std::env::temp_dir();
|
||||
|
||||
let (pdf_path, _html_path) = downloader.download_paper(bibcode, None, &temp_dir).await;
|
||||
assert!(pdf_path.is_some());
|
||||
|
||||
let path = pdf_path.unwrap();
|
||||
assert!(path.exists());
|
||||
|
||||
let bytes = std::fs::read(&path)?;
|
||||
assert!(bytes.starts_with(b"%PDF"));
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
84
src/services/logging.rs
Normal file
84
src/services/logging.rs
Normal file
@ -0,0 +1,84 @@
|
||||
// src/services/logging.rs
|
||||
use chrono::{DateTime, FixedOffset, Utc};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
use tracing_appender::rolling;
|
||||
use tracing_subscriber::fmt::format::Writer;
|
||||
use tracing_subscriber::fmt::time::FormatTime;
|
||||
use tracing_subscriber::{
|
||||
fmt, layer::SubscriberExt, util::SubscriberInitExt,
|
||||
EnvFilter, Layer,
|
||||
};
|
||||
|
||||
pub struct ShanghaiTime;
|
||||
|
||||
impl FormatTime for ShanghaiTime {
|
||||
fn format_time(&self, w: &mut Writer<'_>) -> std::fmt::Result {
|
||||
let now: DateTime<Utc> = Utc::now();
|
||||
let offset = FixedOffset::east_opt(8 * 3600).unwrap();
|
||||
let shanghai_time = now.with_timezone(&offset);
|
||||
write!(w, "{}", shanghai_time.format("%Y-%m-%dT%H:%M:%S%.3f%:z"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化系统全局日志模块,支持控制台输出与每天自动滚动的日志文件
|
||||
pub fn init_logging() -> anyhow::Result<Vec<WorkerGuard>> {
|
||||
let mut guards = Vec::new();
|
||||
|
||||
// 从环境变量中读取配置
|
||||
let log_level = env::var("LOG_LEVEL").unwrap_or_else(|_| "info,astroresearch=debug".to_string());
|
||||
let log_format = env::var("LOG_FORMAT").unwrap_or_else(|_| "pretty".to_string());
|
||||
let log_outputs = env::var("LOG_OUTPUTS").unwrap_or_else(|_| "stdout,file".to_string());
|
||||
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new(&log_level));
|
||||
let is_json = log_format.to_lowercase() == "json";
|
||||
|
||||
let mut layers: Vec<Box<dyn Layer<tracing_subscriber::Registry> + Send + Sync>> = Vec::new();
|
||||
|
||||
// 1. 控制台输出层 (stdout)
|
||||
if log_outputs.contains("stdout") {
|
||||
let (non_blocking, guard) = tracing_appender::non_blocking(std::io::stdout());
|
||||
guards.push(guard);
|
||||
|
||||
let fmt_layer = fmt::layer()
|
||||
.with_timer(ShanghaiTime)
|
||||
.with_writer(non_blocking);
|
||||
|
||||
if is_json {
|
||||
layers.push(fmt_layer.json().with_ansi(false).boxed());
|
||||
} else {
|
||||
layers.push(fmt_layer.pretty().with_ansi(true).boxed());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 每日滚动文件日志层 (file)
|
||||
if log_outputs.contains("file") {
|
||||
let log_dir = env::var("LOG_DIR").unwrap_or_else(|_| "logs".to_string());
|
||||
fs::create_dir_all(&log_dir).unwrap_or(());
|
||||
|
||||
let file_appender = rolling::daily(log_dir, "astro_research.log");
|
||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||
guards.push(guard);
|
||||
|
||||
let fmt_layer = fmt::layer()
|
||||
.with_timer(ShanghaiTime)
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false);
|
||||
|
||||
if is_json {
|
||||
layers.push(fmt_layer.json().boxed());
|
||||
} else {
|
||||
layers.push(fmt_layer.boxed());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 注册全部日志层
|
||||
tracing_subscriber::registry()
|
||||
.with(layers)
|
||||
.with(env_filter)
|
||||
.init();
|
||||
|
||||
Ok(guards)
|
||||
}
|
||||
@ -3,3 +3,4 @@ pub mod parser;
|
||||
pub mod translation;
|
||||
pub mod query_parser;
|
||||
pub mod batch_sync;
|
||||
pub mod logging;
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
// src/parser.rs
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use serde::Deserialize;
|
||||
use reqwest::multipart;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
use regex::Regex;
|
||||
use base64::Engine;
|
||||
|
||||
use crate::Config;
|
||||
use crate::clients::qiniu::QiniuClient;
|
||||
@ -13,7 +11,20 @@ use crate::clients::qiniu::QiniuClient;
|
||||
// 清理 HTML 结构,仅提取正文部分并转换为标准 Markdown
|
||||
pub fn html_to_markdown(html_path: &Path) -> anyhow::Result<String> {
|
||||
info!("正在解析本地 HTML 并提取 Markdown: {:?}", html_path);
|
||||
let html_content = fs::read_to_string(html_path)?;
|
||||
let html_bytes = fs::read(html_path)?;
|
||||
|
||||
// 检查是否为 Gzip 压缩文件 (Gzip 幻数: 0x1f 0x8b)
|
||||
let decompressed_bytes = if html_bytes.starts_with(&[0x1f, 0x8b]) {
|
||||
use std::io::Read;
|
||||
let mut decoder = flate2::read::GzDecoder::new(&html_bytes[..]);
|
||||
let mut buf = Vec::new();
|
||||
decoder.read_to_end(&mut buf)?;
|
||||
buf
|
||||
} else {
|
||||
html_bytes
|
||||
};
|
||||
|
||||
let html_content = String::from_utf8_lossy(&decompressed_bytes).into_owned();
|
||||
|
||||
// 截断页脚及之后的不相关内容以防干扰解析
|
||||
let mut truncated_html = html_content.as_str();
|
||||
@ -287,10 +298,61 @@ fn strip_html_tags(html: &str) -> String {
|
||||
.replace("'", "'")
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct BatchUploadRequest {
|
||||
files: Vec<PendingFile>,
|
||||
language: String,
|
||||
is_ocr: bool,
|
||||
model_version: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PendingFile {
|
||||
name: String,
|
||||
data_id: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize)]
|
||||
struct BatchUploadResponse {
|
||||
code: i32,
|
||||
msg: String,
|
||||
data: BatchUploadData,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BatchUploadData {
|
||||
batch_id: String,
|
||||
file_urls: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize)]
|
||||
struct BatchResultResponse {
|
||||
code: i32,
|
||||
msg: String,
|
||||
data: Option<BatchResultData>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize)]
|
||||
struct BatchResultData {
|
||||
batch_id: String,
|
||||
extract_result: Vec<ExtractResult>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize)]
|
||||
struct ExtractResult {
|
||||
file_name: String,
|
||||
state: String,
|
||||
full_zip_url: Option<String>,
|
||||
err_msg: Option<String>,
|
||||
}
|
||||
|
||||
// 调用 MinerU 远程接口解析 PDF,并在提取出图片后自动上传至七牛云进行外链替换
|
||||
pub async fn parse_pdf_via_mineru(
|
||||
pub async fn submit_pdf_to_mineru(
|
||||
pdf_path: &Path,
|
||||
qiniu_client: &QiniuClient,
|
||||
config: &Config
|
||||
) -> anyhow::Result<String> {
|
||||
info!("正在请求 MinerU 解析本地 PDF 文献: {:?}", pdf_path);
|
||||
@ -305,60 +367,221 @@ pub async fn parse_pdf_via_mineru(
|
||||
.unwrap_or("paper.pdf")
|
||||
.to_string();
|
||||
|
||||
let file_part = multipart::Part::bytes(pdf_bytes).file_name(filename);
|
||||
let form = multipart::Form::new()
|
||||
.part("file", file_part);
|
||||
let bibcode = pdf_path.file_stem()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("paper")
|
||||
.to_string();
|
||||
|
||||
// 提取 base_url
|
||||
let base_url = config.mineru_api_url
|
||||
.replace("/extract/task", "")
|
||||
.replace("/extract", "")
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
|
||||
info!("正在发送 PDF 字节流至 MinerU 接口地址: {}", config.mineru_api_url);
|
||||
let client = reqwest::Client::new();
|
||||
let data_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// 1. 获取预签名上传 URL
|
||||
info!("MinerU: 正在请求批量直传 URL (Bibcode: {})", bibcode);
|
||||
let upload_req = BatchUploadRequest {
|
||||
files: vec![PendingFile {
|
||||
name: filename.clone(),
|
||||
data_id: data_id.clone(),
|
||||
}],
|
||||
language: "en".to_string(),
|
||||
is_ocr: true,
|
||||
model_version: "vlm".to_string(),
|
||||
};
|
||||
|
||||
let mut request = client.post(format!("{}/file-urls/batch/", base_url))
|
||||
.json(&upload_req);
|
||||
|
||||
let mut request = client.post(&config.mineru_api_url).multipart(form);
|
||||
if !config.mineru_api_key.is_empty() {
|
||||
request = request.header("Authorization", format!("Bearer {}", config.mineru_api_key));
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!("MinerU 解析接口返回失败码: {}", response.status()));
|
||||
let status = response.status();
|
||||
let res_text = response.text().await?;
|
||||
if !status.is_success() {
|
||||
return Err(anyhow::anyhow!("请求 MinerU 批量上传 URL 失败 (状态码: {}): {}", status, res_text));
|
||||
}
|
||||
|
||||
// MinerU 远程服务响应 JSON,包含转换出的 markdown 正文和图片映射
|
||||
#[derive(Deserialize)]
|
||||
struct MinerUResponse {
|
||||
markdown: String,
|
||||
images: Option<std::collections::HashMap<String, String>>, // 图片文件名 -> Base64 字符串
|
||||
let upload_res: BatchUploadResponse = serde_json::from_str(&res_text)?;
|
||||
if upload_res.code != 0 {
|
||||
return Err(anyhow::anyhow!("MinerU API 错误: {}", upload_res.msg));
|
||||
}
|
||||
|
||||
let result: MinerUResponse = response.json().await?;
|
||||
let mut markdown = result.markdown;
|
||||
let upload_url = upload_res.data.file_urls.first()
|
||||
.ok_or_else(|| anyhow::anyhow!("MinerU 未返回上传 URL"))?;
|
||||
|
||||
// 2. 上传文件 (PUT)
|
||||
info!("MinerU: 正在直接上传 PDF 字节流至对象存储...");
|
||||
let put_res = client.put(upload_url)
|
||||
.body(pdf_bytes)
|
||||
.send()
|
||||
.await?;
|
||||
if !put_res.status().is_success() {
|
||||
return Err(anyhow::anyhow!("上传 PDF 至 MinerU 对象存储直传 URL 失败: {}", put_res.status()));
|
||||
}
|
||||
|
||||
let batch_id = upload_res.data.batch_id;
|
||||
Ok(batch_id)
|
||||
}
|
||||
|
||||
pub async fn poll_and_extract_mineru(
|
||||
batch_id: &str,
|
||||
bibcode: &str,
|
||||
qiniu_client: &QiniuClient,
|
||||
config: &Config
|
||||
) -> anyhow::Result<String> {
|
||||
let client = reqwest::Client::new();
|
||||
let base_url = config.mineru_api_url
|
||||
.replace("/extract/task", "")
|
||||
.replace("/extract", "")
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
|
||||
let mut poll_count = 0;
|
||||
let max_polls = 45; // 45 * 10s = 7.5 min
|
||||
info!("MinerU: 开始轮询任务结果 (Batch ID: {})...", batch_id);
|
||||
|
||||
let mut full_zip_url = String::new();
|
||||
loop {
|
||||
poll_count += 1;
|
||||
if poll_count > max_polls {
|
||||
return Err(anyhow::anyhow!("MinerU 结构化解析超时 (Bibcode: {})", bibcode));
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
||||
|
||||
let mut status_req = client.get(format!("{}/extract-results/batch/{}", base_url, batch_id));
|
||||
if !config.mineru_api_key.is_empty() {
|
||||
status_req = status_req.header("Authorization", format!("Bearer {}", config.mineru_api_key));
|
||||
}
|
||||
|
||||
let status_res = status_req.send().await?;
|
||||
let status_text = status_res.text().await?;
|
||||
let result_data: BatchResultResponse = serde_json::from_str(&status_text)?;
|
||||
|
||||
if let Some(data) = result_data.data {
|
||||
if let Some(file_result) = data.extract_result.first() {
|
||||
match file_result.state.as_str() {
|
||||
"done" => {
|
||||
info!("MinerU: 解析成功!");
|
||||
full_zip_url = file_result.full_zip_url.clone().unwrap_or_default();
|
||||
break;
|
||||
}
|
||||
"error" | "failed" => {
|
||||
let err_msg = file_result.err_msg.clone().unwrap_or_default();
|
||||
return Err(anyhow::anyhow!("MinerU 批量解析任务失败: {}", err_msg));
|
||||
}
|
||||
other => {
|
||||
info!("MinerU 任务处理中... 当前状态: {}", other);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("MinerU 轮询响应中未发现文件解析任务结果"));
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("MinerU 轮询响应数据为空"));
|
||||
}
|
||||
}
|
||||
|
||||
if full_zip_url.is_empty() {
|
||||
return Err(anyhow::anyhow!("MinerU 转换成功但未返回结果 ZIP 下载 URL"));
|
||||
}
|
||||
|
||||
// 4. 下载并解压 ZIP
|
||||
info!("MinerU: 正在下载最终提取压缩包: {}", full_zip_url);
|
||||
let zip_bytes = client.get(&full_zip_url).send().await?.bytes().await?;
|
||||
|
||||
let reader = std::io::Cursor::new(zip_bytes);
|
||||
let mut archive = zip::ZipArchive::new(reader)?;
|
||||
|
||||
let mut markdown = String::new();
|
||||
let mut image_buffers = std::collections::HashMap::new();
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
let name = file.name().to_string();
|
||||
|
||||
if name.ends_with(".md") {
|
||||
let mut md_content = String::new();
|
||||
std::io::Read::read_to_string(&mut file, &mut md_content)?;
|
||||
markdown = md_content;
|
||||
} else if file.is_file() {
|
||||
let lower = name.to_lowercase();
|
||||
if lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") || lower.ends_with(".gif") || lower.ends_with(".svg") {
|
||||
let mut buf = Vec::new();
|
||||
std::io::copy(&mut file, &mut buf)?;
|
||||
let file_basename = Path::new(&name)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or(&name)
|
||||
.to_string();
|
||||
image_buffers.insert(file_basename, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if markdown.is_empty() {
|
||||
return Err(anyhow::anyhow!("解析后的压缩包中未发现核心 Markdown 文档"));
|
||||
}
|
||||
|
||||
// 5. 上传图片并重写链接
|
||||
if !image_buffers.is_empty() {
|
||||
let local_img_dir = config.library_dir.join("images").join(bibcode);
|
||||
let _ = fs::create_dir_all(&local_img_dir);
|
||||
|
||||
// 上传图片并重写 Markdown 连接地址
|
||||
if let Some(images) = result.images {
|
||||
if qiniu_client.is_configured() {
|
||||
info!("MinerU 成功解析出 {} 张本地插图。正在准备同步至七牛云...", images.len());
|
||||
for (img_name, base64_data) in images {
|
||||
if let Ok(img_bytes) = base64::engine::general_purpose::STANDARD.decode(base64_data) {
|
||||
info!("MinerU 批量模式解析出 {} 张本地插图。准备上传至七牛云...", image_buffers.len());
|
||||
for (img_name, img_bytes) in image_buffers {
|
||||
let local_path = local_img_dir.join(&img_name);
|
||||
let _ = fs::write(&local_path, &img_bytes);
|
||||
|
||||
match qiniu_client.upload_buffer(img_bytes, &img_name).await {
|
||||
Ok(qiniu_url) => {
|
||||
// 使用正则将 Markdown 中的本地临时图地址替换为七牛云 CDN 地址
|
||||
let escaped_img_name = regex::escape(&img_name);
|
||||
let link_re = Regex::new(&format!(r"\(([^)]*?){}\)", escaped_img_name)).unwrap();
|
||||
markdown = link_re.replace_all(&markdown, |_: ®ex::Captures| {
|
||||
format!("({})", qiniu_url)
|
||||
}).to_string();
|
||||
},
|
||||
}
|
||||
Err(e) => warn!("上传图片至七牛云失败 {}: {}", img_name, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("未检测到七牛云配置,解析出的图片将保留临时地址,无法在外网或 Obsidian 中直观预览");
|
||||
warn!("未检测到七牛云配置,解析出的图片将保存在本地 images 目录下");
|
||||
for (img_name, img_bytes) in image_buffers {
|
||||
let local_path = local_img_dir.join(&img_name);
|
||||
let _ = fs::write(&local_path, &img_bytes);
|
||||
|
||||
let escaped_img_name = regex::escape(&img_name);
|
||||
let link_re = Regex::new(&format!(r"\(([^)]*?){}\)", escaped_img_name)).unwrap();
|
||||
let replacement_link = format!("(images/{}/{})", bibcode, img_name);
|
||||
markdown = link_re.replace_all(&markdown, replacement_link.as_str()).to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(markdown)
|
||||
}
|
||||
|
||||
pub async fn parse_pdf_via_mineru(
|
||||
pdf_path: &Path,
|
||||
qiniu_client: &QiniuClient,
|
||||
config: &Config
|
||||
) -> anyhow::Result<String> {
|
||||
let bibcode = pdf_path.file_stem()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("paper")
|
||||
.to_string();
|
||||
let batch_id = submit_pdf_to_mineru(pdf_path, config).await?;
|
||||
poll_and_extract_mineru(&batch_id, &bibcode, qiniu_client, config).await
|
||||
}
|
||||
|
||||
// 采用栈式解析模型,将 LaTeXML 用 span/div 模拟出的表格容器(ltx_tabular/tbody/thead/tfoot/tr/td/th)还原为真正的 HTML <table> 结构
|
||||
fn replace_latexml_tables(html: &str) -> String {
|
||||
use regex::Regex;
|
||||
|
||||
@ -129,6 +129,7 @@ pub async fn translate_markdown(
|
||||
);
|
||||
|
||||
info!("正在请求大模型开展中英翻译。所选大模型: {}", config.llm_model);
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/chat/completions", config.llm_api_base);
|
||||
@ -179,6 +180,8 @@ pub async fn translate_markdown(
|
||||
|
||||
let res_data: LLMResponse = response.json().await?;
|
||||
if let Some(choice) = res_data.choices.first() {
|
||||
let duration = start_time.elapsed();
|
||||
info!("LLM 翻译成功。所选大模型: {}, 耗时: {:?}, 译文字符数: {}", config.llm_model, duration, choice.message.content.len());
|
||||
Ok(choice.message.content.clone())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("大模型返回空翻译选项集"))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user