refactor: 服务层抽象下沉、异步锁全栈迁移、客户端韧性加固与移动端适配

- 服务层拆分:删除 api/helpers.rs,新增 citation/note/session/pipeline/paper/vision 独立服务模块
  - Agent 工具精简:paper_content+paper_outline 合并为 paper.rs,图片分析逻辑下沉至 services/vision
  - 并发模型升级:std::sync::{Mutex,RwLock} → tokio::sync::{Mutex,RwLock},消除 async
  上下文中的阻塞风险
  - 客户端加固:HTTP 客户端统一超时配置、ADS 429 / arXiv 503 自动重试、构造函数返回 Result
  - 启动安全:全局 panic hook 日志化、空密码拒绝启动、向量表维度不匹配需显式确认
  - CLI 扩展:构建完整 AppState 复用服务层,新增 Content/Outline/Citations/Search/Process 子命令
  - 前端:移动端汉堡菜单、侧栏滑出面板、引用星系触屏手势(单指拖拽/双指缩放)
This commit is contained in:
fmq
2026-06-30 19:26:01 +08:00
parent c5fd5b0d66
commit f885c0a4a8
90 changed files with 5184 additions and 3916 deletions
+35 -2
View File
@@ -1,7 +1,7 @@
// dashboard/src/App.tsx
import { useState, useEffect, useCallback, useRef } from 'react';
import axios from 'axios';
import { Loader, BookOpen, GitFork, Lock } from 'lucide-react';
import { Loader, BookOpen, GitFork, Lock, Menu } from 'lucide-react';
import { Sidebar } from './components/layout/Sidebar';
import { SearchPanel } from './pages/SearchPanel';
import { LibraryPanel } from './pages/LibraryPanel';
@@ -24,6 +24,9 @@ import { useNotes } from './hooks/useNotes';
import { useCitations } from './hooks/useCitations';
export default function App() {
// 移动端菜单显示状态
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
// 1. 全局自定义 Dialog 弹窗管理 (Alert / Confirm)
const [dialog, setDialog] = useState<{
type: 'alert' | 'confirm';
@@ -318,11 +321,41 @@ export default function App() {
selectedPaper={library.selectedPaper}
loadCitations={citations.loadCitations}
onLogout={auth.handleLogout}
isOpen={isMobileSidebarOpen}
onClose={() => setIsMobileSidebarOpen(false)}
/>
{/* 主工作区 */}
<main className="flex-1 flex flex-col overflow-hidden relative">
<div className="flex-1 overflow-y-auto p-4 sm:p-6 md:p-8 relative z-10 w-full flex flex-col">
{/* 移动端顶部 Header */}
<header className="lg:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-slate-200 select-none shrink-0 z-20">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setIsMobileSidebarOpen(true)}
className="p-1.5 rounded-lg border border-slate-200 hover:bg-slate-55 text-slate-600 transition-all cursor-pointer flex items-center justify-center"
title="打开菜单"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center gap-2">
<div className="w-6 h-6">
<Logo gradientId="mobileHeaderStarGrad" />
</div>
<span className="text-xs font-bold text-slate-800 tracking-wider">AstroResearch</span>
</div>
</div>
<span className="text-[10px] font-bold px-2.5 py-1 rounded bg-slate-100 text-slate-600 font-sans tracking-wide uppercase">
{activeTab === 'search' && '统一检索'}
{activeTab === 'library' && '馆藏管理'}
{activeTab === 'reader' && '双语阅读'}
{activeTab === 'citation' && '引用星系'}
{activeTab === 'sync' && '批量任务'}
{activeTab === 'agent' && '智能科研'}
</span>
</header>
<div className="flex-1 overflow-y-auto p-4 sm:p-6 md:p-8 relative w-full flex flex-col">
<div className={`w-full flex-1 flex flex-col min-h-0 ${
(activeTab === 'reader' || activeTab === 'citation' || activeTab === 'agent') ? 'max-w-none' : 'max-w-7xl mx-auto'
}`}>
@@ -298,6 +298,29 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
render();
let initialTouchDistance = 0;
let initialScale = 1.0;
const findNodeAtCoordinate = (clientX: number, clientY: number) => {
const rect = canvas.getBoundingClientRect();
const touchX = clientX - rect.left;
const touchY = clientY - rect.top;
const cx = rect.width / 2;
const cy = rect.height / 2;
const gx = (touchX - cx - offsetX) / scale + cx;
const gy = (touchY - cy - offsetY) / scale + cy;
for (const node of nodes) {
const dx = node.x - gx;
const dy = node.y - gy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < node.radius + 12) { // 移动端稍微加大触碰敏感区
return node;
}
}
return null;
};
const handleMouseDown = (e: MouseEvent) => {
isDragging = true;
dragStartX = e.clientX - offsetX;
@@ -379,12 +402,68 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
offsetY = mouseY - cy - (gy - cy) * scale;
};
// 触屏手势处理
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) {
isDragging = true;
hasDragged = false;
dragStartX = e.touches[0].clientX - offsetX;
dragStartY = e.touches[0].clientY - offsetY;
// 触碰选中节点
hoveredNode = findNodeAtCoordinate(e.touches[0].clientX, e.touches[0].clientY);
} else if (e.touches.length === 2) {
isDragging = false;
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
initialTouchDistance = Math.sqrt(dx * dx + dy * dy) || 1;
initialScale = scale;
}
};
const handleTouchMove = (e: TouchEvent) => {
if (isDragging && e.touches.length === 1) {
const dx = e.touches[0].clientX - dragStartX;
const dy = e.touches[0].clientY - dragStartY;
if (Math.sqrt((dx - offsetX) ** 2 + (dy - offsetY) ** 2) > 3) {
hasDragged = true;
}
offsetX = dx;
offsetY = dy;
e.preventDefault(); // 阻止滚动
} else if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
// 双指开合缩放
const newScale = initialScale * (dist / initialTouchDistance);
scale = Math.max(0.15, Math.min(5.0, newScale));
e.preventDefault(); // 阻止默认双指缩放
}
};
const handleTouchEnd = () => {
isDragging = false;
if (!hasDragged && hoveredNode) {
if (hoveredNode.id !== activeNetwork.bibcode) {
onNodeClick(hoveredNode.id);
}
}
hoveredNode = null;
};
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('mouseleave', handleMouseLeave);
canvas.addEventListener('click', handleCanvasClick);
canvas.addEventListener('wheel', handleWheel, { passive: false });
// 注册触控事件
canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
canvas.addEventListener('touchend', handleTouchEnd);
return () => {
cancelAnimationFrame(animationFrameId);
@@ -394,6 +473,11 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
canvas.removeEventListener('mouseleave', handleMouseLeave);
canvas.removeEventListener('click', handleCanvasClick);
canvas.removeEventListener('wheel', handleWheel);
// 注销触控事件
canvas.removeEventListener('touchstart', handleTouchStart);
canvas.removeEventListener('touchmove', handleTouchMove);
canvas.removeEventListener('touchend', handleTouchEnd);
};
}, [networks, activeNetwork, onNodeClick]);
@@ -190,7 +190,7 @@ export function AgentMessageList({
: '探索性科研研讨'}
</h3>
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">
(ReAct Thought Action Observation)
</p>
</div>
</div>
@@ -37,173 +37,206 @@ export function AgentSessionSidebar({
onNewSession,
onDeleteSession,
}: AgentSessionSidebarProps) {
return (
<div className={`transition-all duration-300 ease-in-out ${collapsed ? 'w-0 overflow-hidden opacity-0 border-r-0' : 'w-64 border-r border-slate-200'} bg-slate-50 flex flex-col justify-between shrink-0 select-none`}>
<div className="flex flex-col min-h-0 flex-1">
<div className="p-4 border-b border-slate-200 bg-white flex items-center justify-between shrink-0">
<span className="text-xs font-extrabold text-slate-800 tracking-wider flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-blueprint" />
{!collapsed && <span></span>}
</span>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={onNewSession}
className="p-1 rounded-md border border-slate-200 bg-white hover:bg-slate-50 text-slate-600 hover:text-slate-800 transition-all cursor-pointer shadow-2xs"
title="新建会话"
>
<Plus className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onToggleCollapse(true)}
className="p-1 rounded-md border border-slate-200 bg-white hover:bg-slate-50 text-slate-500 hover:text-slate-700 transition-all cursor-pointer"
title="收起侧栏"
>
<PanelLeftClose className="w-3.5 h-3.5" />
</button>
</div>
</div>
const handleSelectSession = (id: string | null) => {
setCurrentSessionId(id);
if (typeof window !== 'undefined' && window.innerWidth < 1024) {
onToggleCollapse(true);
}
};
{/* 搜索框区域 */}
<div className="px-3 py-2 border-b border-slate-200/60 bg-white flex flex-col gap-1.5 shrink-0">
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索历史会话或消息内容..."
className="w-full pl-8 pr-7 py-1.5 rounded-md bg-slate-50 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-[11px] font-medium"
/>
<div className="absolute left-2.5 top-2.5 text-slate-400">
<Search className="w-3.5 h-3.5 text-slate-400" />
</div>
{searchQuery && (
const handleCreateNewSession = () => {
onNewSession();
if (typeof window !== 'undefined' && window.innerWidth < 1024) {
onToggleCollapse(true);
}
};
return (
<>
{/* 移动端背景遮罩层 */}
{!collapsed && (
<button
type="button"
onClick={() => onToggleCollapse(true)}
className="fixed inset-0 bg-slate-900/30 backdrop-blur-xs z-35 lg:hidden cursor-pointer w-full h-full border-none outline-none"
aria-label="关闭侧栏"
/>
)}
<div
className={`bg-slate-50 flex flex-col justify-between shrink-0 select-none transition-all duration-300 ease-in-out fixed inset-y-0 left-0 z-40 lg:relative lg:translate-x-0 ${
collapsed
? '-translate-x-full lg:translate-x-0 lg:w-0 lg:overflow-hidden lg:opacity-0 lg:border-r-0'
: 'translate-x-0 w-64 border-r border-slate-200 lg:w-64'
}`}
>
<div className="flex flex-col min-h-0 flex-1">
<div className="p-4 border-b border-slate-200 bg-white flex items-center justify-between shrink-0">
<span className="text-xs font-extrabold text-slate-800 tracking-wider flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-blueprint" />
<span></span>
</span>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-2 p-0.5 rounded-full hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
onClick={handleCreateNewSession}
className="p-1 rounded-md border border-slate-200 bg-white hover:bg-slate-50 text-slate-650 hover:text-slate-800 transition-all cursor-pointer shadow-2xs"
title="新建会话"
>
<X className="w-3 h-3" />
<Plus className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onToggleCollapse(true)}
className="p-1 rounded-md border border-slate-200 bg-white hover:bg-slate-50 text-slate-555 hover:text-slate-700 transition-all cursor-pointer"
title="收起侧栏"
>
<PanelLeftClose className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 搜索框区域 */}
<div className="px-3 py-2 border-b border-slate-200/60 bg-white flex flex-col gap-1.5 shrink-0">
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索历史会话或消息内容..."
className="w-full pl-8 pr-7 py-1.5 rounded-md bg-slate-50 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-[11px] font-medium"
/>
<div className="absolute left-2.5 top-2.5 text-slate-400">
<Search className="w-3.5 h-3.5 text-slate-400" />
</div>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-2 p-0.5 rounded-full hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{currentSessionId && (
<label className="flex items-center gap-1.5 text-[10px] text-slate-500 cursor-pointer font-medium select-none ml-0.5">
<input
type="checkbox"
checked={searchScopeOnlyCurrent}
onChange={e => setSearchScopeOnlyCurrent(e.target.checked)}
className="rounded text-blueprint border-slate-300 focus:ring-blueprint w-3 h-3 cursor-pointer"
/>
<span></span>
</label>
)}
</div>
{currentSessionId && (
<label className="flex items-center gap-1.5 text-[10px] text-slate-500 cursor-pointer font-medium select-none ml-0.5">
<input
type="checkbox"
checked={searchScopeOnlyCurrent}
onChange={e => setSearchScopeOnlyCurrent(e.target.checked)}
className="rounded text-blueprint border-slate-300 focus:ring-blueprint w-3 h-3 cursor-pointer"
/>
<span></span>
</label>
)}
</div>
<div className="flex-1 overflow-y-auto p-2.5 space-y-1.5 scrollbar-thin">
{searchQuery.trim() ? (
loadingSearch ? (
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
<Loader className="w-3.5 h-3.5 animate-spin text-blueprint" />
<span>...</span>
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-12 text-slate-400 text-[11px] italic">
</div>
) : (
searchResults.map((result, idx) => {
const isActive = result.session_id === currentSessionId;
const isMessage = result.result_type.startsWith('message/');
const role = isMessage ? result.result_type.split('/')[1] : null;
<div className="flex-1 overflow-y-auto p-2.5 space-y-1.5 scrollbar-thin">
{searchQuery.trim() ? (
loadingSearch ? (
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
<Loader className="w-3.5 h-3.5 animate-spin text-blueprint" />
<span>...</span>
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-12 text-slate-400 text-[11px] italic">
</div>
) : (
searchResults.map((result, idx) => {
const isActive = result.session_id === currentSessionId;
const isMessage = result.result_type.startsWith('message/');
const role = isMessage ? result.result_type.split('/')[1] : null;
return (
<button
key={`${result.session_id}-${idx}`}
onClick={() => setCurrentSessionId(result.session_id)}
className={`w-full text-left p-2.5 rounded-md border transition-all duration-200 flex flex-col gap-1 cursor-pointer ${
isActive
? 'bg-blueprint/5 border-blueprint text-blueprint font-bold shadow-2xs'
: 'border-transparent bg-white hover:bg-slate-100 text-slate-650 shadow-3xs'
}`}
>
<div className="flex items-center justify-between w-full">
<span className="text-[10px] font-bold text-slate-400 flex items-center gap-1">
{isMessage ? (
<>
<MessageSquare className="w-3 h-3 text-slate-400" />
<span>{role === 'user' ? '提问' : '解答'}</span>
</>
) : (
<>
<BookOpen className="w-3 h-3 text-blueprint" />
<span></span>
</>
)}
</span>
{result.created_at && (
<span className="text-[8px] text-slate-400 font-mono">
{result.created_at.split(' ')[0]}
</span>
)}
</div>
<span className="text-xs leading-snug line-clamp-1 block text-left font-bold text-slate-800">
{result.title || '无标题会话'}
</span>
<p
className="text-[10px] text-slate-500 font-medium leading-normal block text-left line-clamp-3 bg-slate-50/50 p-1.5 rounded border border-slate-100/50"
dangerouslySetInnerHTML={{ __html: result.snippet }}
/>
</button>
);
})
)
) : (
// 正常的会话列表渲染
loadingSessions ? (
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
<Loader className="w-3.5 h-3.5 animate-spin text-blueprint" />
<span>...</span>
</div>
) : sessions.length === 0 ? (
<div className="text-center py-12 text-slate-400 text-[11px] italic">
</div>
) : (
sessions.map(session => {
const isActive = session.session_id === currentSessionId;
return (
<button
key={session.session_id}
onClick={() => setCurrentSessionId(session.session_id)}
className={`w-full text-left p-3 rounded-md border transition-all duration-200 group flex items-start justify-between gap-2 cursor-pointer ${
isActive
? 'bg-blueprint/5 border-blueprint text-blueprint font-bold shadow-2xs'
: 'border-transparent bg-transparent hover:bg-slate-100 text-slate-655'
}`}
>
<div className="flex-1 min-w-0 flex flex-col gap-1 text-left">
<span className="text-xs leading-snug line-clamp-2 block text-left">
{session.title || '无标题会话'}
</span>
<span className="text-[9px] text-slate-400 font-semibold block text-left">
{session.turn_count} {session.updated_at.split(' ')[0]}
</span>
</div>
return (
<button
onClick={(e) => onDeleteSession(session.session_id, e)}
className="text-slate-400 hover:text-red-655 p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-white border border-transparent hover:border-slate-200 transition-all cursor-pointer shrink-0"
title="删除此会话"
key={`${result.session_id}-${idx}`}
onClick={() => handleSelectSession(result.session_id)}
className={`w-full text-left p-2.5 rounded-md border transition-all duration-200 flex flex-col gap-1 cursor-pointer ${
isActive
? 'bg-blueprint/5 border-blueprint text-blueprint font-bold shadow-2xs'
: 'border-transparent bg-white hover:bg-slate-100 text-slate-650 shadow-3xs'
}`}
>
<Trash2 className="w-3 h-3" />
<div className="flex items-center justify-between w-full">
<span className="text-[10px] font-bold text-slate-400 flex items-center gap-1">
{isMessage ? (
<>
<MessageSquare className="w-3 h-3 text-slate-400" />
<span>{role === 'user' ? '提问' : '解答'}</span>
</>
) : (
<>
<BookOpen className="w-3 h-3 text-blueprint" />
<span></span>
</>
)}
</span>
{result.created_at && (
<span className="text-[8px] text-slate-400 font-mono">
{result.created_at.split(' ')[0]}
</span>
)}
</div>
<span className="text-xs leading-snug line-clamp-1 block text-left font-bold text-slate-800">
{result.title || '无标题会话'}
</span>
<p
className="text-[10px] text-slate-500 font-medium leading-normal block text-left line-clamp-3 bg-slate-50/50 p-1.5 rounded border border-slate-100/50"
dangerouslySetInnerHTML={{ __html: result.snippet }}
/>
</button>
</button>
);
})
)
)}
);
})
)
) : (
// 正常的会话列表渲染
loadingSessions ? (
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
<Loader className="w-3.5 h-3.5 animate-spin text-blueprint" />
<span>...</span>
</div>
) : sessions.length === 0 ? (
<div className="text-center py-12 text-slate-400 text-[11px] italic">
</div>
) : (
sessions.map(session => {
const isActive = session.session_id === currentSessionId;
return (
<button
key={session.session_id}
onClick={() => handleSelectSession(session.session_id)}
className={`w-full text-left p-3 rounded-md border transition-all duration-200 group flex items-start justify-between gap-2 cursor-pointer ${
isActive
? 'bg-blueprint/5 border-blueprint text-blueprint font-bold shadow-2xs'
: 'border-transparent bg-transparent hover:bg-slate-100 text-slate-655'
}`}
>
<div className="flex-1 min-w-0 flex flex-col gap-1 text-left">
<span className="text-xs leading-snug line-clamp-2 block text-left">
{session.title || '无标题会话'}
</span>
<span className="text-[9px] text-slate-400 font-semibold block text-left">
{session.turn_count} {session.updated_at.split(' ')[0]}
</span>
</div>
<button
onClick={(e) => onDeleteSession(session.session_id, e)}
className="text-slate-400 hover:text-red-655 p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-white border border-transparent hover:border-slate-200 transition-all cursor-pointer shrink-0"
title="删除此会话"
>
<Trash2 className="w-3 h-3" />
</button>
</button>
);
})
)
)}
</div>
</div>
</div>
</div>
</>
);
}
@@ -75,7 +75,7 @@ export function ToolCallCard({
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[10px] font-extrabold text-emerald-700 tracking-wider uppercase">
<Eye className="w-3.5 h-3.5" />
<span> (Observation)</span>
<span></span>
{result!.isError && (
<span className="px-1.5 py-0.2 rounded bg-rose-50 text-rose-700 border border-rose-100 text-[9px]">
+211 -193
View File
@@ -12,207 +12,225 @@ interface SidebarProps {
selectedPaper: StandardPaper | null;
loadCitations: (bibcode: string) => void;
onLogout: () => void;
isOpen?: boolean;
onClose?: () => void;
}
export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations, onLogout }: SidebarProps) {
export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations, onLogout, isOpen = false, onClose }: SidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const effectiveCollapsed = isCollapsed && !isOpen;
return (
<aside
className={`bg-slate-50 border-r border-slate-200 flex flex-col justify-between py-6 z-20 select-none transition-all duration-355 cubic-bezier(0.4, 0, 0.2, 1) ${
isCollapsed ? 'w-16 px-2' : 'w-64 px-4'
}`}
>
<div>
{/* 系统LOGO与折叠控制区 */}
<div className={`mb-8 flex items-center transition-all duration-300 ${
isCollapsed ? 'justify-center px-0' : 'justify-between px-3'
}`}>
{/* Logo & 标题文字 */}
<div className="flex items-center gap-3 min-w-0">
{/* Logo按钮 (只在折叠状态下可点击展开) */}
<button
type="button"
disabled={!isCollapsed}
onClick={() => setIsCollapsed(false)}
className={`flex items-center justify-center shrink-0 rounded-lg transition-all duration-300 ${
isCollapsed
? 'w-11 h-11 bg-white hover:bg-slate-50 border border-slate-200 cursor-pointer shadow-xs hover:shadow-sm'
: 'w-9 h-9 bg-transparent border border-transparent cursor-default'
}`}
title={isCollapsed ? "展开导航" : undefined}
>
<div className={`transition-all duration-300 flex items-center justify-center ${isCollapsed ? 'w-8 h-8' : 'w-9 h-9'}`}>
<Logo gradientId="sidebarStarGrad" />
</div>
</button>
{/* 系统说明文字 */}
<div
className={`flex flex-col transition-all duration-300 origin-left ${
isCollapsed
? 'opacity-0 max-w-0 scale-95 -translate-x-2 pointer-events-none select-none overflow-hidden h-0'
: 'opacity-100 max-w-[150px] scale-100 translate-x-0'
}`}
>
<h1 className="text-sm font-bold text-slate-800 tracking-wider whitespace-nowrap">AstroResearch</h1>
<span className="text-[11px] text-slate-500 block font-medium font-sans whitespace-nowrap"></span>
</div>
</div>
{/* 折叠控制按钮 (圆润悬浮效果) */}
<button
type="button"
onClick={() => setIsCollapsed(true)}
className={`p-1.5 rounded-full bg-slate-100 hover:bg-slate-200 border border-slate-200 text-slate-500 hover:text-slate-800 transition-all duration-300 cursor-pointer shadow-2xs shrink-0 flex items-center justify-center hover:scale-105 active:scale-95 ${
isCollapsed
? 'opacity-0 scale-75 pointer-events-none w-0 h-0 p-0 border-0 overflow-hidden'
: 'opacity-100 scale-100'
}`}
title="收起导航"
>
<ChevronLeft className="w-3.5 h-3.5" />
</button>
</div>
{/* 导航菜单列表 */}
<nav className="space-y-1">
{[
{ id: 'search' as TabId, label: '统一检索', icon: Search },
{ id: 'library' as TabId, label: '馆藏管理', icon: Library },
{ id: 'reader' as TabId, label: '双语阅读', icon: BookOpen },
{ id: 'citation' as TabId, label: '引用星系', icon: GitFork },
{ id: 'sync' as TabId, label: '批量任务', icon: RefreshCw },
{ id: 'agent' as TabId, label: '智能科研', icon: Sparkles },
].map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
title={isCollapsed ? tab.label : undefined}
onClick={() => {
setActiveTab(tab.id);
if (tab.id === 'citation' && selectedPaper) {
loadCitations(selectedPaper.bibcode);
}
}}
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border ${
isCollapsed ? 'px-2 py-2.5 justify-center' : 'px-3 py-2.5'
} ${
isActive
? 'bg-slate-100 border-slate-200 text-slate-850 shadow-xs'
: 'border-transparent text-slate-650 hover:bg-slate-100 hover:text-slate-800'
}`}
>
<Icon className={`w-4 h-4 shrink-0 transition-colors duration-300 ${isActive ? 'text-slate-700' : 'text-slate-500'}`} />
<span
className={`truncate transition-all duration-300 origin-left ${
isCollapsed
? 'opacity-0 max-w-0 pointer-events-none select-none overflow-hidden scale-90 -translate-x-2'
: 'opacity-100 max-w-[150px] scale-100 translate-x-0 ml-3'
}`}
>
{tab.label}
</span>
</button>
);
})}
</nav>
</div>
{/* 底部当前选定文献提示 (平滑动画版本) 与 退出登录 */}
<div className="space-y-3">
{selectedPaper ? (
<div
className={`relative overflow-hidden transition-all duration-300 border rounded-lg ${
isCollapsed
? 'p-0 border-transparent bg-transparent flex justify-center'
: 'p-3.5 border-slate-200 bg-slate-100/50'
}`}
title={isCollapsed ? `当前选定文献: ${selectedPaper.title}` : undefined}
>
{/* 折叠下的微型图书图标 */}
<div
className={`transition-all duration-300 flex items-center justify-center rounded-lg border border-slate-200 bg-slate-50 text-slate-650 shrink-0 shadow-xs ${
isCollapsed
? 'w-9 h-9 opacity-100 scale-100'
: 'w-0 h-0 opacity-0 scale-75 overflow-hidden'
}`}
>
<BookOpen className="w-4 h-4" />
</div>
{/* 展开下的完整详情 */}
<div
className={`transition-all duration-300 origin-left ${
isCollapsed
? 'opacity-0 max-w-0 max-h-0 pointer-events-none select-none overflow-hidden'
: 'opacity-100 max-w-[200px] max-h-40'
}`}
>
<span className="text-[9px] font-bold text-slate-500 tracking-widest block mb-1"></span>
<h4 className="text-xs text-slate-800 font-bold line-clamp-2 mb-2 leading-relaxed">{selectedPaper.title}</h4>
<div className="flex items-center justify-between text-[10px] font-medium text-slate-500 gap-2">
<span className="shrink-0">: {selectedPaper.year}</span>
<span className="truncate max-w-[90px] font-mono">{selectedPaper.bibcode}</span>
</div>
</div>
</div>
) : (
<div
className={`relative overflow-hidden transition-all duration-300 border rounded-lg ${
isCollapsed
? 'p-0 border-transparent bg-transparent flex justify-center'
: 'p-3 border-slate-200 bg-slate-100/30'
}`}
title={isCollapsed ? "未选定研究目标" : undefined}
>
{/* 折叠下的微型馆藏图标 */}
<div
className={`transition-all duration-300 flex items-center justify-center rounded-lg border border-slate-200 bg-slate-100/30 text-slate-400 shrink-0 ${
isCollapsed
? 'w-9 h-9 opacity-100 scale-100'
: 'w-0 h-0 opacity-0 scale-75 overflow-hidden'
}`}
>
<Library className="w-4 h-4" />
</div>
{/* 展开下的提示文字 */}
<div
className={`text-center transition-all duration-300 origin-left ${
isCollapsed
? 'opacity-0 max-w-0 max-h-0 pointer-events-none select-none overflow-hidden'
: 'opacity-100 max-w-[200px] max-h-12'
}`}
>
<span className="text-[10px] text-slate-400 font-medium tracking-wide"></span>
</div>
</div>
)}
{/* 退出登录按钮 */}
<>
{/* 移动端背景遮罩层 */}
{isOpen && (
<button
type="button"
onClick={onLogout}
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border border-transparent text-slate-500 hover:bg-rose-50/50 hover:text-rose-600 cursor-pointer group ${
isCollapsed ? 'px-2 py-2.5 justify-center' : 'px-3 py-2.5'
}`}
title={isCollapsed ? "退出登录" : undefined}
>
<LogOut className="w-4 h-4 shrink-0 text-slate-400 group-hover:text-rose-500 transition-colors" />
<span
className={`truncate transition-all duration-300 origin-left ${
isCollapsed
? 'opacity-0 max-w-0 pointer-events-none select-none overflow-hidden scale-90 -translate-x-2'
: 'opacity-100 max-w-[150px] scale-100 translate-x-0 ml-3'
onClick={onClose}
className="fixed inset-0 bg-slate-900/30 backdrop-blur-xs z-35 lg:hidden cursor-pointer w-full h-full border-none outline-none"
aria-label="关闭导航"
/>
)}
<aside
className={`bg-slate-50 border-r border-slate-200 flex flex-col justify-between py-6 z-40 select-none transition-all duration-355 cubic-bezier(0.4, 0, 0.2, 1) fixed inset-y-0 left-0 lg:relative lg:translate-x-0 ${
isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
} ${
isOpen ? 'w-64 px-4' : (effectiveCollapsed ? 'w-16 px-2' : 'w-64 px-4')
}`}
>
<div>
{/* 系统LOGO与折叠控制区 */}
<div className={`mb-8 flex items-center transition-all duration-300 ${
effectiveCollapsed ? 'justify-center px-0' : 'justify-between px-3'
}`}>
{/* Logo & 标题文字 */}
<div className="flex items-center gap-3 min-w-0">
{/* Logo按钮 (只在折叠状态下可点击展开) */}
<button
type="button"
disabled={!effectiveCollapsed}
onClick={() => setIsCollapsed(false)}
className={`flex items-center justify-center shrink-0 rounded-lg transition-all duration-300 ${
effectiveCollapsed
? 'w-11 h-11 bg-white hover:bg-slate-55 border border-slate-200 cursor-pointer shadow-xs hover:shadow-sm'
: 'w-9 h-9 bg-transparent border border-transparent cursor-default'
}`}
title={effectiveCollapsed ? "展开导航" : undefined}
>
<div className={`transition-all duration-300 flex items-center justify-center ${effectiveCollapsed ? 'w-8 h-8' : 'w-9 h-9'}`}>
<Logo gradientId="sidebarStarGrad" />
</div>
</button>
{/* 系统说明文字 */}
<div
className={`flex flex-col transition-all duration-300 origin-left ${
effectiveCollapsed
? 'opacity-0 max-w-0 scale-95 -translate-x-2 pointer-events-none select-none overflow-hidden h-0'
: 'opacity-100 max-w-[150px] scale-100 translate-x-0'
}`}
>
<h1 className="text-sm font-bold text-slate-800 tracking-wider whitespace-nowrap">AstroResearch</h1>
<span className="text-[11px] text-slate-500 block font-medium font-sans whitespace-nowrap"></span>
</div>
</div>
{/* 折叠控制按钮 (圆润悬浮效果,仅在桌面端显示) */}
<button
type="button"
onClick={() => setIsCollapsed(true)}
className={`p-1.5 rounded-full bg-slate-100 hover:bg-slate-200 border border-slate-200 text-slate-505 hover:text-slate-800 transition-all duration-300 cursor-pointer shadow-2xs shrink-0 items-center justify-center hover:scale-105 active:scale-95 hidden lg:flex ${
effectiveCollapsed
? 'opacity-0 scale-75 pointer-events-none w-0 h-0 p-0 border-0 overflow-hidden'
: 'opacity-100 scale-100'
}`}
title="收起导航"
>
<ChevronLeft className="w-3.5 h-3.5" />
</button>
</div>
{/* 导航菜单列表 */}
<nav className="space-y-1">
{[
{ id: 'search' as TabId, label: '统一检索', icon: Search },
{ id: 'library' as TabId, label: '馆藏管理', icon: Library },
{ id: 'reader' as TabId, label: '双语阅读', icon: BookOpen },
{ id: 'citation' as TabId, label: '引用星系', icon: GitFork },
{ id: 'sync' as TabId, label: '批量任务', icon: RefreshCw },
{ id: 'agent' as TabId, label: '智能科研', icon: Sparkles },
].map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
title={effectiveCollapsed ? tab.label : undefined}
onClick={() => {
setActiveTab(tab.id);
if (tab.id === 'citation' && selectedPaper) {
loadCitations(selectedPaper.bibcode);
}
if (onClose) onClose(); // 移动端切页时自动收起
}}
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border ${
effectiveCollapsed ? 'px-2 py-2.5 justify-center' : 'px-3 py-2.5'
} ${
isActive
? 'bg-slate-100 border-slate-200 text-slate-850 shadow-xs'
: 'border-transparent text-slate-650 hover:bg-slate-100 hover:text-slate-800'
}`}
>
<Icon className={`w-4 h-4 shrink-0 transition-colors duration-300 ${isActive ? 'text-slate-700' : 'text-slate-500'}`} />
<span
className={`truncate transition-all duration-300 origin-left ${
effectiveCollapsed
? 'opacity-0 max-w-0 pointer-events-none select-none overflow-hidden scale-90 -translate-x-2'
: 'opacity-100 max-w-[150px] scale-100 translate-x-0 ml-3'
}`}
>
{tab.label}
</span>
</button>
);
})}
</nav>
</div>
{/* 底部当前选定文献提示 (平滑动画版本) 与 退出登录 */}
<div className="space-y-3">
{selectedPaper ? (
<div
className={`relative overflow-hidden transition-all duration-300 border rounded-lg ${
effectiveCollapsed
? 'p-0 border-transparent bg-transparent flex justify-center'
: 'p-3.5 border-slate-200 bg-slate-100/50'
}`}
title={effectiveCollapsed ? `当前选定文献: ${selectedPaper.title}` : undefined}
>
{/* 折叠下的微型图书图标 */}
<div
className={`transition-all duration-300 flex items-center justify-center rounded-lg border border-slate-200 bg-slate-55 text-slate-650 shrink-0 shadow-xs ${
effectiveCollapsed
? 'w-9 h-9 opacity-100 scale-100'
: 'w-0 h-0 opacity-0 scale-75 overflow-hidden'
}`}
>
<BookOpen className="w-4 h-4" />
</div>
{/* 展开下的完整详情 */}
<div
className={`transition-all duration-300 origin-left ${
effectiveCollapsed
? 'opacity-0 max-w-0 max-h-0 pointer-events-none select-none overflow-hidden'
: 'opacity-100 max-w-[200px] max-h-40'
}`}
>
<span className="text-[9px] font-bold text-slate-500 tracking-widest block mb-1"></span>
<h4 className="text-xs text-slate-800 font-bold line-clamp-2 mb-2 leading-relaxed">{selectedPaper.title}</h4>
<div className="flex items-center justify-between text-[10px] font-medium text-slate-500 gap-2">
<span className="shrink-0">: {selectedPaper.year}</span>
<span className="truncate max-w-[90px] font-mono">{selectedPaper.bibcode}</span>
</div>
</div>
</div>
) : (
<div
className={`relative overflow-hidden transition-all duration-300 border rounded-lg ${
effectiveCollapsed
? 'p-0 border-transparent bg-transparent flex justify-center'
: 'p-3 border-slate-200 bg-slate-100/30'
}`}
title={effectiveCollapsed ? "未选定研究目标" : undefined}
>
{/* 折叠下的微型馆藏图标 */}
<div
className={`transition-all duration-300 flex items-center justify-center rounded-lg border border-slate-200 bg-slate-100/30 text-slate-400 shrink-0 ${
effectiveCollapsed
? 'w-9 h-9 opacity-100 scale-100'
: 'w-0 h-0 opacity-0 scale-75 overflow-hidden'
}`}
>
<Library className="w-4 h-4" />
</div>
{/* 展开下的提示文字 */}
<div
className={`text-center transition-all duration-300 origin-left ${
effectiveCollapsed
? 'opacity-0 max-w-0 max-h-0 pointer-events-none select-none overflow-hidden'
: 'opacity-100 max-w-[200px] max-h-12'
}`}
>
<span className="text-[10px] text-slate-400 font-medium tracking-wide"></span>
</div>
</div>
)}
{/* 退出登录按钮 */}
<button
type="button"
onClick={onLogout}
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border border-transparent text-slate-500 hover:bg-rose-50/50 hover:text-rose-600 cursor-pointer group ${
effectiveCollapsed ? 'px-2 py-2.5 justify-center' : 'px-3 py-2.5'
}`}
title={effectiveCollapsed ? "退出登录" : undefined}
>
退
</span>
</button>
</div>
</aside>
<LogOut className="w-4 h-4 shrink-0 text-slate-400 group-hover:text-rose-505 transition-colors" />
<span
className={`truncate transition-all duration-300 origin-left ${
effectiveCollapsed
? 'opacity-0 max-w-0 pointer-events-none select-none overflow-hidden scale-90 -translate-x-2'
: 'opacity-100 max-w-[150px] scale-100 translate-x-0 ml-3'
}`}
>
退
</span>
</button>
</div>
</aside>
</>
);
}
@@ -1,4 +1,5 @@
// dashboard/src/components/reader/BilingualViewer.tsx
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
@@ -128,6 +129,28 @@ export function BilingualViewer({
hoverCardPos,
children,
}: BilingualViewerProps) {
// 监测屏幕宽度以判断是否在移动端/平板设备上(宽度 < 1024px
const [isMobileViewport, setIsMobileViewport] = useState(() => {
if (typeof window !== 'undefined') {
return window.innerWidth < 1024;
}
return false;
});
useEffect(() => {
const handleResize = () => {
setIsMobileViewport(window.innerWidth < 1024);
};
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'));
// 解析英文和中文段落的 Front Matter 头部元数据
const { metadata: engMeta, pureMarkdown: engPure } = parseMarkdownFrontMatter(englishText);
const { metadata: chnMeta, pureMarkdown: chnPure } = parseMarkdownFrontMatter(chineseText);
@@ -150,11 +173,7 @@ export function BilingualViewer({
return (
<div
className="flex-1 grid gap-6 overflow-hidden w-full min-h-0"
style={{
gridTemplateColumns: viewMode === 'bilingual'
? (showNotesPanel ? '1fr 1fr 380px' : '1fr 1fr')
: (showNotesPanel ? '1fr 380px' : '1fr')
}}
style={{ gridTemplateColumns }}
>
{/* 英文正文视窗 (或双语对照) */}
{(viewMode === 'english' || viewMode === 'bilingual') && (
@@ -69,15 +69,24 @@ export function ReaderNotesSidebar({
if (!showNotesPanel) return null;
return (
<div className="console-panel rounded-lg border border-slate-200 bg-slate-50 flex flex-col overflow-hidden relative shadow-sm h-full">
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between bg-white shrink-0">
<span className="text-xs font-bold text-slate-800">
{sidebarTab === 'notes' ? '观测记录手札' : '文献 AI 问答'}
</span>
<button onClick={() => setShowNotesPanel(false)} className="text-slate-400 hover:text-slate-600 transition-colors cursor-pointer">
<X className="w-4 h-4" />
</button>
</div>
<>
{/* 移动端遮罩层 */}
<button
type="button"
onClick={() => setShowNotesPanel(false)}
className="fixed inset-0 bg-slate-900/30 backdrop-blur-xs z-35 lg:hidden cursor-pointer w-full h-full border-none outline-none"
aria-label="关闭侧栏"
/>
<div className="console-panel rounded-lg border border-slate-200 bg-slate-50 flex flex-col overflow-hidden shadow-sm h-full fixed top-0 right-0 h-full w-[88vw] sm:w-[380px] z-40 lg:relative lg:top-auto lg:right-auto lg:h-full lg:w-auto lg:z-10">
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between bg-white shrink-0">
<span className="text-xs font-bold text-slate-800">
{sidebarTab === 'notes' ? '观测记录手札' : '文献 AI 问答'}
</span>
<button onClick={() => setShowNotesPanel(false)} className="text-slate-400 hover:text-slate-600 transition-colors cursor-pointer">
<X className="w-4 h-4" />
</button>
</div>
{/* 标签栏选择器 */}
<div className="flex border-b border-slate-200 bg-white select-none shrink-0">
@@ -281,5 +290,6 @@ export function ReaderNotesSidebar({
</div>
)}
</div>
</>
);
}
@@ -57,18 +57,18 @@ export function ReaderToolbar({
showPdf,
}: ReaderToolbarProps) {
return (
<div className="flex items-center justify-between border-b border-slate-200 pb-3 shrink-0">
<div className="flex-1 min-w-0 pr-4">
<div className="flex flex-col xl:flex-row xl:items-center xl:justify-between border-b border-slate-200 pb-3 shrink-0 gap-3">
<div className="min-w-0 w-full xl:flex-1 pr-0 xl:pr-4">
<h2 className="text-sm font-bold text-slate-900 line-clamp-1 leading-snug" title={selectedPaper.title}>
{selectedPaper.title}
</h2>
<div className="flex items-center gap-2 text-xs text-slate-500 mt-1 font-semibold">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-500 mt-1 font-semibold">
<span>: {selectedPaper.pub_journal || '未标注'}</span>
<span></span>
<span className="hidden sm:inline"></span>
<span>: {selectedPaper.bibcode}</span>
</div>
</div>
<div className="flex gap-2 items-center relative">
<div className="flex flex-wrap gap-2 items-center relative w-full xl:w-auto">
{/* 快速切换文献菜单 */}
<div className="relative shrink-0">
<button
@@ -175,7 +175,7 @@ export function ReaderToolbar({
<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 ${
className={`hidden lg: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'
+6 -1
View File
@@ -94,7 +94,12 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
const fileInputRef = useRef<HTMLInputElement>(null);
const [loadingSessions, setLoadingSessions] = useState(false);
const [loadingHistory, setLoadingHistory] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
if (typeof window !== 'undefined') {
return window.innerWidth < 1024;
}
return false;
});
// 全文搜索历史记录状态
const [searchQuery, setSearchQuery] = useState('');
+38 -3
View File
@@ -71,7 +71,7 @@ export function ReaderPanel(props: ReaderPanelProps) {
// 局部管理阅读视角模式(原文/中文/对照)
const [viewMode, setViewMode] = useState<'bilingual' | 'english' | 'chinese'>(() => {
if (typeof window !== 'undefined' && window.innerWidth < 768) {
if (typeof window !== 'undefined' && window.innerWidth < 1024) {
return 'english';
}
if (!chineseText) {
@@ -80,16 +80,51 @@ export function ReaderPanel(props: ReaderPanelProps) {
return 'bilingual';
});
// 无翻译时强制回退到英文模式,有翻译时自动切换到双语对照
const [userOverrode, setUserOverrode] = useState(false);
// 监听窗口尺寸变化,处理移动端与桌面端的自适应视角切换
useEffect(() => {
if (typeof window === 'undefined') return;
let prevWidth = window.innerWidth;
const handleResize = () => {
const currentWidth = window.innerWidth;
const wasMobile = prevWidth < 1024;
const isMobile = currentWidth < 1024;
if (wasMobile !== isMobile) {
if (isMobile) {
// 从桌面缩放到移动端:切换显示英文原文
setViewMode('english');
} else {
// 从移动端拉宽到桌面端:若有翻译则显示双语对照,否则显示原文
setViewMode(chineseText ? 'bilingual' : 'english');
}
}
prevWidth = currentWidth;
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [chineseText]);
// 无翻译时强制回退到英文模式,有翻译时且在桌面端时自动切换到双语对照
useEffect(() => {
if (userOverrode) return;
const isMobile = typeof window !== 'undefined' && window.innerWidth < 1024;
if (isMobile) {
if (viewMode !== 'english') {
queueMicrotask(() => setViewMode('english'));
}
return;
}
if (!chineseText && viewMode === 'bilingual') {
queueMicrotask(() => setViewMode('english'));
} else if (chineseText && viewMode === 'english') {
queueMicrotask(() => setViewMode('bilingual'));
}
}, [chineseText]);
}, [chineseText, viewMode, userOverrode]);
const handleViewModeChange = (mode: 'bilingual' | 'english' | 'chinese') => {
setUserOverrode(true);