diff --git a/dashboard/index.html b/dashboard/index.html index 2b705d3..9f5c916 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -3,8 +3,15 @@ + + + + + + + - AstroResearch 天文科研辅助系统 + Astro科研
diff --git a/dashboard/public/logo.png b/dashboard/public/logo.png new file mode 100644 index 0000000..9181e74 Binary files /dev/null and b/dashboard/public/logo.png differ diff --git a/dashboard/public/manifest.json b/dashboard/public/manifest.json new file mode 100644 index 0000000..3a4babc --- /dev/null +++ b/dashboard/public/manifest.json @@ -0,0 +1,17 @@ +{ + "short_name": "Astro科研", + "name": "Astro科研", + "icons": [ + { + "src": "logo.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "any maskable" + } + ], + "start_url": "/", + "display": "standalone", + "orientation": "any", + "theme_color": "#ffffff", + "background_color": "#ffffff" +} diff --git a/dashboard/public/sw.js b/dashboard/public/sw.js new file mode 100644 index 0000000..f03116a --- /dev/null +++ b/dashboard/public/sw.js @@ -0,0 +1,15 @@ +// dashboard/public/sw.js +// 极简 PWA 通用 Service Worker,仅作请求透传,不作本地缓存以防破坏 SSE 及 RAG 的动态交互。 + +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('fetch', (event) => { + // 直接透传,不拦截或缓存任何请求 + event.respondWith(fetch(event.request)); +}); diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 0f2ce19..1d03440 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -69,6 +69,7 @@ export default function App() { useEffect(() => { localStorage.setItem('astro_active_tab', activeTab); + window.scrollTo(0, 0); }, [activeTab]); // 3. 调用拆分后的业务 Hook @@ -312,7 +313,7 @@ export default function App() { // 5. 正常工作面板布局装配 return ( -
+
{/* 导航左侧栏 */} {/* 移动端顶部 Header */} -
+
-
+
diff --git a/dashboard/src/components/PaperCard.tsx b/dashboard/src/components/PaperCard.tsx index 8338896..e7cf6ad 100644 --- a/dashboard/src/components/PaperCard.tsx +++ b/dashboard/src/components/PaperCard.tsx @@ -88,7 +88,7 @@ export function PaperCard({
{headerActions && ( -
+
{headerActions}
)} diff --git a/dashboard/src/components/agent/useAutoScroll.ts b/dashboard/src/components/agent/useAutoScroll.ts index 8487457..c5bfd1e 100644 --- a/dashboard/src/components/agent/useAutoScroll.ts +++ b/dashboard/src/components/agent/useAutoScroll.ts @@ -17,7 +17,13 @@ export function useAutoScroll(deps: React.DependencyList): UseAutoScrollReturn { const [shouldAutoScroll, setShouldAutoScroll] = useState(true); const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { - chatEndRef.current?.scrollIntoView({ behavior }); + const el = scrollContainerRef.current; + if (el) { + el.scrollTo({ + top: el.scrollHeight, + behavior, + }); + } }, []); useEffect(() => { diff --git a/dashboard/src/components/reader/BilingualViewer.tsx b/dashboard/src/components/reader/BilingualViewer.tsx index c4d41c0..2b661d8 100644 --- a/dashboard/src/components/reader/BilingualViewer.tsx +++ b/dashboard/src/components/reader/BilingualViewer.tsx @@ -129,27 +129,47 @@ export function BilingualViewer({ hoverCardPos, children, }: BilingualViewerProps) { - // 监测屏幕宽度以判断是否在移动端/平板设备上(宽度 < 1024px) - const [isMobileViewport, setIsMobileViewport] = useState(() => { + // 监测屏幕宽度以判断是否在平板设备上(宽度 < 1024px) + const [isTabletViewport, setIsTabletViewport] = useState(() => { if (typeof window !== 'undefined') { return window.innerWidth < 1024; } return false; }); + // 检测是否在手机端(宽度 < 768px) + const [isMobileViewport, setIsMobileViewport] = useState(() => { + if (typeof window !== 'undefined') { + return window.innerWidth < 768; + } + return false; + }); + useEffect(() => { const handleResize = () => { - setIsMobileViewport(window.innerWidth < 1024); + setIsTabletViewport(window.innerWidth < 1024); + setIsMobileViewport(window.innerWidth < 768); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); const gridTemplateColumns = isMobileViewport - ? (viewMode === 'bilingual' ? '1fr 1fr' : '1fr') - : (viewMode === 'bilingual' - ? (showNotesPanel ? '1fr 1fr 380px' : '1fr 1fr') - : (showNotesPanel ? '1fr 380px' : '1fr')); + ? '1fr' // 手机端一律垂直单列堆叠 + : (isTabletViewport + ? (viewMode === 'bilingual' ? '1fr 1fr' : '1fr') + : (viewMode === 'bilingual' + ? (showNotesPanel ? '1fr 1fr 380px' : '1fr 1fr') + : (showNotesPanel ? '1fr 380px' : '1fr'))); + + const style = isMobileViewport + ? { + gridTemplateColumns: '1fr', + gridTemplateRows: viewMode === 'bilingual' + ? (showNotesPanel ? '1fr 1fr 1fr' : '1fr 1fr') + : (showNotesPanel ? '1fr 1fr' : '1fr') + } + : { gridTemplateColumns }; // 解析英文和中文段落的 Front Matter 头部元数据 const { metadata: engMeta, pureMarkdown: engPure } = parseMarkdownFrontMatter(englishText); @@ -173,7 +193,7 @@ export function BilingualViewer({ return (
{/* 英文正文视窗 (或双语对照) */} {(viewMode === 'english' || viewMode === 'bilingual') && ( diff --git a/dashboard/src/components/reader/ReaderToolbar.tsx b/dashboard/src/components/reader/ReaderToolbar.tsx index cf29090..e389cd1 100644 --- a/dashboard/src/components/reader/ReaderToolbar.tsx +++ b/dashboard/src/components/reader/ReaderToolbar.tsx @@ -1,5 +1,6 @@ // dashboard/src/components/reader/ReaderToolbar.tsx -import { FileText, Loader, Languages, RotateCw, BookOpen, Sparkles, ChevronDown, History } from 'lucide-react'; +import { useState } from 'react'; +import { FileText, Loader, Languages, RotateCw, BookOpen, Sparkles, ChevronDown, History, SlidersHorizontal } from 'lucide-react'; import type { StandardPaper } from '../../types'; interface ReaderToolbarProps { @@ -56,6 +57,8 @@ export function ReaderToolbar({ setSyncScroll, showPdf, }: ReaderToolbarProps) { + const [showActionsMenu, setShowActionsMenu] = useState(false); + return (
@@ -68,23 +71,24 @@ export function ReaderToolbar({ 文献编码: {selectedPaper.bibcode}
-
+
{/* 快速切换文献菜单 */}
{showSwitchMenu && ( <>
setShowSwitchMenu(false)} /> -
+
{/* 最近阅读部分 */} {recentlySelected.length > 0 && (
@@ -149,11 +153,11 @@ export function ReaderToolbar({ {/* 原文/中文/对照视图切换按钮 */} {(englishText || chineseText) && ( -
+
)} + {/* 移动端/平板端 折叠后的“文献操作”菜单 */} +
+ + + {showActionsMenu && ( + <> +
setShowActionsMenu(false)} /> +
+ + {/* 同步滚动 */} + {(englishText || chineseText) && viewMode === 'bilingual' && ( + + )} + + {/* 解析正文 */} + {!selectedPaper.has_markdown ? ( + + ) : ( + + )} + + {/* 智能翻译 */} + {selectedPaper.has_markdown && !selectedPaper.has_translation && ( + + )} + + {selectedPaper.has_translation && ( + + )} + + {/* 知识入库 */} + {selectedPaper.has_markdown && !selectedPaper.has_vector && ( + + )} + + {selectedPaper.has_markdown && selectedPaper.has_vector && ( + + )} + +
+ + )} +
+ {/* 4. 学术助手按钮 */} )}
diff --git a/src/api/auth.rs b/src/api/auth.rs index 39bcfe1..9a69449 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -20,6 +20,7 @@ pub struct LoginRequest { #[derive(Debug, Serialize)] pub struct LoginResponse { pub status: String, + pub bookmarklet_key: String, } // 辅助函数:从 Cookie 字符串中解析出 session_id 的值 @@ -93,6 +94,15 @@ fn prune_expired_sessions( }); } +/// 计算浏览器书签专用的永久 API 密钥(基于 admin_password + salt 的 SHA-1 哈希) +fn get_bookmarklet_api_key(password: &str) -> String { + use sha1::{Digest, Sha1}; + let mut hasher = Sha1::new(); + hasher.update(password.as_bytes()); + hasher.update(b"_bookmarklet_salt_key"); + format!("{:x}", hasher.finalize()) +} + /// 最大会话数上限,超过后拒绝新登录以防止内存耗尽 const MAX_SESSIONS: usize = 1000; @@ -154,10 +164,9 @@ pub async fn login( } // 构建 Set-Cookie 报头 - // 书签脚本等跨站第三方页面调用要求 SameSite=None 和 Secure - // localhost 在现代浏览器中即使使用 HTTP 协议,其 Secure Cookie 也会被正常放行并读取 + // 全局统一使用 SameSite=Lax 且不带 Secure,以完美支持 HTTP、HTTPS 以及局域网 IP 访问 let cookie_value = format!( - "session_id={}; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=86400", + "session_id={}; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400", token ); let mut headers = HeaderMap::new(); @@ -165,10 +174,13 @@ pub async fn login( headers.insert(header::SET_COOKIE, val); } + let bookmarklet_key = get_bookmarklet_api_key(&state.config.admin_password); + Ok(( headers, Json(LoginResponse { status: "ok".to_string(), + bookmarklet_key, }), )) } else { @@ -195,7 +207,7 @@ pub async fn logout( // 通过 Max-Age=0 清除浏览器端的 Cookie let mut headers = HeaderMap::new(); - let delete_cookie = "session_id=; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=0"; + let delete_cookie = "session_id=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"; if let Ok(val) = HeaderValue::from_str(delete_cookie) { headers.insert(header::SET_COOKIE, val); } @@ -221,6 +233,7 @@ pub async fn auth_middleware( let mut is_valid = false; if let Some(tok) = token { + // 1. 优先校验内存中的动态 Session let mut sessions = state.sessions.lock().await; // 顺便执行过期会话自动清理垃圾收集 prune_expired_sessions(&mut sessions); @@ -230,6 +243,17 @@ pub async fn auth_middleware( *last_active = chrono::Utc::now(); is_valid = true; } + + // 2. 校验书签专属永久 API 密钥(严格隔离限制:仅允许用于特定的书签导入和同步路径) + if !is_valid { + let path = req.uri().path(); + if path == "/api/upload" || path == "/api/active_bibcode" { + let expected_key = get_bookmarklet_api_key(&state.config.admin_password); + if tok == expected_key { + is_valid = true; + } + } + } } if is_valid { diff --git a/src/main.rs b/src/main.rs index a885af3..f30f1ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -330,13 +330,35 @@ async fn main() -> anyhow::Result<()> { .allow_origin(tower_http::cors::AllowOrigin::predicate( |origin, _parts| { if let Ok(origin_str) = origin.to_str() { - // 仅允许本地开发或回环地址(允许任意端口,支持 Vite dev 端口 :5173) - if origin_str.starts_with("http://localhost:") - || origin_str.starts_with("http://127.0.0.1:") - || origin_str == "http://localhost" - || origin_str == "http://127.0.0.1" - { - return true; + // 支持 http:// 或 https:// 协议下的本地回环及局域网私有网段 IP 来源(允许任意端口,如 Vite dev 端口 :5173) + let host_part = if let Some(stripped) = origin_str.strip_prefix("http://") { + Some(stripped) + } else { + origin_str.strip_prefix("https://") + }; + + if let Some(host_and_port) = host_part { + let host = host_and_port.split(':').next().unwrap_or(""); + if host == "localhost" + || host == "127.0.0.1" + || host == "[::1]" + || host.starts_with("192.168.") + || host.starts_with("10.") + || host.starts_with("169.254.") + { + return true; + } + if host.starts_with("172.") { + if let Some(second_octet_str) = + host.strip_prefix("172.").and_then(|s| s.split('.').next()) + { + if let Ok(second_octet) = second_octet_str.parse::() { + if (16..=31).contains(&second_octet) { + return true; + } + } + } + } } } false @@ -454,6 +476,7 @@ async fn main() -> anyhow::Result<()> { let protected_files = Router::new() .fallback_service(ServeDir::new(&config.library_dir)) + .layer(axum::middleware::from_fn(pdf_inline_middleware)) .layer(axum::middleware::from_fn_with_state( app_state.clone(), astroresearch::api::auth::auth_middleware, @@ -504,3 +527,38 @@ async fn main() -> anyhow::Result<()> { info!("服务已安全关闭。"); Ok(()) } + +// 针对 PDF 文件请求的中间件:强行设置 Content-Type 和 Content-Disposition 头,防止在移动端/手机浏览器上被强制下载。 +async fn pdf_inline_middleware( + req: axum::http::Request, + next: axum::middleware::Next, +) -> Result, axum::http::StatusCode> { + let path = req.uri().path().to_lowercase(); + let is_pdf = path.ends_with(".pdf"); + + let mut response = next.run(req).await; + + if is_pdf && response.status().is_success() { + // 获取文件名 + let filename = std::path::Path::new(&path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("document.pdf"); + + // 强行设置 Content-Type 为 application/pdf + response.headers_mut().insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/pdf"), + ); + + // 强行设置 Content-Disposition 为 inline + let disposition_val = format!("inline; filename=\"{}\"", filename); + if let Ok(hv) = axum::http::HeaderValue::from_str(&disposition_val) { + response + .headers_mut() + .insert(axum::http::header::CONTENT_DISPOSITION, hv); + } + } + + Ok(response) +} diff --git a/src/services/batch/meta.rs b/src/services/batch/meta.rs index aabc516..896ddcc 100644 --- a/src/services/batch/meta.rs +++ b/src/services/batch/meta.rs @@ -317,7 +317,7 @@ impl MetaSync { // 获取所有已存同步检索配置 pub async fn list_queries(db: &SqlitePool) -> Result, sqlx::Error> { - let rows = sqlx::query("SELECT id, query, source, limit_count, datetime(last_run, 'localtime') FROM sync_queries ORDER BY last_run DESC") + let rows = sqlx::query("SELECT id, query, source, limit_count, datetime(last_run, 'localtime') AS last_run FROM sync_queries ORDER BY last_run DESC") .fetch_all(db) .await?;