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:
Asfmq 2026-06-10 17:29:07 +08:00
parent e13fa2ad40
commit cd6af4f995
43 changed files with 4029 additions and 958 deletions

View File

@ -24,3 +24,9 @@ MINERU_API_KEY=your_mineru_api_key
LIBRARY_DIR=./library LIBRARY_DIR=./library
PORT=8000 PORT=8000
DATABASE_URL=sqlite://astro_research.db 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
View File

@ -12,6 +12,7 @@
# Local literature storage (contains downloaded PDFs, HTML, Markdowns and Translations) # Local literature storage (contains downloaded PDFs, HTML, Markdowns and Translations)
/library/ /library/
/logs/
# IDEs and OS files # IDEs and OS files
.vscode/ .vscode/

462
Cargo.lock generated
View File

@ -2,6 +2,23 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@ -54,8 +71,9 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"dotenvy", "dotenvy",
"flate2",
"futures-util", "futures-util",
"hmac", "hmac 0.12.1",
"html2md", "html2md",
"quick-xml", "quick-xml",
"rand", "rand",
@ -63,15 +81,18 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha1", "sha1 0.10.6",
"sqlx", "sqlx",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"tower-http 0.5.2", "tower-http 0.5.2",
"tracing", "tracing",
"tracing-appender",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
"urlencoding", "urlencoding",
"uuid",
"zip",
] ]
[[package]] [[package]]
@ -209,6 +230,16 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
@ -227,6 +258,15 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" 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]] [[package]]
name = "cc" name = "cc"
version = "1.2.63" version = "1.2.63"
@ -234,6 +274,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver",
"libc",
"shlex", "shlex",
] ]
@ -263,6 +305,22 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@ -279,6 +337,18 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 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]] [[package]]
name = "cookie" name = "cookie"
version = "0.18.1" version = "0.18.1"
@ -334,6 +404,12 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@ -343,6 +419,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc" name = "crc"
version = "3.4.0" version = "3.4.0"
@ -358,6 +443,24 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" 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]] [[package]]
name = "crossbeam-queue" name = "crossbeam-queue"
version = "0.3.12" version = "0.3.12"
@ -383,13 +486,37 @@ dependencies = [
"typenum", "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]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [ dependencies = [
"const-oid", "const-oid 0.9.6",
"pem-rfc7468", "pem-rfc7468",
"zeroize", "zeroize",
] ]
@ -409,12 +536,25 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer 0.10.4",
"const-oid", "const-oid 0.9.6",
"crypto-common", "crypto-common 0.1.7",
"subtle", "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]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.6" version = "0.2.6"
@ -504,6 +644,17 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 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]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@ -684,10 +835,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"wasip2", "wasip2",
"wasip3", "wasip3",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -770,7 +923,7 @@ version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [ dependencies = [
"hmac", "hmac 0.12.1",
] ]
[[package]] [[package]]
@ -779,7 +932,16 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [ 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]] [[package]]
@ -870,6 +1032,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 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]] [[package]]
name = "hyper" name = "hyper"
version = "1.10.1" version = "1.10.1"
@ -1093,6 +1264,15 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@ -1115,7 +1295,7 @@ dependencies = [
"combine", "combine",
"jni-sys 0.3.1", "jni-sys 0.3.1",
"log", "log",
"thiserror", "thiserror 1.0.69",
"walkdir", "walkdir",
] ]
@ -1147,6 +1327,16 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.99" version = "0.3.99"
@ -1174,6 +1364,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libbz2-rs-sys"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
@ -1242,6 +1438,15 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" 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]] [[package]]
name = "mac" name = "mac"
version = "0.1.1" version = "0.1.1"
@ -1296,7 +1501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@ -1327,6 +1532,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 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]] [[package]]
name = "mio" name = "mio"
version = "1.2.1" version = "1.2.1"
@ -1510,6 +1725,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 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]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@ -1617,6 +1842,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppmd-rust"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@ -1841,8 +2072,8 @@ version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [ dependencies = [
"const-oid", "const-oid 0.9.6",
"digest", "digest 0.10.7",
"num-bigint-dig", "num-bigint-dig",
"num-integer", "num-integer",
"num-traits", "num-traits",
@ -2079,8 +2310,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "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]] [[package]]
@ -2090,8 +2332,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "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]] [[package]]
@ -2125,10 +2378,16 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest", "digest 0.10.7",
"rand_core", "rand_core",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.3"
@ -2231,10 +2490,10 @@ dependencies = [
"rustls-pemfile", "rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"smallvec", "smallvec",
"sqlformat", "sqlformat",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",
@ -2270,7 +2529,7 @@ dependencies = [
"quote", "quote",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"sqlx-core", "sqlx-core",
"sqlx-mysql", "sqlx-mysql",
"sqlx-postgres", "sqlx-postgres",
@ -2294,7 +2553,7 @@ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
"crc", "crc",
"digest", "digest 0.10.7",
"dotenvy", "dotenvy",
"either", "either",
"futures-channel", "futures-channel",
@ -2304,7 +2563,7 @@ dependencies = [
"generic-array", "generic-array",
"hex", "hex",
"hkdf", "hkdf",
"hmac", "hmac 0.12.1",
"itoa", "itoa",
"log", "log",
"md-5", "md-5",
@ -2314,12 +2573,12 @@ dependencies = [
"rand", "rand",
"rsa", "rsa",
"serde", "serde",
"sha1", "sha1 0.10.6",
"sha2", "sha2 0.10.9",
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror", "thiserror 1.0.69",
"tracing", "tracing",
"whoami", "whoami",
] ]
@ -2344,7 +2603,7 @@ dependencies = [
"futures-util", "futures-util",
"hex", "hex",
"hkdf", "hkdf",
"hmac", "hmac 0.12.1",
"home", "home",
"itoa", "itoa",
"log", "log",
@ -2354,11 +2613,11 @@ dependencies = [
"rand", "rand",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror", "thiserror 1.0.69",
"tracing", "tracing",
"whoami", "whoami",
] ]
@ -2435,6 +2694,12 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "1.0.109"
@ -2528,7 +2793,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [ 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]] [[package]]
@ -2542,6 +2816,17 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.1.9" version = "1.1.9"
@ -2559,6 +2844,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa", "itoa",
"js-sys",
"num-conv", "num-conv",
"powerfmt", "powerfmt",
"serde_core", "serde_core",
@ -2762,6 +3048,19 @@ dependencies = [
"tracing-core", "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]] [[package]]
name = "tracing-attributes" name = "tracing-attributes"
version = "0.1.31" version = "0.1.31"
@ -2794,6 +3093,16 @@ dependencies = [
"tracing-core", "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]] [[package]]
name = "tracing-subscriber" name = "tracing-subscriber"
version = "0.3.23" version = "0.3.23"
@ -2804,12 +3113,15 @@ dependencies = [
"nu-ansi-term", "nu-ansi-term",
"once_cell", "once_cell",
"regex-automata", "regex-automata",
"serde",
"serde_json",
"sharded-slab", "sharded-slab",
"smallvec", "smallvec",
"thread_local", "thread_local",
"tracing", "tracing",
"tracing-core", "tracing-core",
"tracing-log", "tracing-log",
"tracing-serde",
] ]
[[package]] [[package]]
@ -2818,6 +3130,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
@ -2911,6 +3229,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 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]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@ -3547,8 +3876,81 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" 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",
]

View File

@ -10,10 +10,14 @@ path = "src/lib.rs"
name = "astroresearch" name = "astroresearch"
path = "src/main.rs" path = "src/main.rs"
[[bin]]
name = "test_qiniu"
path = "scratch/test_qiniu.rs"
[dependencies] [dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["macros"] } 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"] } sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "chrono", "json"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
@ -23,7 +27,7 @@ quick-xml = { version = "0.31", features = ["serialize"] }
anyhow = "1.0" anyhow = "1.0"
thiserror = "1.0" thiserror = "1.0"
tracing = "0.1" 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"] } futures-util = { version = "0.3", features = ["io"] }
rand = "0.8" rand = "0.8"
regex = "1.10" regex = "1.10"
@ -34,3 +38,7 @@ base64 = "0.22"
urlencoding = "2.1" urlencoding = "2.1"
url = "2.5" url = "2.5"
html2md = "0.2" html2md = "0.2"
flate2 = "1.1.9"
zip = "8.6.0"
uuid = { version = "1.23.2", features = ["v4"] }
tracing-appender = "0.2.5"

View File

@ -68,7 +68,7 @@ cp .env.example .env
- 🏗️ **[架构设计](docs/architecture.md)**:包含系统宏观流程图与序列图。 - 🏗️ **[架构设计](docs/architecture.md)**:包含系统宏观流程图与序列图。
- 🌐 **[API 接口规范](docs/api.md)**:后端 Axum 路由及 HTTP 接口格式。 - 🌐 **[API 接口规范](docs/api.md)**:后端 Axum 路由及 HTTP 接口格式。
- 🗄️ **[数据库设计](docs/database.md)**SQLite 表结构、ER 图与索引优化。 - 🗄️ **[数据库设计](docs/database.md)**SQLite 表结构、ER 图与索引优化。
- 🎨 **[视觉与交互设计](docs/design.md)**浅色/深色磨砂玻璃、自研 Canvas 图谱引擎说明。 - 🎨 **[视觉与交互设计](docs/design.md)**高对比度浅色中文控制台、自研 Canvas 图谱引擎说明。
- 🛠️ **[排障指南](docs/troubleshooting.md)**:人机校验、解析失败等常见问题解法。 - 🛠️ **[排障指南](docs/troubleshooting.md)**:人机校验、解析失败等常见问题解法。
- 🚀 **[编译与部署指南](docs/deployment.md)**:单执行文件打包与发布流程。 - 🚀 **[编译与部署指南](docs/deployment.md)**:单执行文件打包与发布流程。
- 🤝 **[参与贡献指南](docs/contributing.md)**:开发规范及单元测试。 - 🤝 **[参与贡献指南](docs/contributing.md)**:开发规范及单元测试。

View File

@ -17,6 +17,7 @@
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"tailwind-merge": "^3.6.0" "tailwind-merge": "^3.6.0"
}, },
@ -3047,6 +3048,15 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -3055,6 +3065,32 @@
"node": ">= 0.4" "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": { "node_modules/mdast-util-from-markdown": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", "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" "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": { "node_modules/mdast-util-math": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", "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" "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": { "node_modules/micromark-extension-math": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz",
@ -4028,6 +4273,23 @@
"katex": "cli.js" "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": { "node_modules/remark-math": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz",
@ -4074,6 +4336,20 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/rolldown": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",

View File

@ -19,6 +19,7 @@
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"tailwind-merge": "^3.6.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

View File

@ -1,8 +1,9 @@
// dashboard/src/App.tsx // dashboard/src/App.tsx
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import axios from 'axios'; import axios from 'axios';
import { Loader, Download } from 'lucide-react';
import { Sidebar } from './components/layout/Sidebar'; 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 { LibraryPanel } from './features/library/LibraryPanel';
import { ReaderPanel } from './features/reader/ReaderPanel'; import { ReaderPanel } from './features/reader/ReaderPanel';
import { CitationPanel } from './features/citation/CitationPanel'; import { CitationPanel } from './features/citation/CitationPanel';
@ -12,14 +13,45 @@ import type { StandardPaper, CitationNetwork, NoteRecord } from './types';
export default function App() { export default function App() {
const [activeTab, setActiveTab] = useState<'search' | 'library' | 'reader' | 'citation' | 'sync'>('search'); 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 [library, setLibrary] = useState<StandardPaper[]>([]);
const [selectedPaper, setSelectedPaper] = useState<StandardPaper | null>(null); const [selectedPaper, setSelectedPaper] = useState<StandardPaper | null>(null);
const [detailBibcode, setDetailBibcode] = useState<string | null>(null);
// 检索页状态 // 检索页状态
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchSource, setSearchSource] = useState<'all' | 'ads' | 'arxiv'>('all'); const [searchSource, setSearchSource] = useState<'all' | 'ads' | 'arxiv'>('all');
const [searchResults, setSearchResults] = useState<StandardPaper[]>([]); 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 [searching, setSearching] = useState(false);
const [exportingList, setExportingList] = useState<string[]>([]); const [exportingList, setExportingList] = useState<string[]>([]);
const [bibtexContent, setBibtexContent] = useState<string | null>(null); const [bibtexContent, setBibtexContent] = useState<string | null>(null);
@ -39,6 +71,7 @@ export default function App() {
const [citationNetwork, setCitationNetwork] = useState<CitationNetwork | null>(null); const [citationNetwork, setCitationNetwork] = useState<CitationNetwork | null>(null);
const [loadingCitations, setLoadingCitations] = useState(false); const [loadingCitations, setLoadingCitations] = useState(false);
const [citationHistory, setCitationHistory] = useState<CitationNetwork[]>([]); // 多跳历史 const [citationHistory, setCitationHistory] = useState<CitationNetwork[]>([]); // 多跳历史
const [uncachedBibcode, setUncachedBibcode] = useState<string | null>(null);
// 笔记系统状态 // 笔记系统状态
const [notes, setNotes] = useState<NoteRecord[]>([]); const [notes, setNotes] = useState<NoteRecord[]>([]);
@ -87,7 +120,7 @@ export default function App() {
setSearchCache(prev => ({ ...prev, [cacheKey]: res.data })); setSearchCache(prev => ({ ...prev, [cacheKey]: res.data }));
} catch (e) { } catch (e) {
console.error('检索文献失败', e); console.error('检索文献失败', e);
alert('检索失败,请确认后端连接及 API 密钥配置。'); showAlert('检索失败,请确认后端连接及 API 密钥配置。', '检索出错');
} finally { } finally {
setSearching(false); setSearching(false);
} }
@ -135,7 +168,7 @@ export default function App() {
} }
} catch (e) { } catch (e) {
console.error('下载文献失败', e); console.error('下载文献失败', e);
alert('文献下载失败,请检查 ADS 网络限制与网络代理!'); showAlert('文献下载失败,请检查 ADS 网络限制与网络代理!', '下载失败');
} finally { } finally {
setDownloadingBibcodes(prev => ({ ...prev, [bibcode]: false })); setDownloadingBibcodes(prev => ({ ...prev, [bibcode]: false }));
} }
@ -155,7 +188,7 @@ export default function App() {
} }
} catch (e) { } catch (e) {
console.error('文献解析失败', e); console.error('文献解析失败', e);
alert('文献排版解析失败,请检查是否已完成 HTML/PDF 下载,并配置了 MinerU API 节点。'); showAlert('文献排版解析失败,请检查是否已完成 HTML/PDF 下载,并配置了 MinerU API 节点。', '解析失败');
} finally { } finally {
setParsing(false); setParsing(false);
} }
@ -174,7 +207,7 @@ export default function App() {
} }
} catch (e) { } catch (e) {
console.error('文献翻译失败', e); console.error('文献翻译失败', e);
alert('翻译失败,请检查 .env 中的大模型 API 密钥与端点配置。'); showAlert('翻译失败,请检查 .env 中的大模型 API 密钥与端点配置。', '翻译失败');
} finally { } finally {
setTranslating(false); setTranslating(false);
} }
@ -246,7 +279,7 @@ export default function App() {
setBibtexContent(res.data.bibtex); setBibtexContent(res.data.bibtex);
} catch (e) { } catch (e) {
console.error('导出 BibTeX 失败', e); console.error('导出 BibTeX 失败', e);
alert('导出 BibTeX 失败,请检查 ADS Token。'); showAlert('导出 BibTeX 失败,请检查 ADS Token。', '导出失败');
} finally { } finally {
setExporting(false); setExporting(false);
} }
@ -299,13 +332,7 @@ export default function App() {
}; };
return ( return (
<div className="flex h-screen overflow-hidden text-slate-800"> <div className="flex h-screen overflow-hidden text-slate-800 bg-slate-100 select-text">
{/* 炫酷淡雅背景装饰 */}
<div className="absolute inset-0 bg-[#f8fafc] z-[-10]">
<div className="absolute top-[10%] left-[20%] w-[300px] h-[300px] bg-purple-500/5 rounded-full blur-[120px] animate-pulse-slow" />
<div className="absolute bottom-[10%] right-[20%] w-[400px] h-[400px] bg-blue-500/5 rounded-full blur-[150px] animate-pulse-slow" style={{ animationDelay: '1.5s' }} />
</div>
{/* 导航左侧栏 */} {/* 导航左侧栏 */}
<Sidebar <Sidebar
@ -316,20 +343,10 @@ export default function App() {
/> />
{/* 主工作区 */} {/* 主工作区 */}
<main className="flex-1 flex flex-col overflow-hidden"> <main className="flex-1 flex flex-col overflow-hidden relative">
{/* 顶部状态条 */}
<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>
{/* 选项卡容器 */} {/* 选项卡容器 */}
<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' && ( {activeTab === 'search' && (
<SearchPanel <SearchPanel
searchQuery={searchQuery} searchQuery={searchQuery}
@ -357,6 +374,8 @@ export default function App() {
openReader={openReader} openReader={openReader}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
loadCitations={loadCitations} loadCitations={loadCitations}
showAlert={showAlert}
onShowDetail={(paper) => setDetailBibcode(paper.bibcode)}
/> />
)} )}
@ -364,12 +383,8 @@ export default function App() {
<LibraryPanel <LibraryPanel
library={library} library={library}
fetchLibrary={fetchLibrary} fetchLibrary={fetchLibrary}
openReader={openReader}
setSelectedPaper={setSelectedPaper}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
loadCitations={loadCitations} onShowDetail={(paper) => setDetailBibcode(paper.bibcode)}
downloadingBibcodes={downloadingBibcodes}
handleDownload={handleDownload}
/> />
)} )}
@ -396,6 +411,7 @@ export default function App() {
setNewNoteText={setNewNoteText} setNewNoteText={setNewNoteText}
handleCreateNote={handleCreateNote} handleCreateNote={handleCreateNote}
handleDeleteNote={handleDeleteNote} handleDeleteNote={handleDeleteNote}
showConfirm={showConfirm}
/> />
)} )}
@ -406,6 +422,7 @@ export default function App() {
citationNetwork={citationNetwork} citationNetwork={citationNetwork}
citationHistory={citationHistory} citationHistory={citationHistory}
loadCitations={loadCitations} loadCitations={loadCitations}
onUncachedClick={setUncachedBibcode}
/> />
)} )}
@ -413,7 +430,312 @@ export default function App() {
<SyncPanel /> <SyncPanel />
)} )}
</div> </div>
</div>
</main> </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> </div>
); );
} }

View File

@ -5,6 +5,7 @@ import type { CitationNetwork } from '../types';
interface CanvasProps { interface CanvasProps {
networks: CitationNetwork[]; networks: CitationNetwork[];
activeNetwork: CitationNetwork; activeNetwork: CitationNetwork;
nodeLimit: number;
onNodeClick: (bibcode: string) => void; onNodeClick: (bibcode: string) => void;
} }
@ -18,6 +19,7 @@ interface Node {
radius: number; radius: number;
color: string; color: string;
type: 'center' | 'reference' | 'citation'; type: 'center' | 'reference' | 'citation';
inDb: boolean;
} }
interface Link { interface Link {
@ -25,7 +27,7 @@ interface Link {
target: string; target: string;
} }
export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: CanvasProps) { export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNodeClick }: CanvasProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => { useEffect(() => {
@ -34,24 +36,26 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
// 适配高清屏幕像素比
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr; canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr; canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
// 合并所有 networks 的节点,去重,最多 50 个 const MAX_NODES = nodeLimit;
const MAX_NODES = 50;
const allIds = new Set<string>(); const allIds = new Set<string>();
const nodes: Node[] = []; const nodes: Node[] = [];
const links: Link[] = []; const links: Link[] = [];
networks.forEach((net, netIdx) => { networks.forEach((net, netIdx) => {
const isActive = net.bibcode === activeNetwork.bibcode; const isActive = net.bibcode === activeNetwork.bibcode;
// 添加中心节点
if (!allIds.has(net.bibcode) && nodes.length < MAX_NODES) { if (!allIds.has(net.bibcode) && nodes.length < MAX_NODES) {
allIds.add(net.bibcode); 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({ nodes.push({
id: net.bibcode, id: net.bibcode,
label: 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), y: rect.height / 2 + (netIdx === 0 ? 0 : (Math.random() - 0.5) * 200),
vx: 0, vx: 0,
vy: 0, vy: 0,
radius: isActive ? 24 : 16, radius,
color: isActive ? '#a855f7' : '#6366f1', color: isActive ? '#0284c7' : '#475569',
type: 'center', type: 'center',
inDb: true,
}); });
} }
// 添加参考文献节点
net.references.forEach((ref, idx) => { net.references.forEach((ref, idx) => {
if (nodes.length >= MAX_NODES) return; if (nodes.length >= MAX_NODES) return;
if (!allIds.has(ref)) { if (!allIds.has(ref)) {
allIds.add(ref); allIds.add(ref);
const angle = (idx / Math.max(1, net.references.length)) * Math.PI * 2; 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 centerNode = nodes.find(n => n.id === net.bibcode);
const inDb = activeNetwork.citation_counts ? Object.prototype.hasOwnProperty.call(activeNetwork.citation_counts, ref) : false;
nodes.push({ nodes.push({
id: ref, id: ref,
label: ref, label: ref,
@ -80,9 +85,10 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
y: (centerNode?.y ?? rect.height / 2) + Math.sin(angle) * dist, y: (centerNode?.y ?? rect.height / 2) + Math.sin(angle) * dist,
vx: 0, vx: 0,
vy: 0, vy: 0,
radius: 12, radius: 8, // 初始值,稍后按连线数重算
color: '#d97706', color: '#d97706',
type: 'reference', type: 'reference',
inDb,
}); });
} }
if (allIds.has(ref)) { if (allIds.has(ref)) {
@ -90,14 +96,14 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
} }
}); });
// 添加被引文献节点
net.citations.forEach((cit, idx) => { net.citations.forEach((cit, idx) => {
if (nodes.length >= MAX_NODES) return; if (nodes.length >= MAX_NODES) return;
if (!allIds.has(cit)) { if (!allIds.has(cit)) {
allIds.add(cit); allIds.add(cit);
const angle = (idx / Math.max(1, net.citations.length)) * Math.PI * 2 + Math.PI; 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 centerNode = nodes.find(n => n.id === net.bibcode);
const inDb = activeNetwork.citation_counts ? Object.prototype.hasOwnProperty.call(activeNetwork.citation_counts, cit) : false;
nodes.push({ nodes.push({
id: cit, id: cit,
label: cit, label: cit,
@ -105,9 +111,10 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
y: (centerNode?.y ?? rect.height / 2) + Math.sin(angle) * dist, y: (centerNode?.y ?? rect.height / 2) + Math.sin(angle) * dist,
vx: 0, vx: 0,
vy: 0, vy: 0,
radius: 12, radius: 8, // 初始值,稍后按连线数重算
color: '#4f46e5', color: '#0891b2',
type: 'citation', type: 'citation',
inDb,
}); });
} }
if (allIds.has(cit)) { 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 animationFrameId: number;
let hoveredNode: Node | null = null; let hoveredNode: Node | null = null;
let frameCount = 0;
// 经典力导向算法迭代
const updatePhysics = () => { const updatePhysics = () => {
// 1. 斥力:任何两个节点之间均产生反向推力
for (let i = 0; i < nodes.length; i++) { for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) { for (let j = i + 1; j < nodes.length; j++) {
let dx = nodes[j].x - nodes[i].x; let dx = nodes[j].x - nodes[i].x;
let dy = nodes[j].y - nodes[i].y; let dy = nodes[j].y - nodes[i].y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1; 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) { if (dist < minDist) {
let force = (minDist - dist) * 0.08; let force = (minDist - dist) * 0.08;
let fx = (dx / dist) * force; let fx = (dx / dist) * force;
let fy = (dy / dist) * force; let fy = (dy / dist) * force;
// 节点不强行推动中心大节点
if (nodes[i].type !== 'center' || nodes[i].id !== activeNetwork.bibcode) { if (nodes[i].type !== 'center' || nodes[i].id !== activeNetwork.bibcode) {
nodes[i].vx -= fx; nodes[i].vx -= fx;
nodes[i].vy -= fy; nodes[i].vy -= fy;
@ -146,7 +175,6 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
} }
} }
// 2. 引力与向心力:被连线连接的节点之间产生向中心靠拢力
links.forEach(link => { links.forEach(link => {
const sourceNode = nodes.find(n => n.id === link.source); const sourceNode = nodes.find(n => n.id === link.source);
const targetNode = nodes.find(n => n.id === link.target); 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 dx = targetNode.x - sourceNode.x;
let dy = targetNode.y - sourceNode.y; let dy = targetNode.y - sourceNode.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1; 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 fx = (dx / dist) * force;
let fy = (dy / dist) * force; let fy = (dy / dist) * force;
@ -169,23 +197,54 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
} }
}); });
// 3. 应用阻尼阻力,限制极限加速
nodes.forEach(node => { nodes.forEach(node => {
if (node.id !== activeNetwork.bibcode) { if (node.id !== activeNetwork.bibcode) {
node.x += node.vx; node.x += node.vx;
node.y += node.vy; node.y += node.vy;
node.vx *= 0.85; // 阻尼 node.vx *= 0.85;
node.vy *= 0.85; node.vy *= 0.85;
} }
}); });
}; };
// 画布渲染渲染循环
const render = () => { const render = () => {
frameCount++;
updatePhysics(); updatePhysics();
ctx.clearRect(0, 0, rect.width, rect.height); 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; ctx.lineWidth = 1;
links.forEach(link => { links.forEach(link => {
@ -195,7 +254,7 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(sourceNode.x, sourceNode.y); ctx.moveTo(sourceNode.x, sourceNode.y);
ctx.lineTo(targetNode.x, targetNode.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(); ctx.stroke();
} }
}); });
@ -203,62 +262,137 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: C
// 绘制节点 // 绘制节点
nodes.forEach(node => { nodes.forEach(node => {
const isHovered = hoveredNode?.id === node.id; const isHovered = hoveredNode?.id === node.id;
ctx.save();
ctx.globalAlpha = node.inDb ? 1.0 : 0.35; // 未入库文献透明度降为 0.35
ctx.beginPath(); 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.fillStyle = node.color;
ctx.fill(); ctx.fill();
// 绘制光晕环绕
ctx.beginPath(); ctx.beginPath();
ctx.arc(node.x, node.y, node.radius + (isHovered ? 8 : 4), 0, Math.PI * 2); ctx.arc(node.x, node.y, node.radius + (isHovered ? 4 : 2), 0, Math.PI * 2);
ctx.strokeStyle = node.color + '40'; // 附加透明度光晕 ctx.strokeStyle = node.color + '40';
ctx.lineWidth = 2; ctx.lineWidth = 1;
ctx.stroke(); ctx.stroke();
// 绘制 bibcode 文本说明 ctx.fillStyle = isHovered ? '#0284c7' : '#334155';
ctx.fillStyle = isHovered ? '#0f172a' : '#64748b'; ctx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif';
ctx.font = isHovered ? 'bold 10px monospace' : '9px monospace';
ctx.textAlign = 'center'; 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); animationFrameId = requestAnimationFrame(render);
}; };
render(); render();
// 交互鼠标监听 const handleMouseDown = (e: MouseEvent) => {
isDragging = true;
dragStartX = e.clientX - offsetX;
dragStartY = e.clientY - offsetY;
hasDragged = false;
};
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left; const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top; 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; let found: Node | null = null;
for (const node of nodes) { for (const node of nodes) {
let dx = node.x - mouseX; let dx = node.x - gx;
let dy = node.y - mouseY; let dy = node.y - gy;
let dist = Math.sqrt(dx * dx + dy * dy); let dist = Math.sqrt(dx * dx + dy * dy);
if (dist < node.radius + 5) { if (dist < node.radius + 6) {
found = node; found = node;
break; break;
} }
} }
hoveredNode = found; 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 = () => { const handleCanvasClick = () => {
if (hasDragged) return;
if (hoveredNode && hoveredNode.id !== activeNetwork.bibcode) { if (hoveredNode && hoveredNode.id !== activeNetwork.bibcode) {
onNodeClick(hoveredNode.id); 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('mousemove', handleMouseMove);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('mouseleave', handleMouseLeave);
canvas.addEventListener('click', handleCanvasClick); canvas.addEventListener('click', handleCanvasClick);
canvas.addEventListener('wheel', handleWheel, { passive: false });
return () => { return () => {
cancelAnimationFrame(animationFrameId); cancelAnimationFrame(animationFrameId);
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mousemove', handleMouseMove); canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseup', handleMouseUp);
canvas.removeEventListener('mouseleave', handleMouseLeave);
canvas.removeEventListener('click', handleCanvasClick); canvas.removeEventListener('click', handleCanvasClick);
canvas.removeEventListener('wheel', handleWheel);
}; };
}, [networks, activeNetwork, onNodeClick]); }, [networks, activeNetwork, onNodeClick]);

View File

@ -11,25 +11,38 @@ interface SidebarProps {
export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations }: SidebarProps) { export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations }: SidebarProps) {
return ( 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> <div>
{/* Logo */} {/* 系统LOGO区 */}
<div className="px-6 mb-8 flex items-center gap-3"> <div className="px-3 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"> <div className="w-9 h-9 flex items-center justify-center">
<span className="font-extrabold text-white text-lg tracking-wider">A</span> <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>
<div> <div>
<h1 className="text-lg font-bold text-slate-800 leading-none font-outfit">AstroResearch</h1> <h1 className="text-sm font-bold text-slate-800 tracking-wider">AstroResearch</h1>
<span className="text-xs text-slate-500"></span> <span className="text-[11px] text-slate-500 block font-medium"></span>
</div> </div>
</div> </div>
{/* 选项卡导航 */} {/* 导航菜单列表 */}
<nav className="px-4 space-y-1.5"> <nav className="space-y-1">
{[ {[
{ id: 'search', label: '统一检索', icon: Search }, { id: 'search', label: '统一检索', icon: Search },
{ id: 'library', label: '馆藏管理', icon: Library }, { id: 'library', label: '馆藏管理', icon: Library },
{ id: 'sync', label: '批量同步', icon: RefreshCw }, { id: 'sync', label: '批量任务', icon: RefreshCw },
{ id: 'reader', label: '双语阅读', icon: BookOpen, disabled: !selectedPaper }, { id: 'reader', label: '双语阅读', icon: BookOpen, disabled: !selectedPaper },
{ id: 'citation', label: '引用星系', icon: GitFork, disabled: !selectedPaper }, { id: 'citation', label: '引用星系', icon: GitFork, disabled: !selectedPaper },
].map(tab => { ].map(tab => {
@ -45,32 +58,36 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations
loadCitations(selectedPaper.bibcode); 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 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 : tab.disabled
? 'opacity-40 cursor-not-allowed text-slate-400' ? 'opacity-30 cursor-not-allowed border-transparent text-slate-400'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900' : 'border-transparent text-slate-650 hover:bg-slate-100 hover:text-slate-800'
}`} }`}
> >
<Icon className="w-4 h-4" /> <Icon className={`w-4 h-4 ${isActive ? 'text-sky-600' : 'text-slate-500'}`} />
{tab.label} <span>{tab.label}</span>
</button> </button>
); );
})} })}
</nav> </nav>
</div> </div>
{/* 底部当前文献卡片 */} {/* 底部当前选定文献提示 */}
{selectedPaper && ( {selectedPaper ? (
<div className="mx-4 p-4 rounded-xl bg-slate-100/50 border border-slate-200/80"> <div className="p-3.5 rounded-lg border border-sky-100 bg-sky-50/50">
<span className="text-[10px] text-purple-600 font-bold uppercase tracking-wider block mb-1"></span> <span className="text-[9px] font-bold text-sky-600 tracking-widest block mb-1"></span>
<h4 className="text-xs text-slate-800 font-medium line-clamp-2 mb-2">{selectedPaper.title}</h4> <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] text-slate-500"> <div className="flex items-center justify-between text-[10px] font-medium text-slate-500">
<span>{selectedPaper.year}</span> <span>: {selectedPaper.year}</span>
<span className="truncate max-w-[100px] text-slate-400">{selectedPaper.bibcode}</span> <span className="truncate max-w-[90px] font-mono">{selectedPaper.bibcode}</span>
</div> </div>
</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> </aside>
); );

View File

@ -1,4 +1,5 @@
// dashboard/src/features/citation/CitationPanel.tsx // dashboard/src/features/citation/CitationPanel.tsx
import { useState } from 'react';
import { Loader, GitFork, RotateCcw } from 'lucide-react'; import { Loader, GitFork, RotateCcw } from 'lucide-react';
import { CitationGalaxyCanvas } from '../../components/CitationGalaxyCanvas'; import { CitationGalaxyCanvas } from '../../components/CitationGalaxyCanvas';
import type { StandardPaper, CitationNetwork } from '../../types'; import type { StandardPaper, CitationNetwork } from '../../types';
@ -9,6 +10,7 @@ interface CitationPanelProps {
citationNetwork: CitationNetwork | null; citationNetwork: CitationNetwork | null;
citationHistory: CitationNetwork[]; citationHistory: CitationNetwork[];
loadCitations: (bibcode: string, reset?: boolean) => void; loadCitations: (bibcode: string, reset?: boolean) => void;
onUncachedClick: (bibcode: string) => void;
} }
export function CitationPanel({ export function CitationPanel({
@ -17,62 +19,90 @@ export function CitationPanel({
citationNetwork, citationNetwork,
citationHistory, citationHistory,
loadCitations, loadCitations,
onUncachedClick,
}: CitationPanelProps) { }: 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 ( 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> <div>
<h2 className="text-lg font-bold text-slate-800"></h2> <h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase"></h2>
<p className="text-xs text-slate-500"> (References) - - (Citations)</p> <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> </div>
{selectedPaper && ( {selectedPaper && (
<button <button
onClick={() => loadCitations(selectedPaper.bibcode, true)} 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> </button>
)} )}
</div> </div>
</div>
{loadingCitations ? ( {loadingCitations ? (
<div className="glass rounded-2xl flex-1 flex flex-col items-center justify-center text-slate-500"> <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-purple-500 mb-2" /> <Loader className="w-8 h-8 animate-spin text-sky-600 mb-2" />
<p className="text-xs"> SQLite ...</p> <p className="text-xs font-bold text-slate-600">...</p>
</div> </div>
) : citationNetwork ? ( ) : 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 <CitationGalaxyCanvas
networks={citationHistory} networks={citationHistory}
activeNetwork={citationNetwork} activeNetwork={citationNetwork}
onNodeClick={(bibcode) => { nodeLimit={nodeLimit}
loadCitations(bibcode, false); onNodeClick={handleNodeClick}
}}
/> />
</div> </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> <div>
<span className="text-[10px] text-purple-600 font-bold uppercase tracking-wider block mb-1"></span> <span className="text-[10px] font-bold text-sky-700 tracking-wider block mb-2"> </span>
<h4 className="text-sm font-semibold text-slate-800 leading-snug">{citationNetwork.title}</h4> <h4 className="text-xs font-bold text-slate-900 leading-relaxed font-sans">{citationNetwork.title}</h4>
<p className="text-xs text-slate-500 font-mono mt-1">{citationNetwork.bibcode}</p> <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>
<div> <div className="border-t border-slate-200 pt-4">
<span className="text-[10px] text-amber-700 font-bold uppercase tracking-wider block mb-2"> <span className="text-xs font-bold text-slate-700 block mb-2">
(References: {citationNetwork.references.length}) ( // 共 {citationNetwork.references.length} 篇)
</span> </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 ? ( {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 => ( citationNetwork.references.map(bib => (
<div <div
key={bib} key={bib}
onClick={() => loadCitations(bib)} onClick={() => handleNodeClick(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" 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} {bib}
</div> </div>
@ -81,19 +111,19 @@ export function CitationPanel({
</div> </div>
</div> </div>
<div> <div className="border-t border-slate-200 pt-4">
<span className="text-[10px] text-indigo-700 font-bold uppercase tracking-wider block mb-2"> <span className="text-xs font-bold text-slate-700 block mb-2">
(Citations: {citationNetwork.citations.length}) ( // 共 {citationNetwork.citations.length} 篇)
</span> </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 ? ( {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 => ( citationNetwork.citations.map(bib => (
<div <div
key={bib} key={bib}
onClick={() => loadCitations(bib)} onClick={() => handleNodeClick(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" 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} {bib}
</div> </div>
@ -104,9 +134,9 @@ export function CitationPanel({
</div> </div>
</div> </div>
) : ( ) : (
<div className="glass rounded-2xl flex-1 flex flex-col items-center justify-center text-slate-400"> <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" /> <GitFork className="w-12 h-12 mb-3 text-slate-300" />
<p className="text-xs"></p> <p className="text-xs font-bold text-slate-500"></p>
</div> </div>
)} )}
</div> </div>

View File

@ -1,124 +1,304 @@
// dashboard/src/features/library/LibraryPanel.tsx // 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 type { StandardPaper } from '../../types';
import { getDoctypeBadge } from '../search/SearchPanel';
interface LibraryPanelProps { interface LibraryPanelProps {
library: StandardPaper[]; library: StandardPaper[];
fetchLibrary: () => void; fetchLibrary: () => void;
openReader: (paper: StandardPaper) => void;
setSelectedPaper: (paper: StandardPaper | null) => void;
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync') => void; setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync') => void;
loadCitations: (bibcode: string) => void; onShowDetail: (paper: StandardPaper) => void;
downloadingBibcodes: Record<string, boolean>;
handleDownload: (bibcode: string, force?: boolean) => void;
} }
export function LibraryPanel({ export function LibraryPanel({
library, library,
fetchLibrary, fetchLibrary,
openReader,
setSelectedPaper,
setActiveTab, setActiveTab,
loadCitations, onShowDetail,
downloadingBibcodes,
handleDownload,
}: LibraryPanelProps) { }: 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 ( return (
<div className="max-w-5xl mx-auto space-y-6"> <div className="w-full max-w-5xl mx-auto space-y-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4 border-b border-slate-200 pb-4">
<div> <div>
<h2 className="text-lg font-bold text-slate-800"></h2> <h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase"></h2>
<p className="text-xs text-slate-500"></p> <p className="text-xs text-slate-500 mt-1">线</p>
</div> </div>
<button <button
onClick={fetchLibrary} 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> </button>
</div> </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 ? ( {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" /> <Library className="w-12 h-12 text-slate-400 mx-auto mb-4" />
<h3 className="font-bold text-slate-800 mb-1"></h3> <h3 className="font-bold text-slate-800 text-sm mb-2"></h3>
<p className="text-xs text-slate-500 mb-6">线</p> <p className="text-xs text-slate-500 mb-6 max-w-md mx-auto">线便</p>
<button <button
onClick={() => setActiveTab('search')} 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> </button>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{library.map(paper => { {sortedLibrary.map(paper => {
const isDownloading = downloadingBibcodes[paper.bibcode] || false;
return ( 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>
<div className="flex justify-between items-start gap-4 mb-2"> <h3 className="font-bold text-xs text-slate-900 line-clamp-2 hover:text-sky-700 transition-all leading-relaxed">
<h3 {getDoctypeBadge(paper.doctype)}
className="font-bold text-sm text-slate-800 line-clamp-2 hover:text-purple-600 cursor-pointer" <span className="align-middle">{paper.title}</span>
onClick={() => openReader(paper)}
>
{paper.title}
</h3> </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>
<div className="text-xs text-slate-500 mb-3"> <div className="text-[10px] text-slate-500 font-semibold mt-1.5 uppercase">
{paper.authors.slice(0, 2).join(', ')}{paper.authors.length > 2 ? ' et al.' : ''} | {paper.year} : {paper.authors.slice(0, 2).join(', ')}{paper.authors.length > 2 ? ' 等' : ''} | : {paper.year}
</div> </div>
<p className="text-xs text-slate-600 line-clamp-3 leading-relaxed mb-4">{paper.abstract_text}</p>
</div> </div>
<div className="flex items-center justify-between border-t border-slate-200/60 pt-4 mt-auto"> <div className="flex items-center justify-between border-t border-slate-100 pt-3 mt-4 text-[10px]">
<div className="flex flex-wrap gap-2"> <span className="font-mono text-slate-400 select-all" onClick={(e) => e.stopPropagation()}>
<button {paper.bibcode === paper.arxiv_id ? `arXiv:${paper.arxiv_id}` : paper.bibcode}
onClick={() => openReader(paper)} </span>
className="px-3.5 py-1.5 rounded-lg bg-purple-600 text-white text-xs hover:bg-purple-500 transition-all font-semibold" <div className="flex items-center gap-2 shrink-0">
> <div className="flex items-center gap-1">
<span
</button> className={`w-1.5 h-1.5 rounded-full ${paper.has_markdown ? 'bg-sky-500' : 'bg-slate-200'}`}
<button />
onClick={() => { <span className="text-slate-400 text-[9px]">{paper.has_markdown ? '正文' : '未解析'}</span>
setSelectedPaper(paper); </div>
setActiveTab('citation'); <div className="flex items-center gap-1">
loadCitations(paper.bibcode); <span
}} className={`w-1.5 h-1.5 rounded-full ${paper.has_translation ? 'bg-emerald-500' : 'bg-slate-200'}`}
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" />
> <span className="text-slate-400 text-[9px]">{paper.has_translation ? '翻译' : '未翻译'}</span>
</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> </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> </div>
</div> </div>

View File

@ -1,6 +1,8 @@
// dashboard/src/features/reader/ReaderPanel.tsx // dashboard/src/features/reader/ReaderPanel.tsx
import { useState, useRef } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
import remarkGfm from 'remark-gfm';
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import { FileText, Loader, Languages, RotateCw, Pencil, X, PlusCircle, Trash2 } from 'lucide-react'; import { FileText, Loader, Languages, RotateCw, Pencil, X, PlusCircle, Trash2 } from 'lucide-react';
@ -28,13 +30,14 @@ interface ReaderPanelProps {
setNewNoteText: (text: string) => void; setNewNoteText: (text: string) => void;
handleCreateNote: () => void; handleCreateNote: () => void;
handleDeleteNote: (id: number) => void; handleDeleteNote: (id: number) => void;
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
} }
const NOTE_COLORS: Record<string, { bg: string; border: string; label: string }> = { const NOTE_COLORS: Record<string, { bg: string; border: string; label: string; text: string }> = {
yellow: { bg: 'bg-yellow-500/20', border: 'border-yellow-500/40', label: '黄色' }, cyan: { bg: 'bg-cyan-50 border-cyan-200', border: 'border-cyan-300', label: '天蓝色', text: 'text-cyan-800' },
green: { bg: 'bg-emerald-500/20', border: 'border-emerald-500/40', label: '绿色' }, amber: { bg: 'bg-amber-50 border-amber-200', border: 'border-amber-300', label: '星光金', text: 'text-amber-800' },
blue: { bg: 'bg-blue-500/20', border: 'border-blue-500/40', label: '蓝色' }, purple: { bg: 'bg-purple-50 border-purple-200', border: 'border-purple-300', label: '淡紫色', text: 'text-purple-800' },
pink: { bg: 'bg-pink-500/20', border: 'border-pink-500/40', label: '粉色' }, green: { bg: 'bg-emerald-50 border-emerald-200', border: 'border-emerald-300', label: '荧光绿', text: 'text-emerald-800' },
}; };
export function ReaderPanel({ export function ReaderPanel({
@ -59,34 +62,131 @@ export function ReaderPanel({
setNewNoteText, setNewNoteText,
handleCreateNote, handleCreateNote,
handleDeleteNote, handleDeleteNote,
showConfirm,
}: ReaderPanelProps) { }: 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 ( 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> <div>
<h2 className="text-base font-bold text-slate-800 line-clamp-1 leading-snug">{selectedPaper.title}</h2> <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"> <div className="flex items-center gap-2 text-xs text-slate-500 mt-1 font-semibold">
<span>: {selectedPaper.pub_journal}</span> <span>: {selectedPaper.pub_journal || '未标注'}</span>
<span></span> <span></span>
<span>Bibcode: {selectedPaper.bibcode}</span> <span>: {selectedPaper.bibcode}</span>
</div> </div>
</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 ? ( {!selectedPaper.has_markdown ? (
<button <button
onClick={() => handleParse(selectedPaper.bibcode)} onClick={() => handleParse(selectedPaper.bibcode)}
disabled={parsing} 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 ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <FileText className="w-3.5 h-3.5" />}
{parsing ? '提取英文正文中...' : '第一步:解析 PDF/HTML 正文'} {parsing ? '正文结构解析中...' : '解析源文正文'}
</button> </button>
) : ( ) : (
<button <button
onClick={() => { if (confirm('确定要重新解析正文吗?这会覆盖本地已解析的 Markdown。')) handleParse(selectedPaper.bibcode, true); }} onClick={() => {
showConfirm('确定要重新解析正文吗?这会覆盖本地已解析的 Markdown。', () => {
handleParse(selectedPaper.bibcode, true);
}, '确认重新解析');
}}
disabled={parsing} 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" title="覆盖本地解析结果,重新从 HTML/PDF 转换为 Markdown"
> >
{parsing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />} {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> </button>
)} )}
{selectedPaper.has_markdown && ( {selectedPaper.has_markdown && !selectedPaper.has_translation && (
<button <button
onClick={() => handleTranslate(selectedPaper.bibcode)} onClick={() => handleTranslate(selectedPaper.bibcode)}
disabled={translating} 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 ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Languages className="w-3.5 h-3.5" />}
{translating ? 'LLM 学术术语修正翻译中...' : '第二步:大模型对比翻译'} {translating ? '天文学术语对比翻译中...' : '生成智能翻译'}
</button> </button>
)} )}
@ -109,7 +209,7 @@ export function ReaderPanel({
<button <button
onClick={() => handleTranslate(selectedPaper.bibcode, true)} onClick={() => handleTranslate(selectedPaper.bibcode, true)}
disabled={translating} 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="清除翻译缓存并重新生成大模型对照翻译" title="清除翻译缓存并重新生成大模型对照翻译"
> >
{translating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />} {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>
{/* 并排对比阅读器 */} {/* 并排对比阅读器 */}
<div className="flex-1 grid gap-6 overflow-hidden w-full" style={{ gridTemplateColumns: showNotesPanel ? '1fr 1fr 320px' : '1fr 1fr' }}> <div
{/* 英文正文面板 */} className="flex-1 grid gap-6 overflow-hidden w-full"
<div className="glass rounded-2xl p-6 overflow-y-auto border border-slate-200 relative flex flex-col"> style={{
<div className="flex items-center justify-between mb-3 border-b border-slate-200/60 pb-2"> gridTemplateColumns: viewMode === 'bilingual'
<span className="text-[10px] text-purple-650 font-bold uppercase tracking-wider"> (Markdown/LaTeX)</span> ? (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 <button
onClick={() => setShowNotesPanel(!showNotesPanel)} onClick={() => setShowNotesPanel(!showNotesPanel)}
className={`flex items-center gap-1 text-[10px] px-2 py-1 rounded-lg transition-all ${ className={`flex items-center gap-1 text-xs font-bold px-2.5 py-1 rounded-lg border 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' showNotesPanel ? 'bg-sky-50 text-sky-700 border-sky-200' : 'btn-console btn-console-secondary'
}`} }`}
> >
<Pencil className="w-3 h-3" /> <Pencil className="w-3.5 h-3.5" />
({notes.length}) ({notes.length})
</button> </button>
</div> </div>
{parsing ? ( {parsing ? (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-2"> <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-purple-500" /> <Loader className="w-8 h-8 animate-spin text-sky-600" />
<p className="text-xs"> HTML MD退 MinerU PDF ...</p> <p className="text-xs font-bold"> Markdown ...</p>
</div> </div>
) : englishText ? ( ) : 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) => ( {englishText.split('\n\n').map((para, idx) => (
<div <div
key={idx} key={idx}
onMouseUp={() => handleTextSelection(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) 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` ? `${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' : 'hover:bg-slate-100'
}`} }`}
> >
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkMath]} remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex]} rehypePlugins={[rehypeKatex]}
> >
{para} {para}
@ -164,26 +275,33 @@ export function ReaderPanel({
</div> </div>
) : ( ) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400"> <div className="flex-1 flex flex-col items-center justify-center text-slate-400">
<FileText className="w-12 h-12 mb-3" /> <FileText className="w-12 h-12 mb-3 text-slate-300" />
<p className="text-xs"> Markdown </p> <p className="text-xs font-bold text-slate-500"></p>
<p className="text-[11px] text-slate-500 mt-1"></p> <p className="text-xs text-slate-400 mt-1"></p>
</div> </div>
)} )}
</div> </div>
)}
{/* 中文翻译面板 */} {(viewMode === 'chinese' || viewMode === 'bilingual') && (
<div className="glass rounded-2xl p-6 overflow-y-auto border border-slate-200 relative flex flex-col"> <div
<span className="text-[10px] text-emerald-700 font-bold uppercase tracking-wider block mb-3 border-b border-slate-200/60 pb-2"> ()</span> 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 ? ( {translating ? (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-2"> <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-emerald-500" /> <Loader className="w-8 h-8 animate-spin text-sky-600" />
<p className="text-xs"> prompt ...</p> <p className="text-xs font-bold">...</p>
</div> </div>
) : chineseText ? ( ) : 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 <ReactMarkdown
remarkPlugins={[remarkMath]} remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex]} rehypePlugins={[rehypeKatex]}
> >
{chineseText} {chineseText}
@ -191,58 +309,66 @@ export function ReaderPanel({
</div> </div>
) : ( ) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400"> <div className="flex-1 flex flex-col items-center justify-center text-slate-400">
<Languages className="w-12 h-12 mb-3" /> <Languages className="w-12 h-12 mb-3 text-slate-300" />
<p className="text-xs"></p> <p className="text-xs font-bold text-slate-500"></p>
<p className="text-[11px] text-slate-500 mt-1"></p> <p className="text-xs text-slate-400 mt-1"></p>
</div> </div>
)} )}
</div> </div>
)}
{/* 笔记侧边栏 */} {/* 笔记侧边栏 */}
{showNotesPanel && ( {showNotesPanel && (
<div className="glass rounded-2xl border border-amber-200 bg-amber-50/20 flex flex-col overflow-hidden"> <div className="console-panel rounded-xl border border-slate-200 bg-slate-50 flex flex-col overflow-hidden relative shadow-sm">
<div className="px-5 py-4 border-b border-amber-200 flex items-center justify-between"> <div className="px-4 py-3.5 border-b border-slate-200 flex items-center justify-between bg-white">
<span className="text-xs text-amber-700 font-bold"> ({notes.length})</span> <span className="text-xs font-bold text-slate-800"> ({notes.length})</span>
<button onClick={() => setShowNotesPanel(false)} className="text-slate-500 hover:text-slate-800"> <button onClick={() => setShowNotesPanel(false)} className="text-slate-400 hover:text-slate-655 transition-colors">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
{/* 新建笔记输入区 */} {/* 新建笔记输入区 */}
{selectedParagraphIdx !== null && ( {selectedParagraphIdx !== null && (
<div className="px-4 py-3 border-b border-slate-200 bg-slate-50"> <div className="px-4 py-4 border-b border-slate-200 bg-white space-y-3">
<div className="text-[10px] text-slate-500 mb-2"> #{selectedParagraphIdx + 1}</div> <div className="text-xs font-bold text-slate-700"> #{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>} {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">
<div className="flex gap-1.5 mb-2"> "{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]) => ( {Object.entries(NOTE_COLORS).map(([color, style]) => (
<button <button
key={color} key={color}
onClick={() => setNewNoteColor(color)} onClick={() => setNewNoteColor(color)}
className={`w-5 h-5 rounded-full border-2 transition-transform ${ className={`w-4 h-4 rounded-full border transition-transform ${
newNoteColor === color ? 'border-slate-700 scale-110' : 'border-transparent' newNoteColor === color ? 'border-slate-800 scale-120 shadow-sm' : 'border-transparent'
} ${style.bg.replace('/20', '')}`} } ${style.bg.replace('border-cyan-200', '')}`}
title={style.label} title={style.label}
/> />
))} ))}
</div> </div>
</div>
<textarea <textarea
value={newNoteText} value={newNoteText}
onChange={e => setNewNoteText(e.target.value)} onChange={e => setNewNoteText(e.target.value)}
placeholder="输入笔记内容..." placeholder="记录段落心得或重点..."
rows={3} 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"> <div className="flex gap-2">
<button <button
onClick={handleCreateNote} 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>
<button <button
onClick={() => { setSelectedParagraphIdx(null); setSelectedText(''); setNewNoteText(''); }} 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> </button>
@ -253,30 +379,40 @@ export function ReaderPanel({
{/* 笔记列表 */} {/* 笔记列表 */}
<div className="flex-1 overflow-y-auto p-4 space-y-3"> <div className="flex-1 overflow-y-auto p-4 space-y-3">
{notes.length === 0 ? ( {notes.length === 0 ? (
<div className="text-center text-slate-400 text-xs py-8"> <div className="text-center text-slate-400 text-xs py-12 space-y-2">
<Pencil className="w-8 h-8 mx-auto mb-2 opacity-40" /> <Pencil className="w-8 h-8 mx-auto opacity-30 text-slate-500" />
<div></div>
</div> </div>
) : ( ) : (
notes.map(note => ( notes.map(note => {
const style = NOTE_COLORS[note.highlight_color] || NOTE_COLORS.cyan;
return (
<div <div
key={note.id} 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>} <div className={`absolute left-0 top-0 w-1 h-full ${style.bg.split(' ')[0]}`} />
<p className="text-slate-800 leading-relaxed">{note.note_text}</p>
<div className="flex items-center justify-between mt-2"> <div className="text-slate-500 text-[10px] font-bold mb-1"> #{note.paragraph_index + 1}</div>
<span className="text-[10px] text-slate-400">{note.created_at.split('T')[0]}</span> {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 <button
onClick={() => handleDeleteNote(note.id)} 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> </button>
</div> </div>
</div> </div>
)) );
})
)} )}
</div> </div>
</div> </div>

View File

@ -1,8 +1,30 @@
// dashboard/src/features/search/SearchPanel.tsx // dashboard/src/features/search/SearchPanel.tsx
import React from 'react'; 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'; 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 { interface SearchPanelProps {
searchQuery: string; searchQuery: string;
setSearchQuery: (query: string) => void; setSearchQuery: (query: string) => void;
@ -29,6 +51,8 @@ interface SearchPanelProps {
openReader: (paper: StandardPaper) => void; openReader: (paper: StandardPaper) => void;
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync') => void; setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync') => void;
loadCitations: (bibcode: string, reset?: boolean) => void; loadCitations: (bibcode: string, reset?: boolean) => void;
showAlert: (msg: string, title?: string) => void;
onShowDetail: (paper: StandardPaper) => void;
} }
export function SearchPanel({ export function SearchPanel({
@ -57,6 +81,8 @@ export function SearchPanel({
openReader, openReader,
setActiveTab, setActiveTab,
loadCitations, loadCitations,
showAlert,
onShowDetail,
}: SearchPanelProps) { }: SearchPanelProps) {
const currentPage = Math.floor(searchStart / searchRows) + 1; const currentPage = Math.floor(searchStart / searchRows) + 1;
@ -68,13 +94,11 @@ export function SearchPanel({
{ field: 'all', op: 'AND', val: '' } { field: 'all', op: 'AND', val: '' }
]); ]);
// 当高级表单规则变化时,自动更新主输入框的检索式
const updateQueryFromRules = (currentRules: typeof rules) => { const updateQueryFromRules = (currentRules: typeof rules) => {
let qParts: string[] = []; let qParts: string[] = [];
currentRules.forEach((rule, idx) => { currentRules.forEach((rule, idx) => {
if (!rule.val.trim()) return; if (!rule.val.trim()) return;
let valStr = rule.val.trim(); let valStr = rule.val.trim();
// 如果包含空格且未加双引号,且不是括号表达式,则自动加上双引号
if (valStr.includes(' ') && !valStr.startsWith('"') && !valStr.startsWith('(')) { if (valStr.includes(' ') && !valStr.startsWith('"') && !valStr.startsWith('(')) {
valStr = `"${valStr}"`; valStr = `"${valStr}"`;
} }
@ -112,51 +136,60 @@ export function SearchPanel({
}; };
return ( return (
<div className="space-y-6 max-w-5xl mx-auto"> <div className="space-y-6 w-full max-w-5xl mx-auto">
{/* 搜索和过滤控制面板 */} {/* 标题 */}
<div className="glass p-6 rounded-2xl space-y-4"> <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"> <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"> <div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 w-5 h-5" /> <Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 w-5 h-5" />
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
placeholder="检索天文学文献 (支持关键字、作者、年份范围检索,如 'hot subdwarf year:2020-2023')" 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" 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>
<div className="flex gap-2">
<button <button
type="button" type="button"
onClick={() => setShowBuilder(!showBuilder)} 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 showBuilder
? 'bg-purple-50 border-purple-300 text-purple-600' ? 'bg-sky-50 border-sky-300 text-sky-700'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50' : 'btn-console'
}`} }`}
> >
{showBuilder ? '隐藏生成器' : '高级检索生成器'} <SlidersHorizontal className="w-3.5 h-3.5" />
{showBuilder ? '隐藏构造器' : '条件构造器'}
</button> </button>
<button <button
type="submit" type="submit"
disabled={searching} 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 ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
{searching ? '检索中' : '开始检索'} {searching ? '检索中...' : '检索文献'}
</button> </button>
</div> </div>
</div>
{/* 动态表单生成器 */} {/* 条件构造器 */}
{showBuilder && ( {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"> <div className="text-xs font-bold text-slate-700 flex justify-between items-center">
<span></span> <span></span>
<button <button
type="button" type="button"
onClick={handleAddRule} onClick={handleAddRule}
className="text-[10px] text-purple-600 hover:underline" className="text-xs text-sky-600 hover:text-sky-700 font-bold"
> >
+ +
</button> </button>
@ -169,26 +202,26 @@ export function SearchPanel({
<select <select
value={rule.op} value={rule.op}
onChange={e => handleRuleChange(idx, 'op', e.target.value)} 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="AND"> (AND)</option>
<option value="OR">OR </option> <option value="OR"> (OR)</option>
<option value="NOT">NOT </option> <option value="NOT"> (NOT)</option>
</select> </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 <select
value={rule.field} value={rule.field}
onChange={e => handleRuleChange(idx, 'field', e.target.value)} 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="all"></option>
<option value="title"> (title)</option> <option value="title"></option>
<option value="author"> (author)</option> <option value="author"></option>
<option value="abs"> (abs)</option> <option value="abs"></option>
<option value="year"> (year)</option> <option value="year"></option>
</select> </select>
<input <input
@ -200,16 +233,16 @@ export function SearchPanel({
? '例如: 2020-2023 或 2022' ? '例如: 2020-2023 或 2022'
: rule.field === 'author' : rule.field === 'author'
? '例如: Althaus' ? '例如: 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 && ( {rules.length > 1 && (
<button <button
type="button" type="button"
onClick={() => handleRemoveRule(idx)} 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> </button>
@ -220,76 +253,75 @@ export function SearchPanel({
</div> </div>
)} )}
<div className="text-[10px] text-slate-400 flex flex-wrap gap-x-4 gap-y-1 px-1"> <div className="text-[11px] text-slate-500 flex flex-wrap gap-x-4 gap-y-1 px-1">
<span>💡 :</span> <span className="font-semibold text-slate-650">💡 :</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-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">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-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">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-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">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> </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"> <div className="flex gap-4">
{[ {[
{ id: 'all', label: '全部数据源' }, { id: 'all', label: '全部数据源' },
{ id: 'ads', label: 'NASA ADS' }, { id: 'ads', label: 'NASA ADS' },
{ id: 'arxiv', label: 'arXiv 预印本' }, { id: 'arxiv', label: 'arXiv 预印本' },
].map(src => ( ].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 <input
type="radio" type="radio"
name="searchSource" name="searchSource"
checked={searchSource === src.id} checked={searchSource === src.id}
onChange={() => setSearchSource(src.id as any)} 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} {src.label}
</span> </span>
</label> </label>
))} ))}
</div> </div>
{/* 排序及最大结果数量控制 */} {/* 排序及每页条数 */}
<div className="flex items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2"> <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 <select
value={searchSort} value={searchSort}
onChange={e => handleSortChange(e.target.value)} 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="relevance"></option>
<option value="date_desc"> ()</option> <option value="date_desc"> ()</option>
<option value="date_asc"> ()</option> <option value="date_asc"> ()</option>
<option value="citations_desc"> ()</option> <option value="citations_desc"> ()</option>
</select> </select>
</div> </div>
<div className="flex items-center gap-2"> <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 <select
value={searchRows} value={searchRows}
onChange={e => handleRowsChange(Number(e.target.value))} 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="10">10 </option>
<option value="15">15 </option> <option value="15">15 </option>
<option value="30">30 </option> <option value="30">30 </option>
<option value="50">50 </option> <option value="50">50 </option>
<option value="100">100 </option>
</select> </select>
</div> </div>
{exportingList.length > 0 && ( {exportingList.length > 0 && (
<button <button
type="button"
onClick={handleExportBibtex} onClick={handleExportBibtex}
disabled={exporting} 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} {exporting ? <Loader className="w-3.5 h-3.5 animate-spin" /> : null}
({exportingList.length}) BibTeX ({exportingList.length}) BibTeX
</button> </button>
)} )}
</div> </div>
@ -297,23 +329,23 @@ export function SearchPanel({
</form> </form>
</div> </div>
{/* BibTeX 导出结果显示 */} {/* BibTeX 导出显示 */}
{bibtexContent && ( {bibtexContent && (
<div className="glass p-6 rounded-2xl relative"> <div className="console-panel p-5 rounded-xl border border-sky-200 relative overflow-hidden bg-sky-50/20">
<h3 className="text-sm font-bold text-purple-600 mb-3 flex items-center gap-2"> <h3 className="text-xs font-bold text-sky-700 mb-3 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-500" /> BibTeX <CheckCircle className="w-4 h-4 text-emerald-600" /> BibTeX
</h3> </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} {bibtexContent}
</pre> </pre>
<button <button
onClick={() => { onClick={() => {
navigator.clipboard.writeText(bibtexContent); 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> </button>
</div> </div>
)} )}
@ -321,74 +353,87 @@ export function SearchPanel({
{/* 检索列表 */} {/* 检索列表 */}
<div className="relative min-h-[200px]"> <div className="relative min-h-[200px]">
{searching && ( {searching && (
<div className="absolute inset-0 bg-white/40 backdrop-blur-[2px] z-10 flex items-center justify-center rounded-2xl"> <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-2xl shadow-xl border border-slate-200"> <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-purple-600 animate-spin" /> <Loader className="w-8 h-8 text-sky-650 animate-spin" />
<span className="text-xs font-semibold text-slate-500">...</span> <span className="text-xs font-bold text-slate-600">...</span>
</div> </div>
</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 => { {searchResults.map(paper => {
const isDownloading = downloadingBibcodes[paper.bibcode] || false; const isDownloading = downloadingBibcodes[paper.bibcode] || false;
const isSelected = selectedPaper?.bibcode === paper.bibcode; const isSelected = selectedPaper?.bibcode === paper.bibcode;
return ( return (
<div <div
key={paper.bibcode} key={paper.bibcode}
className={`glass p-6 rounded-2xl transition-all border ${ className={`console-panel p-6 rounded-xl border transition-all relative ${
isSelected ? 'border-purple-500/50 bg-purple-50/50' : 'border-slate-200/80 hover:border-slate-300' 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 <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)} onClick={() => openReader(paper)}
> >
{paper.title} {getDoctypeBadge(paper.doctype)}
<span className="align-middle">{paper.title}</span>
</h3> </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 ? ( {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"> <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 h-3" /> <CheckCircle className="w-3.5 h-3.5" />
</span> </span>
) : ( ) : (
<button <button
onClick={() => handleDownload(paper.bibcode)} onClick={() => handleDownload(paper.bibcode)}
disabled={isDownloading} 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> </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 <input
type="checkbox" type="checkbox"
checked={exportingList.includes(paper.bibcode)} checked={exportingList.includes(paper.bibcode)}
onChange={() => toggleExportItem(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> </label>
</div> </div>
</div> </div>
<p className="text-xs text-slate-500 font-medium mb-3"> <p className="text-xs text-slate-600 line-clamp-3 leading-relaxed mb-4 font-normal">
{paper.authors.join(', ')} {paper.year} <span className="italic">{paper.pub_journal}</span> {paper.abstract_text || '暂无文献摘要数据。'}
</p> </p>
<p className="text-xs text-slate-600 line-clamp-3 leading-relaxed mb-4"> <div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center border-t border-slate-100 pt-4 gap-3">
{paper.abstract_text || '暂无摘要'}
</p>
<div className="flex justify-between items-center">
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => openReader(paper)} 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>
<button <button
onClick={() => { onClick={() => {
@ -396,16 +441,16 @@ export function SearchPanel({
setActiveTab('citation'); setActiveTab('citation');
loadCitations(paper.bibcode, true); 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> </button>
</div> </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>} {paper.doi && <span>DOI: {paper.doi}</span>}
<span>Bibcode: {paper.bibcode}</span> <span>Bibcode: <span className="font-semibold text-slate-700">{paper.bibcode}</span></span>
{paper.citation_count > 0 && <span>: {paper.citation_count}</span>} {paper.citation_count > 0 && <span>: <span className="text-sky-750 font-bold">{paper.citation_count}</span></span>}
</div> </div>
</div> </div>
</div> </div>
@ -416,23 +461,23 @@ export function SearchPanel({
{/* 分页控制栏 */} {/* 分页控制栏 */}
{searchResults.length > 0 && ( {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 <button
onClick={() => handlePageChange(searchStart - searchRows)} onClick={() => handlePageChange(searchStart - searchRows)}
disabled={!hasPreviousPage || searching} 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" /> <ChevronLeft className="w-4 h-4" />
</button> </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} ) {currentPage} ( {searchStart + 1} - {searchStart + searchResults.length} )
</span> </span>
<button <button
onClick={() => handlePageChange(searchStart + searchRows)} onClick={() => handlePageChange(searchStart + searchRows)}
disabled={!hasNextPage || searching} 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" /> <ChevronRight className="w-4 h-4" />
</button> </button>

View File

@ -1,15 +1,19 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import axios from 'axios'; 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 { interface ProcessStatus {
active: boolean; active: boolean;
total: number; total: number;
downloaded: number; downloaded: number;
parsed: number; parsed: number;
download_failed: number;
parse_failed: number;
current_bibcode: string; current_bibcode: string;
logs: string[]; logs: string[];
action?: 'all' | 'download' | 'parse'; action?: 'download' | 'parse' | 'translate';
} }
interface HarvestStatus { interface HarvestStatus {
@ -34,16 +38,25 @@ export function SyncPanel() {
total: 0, total: 0,
}); });
const [errorMsg, setErrorMsg] = useState<string | null>(null); const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [syncQueries, setSyncQueries] = useState<SavedSyncQuery[]>([]);
const pollIntervalRef = useRef<any>(null); const pollIntervalRef = useRef<any>(null);
// 批量下载与解析相关状态 // 批量下载与解析相关状态
const [processAction, setProcessAction] = useState<'all' | 'download' | 'parse'>('all'); const [targetPhase, setTargetPhase] = useState<'download' | 'parse' | 'translate'>('download');
const [processScope, setProcessScope] = useState<'all' | 'undownloaded' | 'unparsed'>('undownloaded'); 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>({ const [processStatus, setProcessStatus] = useState<ProcessStatus>({
active: false, active: false,
total: 0, total: 0,
downloaded: 0, downloaded: 0,
parsed: 0, parsed: 0,
download_failed: 0,
parse_failed: 0,
current_bibcode: '', current_bibcode: '',
logs: [], logs: [],
}); });
@ -98,14 +111,61 @@ export function SyncPanel() {
updateQueryFromRules(next); 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 () => { const fetchStatus = async () => {
try { 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); setStatus(res.data);
if (!res.data.active && pollIntervalRef.current) { if (res.data.active) {
startPolling();
} else {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current); clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null; pollIntervalRef.current = null;
fetchSyncQueries();
}
} }
} catch (e) { } catch (e) {
console.error('获取同步状态失败', e); console.error('获取同步状态失败', e);
@ -120,13 +180,7 @@ export function SyncPanel() {
useEffect(() => { useEffect(() => {
fetchStatus(); fetchStatus();
// 如果组件加载时已经在运行中,自动启动轮询 fetchSyncQueries();
axios.get<HarvestStatus>('/api/sync/meta/status').then(res => {
if (res.data.active) {
setStatus(res.data);
startPolling();
}
});
return () => { return () => {
if (pollIntervalRef.current) { if (pollIntervalRef.current) {
@ -138,9 +192,11 @@ export function SyncPanel() {
// 批量下载与解析相关的网络操作 // 批量下载与解析相关的网络操作
const fetchProcessStatus = async () => { const fetchProcessStatus = async () => {
try { 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); setProcessStatus(res.data);
if (!res.data.active && processPollIntervalRef.current) { if (res.data.active) {
startProcessPolling();
} else if (processPollIntervalRef.current) {
clearInterval(processPollIntervalRef.current); clearInterval(processPollIntervalRef.current);
processPollIntervalRef.current = null; processPollIntervalRef.current = null;
} }
@ -158,14 +214,19 @@ export function SyncPanel() {
setProcessError(null); setProcessError(null);
try { try {
await axios.post('/api/sync/asset/run', { await axios.post('/api/sync/asset/run', {
action: processAction, target_phase: targetPhase,
scope: processScope, limit_count: batchLimitCount,
sort_order: sortOrder,
skip_completed: skipCompleted,
skip_failed: skipFailed,
skip_preceding_failed: skipPrecedingFailed,
skip_preceding_uncompleted: skipPrecedingUncompleted,
}); });
fetchProcessStatus(); fetchProcessStatus();
startProcessPolling(); startProcessPolling();
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
setProcessError(e.response?.data || '启动下载与解析任务失败。'); setProcessError(e.response?.data || '启动批量任务失败。');
} }
}; };
@ -181,12 +242,6 @@ export function SyncPanel() {
useEffect(() => { useEffect(() => {
fetchProcessStatus(); fetchProcessStatus();
axios.get<ProcessStatus>('/api/sync/asset/status').then(res => {
if (res.data.active) {
setProcessStatus(res.data);
startProcessPolling();
}
});
return () => { return () => {
if (processPollIntervalRef.current) { if (processPollIntervalRef.current) {
@ -239,6 +294,7 @@ export function SyncPanel() {
}); });
fetchStatus(); fetchStatus();
startPolling(); startPolling();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
setErrorMsg(e.response?.data || '启动收割任务失败。'); 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; const percent = status.total > 0 ? Math.min(100, Math.round((status.synced / status.total) * 100)) : 0;
return ( 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"> <div className="flex flex-col gap-1.5 border-b border-slate-200 pb-3">
<h2 className="text-2xl font-bold tracking-tight text-slate-800 font-outfit"></h2> <h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase"></h2>
<p className="text-slate-500 text-sm"> NASA ADS arXiv </p> <p className="text-slate-500 text-xs"> NASA ADS arXiv 线</p>
</div> </div>
{errorMsg && ( {errorMsg && (
<div className="p-4 rounded-xl bg-rose-50 border border-rose-200 flex gap-3 text-xs text-rose-600 items-start"> <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" /> <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>{errorMsg}</div> <div className="font-semibold">{errorMsg}</div>
</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="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-600 block flex justify-between items-center"> <label className="text-xs font-bold text-slate-700 block flex justify-between items-center">
<span>/ (Query)</span> <span> (Query)</span>
<button <button
type="button" type="button"
onClick={() => setShowBuilder(!showBuilder)} 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> </button>
</label> </label>
<input <input
@ -282,16 +339,16 @@ export function SyncPanel() {
onChange={e => setQuery(e.target.value)} onChange={e => setQuery(e.target.value)}
disabled={status.active} disabled={status.active}
placeholder="例如: hot subdwarf, Gaia BH1..." 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"> <div className="text-[11px] text-slate-450 flex flex-wrap gap-x-2.5 px-0.5 mt-1">
<span>:</span> <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> <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> </div>
<div className="space-y-2"> <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"> <div className="flex gap-2">
{[ {[
{ id: 'all', label: '全部' }, { id: 'all', label: '全部' },
@ -303,10 +360,10 @@ export function SyncPanel() {
type="button" type="button"
disabled={status.active} disabled={status.active}
onClick={() => setSource(src.id as any)} 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 source === src.id
? 'bg-purple-600/10 text-purple-600 border-purple-500/30' ? 'bg-sky-50 border-sky-300 text-sky-700 shadow-sm'
: 'bg-white/60 text-slate-600 border-slate-200 hover:bg-slate-50' : 'btn-console btn-console-secondary'
}`} }`}
> >
{src.label} {src.label}
@ -318,15 +375,15 @@ export function SyncPanel() {
{/* 动态表单生成器 */} {/* 动态表单生成器 */}
{showBuilder && ( {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"> <div className="text-xs font-bold text-slate-700 flex justify-between items-center">
<span></span> <span></span>
<button <button
type="button" type="button"
onClick={handleAddRule} onClick={handleAddRule}
className="text-[10px] text-purple-600 hover:underline" className="text-xs text-sky-600 hover:text-sky-700 font-bold"
> >
+ +
</button> </button>
</div> </div>
@ -337,26 +394,26 @@ export function SyncPanel() {
<select <select
value={rule.op} value={rule.op}
onChange={e => handleRuleChange(idx, 'op', e.target.value)} 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="AND"> (AND)</option>
<option value="OR">OR </option> <option value="OR"> (OR)</option>
<option value="NOT">NOT </option> <option value="NOT"> (NOT)</option>
</select> </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 <select
value={rule.field} value={rule.field}
onChange={e => handleRuleChange(idx, 'field', e.target.value)} 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="all"></option>
<option value="title"> (title)</option> <option value="title"></option>
<option value="author"> (author)</option> <option value="author"></option>
<option value="abs"> (abs)</option> <option value="abs"></option>
<option value="year"> (year)</option> <option value="year"></option>
</select> </select>
<input <input
@ -368,16 +425,16 @@ export function SyncPanel() {
? '例如: 2020-2023 或 2022' ? '例如: 2020-2023 或 2022'
: rule.field === 'author' : rule.field === 'author'
? '例如: Althaus' ? '例如: 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 && ( {rules.length > 1 && (
<button <button
type="button" type="button"
onClick={() => handleRemoveRule(idx)} 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> </button>
@ -388,18 +445,18 @@ export function SyncPanel() {
</div> </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"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-600 block flex items-center justify-between"> <label className="text-xs font-bold text-slate-700 block flex items-center justify-between">
<span></span> <span></span>
<span className="text-[10px] text-slate-400 font-normal"></span> <span className="text-[11px] text-slate-450 font-normal"> API</span>
</label> </label>
<input <input
type="number" type="number"
value={limit} value={limit}
disabled={status.active} disabled={status.active}
onChange={e => setLimit(Math.max(1, parseInt(e.target.value) || 0))} 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> </div>
@ -408,30 +465,30 @@ export function SyncPanel() {
type="button" type="button"
disabled={status.active || estimating} disabled={status.active || estimating}
onClick={handleEstimate} 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" />} {estimating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
</button> </button>
<button <button
type="button" type="button"
disabled={status.active || !query.trim()} disabled={status.active || !query.trim()}
onClick={handleStartHarvest} 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" /> <Play className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
</div> </div>
{/* 预估结果展示 */} {/* 预估结果 */}
{estimatedCount !== null && !status.active && ( {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"> <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" /> <Info className="w-4 h-4 shrink-0 text-sky-600" />
<div> <div>
<strong className="text-sm text-indigo-900">{estimatedCount}</strong> <strong className="text-sky-900 font-bold">{estimatedCount}</strong>
{estimatedCount > limit ? ` 设定的上限为 ${limit} 篇,系统将只拉取前 ${limit} 篇。` : ' 将拉取全部文献。'} {estimatedCount > limit ? ` 限制最大同步前 ${limit} 篇元数据。` : ' 将全部进行同步。'}
</div> </div>
</div> </div>
)} )}
@ -439,134 +496,181 @@ export function SyncPanel() {
{/* 实时同步进度 */} {/* 实时同步进度 */}
{(status.active || status.synced > 0) && ( {(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 className="flex justify-between items-center">
<div> <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 ? ( {status.active ? (
<> <>
<Loader className="w-4 h-4 text-purple-600 animate-spin" /> <Loader className="w-4 h-4 text-sky-600 animate-spin" />
<span>...</span> <span>...</span>
</> </>
) : ( ) : (
<> <>
<CheckCircle className="w-4 h-4 text-emerald-500" /> <CheckCircle className="w-4 h-4 text-emerald-600" />
<span></span> <span></span>
</> </>
)} )}
</h3> </h3>
<p className="text-slate-500 text-xs mt-1"> <p className="text-slate-500 text-[11px] mt-1.5 font-semibold">
: <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'} : <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> </p>
</div> </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>
<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 <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}%` }} style={{ width: `${percent}%` }}
/> />
</div> </div>
{status.active && status.source === 'all' || status.source === 'arxiv' ? ( {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"> <div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-700">
💡 arXiv ( 3 ) 💡 arXiv 3000
</div> </div>
) : null} ) : null}
</div> </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"> <div className="flex flex-col gap-1">
<h3 className="text-sm font-bold text-slate-800 flex items-center gap-2 font-outfit"> <h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<Download className="w-4 h-4 text-purple-600" /> <Download className="w-4 h-4 text-sky-600" />
<span> (Bulk Download & Extraction)</span> <span>线</span>
</h3> </h3>
<p className="text-slate-500 text-xs"> <p className="text-slate-500 text-xs">
(PDF/HTML) (Markdown) PDF/HTML Markdown
</p> </p>
</div> </div>
{processError && ( {processError && (
<div className="p-4 rounded-xl bg-rose-50 border border-rose-200 flex gap-3 text-xs text-rose-600 items-start"> <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" /> <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>{processError}</div> <div>{processError}</div>
</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"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-600 block"> (Action)</label> <label className="text-xs font-bold text-slate-700 block"></label>
<div className="flex gap-2"> <div className="flex flex-col gap-1.5">
{[ {[
{ id: 'all', label: '下载并解析' }, { id: 'download', label: '下载' },
{ id: 'download', label: '仅下载文献' }, { id: 'parse', label: '解析' },
{ id: 'parse', label: '仅解析文献' }, { id: 'translate', label: '翻译' },
].map(act => ( ].map(phase => (
<button <label key={phase.id} className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
key={act.id} <input
type="button" type="radio"
name="targetPhase"
checked={targetPhase === phase.id}
disabled={processStatus.active} disabled={processStatus.active}
onClick={() => setProcessAction(act.id as any)} onChange={() => setTargetPhase(phase.id as any)}
className={`flex-1 py-2.5 rounded-xl text-xs font-medium border transition-all ${ className="text-sky-650 focus:ring-sky-500"
processAction === act.id />
? 'bg-purple-600/10 text-purple-600 border-purple-500/30' <span>{phase.label}</span>
: 'bg-white/60 text-slate-600 border-slate-200 hover:bg-slate-50' </label>
}`}
>
{act.label}
</button>
))} ))}
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-600 block"> (Scope)</label> <label className="text-xs font-bold text-slate-700 block"> (DOCS)</label>
<div className="flex gap-2"> <input
{[ type="number"
{ id: 'all', label: '全部文献' }, value={batchLimitCount}
{ id: 'undownloaded', label: '仅未下载' },
{ id: 'unparsed', label: '仅未解析' },
].map(opt => (
<button
key={opt.id}
type="button"
disabled={processStatus.active} disabled={processStatus.active}
onClick={() => setProcessScope(opt.id as any)} onChange={e => setBatchLimitCount(Math.max(1, parseInt(e.target.value) || 0))}
className={`flex-1 py-2.5 rounded-xl text-xs font-medium border transition-all ${ 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"
processScope === opt.id />
? 'bg-purple-600/10 text-purple-600 border-purple-500/30'
: 'bg-white/60 text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
</div> </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> </div>
<div className="flex justify-end pt-2"> <div className="space-y-2 border-t border-slate-100 pt-4">
<div className="w-full md:w-1/2 flex"> <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 ? ( {processStatus.active ? (
<button <button
type="button" type="button"
onClick={handleStopProcess} 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" /> <StopCircle className="w-3.5 h-3.5" />
</button> </button>
) : ( ) : (
<button <button
type="button" type="button"
onClick={handleStartProcess} 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" /> <Play className="w-3.5 h-3.5" />
</button> </button>
)} )}
</div> </div>
@ -574,60 +678,63 @@ export function SyncPanel() {
{/* 进度与终端日志展示 */} {/* 进度与终端日志展示 */}
{(processStatus.active || processStatus.total > 0) && ( {(processStatus.active || processStatus.total > 0) && (
<div className="space-y-4 pt-2 border-t border-slate-200/50"> <div className="space-y-4 pt-4 border-t border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="space-y-1.5">
{/* 下载进度 */} <div className="flex justify-between text-xs font-bold text-slate-600">
{(!processStatus.action || processStatus.action === 'all' || processStatus.action === 'download') && ( <span className="flex items-center gap-1">
<div className={`space-y-1.5 ${(!processStatus.action || processStatus.action === 'all') ? '' : 'col-span-2'}`}> {processStatus.action === 'download' && <Download className="w-3.5 h-3.5 text-sky-600" />}
<div className="flex justify-between text-xs"> {processStatus.action === 'parse' && <FileText className="w-3.5 h-3.5 text-emerald-600" />}
<span className="font-bold text-slate-700 flex items-center gap-1"> {processStatus.action === 'translate' && <RefreshCw className="w-3.5 h-3.5 text-indigo-600" />}
<Download className="w-3.5 h-3.5 text-blue-500" /> {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>
<span className="text-slate-500 font-medium">{processStatus.downloaded} / {processStatus.total}</span>
</div> </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 <div
className="h-full bg-gradient-to-r from-blue-500 to-indigo-500 transition-all duration-300" className={`h-full transition-all duration-300 ${
style={{ width: `${processStatus.total > 0 ? Math.min(100, Math.round((processStatus.downloaded / processStatus.total) * 100)) : 0}%` }} 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>
</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 && ( {processStatus.active && processStatus.current_bibcode && (
<div className="text-[11px] text-slate-500 flex items-center gap-1.5"> <div className="text-xs font-bold text-slate-600 flex items-center gap-2">
<Loader className="w-3 h-3 text-purple-600 animate-spin" /> <Loader className="w-3.5 h-3.5 text-sky-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> <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>
)} )}
{/* 滚动日志终端 */} {/* 滚动日志终端 */}
<div className="space-y-1.5"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-600 block"></label> <label className="text-xs font-bold text-slate-700 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="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 ? ( {processStatus.logs.length === 0 ? (
<div className="text-slate-500 italic">...</div> <div className="text-slate-400 italic">...</div>
) : ( ) : (
processStatus.logs.map((log, idx) => ( processStatus.logs.map((log, idx) => (
<div key={idx} className="whitespace-pre-wrap leading-relaxed"> <div key={idx} className="whitespace-pre-wrap leading-relaxed">
@ -641,6 +748,66 @@ export function SyncPanel() {
</div> </div>
)} )}
</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> </div>
); );
} }

View File

@ -1,20 +1,32 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
:root { :root {
font-family: 'Inter', system-ui, -apple-system, sans-serif; font-family: 'Inter', system-ui, -apple-system, sans-serif;
color-scheme: light; 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 { body {
margin: 0; margin: 0;
background-color: #f8fafc; background-color: var(--bg-main);
color: #0f172a; color: var(--text-main);
min-height: 100vh; min-height: 100vh;
overflow: hidden;
position: relative;
} }
/* Custom premium scrollbar styling */ /* Custom premium scrollbar styling for light mode */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
@ -30,25 +42,55 @@ body {
background: #94a3b8; background: #94a3b8;
} }
/* Glassmorphism utility for light mode */ /* Premium clean panel cards */
.glass { .console-panel {
background: rgba(255, 255, 255, 0.45); background: var(--bg-card);
backdrop-filter: blur(16px); border: 1px solid var(--border-clean);
-webkit-backdrop-filter: blur(16px); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.03);
} }
.glass-accent { .console-panel-active {
border: 1px solid rgba(192, 132, 252, 0.2); 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 */ /* High contrast clean console button */
@keyframes pulse-slow { .btn-console {
0%, 100% { opacity: 0.4; } background: #ffffff;
50% { opacity: 0.8; } border: 1px solid #cbd5e1;
color: #334155;
font-weight: 500;
transition: all 0.2s ease;
} }
.animate-pulse-slow { .btn-console:hover:not(:disabled) {
animation: pulse-slow 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; 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;
}

View File

@ -15,6 +15,7 @@ export interface StandardPaper {
is_downloaded: boolean; is_downloaded: boolean;
has_markdown: boolean; has_markdown: boolean;
has_translation: boolean; has_translation: boolean;
doctype: string;
} }
export interface CitationNetwork { export interface CitationNetwork {
@ -24,6 +25,7 @@ export interface CitationNetwork {
reference_count: number; reference_count: number;
references: string[]; references: string[];
citations: string[]; citations: string[];
citation_counts?: Record<string, number>;
} }
export interface NoteRecord { export interface NoteRecord {
@ -35,3 +37,11 @@ export interface NoteRecord {
selected_text: string; selected_text: string;
created_at: string; created_at: string;
} }
export interface SavedSyncQuery {
id: number;
query: string;
source: string;
limit_count: number;
last_run: string;
}

View File

@ -22,6 +22,7 @@ graph TD
Downloader[下载器 services/download.rs] Downloader[下载器 services/download.rs]
Translator[翻译器 services/translation.rs] Translator[翻译器 services/translation.rs]
Qiniu[七牛云客户端 clients/qiniu.rs] Qiniu[七牛云客户端 clients/qiniu.rs]
Logging[日志记录器 services/logging.rs]
DB[("SQLite / astro_research.db")] DB[("SQLite / astro_research.db")]
end end
@ -143,12 +144,18 @@ sequenceDiagram
P->>P: 6e. 转换 GFM Markdown 并恢复 LaTeX 公式 P->>P: 6e. 转换 GFM Markdown 并恢复 LaTeX 公式
P->>P: 6f. 后处理:清除冗余的 margin 空白与前导缩进 P->>P: 6f. 后处理:清除冗余的 margin 空白与前导缩进
else 仅有 PDF 文件 (PDF 降级解析) else 仅有 PDF 文件 (PDF 降级解析)
P->>M: 7a. Multipart 格式上传 PDF 至 MinerU 服务 P->>M: 7a. 获取批量预签名上传 URL (POST /file-urls/batch/)
M-->>P: 7b. 返回大模型解析出的 Markdown 文本及插图包 M-->>P: 7b. 返回预签名上传 URL 与 Batch ID
loop 遍历每一个提取的插图 P->>M: 7c. 上传 PDF 二进制字节流 (PUT 至预签名 URL)
P->>Q: 7c. 上传插图文件并获取七牛云 CDN 域名外链 loop 轮询任务状态 (每 10s 一次,最多 45 次)
P->>M: 7d. 查询提取进度与结果 (GET /extract-results/batch/{id})
M-->>P: 7e. 返回处理状态 ("done"/"error"等)
end end
P->>P: 7d. 在 Markdown 中重写插图链接为七牛云 CDN 绝对路径 P->>P: 7f. 下载解析结果的 ZIP 压缩包并解压提取
loop 遍历每一个提取的插图
P->>Q: 7g. 上传插图文件并获取七牛云 CDN 域名外链
end
P->>P: 7h. 在 Markdown 中重写插图链接为七牛云 CDN 绝对路径
end end
P-->>H: 8. 返回清洗转换出的标准英文 Markdown 文本 P-->>H: 8. 返回清洗转换出的标准英文 Markdown 文本
@ -159,7 +166,7 @@ sequenceDiagram
#### 详细解析说明: #### 详细解析说明:
1. **HTML 转换为 Markdown 保护公式**:由于 MathJax/LaTeX 在 Markdown 转换中极易被当成普通字符进行转义(例如 `_` 倾斜或 `\` 换行失效),解析器在 HTML 解析前,通过正则将 `$` / `$$``\(` / `\[` 中的内容全部替换为特定的 UUID 占位符,转换为标准 Markdown 之后,再反向替换恢复公式,确保 LaTeX 渲染无损。 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 解析。 - 统一相对图表链接,并集成 MinerU PDF 解析。
- **[src/services/translation.rs](../src/services/translation.rs)**: - **[src/services/translation.rs](../src/services/translation.rs)**:
- 利用本地千万字级别的天文学双语词典对原文进行分词匹配,注入系统提示词让 LLM 实现学术级精细翻译。 - 利用本地千万字级别的天文学双语词典对原文进行分词匹配,注入系统提示词让 LLM 实现学术级精细翻译。
- **[src/services/logging.rs](../src/services/logging.rs)**:
- 全局日志记录系统,基于 `tracing-subscriber` 实现了控制台美化日志输出与基于时间的每日滚动日志文件写出,使用上海时区 (+08:00) 格式化时间。
- **[dashboard/src/components/CitationGalaxyCanvas.tsx](../dashboard/src/components/CitationGalaxyCanvas.tsx)**: - **[dashboard/src/components/CitationGalaxyCanvas.tsx](../dashboard/src/components/CitationGalaxyCanvas.tsx)**:
- 基于原生 HTML5 Canvas 开发的轻量级、高性能力导向图星系物理引擎,用于文献引文网络拓扑结构的可视化渲染。 - 基于原生 HTML5 Canvas 开发的轻量级、高性能力导向图星系物理引擎,用于文献引文网络拓扑结构的可视化渲染。

View File

@ -34,13 +34,13 @@
### Rust 规范 (Backend) ### Rust 规范 (Backend)
- 遵循 Rust 官方标准样式,提交前必须执行 `cargo fmt``cargo clippy` - 遵循 Rust 官方标准样式,提交前必须执行 `cargo fmt``cargo clippy`
- 注释和系统日志建议统一使用中文,便于开发者追踪阅读。 - 注释和系统日志建议统一使用中文,便于开发者追踪 and 阅读。
- API handers 中的异常信息请使用 `anyhow``thiserror` 进行结构化抛出。 - API handlers 中的异常信息请使用 `anyhow``thiserror` 进行结构化抛出。
### React & TypeScript 规范 (Frontend) ### React & TypeScript 规范 (Frontend)
- 严格遵循 `React 18/19` 函数式组件写法,使用 React Hooks 维护状态。 - 严格遵循 `React 18/19` 函数式组件写法,使用 React Hooks 维护状态。
- 为保证生产编译成功,务必开启类型安全限制(如在导入纯类型时显式使用 `import type { ... }`)。 - 为保证生产编译成功,务必开启类型安全限制(如在导入纯类型时显式使用 `import type { ... }`)。
- CSS 层面使用 Tailwind CSS 统一的磨砂玻璃体 (Glassmorphism) 及响应式布局,所有间距、颜色严格使用 CSS 变量控制以支持主题切换 - CSS 层面使用 Tailwind CSS 统一的高对比度浅色纯中文控制台风格,所有布局、间距、颜色需遵循实边框、高对比度黑白字及高雅按钮样式(`.btn-console` 等),以保障学术沉浸与阅读的高保真性
--- ---

View File

@ -6,14 +6,14 @@ AstroResearch 的前端界面设计坚持“未来科技感与学术沉浸”的
## 1. 视觉系统 (Visual Palette) ## 1. 视觉系统 (Visual Palette)
### 1.1 精致双色主题 ### 1.1 高对比度浅色纯中文控制台
AstroResearch 完美适配了深色与浅色模式。使用精挑细选的 HSL 柔和色彩代替刺眼的饱和色 AstroResearch 前端目前重构并统一为**高对比度浅色纯中文学术控制台**,以确保长久学术检索与对照阅读的沉浸体验
| 模式 | 背景色 | 主文本色 | 卡片容器 | 毛玻璃效果 (Glassmorphism) | | 元素 | 背景色 / 边框色 | 主文本色 | 交互按钮与状态指示 | 设计风格 |
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| **深色模式** | 深夜极光黑 (`#090d16`) | 纯净雪白 (`#f8fafc`) | 磨砂深灰 (`bg-slate-900/60`) | 边框: `border-slate-800/80`, 模糊: `backdrop-blur-md` | | **主背景** | 纯净冷灰白 (`#f1f5f9`) | 深石板灰/接近纯黑 (`#0f172a`) | 控制台按钮 (`.btn-console` / `.btn-console-primary`) | 扁平极简实边框设计 |
| **浅色模式** | 雅致灰石色 (`#f8fafc`) | 深石板色 (`#0f172a`) | 磨砂亮白 (`bg-white/60`) | 边框: `border-slate-200/80`, 模糊: `backdrop-blur-md` | | **卡片/容器** | 纯白背景 (`#ffffff`),实线灰色边框 (`#e2e8f0`) | 辅助灰 (`#64748b`) | 指示灯:深宝石绿 (就绪) / 灰石色 (未解析) | 微卡片投影效果 |
--- ---

View File

@ -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 冲突。 - **原因**:本地 SQLite 数据库文件 `astro_research.db` 出现并发锁死或版本 schema 冲突。
- **解决方法** - **解决方法**
1. 备份并临时删除根目录下的 `astro_research.db` 数据库文件。 1. 备份并临时删除根目录下的 `astro_research.db` 数据库文件。

View File

@ -0,0 +1,3 @@
-- migrations/20260608000002_add_doctype.sql
ALTER TABLE papers ADD COLUMN doctype TEXT;

View 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) -- 去重约束
);

Binary file not shown.

321
scratch/failed_results.txt Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View File

@ -20,6 +20,7 @@
* **[parser.rs](services/parser.rs)**:文献排版转换与清洗器,支持 MathJax LaTeX 占位符防护及 MinerU 图文 PDF 降级解析。 * **[parser.rs](services/parser.rs)**:文献排版转换与清洗器,支持 MathJax LaTeX 占位符防护及 MinerU 图文 PDF 降级解析。
* **[translation.rs](services/translation.rs)**:大模型对比翻译流水线。支持基于天文学对照词表的分词过滤,通过 Trie 树最长匹配机制生成 Glossary 专有名词注入 Prompt。 * **[translation.rs](services/translation.rs)**:大模型对比翻译流水线。支持基于天文学对照词表的分词过滤,通过 Trie 树最长匹配机制生成 Glossary 专有名词注入 Prompt。
* **[query_parser.rs](services/query_parser.rs)**:解析并标准化学术检索式,为 ADS 和 arXiv 分别生成合规的专有检索语法。 * **[query_parser.rs](services/query_parser.rs)**:解析并标准化学术检索式,为 ADS 和 arXiv 分别生成合规的专有检索语法。
* **[logging.rs](services/logging.rs)**:系统日志服务,支持控制台彩色日志输出、每日滚动写入磁盘日志文件,采用自定义上海时区 (+08:00) 格式化时间。
--- ---

View File

@ -57,6 +57,7 @@ pub struct StandardPaper {
pub is_downloaded: bool, pub is_downloaded: bool,
pub has_markdown: bool, pub has_markdown: bool,
pub has_translation: bool, pub has_translation: bool,
pub doctype: String,
} }
// ── GET /api/search ── // ── GET /api/search ──
@ -197,6 +198,7 @@ pub async fn download_paper(
}; };
if pdf_path.is_none() && html_path.is_none() { if pdf_path.is_none() && html_path.is_none() {
error!("文献 {} PDF 和 HTML 均下载失败,无可用物理文件格式", req.bibcode);
return Err((StatusCode::INTERNAL_SERVER_ERROR, "PDF 和 HTML 均下载失败,请检查网络".to_string())); return Err((StatusCode::INTERNAL_SERVER_ERROR, "PDF 和 HTML 均下载失败,请检查网络".to_string()));
} }
@ -324,9 +326,11 @@ pub async fn parse_paper(
} }
} }
} else { } else {
error!("文献 {} 解析失败:本地 PDF 文件 {:?} 丢失", req.bibcode, pdf_abs);
return Err((StatusCode::NOT_FOUND, "本地 PDF 文件未找到".to_string())); return Err((StatusCode::NOT_FOUND, "本地 PDF 文件未找到".to_string()));
} }
} else { } else {
error!("文献 {} 解析失败:请先下载该文献的 HTML 或 PDF 文件", req.bibcode);
return Err((StatusCode::BAD_REQUEST, "请先下载该文献的 HTML 或 PDF 文件".to_string())); 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); let md_abs = state.config.library_dir.join(&md_rel);
if !md_abs.exists() { if !md_abs.exists() {
error!("文献 {} 翻译失败:解析的英文 Markdown 文件 {:?} 不存在", req.bibcode, md_abs);
return Err((StatusCode::BAD_REQUEST, "解析 Markdown 文件丢失".to_string())); return Err((StatusCode::BAD_REQUEST, "解析 Markdown 文件丢失".to_string()));
} }
let english_markdown = fs::read_to_string(&md_abs) 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 翻译服务并注入对照词表 // 调用 LLM 翻译服务并注入对照词表
let translated_markdown = crate::services::translation::translate_markdown(&english_markdown, &state.dict, &state.config) let translated_markdown = crate::services::translation::translate_markdown(&english_markdown, &state.dict, &state.config)
.await .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); let tr_filename = format!("{}_zh.md", req.bibcode);
@ -425,6 +442,7 @@ pub struct CitationsResponse {
pub reference_count: i32, pub reference_count: i32,
pub references: Vec<String>, // 该文献参考文献 bibcode 数组 pub references: Vec<String>, // 该文献参考文献 bibcode 数组
pub citations: Vec<String>, // 引用该文献的 bibcode 数组 pub citations: Vec<String>, // 引用该文献的 bibcode 数组
pub citation_counts: std::collections::HashMap<String, i32>, // 相关文献与被引数映射
} }
// 从 SQLite 查询引用关联,生成引用星系关系树 // 从 SQLite 查询引用关联,生成引用星系关系树
@ -432,9 +450,49 @@ pub async fn get_citation_network(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Query(params): Query<DownloadRequest>, Query(params): Query<DownloadRequest>,
) -> Result<Json<CitationsResponse>, (StatusCode, String)> { ) -> Result<Json<CitationsResponse>, (StatusCode, String)> {
let paper = get_paper_from_db(&state.db, &state.config.library_dir, &params.bibcode) let paper = match get_paper_from_db(&state.db, &state.config.library_dir, &params.bibcode).await {
.await Ok(p) => p,
.map_err(|e| (StatusCode::NOT_FOUND, format!("未找到文献数据: {}", e)))?; 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 = ?") 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(); .unwrap_or_default();
let citations: Vec<String> = cits_rows.iter().map(|row| row.get(0)).collect(); 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 { Ok(Json(CitationsResponse {
bibcode: paper.bibcode, bibcode: paper.bibcode,
title: paper.title, title: paper.title,
@ -459,6 +532,7 @@ pub async fn get_citation_network(
reference_count: paper.reference_count, reference_count: paper.reference_count,
references, references,
citations, citations,
citation_counts,
})) }))
} }
@ -499,7 +573,7 @@ pub async fn get_paper_detail(
pub async fn get_library( pub async fn get_library(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<StandardPaper>>, (StatusCode, String)> { ) -> 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) .fetch_all(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("访问本地数据库失败: {}", e)))?; .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 html_path: Option<String> = r.get(12);
let markdown_path: Option<String> = r.get(13); let markdown_path: Option<String> = r.get(13);
let translation_path: Option<String> = r.get(14); 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_str: Option<String> = r.get(2);
let authors: Vec<String> = authors_str.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); 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), || 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_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), 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, is_downloaded: false,
has_markdown: false, has_markdown: false,
has_translation: 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, is_downloaded: false,
has_markdown: false, has_markdown: false,
has_translation: 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 { if is_existing_temp && is_new_formal {
info!("发现相同 arXiv ID 的文献,将临时主键 {} 升级为正式 ADS Bibcode: {}", existing_bibcode, p.bibcode); info!("发现相同 arXiv ID 的文献,将临时主键 {} 升级为正式 ADS Bibcode: {}", existing_bibcode, p.bibcode);
sqlx::query( 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.bibcode)
.bind(&p.title) .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.doi)
.bind(p.citation_count) .bind(p.citation_count)
.bind(p.reference_count) .bind(p.reference_count)
.bind(&p.doctype)
.bind(&existing_bibcode) .bind(&existing_bibcode)
.execute(db) .execute(db)
.await?; .await?;
@ -787,8 +866,8 @@ pub(crate) async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyh
// 2. 正常插入/冲突更新 // 2. 正常插入/冲突更新
sqlx::query( sqlx::query(
"INSERT INTO papers (bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count) \ "INSERT INTO papers (bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, doctype) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(bibcode) DO UPDATE SET \ ON CONFLICT(bibcode) DO UPDATE SET \
title=excluded.title, \ title=excluded.title, \
authors=excluded.authors, \ authors=excluded.authors, \
@ -798,7 +877,8 @@ pub(crate) async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyh
doi=excluded.doi, \ doi=excluded.doi, \
arxiv_id=excluded.arxiv_id, \ arxiv_id=excluded.arxiv_id, \
citation_count=excluded.citation_count, \ citation_count=excluded.citation_count, \
reference_count=excluded.reference_count" reference_count=excluded.reference_count, \
doctype=excluded.doctype"
) )
.bind(&p.bibcode) .bind(&p.bibcode)
.bind(&p.title) .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.arxiv_id)
.bind(p.citation_count) .bind(p.citation_count)
.bind(p.reference_count) .bind(p.reference_count)
.bind(&p.doctype)
.execute(db) .execute(db)
.await?; .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> { 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) .bind(bibcode)
.fetch_one(db) .fetch_one(db)
.await?; .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 html_path: Option<String> = r.get(12);
let markdown_path: Option<String> = r.get(13); let markdown_path: Option<String> = r.get(13);
let translation_path: Option<String> = r.get(14); 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_str: Option<String> = r.get(2);
let authors: Vec<String> = authors_str.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); 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, is_downloaded: is_pdf_exist || is_html_exist,
has_markdown: is_md_exist, has_markdown: is_md_exist,
has_translation: is_tr_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" => { "unparsed" | "all_unparsed" => {
// 查询所有本地无 Markdown 文件的文献 // 查询所有本地无 Markdown 文件的文献 (或者处于 mineru_batch: 状态的任务)
let rows = sqlx::query("SELECT bibcode FROM papers WHERE markdown_path IS NULL") let rows = sqlx::query("SELECT bibcode FROM papers WHERE markdown_path IS NULL OR markdown_path LIKE 'mineru_batch:%'")
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("读取数据库失败: {}", e)))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("读取数据库失败: {}", e)))?;
@ -1055,6 +1138,58 @@ pub async fn stop_asset_sync(
StatusCode::OK 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 ── // ── GET /api/sync/asset/status ──
pub async fn get_asset_sync_status( pub async fn get_asset_sync_status(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
@ -1086,6 +1221,7 @@ mod tests {
reference: None, reference: None,
citation: None, citation: None,
identifier: None, identifier: None,
doctype: Some("article".to_string()),
}; };
let paper = convert_ads_doc_to_standard(&doc); let paper = convert_ads_doc_to_standard(&doc);
@ -1118,6 +1254,7 @@ mod tests {
reference: None, reference: None,
citation: None, citation: None,
identifier: Some(vec!["2026MNRAS.530.1234A".to_string(), "arXiv:2606.12345".to_string()]), 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); let paper = convert_ads_doc_to_standard(&doc);
@ -1174,6 +1311,7 @@ mod tests {
is_downloaded: false, is_downloaded: false,
has_markdown: false, has_markdown: false,
has_translation: false, has_translation: false,
doctype: "article".to_string(),
}; };
// 保存 // 保存

View File

@ -20,6 +20,7 @@ pub struct AdsPaperDoc {
pub reference: Option<Vec<String>>, pub reference: Option<Vec<String>>,
pub citation: Option<Vec<String>>, pub citation: Option<Vec<String>>,
pub identifier: Option<Vec<String>>, pub identifier: Option<Vec<String>>,
pub doctype: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -69,8 +70,8 @@ impl AdsClient {
let translated = crate::services::query_parser::to_ads_query(query); let translated = crate::services::query_parser::to_ads_query(query);
// fl 声明返回字段,包括 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"; let fl = "bibcode,title,author,year,pub,keyword,abstract,doi,citation_count,reference_count,reference,citation,identifier,doctype";
let ads_sort = match sort { let ads_sort = match sort {
"date_desc" => "date desc", "date_desc" => "date desc",
@ -120,6 +121,7 @@ impl AdsClient {
reference: d.reference, reference: d.reference,
citation: d.citation, citation: d.citation,
identifier: d.identifier, identifier: d.identifier,
doctype: d.doctype,
} }
}).collect(); }).collect();
@ -204,6 +206,7 @@ struct RawDoc {
reference: Option<Vec<String>>, reference: Option<Vec<String>>,
citation: Option<Vec<String>>, citation: Option<Vec<String>>,
identifier: Option<Vec<String>>, identifier: Option<Vec<String>>,
doctype: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]

View File

@ -1,6 +1,6 @@
use sha1::Sha1; use sha1::Sha1;
use hmac::{Hmac, Mac}; 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 reqwest::multipart;
use tracing::{info, error}; use tracing::{info, error};
@ -43,7 +43,7 @@ impl QiniuClient {
}); });
let policy_str = policy.to_string(); 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()) let mut mac = HmacSha1::new_from_slice(self.secret_key.as_bytes())
.expect("HMAC 密钥可接收任意大小"); .expect("HMAC 密钥可接收任意大小");
@ -51,7 +51,7 @@ impl QiniuClient {
let result = mac.finalize(); let result = mac.finalize();
let signature = result.into_bytes(); 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) format!("{}:{}:{}", self.access_key, encoded_signature, encoded_policy)
} }
@ -62,9 +62,9 @@ impl QiniuClient {
return Err(anyhow::anyhow!("本地 .env 文件中未正确配置七牛云参数")); return Err(anyhow::anyhow!("本地 .env 文件中未正确配置七牛云参数"));
} }
// 使用毫秒级时间戳防重名覆盖 // 使用毫秒级时间戳防重名覆盖,并放置在 astroresearch 虚拟文件夹下
let timestamp = chrono::Utc::now().timestamp_millis(); 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); let token = self.generate_upload_token(&key);
info!("正在上传文献提取图片到七牛云: key='{}'", key); info!("正在上传文献提取图片到七牛云: key='{}'", key);
@ -74,7 +74,7 @@ impl QiniuClient {
.text("key", key.clone()) .text("key", key.clone())
.part("file", multipart::Part::bytes(buffer).file_name(filename.to_string())); .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) let response = self.client.post(upload_url)
.multipart(form) .multipart(form)

View File

@ -21,13 +21,8 @@ use astroresearch::api::handlers::{AppState, self};
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
// 1. 初始化日志记录器 // 1. 初始化日志记录器并保留异步写保护 Guard
tracing_subscriber::fmt() let _logging_guards = astroresearch::services::logging::init_logging()?;
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,astroresearch=debug")),
)
.init();
info!("正在启动 AstroResearch 天文学文献辅助系统后端服务..."); info!("正在启动 AstroResearch 天文学文献辅助系统后端服务...");
@ -124,7 +119,9 @@ async fn main() -> anyhow::Result<()> {
.route("/sync/meta/status", get(handlers::get_meta_sync_status)) .route("/sync/meta/status", get(handlers::get_meta_sync_status))
.route("/sync/asset/run", post(handlers::run_asset_sync)) .route("/sync/asset/run", post(handlers::run_asset_sync))
.route("/sync/asset/stop", post(handlers::stop_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 后,直接挂载到主域名根路由) // 静态文件资源代理托管(当前端打包至 dashboard/dist 后,直接挂载到主域名根路由)
let serve_dir = ServeDir::new("dashboard/dist") let serve_dir = ServeDir::new("dashboard/dist")
@ -134,6 +131,7 @@ async fn main() -> anyhow::Result<()> {
.nest("/api", api_routes) .nest("/api", api_routes)
.fallback_service(serve_dir) .fallback_service(serve_dir)
.layer(cors) .layer(cors)
.layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(app_state); .with_state(app_state);
let addr = SocketAddr::from(([0, 0, 0, 0], config.port)); let addr = SocketAddr::from(([0, 0, 0, 0], config.port));

View File

@ -87,6 +87,18 @@ impl MetaSync {
tokio::spawn(async move { tokio::spawn(async move {
info!("启动后台批量收割任务: 查询词='{}', 源='{}', 上限={}", query_clone, source_clone, limit); 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. 并行获取两端预估总量 // 1. 并行获取两端预估总量
let ads_count_fut = { let ads_count_fut = {
let ads = ads.clone(); let ads = ads.clone();
@ -282,6 +294,8 @@ pub struct AssetSyncStatus {
pub total: i32, pub total: i32,
pub downloaded: i32, pub downloaded: i32,
pub parsed: i32, pub parsed: i32,
pub download_failed: i32,
pub parse_failed: i32,
pub current_bibcode: String, pub current_bibcode: String,
pub logs: Vec<String>, pub logs: Vec<String>,
pub action: Option<SyncAction>, pub action: Option<SyncAction>,
@ -294,6 +308,8 @@ impl AssetSyncStatus {
total: 0, total: 0,
downloaded: 0, downloaded: 0,
parsed: 0, parsed: 0,
download_failed: 0,
parse_failed: 0,
current_bibcode: String::new(), current_bibcode: String::new(),
logs: Vec::new(), logs: Vec::new(),
action: None, action: None,
@ -331,6 +347,8 @@ impl AssetSync {
s.total = total; s.total = total;
s.downloaded = 0; s.downloaded = 0;
s.parsed = 0; s.parsed = 0;
s.download_failed = 0;
s.parse_failed = 0;
s.current_bibcode = String::new(); s.current_bibcode = String::new();
s.logs.clear(); s.logs.clear();
s.action = Some(action); s.action = Some(action);
@ -344,7 +362,8 @@ impl AssetSync {
} }
let mut dl_count = 0; 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 { for bibcode in bibcodes {
// 每次循环前检查是否被外部停止了active 设为 false // 每次循环前检查是否被外部停止了active 设为 false
@ -364,20 +383,21 @@ impl AssetSync {
// 1. 获取文献元数据与当前路径状态 // 1. 获取文献元数据与当前路径状态
let paper_res = sqlx::query( 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) .bind(&bibcode)
.fetch_optional(&db) .fetch_optional(&db)
.await; .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)) => { Ok(Some(row)) => {
let arxiv_id: String = row.get(0); let arxiv_id: String = row.get(0);
let doi: String = row.get(1); let doi: String = row.get(1);
let pdf_path: Option<String> = row.get(2); let pdf_path: Option<String> = row.get(2);
let html_path: Option<String> = row.get(3); let html_path: Option<String> = row.get(3);
let markdown_path: Option<String> = row.get(4); 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; 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. 检查并执行下载 // 2. 检查并执行下载
if action == SyncAction::Download || action == SyncAction::All { 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); 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)); s.add_log(format!("文献 {} 下载成功!", bibcode));
} }
} else { } else {
dl_failed_count += 1;
let mut s = status.lock().await; let mut s = status.lock().await;
s.download_failed = dl_failed_count;
s.add_log(format!("文献 {} 下载失败PDF 和 HTML 均下载失败)", bibcode)); s.add_log(format!("文献 {} 下载失败PDF 和 HTML 均下载失败)", bibcode));
} }
@ -457,7 +495,6 @@ impl AssetSync {
s.add_log(format!("文献 {} 开始进行排版提取与 Markdown 转换...", bibcode)); s.add_log(format!("文献 {} 开始进行排版提取与 Markdown 转换...", bibcode));
} }
let mut parsed_markdown = String::new();
let mut relative_md_path = String::new(); let mut relative_md_path = String::new();
// 确定源链接 // 确定源链接
@ -499,7 +536,7 @@ impl AssetSync {
year, year,
keywords.join(",") keywords.join(",")
); );
parsed_markdown = format!("{}{}", front_matter, md); let parsed_markdown = format!("{}{}", front_matter, md);
let md_filename = format!("{}.md", bibcode); let md_filename = format!("{}.md", bibcode);
let md_dest = config.library_dir.join("Markdown").join(&md_filename); let md_dest = config.library_dir.join("Markdown").join(&md_filename);
let _ = fs::create_dir_all(md_dest.parent().unwrap()); let _ = fs::create_dir_all(md_dest.parent().unwrap());
@ -511,18 +548,85 @@ impl AssetSync {
} }
} }
// 策略 2PDF 回退(远程 MinerU if !relative_md_path.is_empty() {
if parsed_markdown.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 { if let Some(pdf_rel) = &pdf_path {
let pdf_abs = config.library_dir.join(pdf_rel); let pdf_abs = config.library_dir.join(pdf_rel);
if pdf_abs.exists() { 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) => { Ok(md) => {
let paper_meta_res = sqlx::query("SELECT title, authors, pub, year, keywords FROM papers WHERE bibcode = ?") let paper_meta_res = sqlx::query("SELECT title, authors, pub, year, keywords FROM papers WHERE bibcode = ?")
.bind(&bibcode) .bind(&bibcode_clone)
.fetch_optional(&db) .fetch_optional(&db_clone)
.await; .await;
let mut rel_md = String::new();
if let Ok(Some(meta_row)) = paper_meta_res { if let Ok(Some(meta_row)) = paper_meta_res {
let title: String = meta_row.get(0); let title: String = meta_row.get(0);
let authors_json: String = meta_row.get(1); let authors_json: String = meta_row.get(1);
@ -538,47 +642,76 @@ impl AssetSync {
serde_json::to_string(&title).unwrap_or_else(|_| format!("\"{}\"", title)), serde_json::to_string(&title).unwrap_or_else(|_| format!("\"{}\"", title)),
authors.iter().map(|a| format!("\"{}\"", a)).collect::<Vec<_>>().join(", "), authors.iter().map(|a| format!("\"{}\"", a)).collect::<Vec<_>>().join(", "),
serde_json::to_string(&pub_journal).unwrap_or_else(|_| format!("\"{}\"", pub_journal)), serde_json::to_string(&pub_journal).unwrap_or_else(|_| format!("\"{}\"", pub_journal)),
source_url, source_url_clone,
year, year,
keywords.join(",") keywords.join(",")
); );
parsed_markdown = format!("{}{}", front_matter, md); let parsed_markdown = format!("{}{}", front_matter, md);
let md_filename = format!("{}.md", bibcode); let md_filename = format!("{}.md", bibcode_clone);
let md_dest = config.library_dir.join("Markdown").join(&md_filename); let md_dest = config_clone.library_dir.join("Markdown").join(&md_filename);
let _ = fs::create_dir_all(md_dest.parent().unwrap()); let _ = fs::create_dir_all(md_dest.parent().unwrap());
if fs::write(&md_dest, &parsed_markdown).is_ok() { 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) => { Err(e) => {
let mut s = status.lock().await; let mut s = status_clone.lock().await;
s.add_log(format!("PDF 结构解析失败 (MinerU): {}", e)); s.parse_failed += 1;
} s.add_log(format!("文献 {} PDF 结构解析失败 (MinerU): {}", bibcode_clone, e));
} let err_reason = format!("error: {}", e);
}
}
}
if !relative_md_path.is_empty() {
let _ = sqlx::query("UPDATE papers SET markdown_path = ? WHERE bibcode = ?") 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) .bind(&bibcode)
.execute(&db) .execute(&db)
.await; .await;
parse_count += 1;
{
let mut s = status.lock().await;
s.parsed = parse_count;
s.add_log(format!("文献 {} Markdown 解析成功!", bibcode));
} }
} else { } else {
let mut s = status.lock().await; 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 { } else {
let mut s = status.lock().await; let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!("文献 {} 无本地 PDF/HTML无法解析跳过。", bibcode)); s.add_log(format!("文献 {} 无本地 PDF/HTML无法解析跳过。", bibcode));
} }
} else { } else {
@ -586,12 +719,19 @@ impl AssetSync {
let mut s = status.lock().await; let mut s = status.lock().await;
s.add_log(format!("文献 {} 已存在解析后的 Markdown跳过。", bibcode)); 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; 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;
} }
} }

View File

@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::header::{HeaderMap, HeaderValue};
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use url::Url; use url::Url;
use tracing::{info, warn}; use tracing::{info, warn, debug};
use anyhow::{Context, Result}; 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", "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-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("DNT", HeaderValue::from_static("1"));
h.insert("Connection", HeaderValue::from_static("keep-alive")); h.insert("Connection", HeaderValue::from_static("keep-alive"));
h.insert("Upgrade-Insecure-Requests", HeaderValue::from_static("1")); 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", "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-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( h.insert("Sec-Ch-Ua", HeaderValue::from_static(
"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"", "\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"",
)); ));
@ -224,7 +222,7 @@ impl Downloader {
/// 解析 ADS Link Gateway 路由,若遇 perfdrive 防护则提取 ssc 参数绕过 /// 解析 ADS Link Gateway 路由,若遇 perfdrive 防护则提取 ssc 参数绕过
async fn resolve_ads_gateway(&self, gateway_url: &str) -> Result<String> { async fn resolve_ads_gateway(&self, gateway_url: &str) -> Result<String> {
info!("解析 ADS 网关: {}", gateway_url); debug!("解析 ADS 网关: {}", gateway_url);
// HEAD 请求跟踪重定向(部分出版商阻断 HEAD自动降级 GET // HEAD 请求跟踪重定向(部分出版商阻断 HEAD自动降级 GET
let response = match self.client.head(gateway_url).send().await { 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(); let final_url = response.url().as_str().to_string();
info!("网关解析结果: {}", final_url); debug!("网关解析结果: {}", final_url);
// 如重定向至 validate.perfdrive.com提取 ssc 参数中的真实 URL // 如重定向至 validate.perfdrive.com提取 ssc 参数中的真实 URL
if final_url.contains("validate.perfdrive.com") { 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 Some(ssc) = parsed.query_pairs().find(|(k, _)| k == "ssc").map(|(_, v)| v.into_owned()) {
if let Ok(decoded) = urlencoding::decode(&ssc) { if let Ok(decoded) = urlencoding::decode(&ssc) {
let real_url = decoded.into_owned(); let real_url = decoded.into_owned();
info!("检测到 perfdrive 拦截,解码真实地址: {}", real_url); debug!("检测到 perfdrive 拦截,解码真实地址: {}", real_url);
return Ok(real_url); return Ok(real_url);
} }
} }
@ -276,18 +274,18 @@ impl Downloader {
let pdf_url = format!("https://iopscience.iop.org/article/{}/pdf", doi); let pdf_url = format!("https://iopscience.iop.org/article/{}/pdf", doi);
// 步骤 1访问文章主页建立 Cookie 会话 // 步骤 1访问文章主页建立 Cookie 会话
info!("[IOP] 预热主页: {}", main_url); debug!("[IOP] 预热主页: {}", main_url);
Self::maybe_delay().await; Self::maybe_delay().await;
match self.client.get(&main_url) match self.client.get(&main_url)
.headers(build_chrome_headers(None)) .headers(build_chrome_headers(None))
.send().await .send().await
{ {
Ok(r) => info!("[IOP] 主页响应: {}", r.status()), Ok(r) => debug!("[IOP] 主页响应: {}", r.status()),
Err(e) => warn!("[IOP] 主页访问失败(继续尝试): {:?}", e), Err(e) => warn!("[IOP] 主页访问失败(继续尝试): {:?}", e),
} }
// 步骤 2携带 Referer 下载 PDF // 步骤 2携带 Referer 下载 PDF
info!("[IOP] 下载 PDF: {}", pdf_url); debug!("[IOP] 下载 PDF: {}", pdf_url);
Self::maybe_delay().await; Self::maybe_delay().await;
let response = self.client.get(&pdf_url) let response = self.client.get(&pdf_url)
.headers(build_chrome_headers(Some(&main_url))) .headers(build_chrome_headers(Some(&main_url)))
@ -516,7 +514,19 @@ impl Downloader {
Err(e) => warn!("[PUB_PDF] 网关解析失败: {:?}", e), 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); let gw = format!("{}/{}/EPRINT_PDF", base, bibcode);
match self.resolve_ads_gateway(&gw).await { match self.resolve_ads_gateway(&gw).await {
Ok(resolved) => { Ok(resolved) => {
@ -531,10 +541,17 @@ impl Downloader {
// 1c. CrossRef API 回退(需要 DOI // 1c. CrossRef API 回退(需要 DOI
if let Some(doi_str) = doi { if let Some(doi_str) = doi {
match self.download_crossref_pdf(doi_str, &pdf_dest).await { 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), 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 下载 ────────────────────────────────────────── // ── HTML 下载 ──────────────────────────────────────────
@ -710,5 +727,25 @@ mod tests {
Some("2101.00001".to_string()) 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
View 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)
}

View File

@ -3,3 +3,4 @@ pub mod parser;
pub mod translation; pub mod translation;
pub mod query_parser; pub mod query_parser;
pub mod batch_sync; pub mod batch_sync;
pub mod logging;

View File

@ -1,11 +1,9 @@
// src/parser.rs // src/parser.rs
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use reqwest::multipart;
use tracing::{info, warn}; use tracing::{info, warn};
use regex::Regex; use regex::Regex;
use base64::Engine;
use crate::Config; use crate::Config;
use crate::clients::qiniu::QiniuClient; use crate::clients::qiniu::QiniuClient;
@ -13,7 +11,20 @@ use crate::clients::qiniu::QiniuClient;
// 清理 HTML 结构,仅提取正文部分并转换为标准 Markdown // 清理 HTML 结构,仅提取正文部分并转换为标准 Markdown
pub fn html_to_markdown(html_path: &Path) -> anyhow::Result<String> { pub fn html_to_markdown(html_path: &Path) -> anyhow::Result<String> {
info!("正在解析本地 HTML 并提取 Markdown: {:?}", html_path); 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(); let mut truncated_html = html_content.as_str();
@ -287,10 +298,61 @@ fn strip_html_tags(html: &str) -> String {
.replace("&#39;", "'") .replace("&#39;", "'")
} }
#[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并在提取出图片后自动上传至七牛云进行外链替换 // 调用 MinerU 远程接口解析 PDF并在提取出图片后自动上传至七牛云进行外链替换
pub async fn parse_pdf_via_mineru( pub async fn submit_pdf_to_mineru(
pdf_path: &Path, pdf_path: &Path,
qiniu_client: &QiniuClient,
config: &Config config: &Config
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
info!("正在请求 MinerU 解析本地 PDF 文献: {:?}", pdf_path); info!("正在请求 MinerU 解析本地 PDF 文献: {:?}", pdf_path);
@ -305,60 +367,221 @@ pub async fn parse_pdf_via_mineru(
.unwrap_or("paper.pdf") .unwrap_or("paper.pdf")
.to_string(); .to_string();
let file_part = multipart::Part::bytes(pdf_bytes).file_name(filename); let bibcode = pdf_path.file_stem()
let form = multipart::Form::new() .and_then(|f| f.to_str())
.part("file", file_part); .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 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() { if !config.mineru_api_key.is_empty() {
request = request.header("Authorization", format!("Bearer {}", config.mineru_api_key)); request = request.header("Authorization", format!("Bearer {}", config.mineru_api_key));
} }
let response = request.send().await?; let response = request.send().await?;
if !response.status().is_success() { let status = response.status();
return Err(anyhow::anyhow!("MinerU 解析接口返回失败码: {}", response.status())); let res_text = response.text().await?;
if !status.is_success() {
return Err(anyhow::anyhow!("请求 MinerU 批量上传 URL 失败 (状态码: {}): {}", status, res_text));
} }
// MinerU 远程服务响应 JSON包含转换出的 markdown 正文和图片映射 let upload_res: BatchUploadResponse = serde_json::from_str(&res_text)?;
#[derive(Deserialize)] if upload_res.code != 0 {
struct MinerUResponse { return Err(anyhow::anyhow!("MinerU API 错误: {}", upload_res.msg));
markdown: String,
images: Option<std::collections::HashMap<String, String>>, // 图片文件名 -> Base64 字符串
} }
let result: MinerUResponse = response.json().await?; let upload_url = upload_res.data.file_urls.first()
let mut markdown = result.markdown; .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() { if qiniu_client.is_configured() {
info!("MinerU 成功解析出 {} 张本地插图。正在准备同步至七牛云...", images.len()); info!("MinerU 批量模式解析出 {} 张本地插图。准备上传至七牛云...", image_buffers.len());
for (img_name, base64_data) in images { for (img_name, img_bytes) in image_buffers {
if let Ok(img_bytes) = base64::engine::general_purpose::STANDARD.decode(base64_data) { 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 { match qiniu_client.upload_buffer(img_bytes, &img_name).await {
Ok(qiniu_url) => { Ok(qiniu_url) => {
// 使用正则将 Markdown 中的本地临时图地址替换为七牛云 CDN 地址
let escaped_img_name = regex::escape(&img_name); let escaped_img_name = regex::escape(&img_name);
let link_re = Regex::new(&format!(r"\(([^)]*?){}\)", escaped_img_name)).unwrap(); let link_re = Regex::new(&format!(r"\(([^)]*?){}\)", escaped_img_name)).unwrap();
markdown = link_re.replace_all(&markdown, |_: &regex::Captures| { markdown = link_re.replace_all(&markdown, |_: &regex::Captures| {
format!("({})", qiniu_url) format!("({})", qiniu_url)
}).to_string(); }).to_string();
}, }
Err(e) => warn!("上传图片至七牛云失败 {}: {}", img_name, e), Err(e) => warn!("上传图片至七牛云失败 {}: {}", img_name, e),
} }
} }
}
} else { } 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) 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> 结构 // 采用栈式解析模型,将 LaTeXML 用 span/div 模拟出的表格容器ltx_tabular/tbody/thead/tfoot/tr/td/th还原为真正的 HTML <table> 结构
fn replace_latexml_tables(html: &str) -> String { fn replace_latexml_tables(html: &str) -> String {
use regex::Regex; use regex::Regex;

View File

@ -129,6 +129,7 @@ pub async fn translate_markdown(
); );
info!("正在请求大模型开展中英翻译。所选大模型: {}", config.llm_model); info!("正在请求大模型开展中英翻译。所选大模型: {}", config.llm_model);
let start_time = std::time::Instant::now();
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let url = format!("{}/chat/completions", config.llm_api_base); 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?; let res_data: LLMResponse = response.json().await?;
if let Some(choice) = res_data.choices.first() { 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()) Ok(choice.message.content.clone())
} else { } else {
Err(anyhow::anyhow!("大模型返回空翻译选项集")) Err(anyhow::anyhow!("大模型返回空翻译选项集"))