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:
+35
-2
@@ -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]">
|
||||
错误返回
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user