refactor: 全栈架构重构与质量硬化——API 错误统一、工具域重组、安全加固、前端组件化

后端核心变更:
  - API 层: 新增 AppError 枚举统一错误类型,替代散落的 (StatusCode, String)
  - Agent 工具域: 重组为 astro/system/ 和 astro/research/ 两级域,新增 ProcessPaperTool 流水线工具
  - 安全: 新增 SSRF 双层防护 (同步字符串级 + 异步 DNS 解析级),覆盖 IPv4/IPv6 私网段
  - 弱密码检测: 扩展弱密码列表并增加最小长度检查
  - LLM 客户端: 新增 ChatCompleter/Embedder trait,支持依赖注入与批量向量化 embed_batch
  - 批量处理: AssetBatch 从串行改为 Semaphore 并发池 (BATCH_CONCURRENCY=3)
  - 分块器: 重写为三阶段结构化管线 (章节解析→短节合并→带标题路径子块)
  - RAG: embedding 计算移出事务,RetrievalResult 新增 headings/section_index 字段
  - 检索: ADS/arXiv 并行检索 (tokio::join!),去重改用 HashSet,本地库回填批量 IN 查询
  - 天体查询: Sesame API 升级到 v4,新增视差误差/自行/视向速度/多波段测光字段
  - 迁移: 14 个增量文件合并为单一 init.sql,支持 sqlx::migrate! 内存库集成测试
  - 测试: circuit_breaker/hooks/task_board/session/memory/streaming_executor 新增修正 15+ 测试

  前端架构重构:
  - 目录重组: features/ → pages/ + components/ + hooks/ 三层分离
  - App.tsx 从 1181 行压缩至 ~174 行 (逻辑抽入 9 个自定义 Hook)
  - Agent 面板拆分为 AgentSessionSidebar/AgentMessageList/AgentInputArea 子组件
  - 新增 GlobalDialog/PaperDetailModal/UncachedPaperModal 通用对话框组件
  - 工具函数抽取: celestial.ts (天体坐标格式), paper.tsx (文献信息渲染)
This commit is contained in:
fmq
2026-06-25 23:45:37 +08:00
parent b11b8ad015
commit 5db4cc5998
131 changed files with 13934 additions and 7887 deletions
+4 -7
View File
@@ -22,13 +22,10 @@ npm run dev
## 2. 核心功能及文件结构 (Core Modules)
- `src/components/layout/`:侧边栏与基本布局组件
- `src/components/CitationGalaxyCanvas.tsx`:自研的 Canvas 力导向引文星系图渲染器
- `src/features/search/`:统一检索面板,支持跨源搜索与收藏
- `src/features/library/`:馆藏管理卡片,提供下载状态实时监测及重新下载操作
- `src/features/reader/`:左右对齐的双分栏阅读器,内置划词高亮笔记及 LLM 重新翻译触发。
- `src/features/sync/`:批量同步面板,支持后台元数据大批量采集、过滤及文献资源批量下载/解析流水线任务管理。
- `src/types.ts`:全局 TypeScript 静态类型定义。
- `src/pages/`:页面级大组件视图(如统一检索、馆藏管理、对照阅读、引文星系、批量同步、智能科研、系统设置)
- `src/components/`:通用与专有 UI 组件(如力导向引文星系图、文献阅读器子组件、流水线任务终端等)
- `src/hooks/`:抽取出的全局共享及业务数据逻辑 Hooks(如 `useLibrary``useReaderState``useSyncState` 等)
- `src/types/`:全局 TypeScript 静态类型定义(`types/index.ts`
---
+201 -980
View File
File diff suppressed because it is too large Load Diff
@@ -11,8 +11,8 @@ import {
ThoughtCard, ToolCallCard, SubAgentContainer,
PARENT_COLORS,
routeThought, routeToolCall, routeToolResult, routeTextDelta,
} from '../../components/agent';
import type { SSEEventHandlers, TimelineItem } from '../../components/agent';
} from './agent';
import type { SSEEventHandlers, TimelineItem } from './agent';
interface RetrievalSource {
bibcode: string;
@@ -0,0 +1,195 @@
import React from 'react';
import {
Brain, Compass, BookOpen, Send, Square, Paperclip, X
} from 'lucide-react';
import type { AgentMode } from '../../hooks/useResearchAgent';
interface AgentInputAreaProps {
input: string;
setInput: (val: string) => void;
streaming: boolean;
thinking: boolean;
setThinking: (val: boolean) => void;
agentMode: string;
setAgentMode: (val: string) => void;
agentModes: AgentMode[];
pendingImage: { data?: string; path?: string; mime_type: string; name: string } | null;
setPendingImage: (val: { data?: string; path?: string; mime_type: string; name: string } | null) => void;
onSend: (text: string) => void;
onStop: () => void;
fileInputRef: React.RefObject<HTMLInputElement | null>;
attachImage: (file: File) => void;
handlePaste: (e: React.ClipboardEvent) => void;
}
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
Compass,
BookOpen
};
function getIconComponent(iconName: string): React.ComponentType<{ className?: string }> {
return ICON_MAP[iconName] || Brain;
}
export function AgentInputArea({
input,
setInput,
streaming,
thinking,
setThinking,
agentMode,
setAgentMode,
agentModes,
pendingImage,
setPendingImage,
onSend,
onStop,
fileInputRef,
attachImage,
handlePaste,
}: AgentInputAreaProps) {
return (
<div className="absolute bottom-0 left-0 right-0 p-4 bg-transparent pointer-events-none shrink-0 z-30 flex flex-col gap-3">
<form
onSubmit={(e) => {
e.preventDefault();
onSend(input);
}}
className="max-w-4xl mx-auto w-full pointer-events-auto"
>
<div className="bg-slate-50 border border-slate-200/60 rounded-2xl p-2.5 focus-within:bg-white focus-within:border-sky-500 focus-within:ring-1 focus-within:ring-sky-500/10 transition-all shadow-lg flex flex-col gap-2">
{/* 图片预览 */}
{pendingImage && (
<div className="relative inline-block mb-1 group select-none self-start">
<img
src={pendingImage.path ? `/api/files/${pendingImage.path}` : `data:${pendingImage.mime_type};base64,${pendingImage.data}`}
alt="Preview"
className="w-12 h-12 object-cover rounded-lg border border-slate-200 shadow-sm"
/>
<button
type="button"
onClick={() => setPendingImage(null)}
className="absolute -top-1.5 -right-1.5 bg-slate-500/80 hover:bg-slate-700 text-white rounded-full p-0.5 shadow-sm transition-colors cursor-pointer flex items-center justify-center"
title="移除图片"
>
<X className="w-2.5 h-2.5" />
</button>
</div>
)}
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend(input);
}
}}
disabled={streaming}
placeholder="向科研智能体提问(可粘贴或上传图片,配合深度研究模式分析图表)..."
rows={2}
onPaste={handlePaste}
className="w-full bg-transparent resize-none border-none outline-none focus:outline-none focus:ring-0 text-xs text-slate-900 placeholder-slate-400 leading-relaxed font-semibold min-h-[44px] max-h-40"
/>
<div className="flex justify-between items-center mt-2.5 pt-2 border-t border-slate-100/80">
<div className="flex flex-wrap gap-2 items-center">
{/* 图片上传 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) attachImage(file);
e.target.value = ''; // 允许重复选择同一文件
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={streaming}
title="上传或粘贴图片(也可直接 Ctrl+V 粘贴)"
className={`p-1.5 rounded-lg border text-[10px] font-bold transition-all cursor-pointer flex items-center gap-1 ${
pendingImage
? 'bg-sky-50 border-sky-300 text-sky-700'
: 'bg-white border-slate-200 text-slate-400 hover:text-sky-600 hover:border-sky-200'
} disabled:opacity-60`}
>
<Paperclip className="w-3.5 h-3.5" />
</button>
{/* Agent 模式选择器 */}
<div className="flex gap-1 bg-slate-100 p-0.5 rounded-lg border border-slate-200">
{agentModes.map((m) => {
const IconComp = getIconComponent(m.icon);
const isSelected = agentMode === m.id;
return (
<button
key={m.id}
type="button"
onClick={() => setAgentMode(m.id)}
disabled={streaming}
title={m.description}
className={`px-2 py-1.5 rounded-md text-[10px] font-extrabold transition-all border cursor-pointer flex items-center gap-1 shrink-0 ${
isSelected
? 'bg-white text-indigo-700 shadow-xs border-slate-200/50'
: 'bg-transparent border-transparent text-slate-400 hover:text-indigo-600'
} disabled:opacity-60`}
>
<IconComp className={`w-3.5 h-3.5 ${isSelected ? 'text-indigo-500' : ''}`} />
<span className="hidden sm:inline">{m.name}</span>
</button>
);
})}
</div>
{/* 思考模式开关 */}
{agentMode === 'default' && (
<>
<div className="h-4 w-px bg-slate-200 mx-1 hidden sm:block" />
<button
type="button"
onClick={() => setThinking(!thinking)}
disabled={streaming}
title={thinking ? '思考模式已开启(启用 LLM 推理过程)' : '思考模式已关闭(点击开启)'}
className={`px-2.5 py-1.5 rounded-lg border text-[10px] font-bold transition-all cursor-pointer flex items-center gap-1 shrink-0 ${
thinking
? 'bg-sky-50 border-sky-205 text-sky-700 shadow-3xs'
: 'bg-white border-slate-200 text-slate-400 hover:text-sky-600 hover:border-sky-200'
} disabled:opacity-60`}
>
<Brain className={`w-3.5 h-3.5 ${thinking ? 'text-sky-500' : ''}`} />
<span className="hidden sm:inline"></span>
</button>
</>
)}
</div>
<div>
{streaming ? (
<button
type="button"
onClick={onStop}
className="p-2 rounded-xl bg-slate-800 hover:bg-slate-950 text-white transition-colors cursor-pointer flex items-center justify-center shadow-xs"
title="手动停止执行"
>
<Square className="w-3.5 h-3.5 fill-white" />
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="p-2 rounded-xl bg-sky-600 hover:bg-sky-700 text-white disabled:bg-slate-100 disabled:text-slate-350 transition-colors cursor-pointer flex items-center justify-center shadow-xs"
>
<Send className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
</div>
</form>
</div>
);
}
@@ -0,0 +1,445 @@
import React from 'react';
import {
PanelLeftOpen, BarChart3, ScrollText, Trash2, Loader, Compass, Rewind,
CheckCircle2, AlertTriangle, RotateCcw, RefreshCw, GitBranch
} from 'lucide-react';
import { AgentMarkdown } from './AgentMarkdown';
import { ThoughtCard } from './ThoughtCard';
import { ToolCallCard } from './ToolCallCard';
import { AnswerCard } from './AnswerCard';
import { SubAgentContainer } from './SubAgentContainer';
import { PARENT_COLORS } from './constants';
import type { ColorScheme } from './constants';
import type { TimelineItem } from './types';
import type { ProcessedTurn, ActiveTurn, SessionSummary } from '../../hooks/useResearchAgent';
import { AskUserQuestionCard } from './AskUserQuestionCard';
import { PermissionRequestCard } from './PermissionRequestCard';
import { AgentMetricsPanel } from './AgentMetricsPanel';
import { AuditLogViewer } from './AuditLogViewer';
const QUICK_PROMPTS = [
"检索 2023 年以后关于热亚矮星 (Subdwarf) 双星演化的研究成果",
"读取文献 2024ApJ...960..123A 并总结其核心观测结论",
"在馆藏中语义搜索关于白矮星非径向振动的讨论",
"查询天体 GD 358 的物理参数"
];
interface AgentMessageListProps {
turns: ProcessedTurn[];
activeTurn: ActiveTurn | null;
streaming: boolean;
currentSessionId: string | null;
expandedThoughts: Record<string, boolean>;
expandedArgs: Record<string, boolean>;
expandedResults: Record<string, boolean>;
collapsedSubAgents: Record<string, boolean>;
toggleThought: (key: string) => void;
toggleArgs: (tcId: string) => void;
toggleResult: (tcId: string) => void;
toggleSubAgent: (id: string) => void;
handleRewind: (messageId?: number, n?: number) => Promise<void>;
handleRestoreRewind: () => Promise<void>;
handleRetry: () => Promise<void>;
handleBranch: () => Promise<void>;
handleSend: (questionText: string) => Promise<void>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
chatEndRef: React.RefObject<HTMLDivElement | null>;
handleScroll: (e: React.UIEvent<HTMLDivElement>) => void;
showMetrics: boolean;
setShowMetrics: (val: boolean) => void;
showAuditLog: boolean;
setShowAuditLog: (val: boolean) => void;
hasPendingPermission: boolean;
setHasPendingPermission: (val: boolean) => void;
hasPendingQuestion: boolean;
setHasPendingQuestion: (val: boolean) => void;
showAlert?: (message: string, title?: string) => void;
sidebarCollapsed: boolean;
setSidebarCollapsed: (val: boolean) => void;
sessions: SessionSummary[];
handleDeleteSession: (sessionId: string, e: React.MouseEvent) => Promise<void>;
loadSessionHistory: (sessionId: string, silent: boolean, onSuccess?: () => void) => Promise<void>;
}
export function AgentMessageList({
turns,
activeTurn,
streaming,
currentSessionId,
expandedThoughts,
expandedArgs,
expandedResults,
collapsedSubAgents,
toggleThought,
toggleArgs,
toggleResult,
toggleSubAgent,
handleRewind,
handleRestoreRewind,
handleRetry,
handleBranch,
handleSend,
scrollContainerRef,
chatEndRef,
handleScroll,
showMetrics,
setShowMetrics,
showAuditLog,
setShowAuditLog,
hasPendingPermission,
setHasPendingPermission,
hasPendingQuestion,
setHasPendingQuestion,
showAlert,
sidebarCollapsed,
setSidebarCollapsed,
sessions,
handleDeleteSession,
loadSessionHistory,
}: AgentMessageListProps) {
const renderTimelineItem = (
item: TimelineItem,
idx: number,
isStreaming: boolean,
colors: ColorScheme = PARENT_COLORS,
) => {
switch (item.type) {
case 'subagent_container':
return (
<SubAgentContainer
key={`subagent-${item.id}-${idx}`}
status={item.status}
children={item.children}
summary={item.summary}
isStreaming={isStreaming}
isCollapsed={collapsedSubAgents[item.id] !== false}
onToggle={() => toggleSubAgent(item.id)}
expandedThoughts={expandedThoughts}
expandedArgs={expandedArgs}
expandedResults={expandedResults}
onToggleThought={toggleThought}
onToggleArgs={toggleArgs}
onToggleResult={toggleResult}
/>
);
case 'thought': {
const thoughtKey = `thought-${item.step}`;
return (
<ThoughtCard
key={`${thoughtKey}-${idx}`}
step={item.step}
content={item.content}
isStreaming={isStreaming}
colorScheme={colors}
isExpanded={expandedThoughts[thoughtKey] === true}
onToggle={() => toggleThought(thoughtKey)}
/>
);
}
case 'tool_call': {
const tcId = item.id;
return (
<ToolCallCard
key={`tc-${tcId}-${idx}`}
step={item.step}
name={item.name}
arguments={item.arguments}
result={item.result}
isStreaming={isStreaming}
colorScheme={colors}
isArgsExpanded={expandedArgs[tcId] === true}
isResultExpanded={expandedResults[tcId] === true}
onToggleArgs={() => toggleArgs(tcId)}
onToggleResult={() => toggleResult(tcId)}
/>
);
}
case 'answer':
return (
<AnswerCard
key={`answer-${idx}`}
content={item.content}
colorScheme={colors}
/>
);
default:
return null;
}
};
return (
<div className="flex-1 flex flex-col overflow-hidden bg-white relative">
{/* 会话头部 */}
<div className="px-5 py-4 border-b border-slate-200 flex items-center justify-between shrink-0 bg-white">
<div className="min-w-0 pr-4 flex items-center gap-2">
{sidebarCollapsed && (
<button
onClick={() => setSidebarCollapsed(false)}
className="p-1.5 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 text-slate-500 hover:text-slate-700 transition-all cursor-pointer mr-1 shrink-0"
title="展开侧栏"
>
<PanelLeftOpen className="w-4 h-4" />
</button>
)}
<div>
<h3 className="text-xs font-bold text-slate-800 tracking-wide line-clamp-1">
{currentSessionId
? (sessions.find(s => s.session_id === currentSessionId)?.title || '未命名会话')
: '探索性科研研讨'}
</h3>
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">
(ReAct Thought Action Observation)
</p>
</div>
</div>
<div className="flex items-center gap-1.5">
{/* 指标面板按钮 */}
<button
onClick={() => { setShowMetrics(!showMetrics); setShowAuditLog(false); }}
className={`p-1.5 rounded-lg border text-xs font-bold transition-all cursor-pointer flex items-center gap-1 ${
showMetrics
? 'bg-sky-50 border-sky-200 text-sky-700'
: 'bg-white border-slate-200 text-slate-500 hover:text-sky-600 hover:border-sky-200'
}`}
title="运行指标"
>
<BarChart3 className="w-3.5 h-3.5" />
</button>
{/* 审计日志按钮 */}
{currentSessionId && (
<button
onClick={() => { setShowAuditLog(!showAuditLog); setShowMetrics(false); }}
className={`p-1.5 rounded-lg border text-xs font-bold transition-all cursor-pointer flex items-center gap-1 ${
showAuditLog
? 'bg-amber-50 border-amber-200 text-amber-700'
: 'bg-white border-slate-200 text-slate-500 hover:text-amber-600 hover:border-amber-200'
}`}
title="审计日志"
>
<ScrollText className="w-3.5 h-3.5" />
</button>
)}
{currentSessionId && (
<button
onClick={(e) => handleDeleteSession(currentSessionId, e)}
className="btn-console btn-console-secondary px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5 text-slate-500 hover:text-red-700 transition-all cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
<span></span>
</button>
)}
</div>
</div>
{/* Agent 运行指标面板 — 固定在聊天区域上方 */}
{showMetrics && (
<div className="border-b border-sky-200 bg-white px-5 py-4 max-h-[45vh] overflow-y-auto scrollbar-thin shrink-0">
<AgentMetricsPanel showAlert={showAlert} />
</div>
)}
{/* 会话审计日志 — 固定在聊天区域上方 */}
{showAuditLog && currentSessionId && (
<div className="border-b border-amber-200 bg-white px-5 py-4 max-h-[45vh] overflow-y-auto scrollbar-thin shrink-0">
<AuditLogViewer
sessionId={currentSessionId}
onClose={() => setShowAuditLog(false)}
/>
</div>
)}
{/* 消息滚动区域 */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className={`flex-1 overflow-y-auto p-5 space-y-6 bg-slate-50/50 scrollbar-thin transition-all duration-300 ${
(hasPendingPermission || hasPendingQuestion) ? 'pb-80' : 'pb-36'
}`}
>
{turns.length === 0 && !activeTurn ? (
<div className="py-12 space-y-8 max-w-xl mx-auto">
<div className="text-center space-y-2">
<Compass className="w-12 h-12 mx-auto text-sky-500 opacity-60" />
<h3 className="text-sm font-extrabold text-slate-800 tracking-wider"></h3>
<p className="text-xs text-slate-500 leading-relaxed font-semibold">
NASA ADS PDF/HTML SQLite-Vec RAG CDS Sesame
</p>
</div>
{/* 快速提示词 */}
<div className="space-y-3">
<span className="text-[10px] font-bold text-slate-400 tracking-widest uppercase block">:</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{QUICK_PROMPTS.map((q, idx) => (
<button
key={idx}
onClick={() => handleSend(q)}
className="w-full text-left bg-white hover:bg-sky-50/20 border border-slate-200 hover:border-sky-300 rounded-xl p-3.5 text-xs text-slate-700 leading-relaxed font-medium transition-all shadow-2xs hover:shadow-xs cursor-pointer flex flex-col gap-1 group"
>
<span className="group-hover:text-sky-700 font-semibold">{q}</span>
<span className="text-[9px] text-slate-400 uppercase font-bold tracking-wider"></span>
</button>
))}
</div>
</div>
</div>
) : (
<div className="space-y-6 max-w-4xl mx-auto">
{/* 历史消息渲染 */}
{turns.map((turn) => (
<div key={turn.turn_index} className="space-y-4">
{/* 用户提问 */}
<div className="flex flex-col items-end space-y-1 group relative">
<span className="text-[10px] font-bold text-slate-400 px-1"></span>
<div className="flex items-center gap-2 max-w-[85%]">
{turn.questionMessageId && (
<button
onClick={() => handleRewind(turn.questionMessageId)}
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-amber-600 p-1.5 rounded-lg hover:bg-slate-100 transition-all cursor-pointer shrink-0"
title="回退到此消息之前"
>
<Rewind className="w-3.5 h-3.5" />
</button>
)}
<div className="rounded-2xl px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-sky-600 text-white border-sky-600 select-text flex-1 space-y-2">
{turn.imagePath && (
<img src={turn.imagePath} alt="用户上传的图片" className="max-h-48 rounded-lg border border-sky-500/30" />
)}
<div>{turn.question}</div>
</div>
</div>
</div>
{/* 时间线 — 思考/工具/答案 */}
{turn.timeline.length > 0 && (
<div className="pl-4 border-l border-slate-200 space-y-3 my-2 relative">
{turn.timeline.map((item: TimelineItem, idx: number) => renderTimelineItem(item, idx, false))}
</div>
)}
{/* Token 用量 */}
{turn.usage && turn.usage.total_tokens > 0 && (
<div className="flex items-center gap-1 text-[9px] text-slate-400 font-mono pl-4">
<span>Tokens: ~{turn.usage.total_tokens}</span>
</div>
)}
</div>
))}
{/* 流式中活跃的步骤展示 */}
{activeTurn && (
<div className="space-y-4">
{/* 当前提问 */}
<div className="flex flex-col items-end space-y-1">
<span className="text-[10px] font-bold text-slate-400 px-1"></span>
<div className="max-w-[85%] rounded-2xl px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-sky-600 text-white border-sky-600 select-text space-y-2">
{activeTurn.imagePath && (
<img src={activeTurn.imagePath} alt="用户上传的图片" className="max-h-48 rounded-lg border border-sky-500/30" />
)}
<div>{activeTurn.question}</div>
</div>
</div>
{/* SSE Stream Timeline */}
{activeTurn.timeline.length > 0 && (
<div className="pl-4 border-l border-slate-200 space-y-3 my-2 relative">
{activeTurn.timeline.map((item: TimelineItem, idx: number) => renderTimelineItem(item, idx, true))}
</div>
)}
{/* 最终回答 */}
{activeTurn.finalAnswer && (
<div className="flex flex-col items-start space-y-1">
<span className="text-[10px] font-bold text-slate-400 px-1 flex items-center gap-1">
<CheckCircle2 className="w-3.5 h-3.5 text-sky-600 animate-pulse" />
<span></span>
</span>
<div className="max-w-[90%] rounded-2xl px-5 py-4 text-xs leading-relaxed font-semibold shadow-xs border bg-white text-slate-800 border-slate-200 select-text prose prose-sm max-w-none prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-img:rounded-lg">
<AgentMarkdown>{activeTurn.finalAnswer}</AgentMarkdown>
</div>
</div>
)}
{/* 活跃的加载指示器 */}
{streaming && !activeTurn.finalAnswer && !activeTurn.error && (
<div className="flex items-center space-x-2 text-slate-500 pl-2">
<Loader className="w-4 h-4 animate-spin text-sky-600" />
<span className="text-[10px] font-bold">...</span>
</div>
)}
{/* Error block */}
{activeTurn.error && (
<div className="flex items-start gap-2 bg-red-50 border border-red-200 text-red-750 rounded-lg p-3 text-xs w-[90%] font-semibold">
<AlertTriangle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<div>
<div className="font-bold"></div>
<div className="text-[10px] opacity-80 mt-0.5">{activeTurn.error}</div>
</div>
</div>
)}
</div>
)}
{/* 会话操作小图标栏 */}
{currentSessionId && turns.length > 0 && !streaming && (
<div className="flex items-center gap-1 pt-2.5 justify-start border-t border-slate-100/60 mt-4 max-w-[200px]">
<button
onClick={() => handleRewind(undefined, 1)}
className="p-1.5 rounded-lg text-slate-400 hover:text-amber-600 hover:bg-slate-100 transition-all cursor-pointer"
title="回退最近一轮对话 (undo)"
>
<Rewind className="w-4 h-4" />
</button>
<button
onClick={handleRestoreRewind}
className="p-1.5 rounded-lg text-slate-400 hover:text-green-600 hover:bg-slate-100 transition-all cursor-pointer"
title="恢复上次回退操作"
>
<RotateCcw className="w-4 h-4" />
</button>
<button
onClick={handleRetry}
className="p-1.5 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-slate-100 transition-all cursor-pointer"
title="重试最后一轮对话"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={handleBranch}
className="p-1.5 rounded-lg text-slate-400 hover:text-blue-600 hover:bg-slate-100 transition-all cursor-pointer"
title="分叉当前会话"
>
<GitBranch className="w-4 h-4" />
</button>
</div>
)}
</div>
)}
<div ref={chatEndRef} />
</div>
{/* 悬浮交互卡片区域:AskUserQuestion 和 PermissionRequest */}
{currentSessionId && (
<div className="absolute bottom-16 left-0 right-0 px-4 pointer-events-none z-25 flex flex-col gap-3">
<div className="max-w-4xl mx-auto w-full pointer-events-auto space-y-3">
<AskUserQuestionCard
onAnswered={() => {
if (currentSessionId) {
loadSessionHistory(currentSessionId, true);
}
}}
onQuestionCountChange={(count: number) => setHasPendingQuestion(count > 0)}
/>
<PermissionRequestCard
sessionId={currentSessionId}
onRequestCountChange={(count: number) => setHasPendingPermission(count > 0)}
/>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,209 @@
import React from 'react';
import {
Clock, Plus, PanelLeftClose, Search, X, MessageSquare, BookOpen, Trash2, Loader
} from 'lucide-react';
import type { SessionSummary, SearchResult } from '../../hooks/useResearchAgent';
interface AgentSessionSidebarProps {
sessions: SessionSummary[];
currentSessionId: string | null;
setCurrentSessionId: (id: string | null) => void;
loadingSessions: boolean;
searchQuery: string;
setSearchQuery: (val: string) => void;
searchScopeOnlyCurrent: boolean;
setSearchScopeOnlyCurrent: (val: boolean) => void;
searchResults: SearchResult[];
loadingSearch: boolean;
collapsed: boolean;
onToggleCollapse: (val: boolean) => void;
onNewSession: () => void;
onDeleteSession: (sessionId: string, e: React.MouseEvent) => void;
}
export function AgentSessionSidebar({
sessions,
currentSessionId,
setCurrentSessionId,
loadingSessions,
searchQuery,
setSearchQuery,
searchScopeOnlyCurrent,
setSearchScopeOnlyCurrent,
searchResults,
loadingSearch,
collapsed,
onToggleCollapse,
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-sky-600" />
{!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 hover:scale-105"
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>
{/* 搜索框区域 */}
<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-lg bg-slate-50 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-sky-500 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-sky-600 border-slate-300 focus:ring-sky-500 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-sky-600" />
<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-lg border transition-all duration-200 flex flex-col gap-1 cursor-pointer ${
isActive
? 'bg-sky-50/70 border-sky-200 text-sky-850 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-purple-400" />
<span>{role === 'user' ? '提问' : '解答'}</span>
</>
) : (
<>
<BookOpen className="w-3 h-3 text-sky-500" />
<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-sky-600" />
<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-lg border transition-all duration-200 group flex items-start justify-between gap-2 cursor-pointer ${
isActive
? 'bg-sky-50 border-sky-200 text-sky-850 font-bold shadow-2xs'
: 'border-transparent bg-transparent hover:bg-slate-100 text-slate-650'
}`}
>
<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>
);
}
@@ -0,0 +1,71 @@
interface GlobalDialogProps {
dialog: {
type: 'alert' | 'confirm';
title: string;
message: string;
onConfirm: () => void;
onCancel?: () => void;
} | null;
onClose: () => void;
}
export function GlobalDialog({ dialog, onClose }: GlobalDialogProps) {
if (!dialog) return null;
const handleCancel = () => {
if (dialog.onCancel) dialog.onCancel();
onClose();
};
const handleConfirm = () => {
dialog.onConfirm();
onClose();
};
return (
<div
onClick={handleCancel}
className="fixed inset-0 z-[60] flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all"
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-[var(--bg-card)] rounded-xl border border-[var(--border-precision)] shadow-md max-w-sm w-full p-6 space-y-4"
>
<div className="flex items-center justify-between border-b border-slate-100 pb-2.5">
<h3 className="text-xs font-bold text-[var(--text-main)] flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${dialog.type === 'confirm' ? 'bg-[var(--accent-blueprint)]' : 'bg-[#ef4444]'} animate-pulse`} />
<span>{dialog.title}</span>
</h3>
<button
onClick={handleCancel}
className="text-slate-400 hover:text-slate-600 p-1 rounded-lg hover:bg-slate-100 transition-colors cursor-pointer"
title="关闭"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-2">
<p className="text-xs text-[var(--text-muted)] leading-relaxed whitespace-pre-line">{dialog.message}</p>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={handleConfirm}
className="flex-1 btn-console btn-console-primary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer"
>
</button>
{dialog.type === 'confirm' && (
<button
onClick={handleCancel}
className="px-4 btn-console btn-console-secondary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer"
>
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,360 @@
import axios from 'axios';
import { Loader, Download, RefreshCw, AlertTriangle } from 'lucide-react';
import type { StandardPaper } from '../../types';
import { getDoctypeBadge } from '../../utils/paper';
interface PaperDetailModalProps {
paper: StandardPaper | null | undefined;
onClose: () => void;
uploadingBibcode: string | null;
downloadingBibcodes: Record<string, boolean>;
handleManualUpload: (bibcode: string, type: 'pdf' | 'html', file: File) => Promise<void>;
handleMarkNoResource: (bibcode: string, clear: boolean) => Promise<void>;
handleDownload: (bibcode: string, force?: boolean) => Promise<void>;
openReader: (paper: StandardPaper) => void;
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent') => void;
setSelectedPaper: (paper: StandardPaper) => void;
loadCitations: (bibcode: string) => Promise<void>;
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
}
export function PaperDetailModal({
paper,
onClose,
uploadingBibcode,
downloadingBibcodes,
handleManualUpload,
handleMarkNoResource,
handleDownload,
openReader,
setActiveTab,
setSelectedPaper,
loadCitations,
showConfirm,
}: PaperDetailModalProps) {
if (!paper) return null;
return (
<div
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all animate-fade-in cursor-pointer"
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-[var(--bg-card)] rounded-xl border border-[var(--border-precision)] shadow-md max-w-lg w-full p-6 space-y-4 cursor-default animate-fade-in"
>
{/* 标题 & 关闭 */}
<div className="flex items-start justify-between border-b border-slate-100 pb-3">
<div className="space-y-1 pr-4">
<span className="text-[10px] font-bold text-[var(--accent-blueprint)] uppercase tracking-wider"></span>
<h3 className="text-xs font-bold text-[var(--text-main)] leading-snug">
{getDoctypeBadge(paper.doctype)}
<span className="align-middle ml-1">{paper.title}</span>
</h3>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 p-1 rounded-lg hover:bg-slate-100 transition-colors shrink-0 cursor-pointer"
title="关闭"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 详情内容 */}
<div className="space-y-4 h-[460px] flex flex-col overflow-y-auto pr-1 text-xs scrollbar-thin">
{/* 作者 */}
<div className="space-y-1 h-12 overflow-y-auto scrollbar-thin shrink-0">
<span className="text-[var(--text-muted)] font-bold"></span>
<p className="text-[var(--text-main)] leading-relaxed font-semibold">{paper.authors.join(', ')}</p>
</div>
{/* 期刊 & 年份 */}
<div className="grid grid-cols-5 gap-4 border-y border-slate-100 py-2.5 shrink-0 h-14">
<div className="space-y-0.5 flex flex-col justify-center min-w-0 col-span-4">
<span className="text-[var(--text-muted)] font-bold block text-[10px] leading-tight"></span>
<span
className="text-[var(--text-main)] font-bold italic truncate block text-[11px] leading-tight"
title={paper.pub_journal || '未标注'}
>
{paper.pub_journal || '未标注'}
</span>
</div>
<div className="space-y-0.5 flex flex-col justify-center col-span-1">
<span className="text-[var(--text-muted)] font-bold block text-[10px] leading-tight"></span>
<span className="text-[var(--text-main)] font-extrabold block text-[11px] leading-tight">{paper.year}</span>
</div>
</div>
{/* 摘要 */}
<div className="space-y-1.5 flex flex-col h-40 shrink-0">
<span className="text-[var(--text-muted)] font-bold block"></span>
<p className="text-[var(--text-main)] leading-relaxed font-normal bg-[#f8fafc] p-3 flex-1 rounded-lg border border-[var(--border-precision)] text-justify overflow-y-auto scrollbar-thin select-text">
{paper.abstract_text || '该文献暂无摘要数据。'}
</p>
</div>
{/* 关键字 */}
<div className="space-y-1.5 flex flex-col h-16 shrink-0">
<span className="text-[var(--text-muted)] font-bold block"></span>
{paper.keywords && paper.keywords.length > 0 ? (
<div className="flex flex-wrap gap-1.5 flex-1 content-start items-start overflow-y-auto scrollbar-thin pr-1">
{paper.keywords.map(kw => (
<span key={kw} className="px-2 py-0.5 rounded bg-[#f1f5f9] border border-[var(--border-precision)] text-[var(--text-muted)] font-bold text-[9px]">
{kw}
</span>
))}
</div>
) : (
<div className="flex items-center justify-center flex-1 bg-[#f8fafc] border border-[var(--border-precision)] rounded-lg text-[10px] text-[var(--text-muted)] font-bold select-none">
</div>
)}
</div>
{/* 标识符 */}
<div className="border-t border-slate-100 pt-3 grid grid-cols-1 sm:grid-cols-3 gap-2 text-[10px] font-mono mt-auto shrink-0">
<div className="bg-[#f8fafc] px-2.5 py-1.5 rounded border border-[var(--border-precision)]">
<span className="text-[var(--text-muted)] font-bold block">BIBCODE</span>
<span className="text-[var(--text-main)] font-semibold select-all truncate block" title={paper.bibcode === paper.arxiv_id ? '暂无' : paper.bibcode}>
{paper.bibcode === paper.arxiv_id ? '暂无' : (
<a
href={`https://ui.adsabs.harvard.edu/abs/${paper.bibcode}/abstract`}
target="_blank"
rel="noreferrer"
onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})}
className="text-[var(--accent-blueprint)] hover:underline"
>
{paper.bibcode}
</a>
)}
</span>
</div>
<div className="bg-[#f8fafc] px-2.5 py-1.5 rounded border border-[var(--border-precision)]">
<span className="text-[var(--text-muted)] font-bold block">DOI</span>
<span className="text-[var(--text-main)] font-semibold select-all truncate block" title={paper.doi || '无'}>
{paper.doi ? (
<a
href={`https://doi.org/${paper.doi}`}
target="_blank"
rel="noreferrer"
onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})}
className="text-[var(--accent-blueprint)] hover:underline"
>
{paper.doi}
</a>
) : '无'}
</span>
</div>
<div className="bg-[#f8fafc] px-2.5 py-1.5 rounded border border-[var(--border-precision)]">
<span className="text-[var(--text-muted)] font-bold block">ARXIV ID</span>
<span className="text-[var(--text-main)] font-semibold select-all truncate block" title={paper.arxiv_id || '无'}>
{paper.arxiv_id ? (
<a
href={`https://arxiv.org/abs/${paper.arxiv_id}`}
target="_blank"
rel="noreferrer"
onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})}
className="text-[var(--accent-blueprint)] hover:underline"
>
{paper.arxiv_id}
</a>
) : '无'}
</span>
</div>
</div>
{/* 手动上传文件(应对防爬阻断) */}
<div className="border-t border-slate-100 pt-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[var(--text-muted)] font-bold block">线</span>
<span className="text-[9px] text-amber-700 font-bold bg-amber-50 px-2 py-0.5 rounded border border-amber-200">/</span>
</div>
<p className="text-[10px] text-[var(--text-muted)] leading-relaxed">
PDF HTML
</p>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col items-center justify-center border border-dashed border-[var(--border-precision)] rounded-lg p-2 hover:bg-[#f8fafc] transition-colors relative cursor-pointer group min-h-[50px]">
<input
type="file"
accept="application/pdf"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleManualUpload(paper.bibcode, 'pdf', file);
}}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
disabled={uploadingBibcode === paper.bibcode}
/>
{paper.has_pdf && (
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-600 bg-emerald-50 border border-emerald-200 px-1 py-0.2 rounded select-none pointer-events-none z-10">
</span>
)}
<span className="text-[10px] font-bold text-[var(--accent-blueprint)] group-hover:underline">
{uploadingBibcode === paper.bibcode ? '上传中...' : '上传 PDF 文献'}
</span>
<span className="text-[8px] text-[var(--text-muted)]"> .pdf </span>
</div>
<div className="flex flex-col items-center justify-center border border-dashed border-[var(--border-precision)] rounded-lg p-2 hover:bg-[#f8fafc] transition-colors relative cursor-pointer group min-h-[50px]">
<input
type="file"
accept="text/html,.html"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleManualUpload(paper.bibcode, 'html', file);
}}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
disabled={uploadingBibcode === paper.bibcode}
/>
{paper.has_html && (
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-600 bg-emerald-50 border border-emerald-200 px-1 py-0.2 rounded select-none pointer-events-none z-10">
</span>
)}
<span className="text-[10px] font-bold text-[var(--accent-blueprint)] group-hover:underline">
{uploadingBibcode === paper.bibcode ? '上传中...' : '上传 HTML 文献'}
</span>
<span className="text-[8px] text-[var(--text-muted)]"> .html </span>
</div>
</div>
{!paper.is_downloaded && (
<div className="pt-1">
{paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource' ? (
<button
type="button"
onClick={() => handleMarkNoResource(paper.bibcode, true)}
className="btn-console btn-console-secondary w-full py-2 rounded-lg text-[10px] font-bold transition-all cursor-pointer flex items-center justify-center gap-1.5"
>
<RefreshCw className="w-3 h-3" /> ()
</button>
) : (
<button
type="button"
onClick={() => handleMarkNoResource(paper.bibcode, false)}
className="btn-console btn-console-secondary w-full py-2 rounded-lg text-[10px] font-bold transition-all cursor-pointer flex items-center justify-center gap-1.5"
>
<AlertTriangle className="w-3 h-3 text-[var(--accent-star)]" /> ()
</button>
)}
</div>
)}
</div>
{/* 自动下载失败诊断 */}
{!paper.is_downloaded && (paper.pdf_error || paper.html_error) && (
<div className={`rounded-lg p-3 text-[10px] space-y-1 ${
(paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource')
? 'bg-amber-50 border border-amber-150 text-amber-850'
: 'bg-rose-50 border border-rose-150 text-rose-850'
}`}>
{paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource' ? (
<div className="leading-relaxed">
<div className="font-bold flex items-center gap-1.5 text-amber-900 text-xs mb-1">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
</div>
</div>
) : (
<>
<div className="font-bold flex items-center gap-1.5 text-rose-900">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500 animate-pulse" />
</div>
{paper.pdf_error && (
<div className="leading-relaxed">
<span className="font-semibold text-rose-750">PDF : </span>
{paper.pdf_error}
</div>
)}
{paper.html_error && (
<div className="leading-relaxed mt-0.5">
<span className="font-semibold text-rose-750">HTML : </span>
{paper.html_error}
</div>
)}
</>
)}
</div>
)}
</div>
{/* 底部操作:整合所有动作(阅读、图谱、下载) */}
<div className="flex flex-wrap gap-2 pt-3 border-t border-slate-100">
{paper.is_downloaded ? (
<>
<button
onClick={() => {
onClose();
openReader(paper);
}}
className="flex-1 btn-console btn-console-primary py-2.5 rounded-lg text-xs font-bold text-center cursor-pointer"
>
</button>
<button
onClick={() => {
onClose();
setSelectedPaper(paper);
setActiveTab('citation');
loadCitations(paper.bibcode);
}}
className="flex-1 btn-console btn-console-secondary py-2.5 rounded-lg text-xs font-bold text-center cursor-pointer"
>
</button>
<button
onClick={() => {
showConfirm('确定要强制重新下载吗?这会覆盖本地文件。', () => {
handleDownload(paper.bibcode, true);
}, '确认重新下载');
}}
disabled={downloadingBibcodes[paper.bibcode]}
className="px-4 btn-console btn-console-secondary text-[var(--accent-star)] py-2.5 rounded-lg text-xs font-bold transition-all cursor-pointer disabled:opacity-50"
>
{downloadingBibcodes[paper.bibcode] ? '重下中...' : '重新下载'}
</button>
</>
) : (
<>
<button
onClick={() => {
handleDownload(paper.bibcode);
}}
disabled={downloadingBibcodes[paper.bibcode]}
className="flex-1 btn-console btn-console-primary py-2.5 rounded-lg text-xs font-bold flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-50"
>
{downloadingBibcodes[paper.bibcode] ? (
<>
<Loader className="w-3.5 h-3.5 animate-spin" />
<span>...</span>
</>
) : (
<>
<Download className="w-3.5 h-3.5" />
<span></span>
</>
)}
</button>
<button
onClick={() => {
onClose();
setSelectedPaper(paper);
setActiveTab('citation');
loadCitations(paper.bibcode);
}}
className="px-6 btn-console btn-console-secondary py-2.5 rounded-lg text-xs font-bold text-center cursor-pointer"
>
</button>
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,73 @@
import axios from 'axios';
interface UncachedPaperModalProps {
bibcode: string | null;
onClose: () => void;
loadCitations: (bibcode: string, reset: boolean) => Promise<void>;
}
export function UncachedPaperModal({ bibcode, onClose, loadCitations }: UncachedPaperModalProps) {
if (!bibcode) return null;
return (
<div
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-xs transition-all animate-fade-in"
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-[var(--bg-card)] rounded-xl border border-[var(--border-precision)] shadow-md max-w-sm w-full p-6 space-y-4"
>
<div className="flex items-center justify-between border-b border-slate-100 pb-2.5">
<h3 className="text-xs font-bold text-[var(--text-main)] flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[var(--accent-star)] animate-pulse" />
<span></span>
</h3>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 p-1 rounded-lg hover:bg-slate-100 transition-colors cursor-pointer"
title="关闭"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-2">
<p className="text-xs text-[var(--text-main)] leading-relaxed">
<span className="font-mono bg-slate-100 px-1.5 py-0.5 rounded text-[var(--accent-blueprint)] font-bold select-all">{bibcode}</span>
</p>
<p className="text-[11px] text-[var(--text-muted)] leading-relaxed">
线 NASA ADS
</p>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => {
loadCitations(bibcode, false);
onClose();
}}
className="flex-1 btn-console btn-console-primary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer"
>
</button>
<button
onClick={() => {
axios.post('/api/active_bibcode', { bibcode }).catch(() => {});
window.open(`https://ui.adsabs.harvard.edu/abs/${bibcode}/abstract`, '_blank');
}}
className="flex-1 btn-console btn-console-secondary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer"
>
ADS
</button>
<button
onClick={onClose}
className="px-3 btn-console btn-console-secondary py-2 rounded-lg text-[11px] font-bold text-center cursor-pointer"
>
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,448 @@
// dashboard/src/components/reader/BilingualViewer.tsx
import { createPortal } from 'react-dom';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeKatex from 'rehype-katex';
import rehypeSanitize from 'rehype-sanitize';
import 'katex/dist/katex.min.css';
import { FileText, Loader, Languages, BookOpen } from 'lucide-react';
import type { StandardPaper, NoteRecord } from '../../types';
import { NOTE_COLORS, safeSchema } from '../../hooks/useReaderState';
import { highlightTargetsInMarkdown, type HighlightCandidate, type TargetInfo } from '../../utils/celestial';
import { parseMarkdownFrontMatter, type PaperMetadata } from '../../utils/paper';
interface BilingualViewerProps {
selectedPaper: StandardPaper;
parsing: boolean;
translating: boolean;
englishText: string;
chineseText: string;
notes: NoteRecord[];
handleTextSelection: (paragraphIdx: number) => void;
showNotesPanel: boolean;
// 来自 useReaderState 的状态和 Ref
viewMode: 'bilingual' | 'english' | 'chinese';
showPdf: boolean;
setShowPdf: (show: boolean) => void;
englishRef: React.RefObject<HTMLDivElement | null>;
chineseRef: React.RefObject<HTMLDivElement | null>;
handleEnglishScroll: () => void;
handleChineseScroll: () => void;
handleMouseOver: (e: React.MouseEvent<HTMLDivElement>) => void;
clearHoverTimeout: () => void;
handleContainerMouseLeave: () => void;
handleMouseLeaveTarget: () => void;
highlightCandidates: HighlightCandidate[];
hoveredTarget: TargetInfo | null;
hoverCardPos: { x: number; y: number; isAbove?: boolean } | null;
children?: React.ReactNode;
}
/**
* 极具学术控制台质感的文献元数据档案(Metadata Dossier)渲染面板
*/
function PaperMetadataDossier({ metadata, isChinese }: { metadata: PaperMetadata; isChinese?: boolean }) {
if (!metadata || (!metadata.title && !metadata.author)) return null;
return (
<div className="mb-6 rounded-xl p-5 bg-gradient-to-br from-slate-50 to-slate-100 border border-slate-200 shadow-xs relative overflow-hidden select-text">
{/* 科技风背景网格点缀 */}
<div className="absolute top-0 right-0 w-24 h-24 bg-sky-550/5 rounded-bl-full pointer-events-none border-l border-b border-sky-500/10" />
{/* 顶部档案标徽 */}
<div className="flex items-center gap-2 mb-3 select-none">
<span className="inline-flex items-center px-2 py-0.5 rounded text-[9px] font-extrabold bg-sky-600 text-white uppercase tracking-wider">
{isChinese ? '文献元数据档案' : 'Paper Metadata Dossier'}
</span>
{metadata.date && (
<span className="text-[10px] font-bold text-slate-400 font-mono">
Year: {metadata.date}
</span>
)}
</div>
{/* 标题 */}
<h1 className="text-sm font-bold text-slate-900 leading-snug mb-3 select-text">
{metadata.title}
</h1>
{/* 作者群 */}
{metadata.author && metadata.author.length > 0 && (
<div className="text-xs text-slate-600 mb-4 font-medium leading-relaxed select-text">
<span className="text-slate-400 font-semibold mr-1">{isChinese ? '作者群体:' : 'Authors:'}</span>
<span className="italic text-slate-750">{metadata.author.join(', ')}</span>
</div>
)}
{/* 底部期刊与跳转 ADS 按钮 */}
<div className="flex flex-wrap items-center justify-between gap-3 pt-3 border-t border-slate-200/60 select-none">
<div className="text-[10px] text-slate-500 font-semibold">
<span className="text-slate-400 mr-1">{isChinese ? '发表期刊/预印本:' : 'Publisher:'}</span>
<span className="text-slate-700">{metadata.publisher || 'arXiv Preprint'}</span>
</div>
{metadata.source && (
<a
href={metadata.source}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[10px] font-extrabold px-3 py-1 rounded bg-sky-50 text-sky-700 border border-sky-200 hover:bg-sky-100 hover:text-sky-850 transition-all cursor-pointer shadow-xs active:scale-95"
title="在 NASA ADS 官方文献数据库中查看源页面"
>
<span>ADS NASA Portal</span>
<span className="text-[8px]"></span>
</a>
)}
</div>
</div>
);
}
export function BilingualViewer({
selectedPaper,
parsing,
translating,
englishText,
chineseText,
notes,
handleTextSelection,
showNotesPanel,
viewMode,
showPdf,
setShowPdf,
englishRef,
chineseRef,
handleEnglishScroll,
handleChineseScroll,
handleMouseOver,
clearHoverTimeout,
handleContainerMouseLeave,
handleMouseLeaveTarget,
highlightCandidates,
hoveredTarget,
hoverCardPos,
children,
}: BilingualViewerProps) {
// 解析英文和中文段落的 Front Matter 头部元数据
const { metadata: engMeta, pureMarkdown: engPure } = parseMarkdownFrontMatter(englishText);
const { metadata: chnMeta, pureMarkdown: chnPure } = parseMarkdownFrontMatter(chineseText);
// 智能合并元数据,以防中文翻译中部分字段丢失或解析失败时,可优雅复用英文侧的数据进行降级补充
const mergedChnMeta = (() => {
if (!engMeta && !chnMeta) return null;
if (!engMeta) return chnMeta;
if (!chnMeta) return engMeta;
return {
title: chnMeta.title || engMeta.title,
author: (chnMeta.author && chnMeta.author.length > 0) ? chnMeta.author : engMeta.author,
publisher: chnMeta.publisher || engMeta.publisher,
source: chnMeta.source || engMeta.source,
date: chnMeta.date || engMeta.date,
tags: chnMeta.tags || engMeta.tags,
};
})();
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')
}}
>
{/* 英文正文视窗 (或双语对照) */}
{(viewMode === 'english' || viewMode === 'bilingual') && (
<div
onMouseOver={handleMouseOver}
onMouseLeave={handleContainerMouseLeave}
className="console-panel rounded-xl p-6 bg-white border border-slate-200 relative flex flex-col min-h-0 overflow-hidden"
>
<div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2.5 shrink-0 select-none">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-slate-800"></span>
{selectedPaper.has_pdf && (
<button
type="button"
onClick={() => setShowPdf(!showPdf)}
className="text-[9px] font-bold px-2 py-0.5 rounded bg-sky-50 text-sky-700 border border-sky-200 hover:bg-sky-100 transition-all cursor-pointer flex items-center gap-1"
>
{showPdf ? (
<>
<FileText className="w-3 h-3" />
</>
) : (
<>
<BookOpen className="w-3 h-3" /> PDF
</>
)}
</button>
)}
</div>
</div>
{showPdf ? (
<div className="flex-1 min-h-0 w-full rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
<iframe
src={`/api/files/PDF/${selectedPaper.bibcode}.pdf#navpanes=0&pagemode=none&view=FitH`}
className="w-full h-full border-none"
title="文献 PDF"
/>
</div>
) : parsing ? (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-3">
<Loader className="w-8 h-8 animate-spin text-sky-600" />
<p className="text-xs font-bold text-center"> Markdown ...</p>
</div>
) : engPure ? (
<div
ref={englishRef}
onScroll={handleEnglishScroll}
className="flex-1 overflow-y-auto pr-1 scrollbar-thin"
>
<div className="prose prose-sm max-w-none leading-relaxed text-slate-800 prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-blockquote:border-slate-300 prose-blockquote:text-slate-500 prose-img:max-w-full prose-img:rounded-lg">
{/* 渲染英文元数据档案面板 */}
{engMeta && <PaperMetadataDossier metadata={engMeta} isChinese={false} />}
{engPure.split('\n\n').map((para, idx) => {
const matchedNote = notes.find(n => n.paragraph_index === idx);
const highlightStyle = matchedNote ? NOTE_COLORS[matchedNote.highlight_color] : null;
return (
<div
key={idx}
id={`paragraph-block-${idx}`}
onMouseUp={() => handleTextSelection(idx)}
className={`cursor-text relative rounded px-1.5 -mx-1.5 py-1 transition-colors duration-200 ${
highlightStyle
? `${highlightStyle.bg} ${highlightStyle.border} border`
: 'hover:bg-slate-100'
}`}
>
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, safeSchema], rehypeKatex]}
components={{
a: ({ href, children, ...props }) => {
if (href && href.startsWith('#celestial-')) {
const targetName = decodeURIComponent(href.slice('#celestial-'.length));
return (
<span
className="celestial-target cursor-pointer font-bold text-sky-600 underline decoration-dotted decoration-2 hover:text-sky-850"
data-target-name={targetName}
>
{children}
</span>
);
}
return (
<a href={href} className="text-sky-600 hover:underline" {...props}>
{children}
</a>
);
}
}}
>
{highlightTargetsInMarkdown(para, highlightCandidates)}
</ReactMarkdown>
</div>
);
})}
</div>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 select-none">
<FileText className="w-12 h-12 mb-3 text-slate-300" />
<p className="text-xs font-bold text-slate-500"></p>
<p className="text-xs text-slate-400 mt-1"></p>
</div>
)}
</div>
)}
{/* 中文翻译视窗 (或双语对照) */}
{(viewMode === 'chinese' || viewMode === 'bilingual') && (
<div
className="console-panel rounded-xl p-6 bg-white border border-slate-200 relative flex flex-col overflow-hidden min-h-0"
>
<div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2.5 shrink-0 select-none">
<span className="text-xs font-bold text-slate-800"></span>
</div>
{translating ? (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-3">
<Loader className="w-8 h-8 animate-spin text-sky-600" />
<p className="text-xs font-bold text-center">...</p>
</div>
) : chnPure ? (
<div
ref={chineseRef}
onScroll={handleChineseScroll}
className="flex-1 overflow-y-auto pr-1 scrollbar-thin"
>
<div className="prose prose-sm max-w-none leading-relaxed text-slate-800 prose-headings:text-slate-900 prose-strong:text-slate-900 prose-code:text-sky-700 prose-blockquote:border-slate-350 prose-img:max-w-full prose-img:rounded-lg">
{/* 渲染中文元数据档案面板 */}
{mergedChnMeta && <PaperMetadataDossier metadata={mergedChnMeta} isChinese={true} />}
{chnPure.split('\n\n').map((para, idx) => (
<div
key={idx}
id={`paragraph-block-${idx}`}
className="relative rounded px-1.5 -mx-1.5 py-1 transition-colors duration-200 hover:bg-slate-100"
>
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, safeSchema], rehypeKatex]}
>
{para}
</ReactMarkdown>
</div>
))}
</div>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 select-none">
<Languages className="w-12 h-12 mb-3 text-slate-300" />
<p className="text-xs font-bold text-slate-500"></p>
<p className="text-xs text-slate-400 mt-1"></p>
</div>
)}
</div>
)}
{children}
{/* 天体详情悬浮卡片 (React Portal 挂载在 body 顶层防止裁剪) */}
{hoveredTarget && hoverCardPos && createPortal(
<div
onMouseEnter={clearHoverTimeout}
onMouseLeave={handleMouseLeaveTarget}
style={{
position: 'fixed',
left: `${hoverCardPos.x}px`,
top: `${hoverCardPos.y}px`,
transform: hoverCardPos.isAbove ? 'translateY(-100%)' : 'none',
zIndex: 9999,
}}
className="console-panel rounded-xl p-4 bg-white border border-slate-200 shadow-md w-72 text-xs space-y-2 pointer-events-auto select-text animate-in fade-in duration-200"
>
<div className="flex items-center justify-between border-b border-slate-100 pb-1.5">
<span className="font-bold text-slate-950 text-sm">{hoveredTarget.target_name}</span>
<div className="flex items-center gap-2">
<a
href={`https://simbad.cds.unistra.fr/simbad/sim-id?Ident=${encodeURIComponent(hoveredTarget.target_name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] font-bold text-sky-600 hover:text-sky-850 hover:underline cursor-pointer"
title="在 SIMBAD 中查询该天体详情"
>
SIMBAD
</a>
<span className="text-[9px] text-slate-300">|</span>
<a
href={`https://vizier.cds.unistra.fr/viz-bin/VizieR?-c=${encodeURIComponent(hoveredTarget.target_name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] font-bold text-sky-600 hover:text-sky-850 hover:underline cursor-pointer"
title="在 VizieR 中查询相关文献和表数据"
>
VizieR
</a>
</div>
</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 text-slate-500">
{/* 天体类型 & 官方名称 */}
<div className="col-span-2 flex items-center gap-2">
{hoveredTarget.otype && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200 text-[10px] font-bold font-mono">
{hoveredTarget.otype}
</span>
)}
{hoveredTarget.oname && hoveredTarget.oname !== hoveredTarget.target_name && (
<span className="text-[10px] text-slate-400 font-medium truncate">
aka {hoveredTarget.oname}
</span>
)}
</div>
{/* 坐标 */}
<div>
<span className="text-slate-400 font-semibold">RA (J2000):</span>
<div className="font-mono font-bold text-slate-800">{hoveredTarget.ra || '未知'}</div>
</div>
<div>
<span className="text-slate-400 font-semibold">Dec (J2000):</span>
<div className="font-mono font-bold text-slate-800">{hoveredTarget.dec || '未知'}</div>
</div>
{/* 光谱型 & 视星等 */}
<div>
<span className="text-slate-400 font-semibold">:</span>
<div className="font-bold text-slate-800">{hoveredTarget.spectral_type || '未知'}</div>
</div>
<div>
<span className="text-slate-400 font-semibold"> (V):</span>
<div className="font-bold text-slate-800">
{hoveredTarget.v_magnitude !== null && hoveredTarget.v_magnitude !== undefined
? `${hoveredTarget.v_magnitude.toFixed(2)}`
: '未知'}
</div>
</div>
{/* 视差 + 误差 + 估算距离 */}
<div className="col-span-2">
<span className="text-slate-400 font-semibold"> / :</span>
<div className="font-bold text-slate-800">
{hoveredTarget.parallax !== null && hoveredTarget.parallax !== undefined
? `${hoveredTarget.parallax.toFixed(2)}${hoveredTarget.parallax_err != null ? `±${hoveredTarget.parallax_err.toFixed(4)}` : ''} mas (~${(1000.0 / hoveredTarget.parallax).toFixed(1)} pc)`
: '未知'}
</div>
</div>
{/* 自行 */}
<div className="col-span-2">
<span className="text-slate-400 font-semibold">:</span>
<div className="font-bold text-slate-800">
{hoveredTarget.pm_ra != null && hoveredTarget.pm_de != null
? `RA ${hoveredTarget.pm_ra.toFixed(3)}, Dec ${hoveredTarget.pm_de.toFixed(3)} mas/yr`
: '未知'}
</div>
</div>
{/* 视向速度 */}
<div className="col-span-2">
<span className="text-slate-400 font-semibold">:</span>
<div className="font-bold text-slate-800">
{hoveredTarget.radial_velocity != null
? `${hoveredTarget.radial_velocity.toFixed(1)} km/s`
: '未知'}
</div>
</div>
</div>
{/* 多波段测光 */}
{hoveredTarget.photometry && Object.keys(hoveredTarget.photometry).length > 1 && (
<div className="border-t border-slate-100 pt-1.5">
<span className="text-slate-400 font-semibold block mb-1">:</span>
<div className="flex flex-wrap gap-x-2 gap-y-0.5 text-[10px] font-mono text-slate-600">
{Object.entries(hoveredTarget.photometry).map(([band, mag]) => (
<span key={band} className="whitespace-nowrap">
<span className="text-slate-400">{band}</span>{' '}
<span className="font-bold text-slate-700">{mag.toFixed(3)}</span>
</span>
))}
</div>
</div>
)}
{hoveredTarget.aliases && hoveredTarget.aliases.length > 0 && (
<div className="border-t border-slate-100 pt-1.5">
<span className="text-slate-400 font-semibold block mb-0.5">:</span>
<div className="text-[10px] text-slate-500 font-medium leading-relaxed max-h-[36px] overflow-y-auto scrollbar-thin font-mono">
{hoveredTarget.aliases.slice(0, 8).join(', ')}
{hoveredTarget.aliases.length > 8 && ' ...'}
</div>
</div>
)}
</div>,
document.body
)}
</div>
);
}
@@ -0,0 +1,285 @@
// dashboard/src/components/reader/ReaderNotesSidebar.tsx
import React from 'react';
import { X, Loader, Sparkles, PlusCircle, Pencil, Trash2 } from 'lucide-react';
import type { StandardPaper, NoteRecord } from '../../types';
import { AIAssistantPanel } from '../AIAssistantPanel';
import { NOTE_COLORS } from '../../hooks/useReaderState';
import { type TargetInfo } from '../../utils/celestial';
interface ReaderNotesSidebarProps {
selectedPaper: StandardPaper;
showNotesPanel: boolean;
setShowNotesPanel: (show: boolean) => void;
notes: NoteRecord[];
selectedParagraphIdx: number | null;
setSelectedParagraphIdx: (idx: number | null) => void;
selectedText: string;
setSelectedText: (text: string) => void;
newNoteColor: string;
setNewNoteColor: (color: string) => void;
newNoteText: string;
setNewNoteText: (text: string) => void;
handleCreateNote: () => void;
handleDeleteNote: (id: number) => void;
onJumpToSource: (bibcode: string, paragraphIndex: number) => void;
// 来自 useReaderState 的状态和处理器
sidebarTab: 'notes' | 'ai';
setSidebarTab: (tab: 'notes' | 'ai') => void;
targets: TargetInfo[];
loadingTargets: boolean;
identifyingTargets: boolean;
associatingTarget: boolean;
manualTargetName: string;
setManualTargetName: (name: string) => void;
handleIdentifyTargets: (bibcode: string) => Promise<void>;
handleAssociateTarget: (e: React.FormEvent) => Promise<void>;
handleJumpToTarget: (target: TargetInfo) => void;
}
export function ReaderNotesSidebar({
selectedPaper,
showNotesPanel,
setShowNotesPanel,
notes,
selectedParagraphIdx,
setSelectedParagraphIdx,
selectedText,
setSelectedText,
newNoteColor,
setNewNoteColor,
newNoteText,
setNewNoteText,
handleCreateNote,
handleDeleteNote,
onJumpToSource,
sidebarTab,
setSidebarTab,
targets,
loadingTargets,
identifyingTargets,
associatingTarget,
manualTargetName,
setManualTargetName,
handleIdentifyTargets,
handleAssociateTarget,
handleJumpToTarget,
}: ReaderNotesSidebarProps) {
if (!showNotesPanel) return null;
return (
<div className="console-panel rounded-xl 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>
{/* 标签栏选择器 */}
<div className="flex border-b border-slate-200 bg-white select-none shrink-0">
<button
type="button"
onClick={() => setSidebarTab('notes')}
className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${
sidebarTab === 'notes'
? 'border-sky-600 text-sky-700'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
({notes.length})
</button>
<button
type="button"
onClick={() => setSidebarTab('ai')}
className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${
sidebarTab === 'ai'
? 'border-sky-600 text-sky-700'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
AI
</button>
</div>
{/* 根据 Tab 渲染内容 */}
{sidebarTab === 'notes' ? (
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
{/* 天体标识符列表 */}
<div className="px-4 py-3 border-b border-slate-200 bg-white space-y-2 shrink-0">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 tracking-wider uppercase"> (CDS)</span>
<div className="flex items-center gap-1.5 select-none">
{(loadingTargets || identifyingTargets) && <Loader className="w-3 h-3 animate-spin text-slate-400" />}
{selectedPaper.has_markdown && (
<button
type="button"
onClick={() => handleIdentifyTargets(selectedPaper.bibcode)}
disabled={identifyingTargets || loadingTargets}
className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-sky-50 text-sky-700 border border-sky-200 hover:bg-sky-100 transition-all cursor-pointer flex items-center gap-0.5"
title="从文献正文中自动识别天体目标并查询 CDS Sesame"
>
<Sparkles className="w-2.5 h-2.5" />
{targets.length > 0 ? '重新识别' : '自动识别'}
</button>
)}
</div>
</div>
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto pr-1 scrollbar-thin">
{targets.length === 0 ? (
<span className="text-[10px] text-slate-400 italic"></span>
) : (
targets.map((t, idx) => (
<button
key={idx}
type="button"
onClick={() => handleJumpToTarget(t)}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-sky-50 hover:bg-sky-100 text-sky-700 hover:text-sky-900 border border-sky-100 hover:border-sky-200 text-[10px] font-bold cursor-pointer transition-all hover:scale-105 active:scale-95"
title={
[
`类型: ${t.otype || '?'}`,
`RA: ${t.ra || '?'} Dec: ${t.dec || '?'}`,
t.parallax != null
? `视差: ${t.parallax}${t.parallax_err != null ? `±${t.parallax_err}` : ''} mas`
: '视差: ?',
`V星等: ${t.v_magnitude != null ? t.v_magnitude : '?'}`,
t.pm_ra != null && t.pm_de != null
? `自行: RA ${t.pm_ra}, Dec ${t.pm_de} mas/yr`
: '自行: ?',
`视向速度: ${t.radial_velocity != null ? t.radial_velocity + ' km/s' : '?'}`,
`官方名: ${t.oname || t.target_name}`,
`别名: ${t.aliases?.join(', ') || '无'}`,
].join('\n')
}
>
{t.target_name}
{t.spectral_type && <span className="text-slate-400 font-medium font-mono">({t.spectral_type})</span>}
</button>
))
)}
</div>
{/* 手动关联输入框 */}
<form onSubmit={handleAssociateTarget} className="flex gap-1.5 mt-2">
<input
type="text"
value={manualTargetName}
onChange={e => setManualTargetName(e.target.value)}
placeholder="手动关联天体(如 M 31)..."
className="flex-1 bg-slate-50 border border-slate-200 rounded px-2.5 py-1 text-[10px] text-slate-900 placeholder-slate-400 leading-relaxed font-semibold focus:outline-none focus:bg-white focus:border-sky-500 focus:ring-1 focus:ring-sky-500/10"
/>
<button
type="submit"
disabled={!manualTargetName.trim() || associatingTarget}
className="btn-console btn-console-primary px-2.5 py-1 rounded text-[10px] font-bold disabled:opacity-50 cursor-pointer"
>
{associatingTarget ? '...' : '关联'}
</button>
</form>
</div>
{/* 新建笔记输入区 */}
{selectedParagraphIdx !== null && (
<div className="px-4 py-4 border-b border-slate-200 bg-white space-y-3 shrink-0">
<div className="text-xs font-bold text-slate-700"> #{selectedParagraphIdx + 1}</div>
{selectedText && (
<div className="text-xs text-slate-600 italic line-clamp-2 bg-slate-55 px-2.5 py-1.5 rounded border border-slate-200">
"{selectedText}"
</div>
)}
{/* 颜色标记 */}
<div className="flex gap-2 items-center">
<span className="text-xs text-slate-500 font-semibold">:</span>
<div className="flex gap-1.5">
{Object.entries(NOTE_COLORS).map(([color, style]) => {
const s = style as { bg: string; label: string };
return (
<button
key={color}
onClick={() => setNewNoteColor(color)}
className={`w-4 h-4 rounded-full border transition-transform ${
newNoteColor === color ? 'border-slate-800 scale-120 shadow-sm' : 'border-transparent'
} ${s.bg.replace('border-cyan-200', '')}`}
title={s.label}
/>
);
})}
</div>
</div>
<textarea
value={newNoteText}
onChange={e => setNewNoteText(e.target.value)}
placeholder="记录段落心得或重点..."
rows={3}
className="w-full bg-white border border-slate-300 rounded-lg text-xs text-slate-900 placeholder-slate-400 px-3 py-2 resize-none focus:outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-500/10 leading-relaxed"
/>
<div className="flex gap-2">
<button
onClick={handleCreateNote}
className="btn-console btn-console-primary flex-1 px-3 py-1.5 rounded-lg text-xs font-bold flex items-center justify-center gap-1"
>
<PlusCircle className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { setSelectedParagraphIdx(null); setSelectedText(''); setNewNoteText(''); }}
className="btn-console btn-console-secondary px-3 py-1.5 rounded-lg text-xs font-bold"
>
</button>
</div>
</div>
)}
{/* 笔记列表 */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 min-h-0 scrollbar-thin">
{notes.length === 0 ? (
<div className="text-center text-slate-400 text-xs py-12 space-y-2 select-none">
<Pencil className="w-8 h-8 mx-auto opacity-30 text-slate-500" />
<div></div>
</div>
) : (
notes.map(note => {
const style = NOTE_COLORS[note.highlight_color] || NOTE_COLORS.cyan;
return (
<div
key={note.id}
className="p-4 rounded-lg border text-xs bg-white border-slate-200 relative group overflow-hidden"
>
{/* 彩色左标记条 */}
<div className={`absolute left-0 top-0 w-1 h-full ${style.bg.split(' ')[0]}`} />
<div className="text-slate-500 text-[10px] font-bold mb-1"> #{note.paragraph_index + 1}</div>
{note.selected_text && (
<div className="text-slate-505 italic line-clamp-2 mb-2 border-l-2 border-slate-200 pl-2">
"{note.selected_text}"
</div>
)}
<p className="text-slate-800 leading-relaxed font-medium">{note.note_text}</p>
<div className="flex items-center justify-between mt-3 border-t border-slate-100 pt-2 text-[10px] select-none">
<span className="text-slate-400 font-semibold">{note.created_at.split('T')[0]}</span>
<button
onClick={() => handleDeleteNote(note.id)}
className="text-slate-400 hover:text-red-600 transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})
)}
</div>
</div>
) : (
<div className="flex-1 overflow-hidden h-full">
<AIAssistantPanel
bibcode={selectedPaper.bibcode}
onJumpToSource={onJumpToSource}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,314 @@
// dashboard/src/components/reader/ReaderToolbar.tsx
import { FileText, Loader, Languages, RotateCw, BookOpen, Sparkles, ChevronDown, History } from 'lucide-react';
import type { StandardPaper } from '../../types';
interface ReaderToolbarProps {
selectedPaper: StandardPaper;
library: StandardPaper[];
recentlySelected: StandardPaper[];
onSwitchPaper: (paper: StandardPaper) => void;
parsing: boolean;
handleParse: (bibcode: string, force?: boolean) => void;
translating: boolean;
handleTranslate: (bibcode: string, force?: boolean) => void;
vectorizing: boolean;
handleVectorize: (bibcode: string) => void;
showNotesPanel: boolean;
setShowNotesPanel: (show: boolean) => void;
notesCount: number;
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
englishText: string;
chineseText: string;
// 来自 useReaderState 的状态
showSwitchMenu: boolean;
setShowSwitchMenu: (show: boolean) => void;
viewMode: 'bilingual' | 'english' | 'chinese';
setViewMode: (mode: 'bilingual' | 'english' | 'chinese') => void;
syncScroll: boolean;
setSyncScroll: (sync: boolean) => void;
showPdf: boolean;
}
export function ReaderToolbar({
selectedPaper,
library,
recentlySelected,
onSwitchPaper,
parsing,
handleParse,
translating,
handleTranslate,
vectorizing,
handleVectorize,
showNotesPanel,
setShowNotesPanel,
notesCount,
showConfirm,
englishText,
chineseText,
showSwitchMenu,
setShowSwitchMenu,
viewMode,
setViewMode,
syncScroll,
setSyncScroll,
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">
<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">
<span>: {selectedPaper.pub_journal || '未标注'}</span>
<span></span>
<span>: {selectedPaper.bibcode}</span>
</div>
</div>
<div className="flex gap-2 items-center relative">
{/* 快速切换文献菜单 */}
<div className="relative shrink-0">
<button
type="button"
onClick={() => setShowSwitchMenu(!showSwitchMenu)}
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2 transition-all cursor-pointer shrink-0"
>
<BookOpen className="w-3.5 h-3.5 text-sky-600" />
<span></span>
<ChevronDown className={`w-3 h-3 transition-transform ${showSwitchMenu ? 'rotate-180' : ''}`} />
</button>
{showSwitchMenu && (
<>
<div className="fixed inset-0 z-45" onClick={() => setShowSwitchMenu(false)} />
<div className="absolute right-0 mt-1.5 w-72 rounded-xl bg-white border border-slate-200 shadow-xl py-1.5 z-50 text-xs max-h-96 overflow-y-auto scrollbar-thin">
{/* 最近阅读部分 */}
{recentlySelected.length > 0 && (
<div className="border-b border-slate-100 pb-1.5 mb-1.5">
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1">
<History className="w-3.5 h-3.5 text-slate-400" />
<span></span>
</div>
{recentlySelected.map(paper => (
<button
key={`recent-${paper.bibcode}`}
onClick={() => {
onSwitchPaper(paper);
setShowSwitchMenu(false);
}}
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
paper.bibcode === selectedPaper.bibcode
? 'border-sky-500 bg-sky-50/30 text-sky-850 font-bold'
: 'border-transparent text-slate-700 font-medium'
}`}
>
<span className="truncate block leading-tight text-left">{paper.title}</span>
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">{paper.bibcode}</span>
</button>
))}
</div>
)}
{/* 全部馆藏已下载文献 */}
<div>
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1">
<FileText className="w-3.5 h-3.5 text-slate-400" />
<span></span>
</div>
{library.filter(p => p.is_downloaded).length === 0 ? (
<div className="px-3 py-2 text-slate-400 italic"></div>
) : (
library
.filter(p => p.is_downloaded)
.map(paper => (
<button
key={`lib-${paper.bibcode}`}
onClick={() => {
onSwitchPaper(paper);
setShowSwitchMenu(false);
}}
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
paper.bibcode === selectedPaper.bibcode
? 'border-sky-500 bg-sky-50/30 text-sky-850 font-bold'
: 'border-transparent text-slate-700 font-medium'
}`}
>
<span className="truncate block leading-tight text-left">{paper.title}</span>
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">{paper.bibcode}</span>
</button>
))
)}
</div>
</div>
</>
)}
</div>
{/* 原文/中文/对照视图切换按钮 */}
{(englishText || chineseText) && (
<div className="flex gap-1 bg-slate-100 p-0.5 rounded-lg border border-slate-200">
<button
type="button"
onClick={() => setViewMode('english')}
className={`px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
viewMode === 'english'
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
type="button"
onClick={() => setViewMode('chinese')}
className={`px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
viewMode === 'chinese'
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
type="button"
onClick={() => setViewMode('bilingual')}
className={`hidden md:block px-2.5 py-1 rounded-md text-[10px] sm:text-xs font-bold transition-all cursor-pointer ${
viewMode === 'bilingual'
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
</button>
</div>
)}
{/* 同步滚动开关 */}
{(englishText || chineseText) && viewMode === 'bilingual' && (
<button
type="button"
onClick={() => {
if (showPdf) {
alert("【PDF同步滚动技术限制说明】\n\nPDF 预览采用的是浏览器原生的 PDF 插件沙箱。受底层浏览器跨域安全限制和外部插件机制影响,网页脚本无法直接读取和控制 PDF 的滚动位置,因而无法实现物理同步滚动。\n\n【强烈建议】\n点击左侧面板顶部的「显示正文」按钮切换到解析正文模式。在解析正文模式下,系统将自动激活「自适应双轨滚动同步引擎(方案三)」,提供段落级的高精度无缝对齐同步滚动体验!");
return;
}
setSyncScroll(!syncScroll);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5 border transition-all cursor-pointer ${
showPdf
? 'bg-slate-50 text-slate-400 border-slate-200 cursor-not-allowed opacity-70'
: syncScroll
? 'bg-sky-50 text-sky-700 border-sky-200 shadow-sm'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'
}`}
title={showPdf ? 'PDF预览模式下不支持同步滚动,请点击左侧“显示正文”切换到正文模式' : '开启或关闭中英段落双轨同步滚动'}
>
<span className={`w-1.5 h-1.5 rounded-full ${(!showPdf && syncScroll) ? 'bg-sky-500 animate-pulse' : 'bg-slate-300'}`} />
<span>{showPdf ? 'PDF暂不支持' : syncScroll ? '开启' : '关闭'}</span>
</button>
)}
{/* 1. 正文解析按钮 */}
{!selectedPaper.has_markdown ? (
<button
onClick={() => handleParse(selectedPaper.bibcode)}
disabled={parsing}
className="btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
>
{parsing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <FileText className="w-3.5 h-3.5" />}
{parsing ? '正文结构解析中...' : '解析源文正文'}
</button>
) : (
<button
onClick={() => {
showConfirm('确定要重新解析正文吗?这会覆盖本地已解析的 Markdown。', () => {
handleParse(selectedPaper.bibcode, true);
}, '确认重新解析');
}}
disabled={parsing}
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
title="覆盖本地解析结果,重新从 HTML/PDF 转换为 Markdown"
>
{parsing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />}
</button>
)}
{/* 2. 术语对照翻译按钮 */}
{selectedPaper.has_markdown && !selectedPaper.has_translation && (
<button
onClick={() => handleTranslate(selectedPaper.bibcode)}
disabled={translating}
className="btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
>
{translating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Languages className="w-3.5 h-3.5" />}
{translating ? '天文学术语对比翻译中...' : '生成智能翻译'}
</button>
)}
{selectedPaper.has_translation && (
<button
onClick={() => handleTranslate(selectedPaper.bibcode, true)}
disabled={translating}
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
title="清除翻译缓存并重新生成大模型对照翻译"
>
{translating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />}
</button>
)}
{/* 3. 知识入库按钮 */}
{selectedPaper.has_markdown && !selectedPaper.has_vector && (
<button
onClick={() => handleVectorize(selectedPaper.bibcode)}
disabled={vectorizing}
className="btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
title="对文献进行知识入库,以开启学术 AI 研讨"
>
{vectorizing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
{vectorizing ? '正在进行知识入库...' : '知识入库'}
</button>
)}
{selectedPaper.has_markdown && selectedPaper.has_vector && (
<button
onClick={() => {
showConfirm('确定要重新进行知识入库吗?这会清除先前该文献已有的知识点记录并重新写入。', () => {
handleVectorize(selectedPaper.bibcode);
}, '确认重新知识入库');
}}
disabled={vectorizing}
className="btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2"
title="重新为该文献解析知识点并入库"
>
{vectorizing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />}
</button>
)}
{/* 4. 学术助手按钮 */}
<button
onClick={() => {
setShowNotesPanel(!showNotesPanel);
}}
className={`btn-console px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2 transition-all cursor-pointer ${
showNotesPanel ? 'bg-sky-50 text-sky-700 border-sky-200 shadow-xs' : 'btn-console-secondary'
}`}
title="开启或关闭侧边学术助手(包含阅读手札与 AI 问答)"
>
<Sparkles className="w-3.5 h-3.5 text-amber-500" />
<span></span>
{notesCount > 0 && (
<span className="px-1.5 py-0.2 text-[9px] rounded-full bg-sky-100 text-sky-850 font-extrabold select-none shrink-0">
{notesCount}
</span>
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,280 @@
// dashboard/src/components/sync/BatchPipelineCard.tsx
import React from 'react';
import { Download, AlertTriangle, Play, StopCircle, Loader, FileText, RefreshCw, SlidersHorizontal } from 'lucide-react';
import { CustomSelect } from '../CustomSelect';
import type { BatchStatus } from '../../hooks/useSyncState';
interface BatchPipelineCardProps {
targetPhase: 'download' | 'parse' | 'translate' | 'embed' | 'target';
setTargetPhase: (p: 'download' | 'parse' | 'translate' | 'embed' | 'target') => void;
batchLimitCount: number;
setBatchLimitCount: (c: number) => void;
sortOrder: 'default' | 'pub_year_desc' | 'created_at_desc';
setSortOrder: (o: 'default' | 'pub_year_desc' | 'created_at_desc') => void;
skipCompleted: boolean;
setSkipCompleted: (s: boolean) => void;
skipFailed: boolean;
setSkipFailed: (s: boolean) => void;
skipPrecedingFailed: boolean;
setSkipPrecedingFailed: (s: boolean) => void;
skipPrecedingUncompleted: boolean;
setSkipPrecedingUncompleted: (s: boolean) => void;
batchStatus: BatchStatus;
batchError: string | null;
logsContainerRef: React.RefObject<HTMLDivElement | null>;
handleStartBatch: () => Promise<void>;
handleStopBatch: () => Promise<void>;
}
export function BatchPipelineCard({
targetPhase,
setTargetPhase,
batchLimitCount,
setBatchLimitCount,
sortOrder,
setSortOrder,
skipCompleted,
setSkipCompleted,
skipFailed,
setSkipFailed,
skipPrecedingFailed,
setSkipPrecedingFailed,
skipPrecedingUncompleted,
setSkipPrecedingUncompleted,
batchStatus,
batchError,
logsContainerRef,
handleStartBatch,
handleStopBatch,
}: BatchPipelineCardProps) {
return (
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-6 relative overflow-hidden shadow-xs">
<div className="flex flex-col gap-1 select-none">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<Download className="w-4 h-4 text-sky-600" />
<span>线</span>
</h3>
<p className="text-slate-500 text-xs mt-0.5">
PDF/HTML Markdown
</p>
</div>
{batchError && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>{batchError}</div>
</div>
)}
{/* 条件设置 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<CustomSelect
value={targetPhase}
disabled={batchStatus.active}
onChange={val => setTargetPhase(val as any)}
className="w-full"
options={[
{ value: 'download', label: '下载' },
{ value: 'parse', label: '解析' },
{ value: 'translate', label: '翻译' },
{ value: 'embed', label: '向量化' },
{ value: 'target', label: '天体识别' },
]}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<input
type="number"
value={batchLimitCount}
disabled={batchStatus.active}
onChange={e => setBatchLimitCount(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<CustomSelect
value={sortOrder}
disabled={batchStatus.active}
onChange={val => setSortOrder(val as any)}
className="w-full"
options={[
{ value: 'default', label: '默认(不指定)' },
{ value: 'pub_year_desc', label: '按出版年份降序' },
{ value: 'created_at_desc', label: '按入库时间降序' },
]}
/>
</div>
</div>
{/* 执行策略 */}
<div className="space-y-2 border-t border-slate-100 pt-4">
<label className="text-xs font-bold text-slate-700 block"></label>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 select-none">
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipCompleted}
disabled={batchStatus.active}
onChange={e => setSkipCompleted(e.target.checked)}
className="rounded text-sky-600 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipFailed}
disabled={batchStatus.active}
onChange={e => setSkipFailed(e.target.checked)}
className="rounded text-sky-600 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipPrecedingFailed}
disabled={batchStatus.active}
onChange={e => setSkipPrecedingFailed(e.target.checked)}
className="rounded text-sky-600 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipPrecedingUncompleted}
disabled={batchStatus.active}
onChange={e => setSkipPrecedingUncompleted(e.target.checked)}
className="rounded text-sky-600 focus:ring-sky-500"
/>
<span></span>
</label>
</div>
</div>
{/* 控制按钮 */}
<div className="flex justify-end pt-2 border-t border-slate-100">
<div className="w-full md:w-1/3 flex">
{batchStatus.active ? (
<button
type="button"
onClick={handleStopBatch}
className="w-full py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-xs font-bold flex items-center justify-center gap-2 transition-all shadow-sm cursor-pointer"
>
<StopCircle className="w-3.5 h-3.5" />
</button>
) : (
<button
type="button"
onClick={handleStartBatch}
className="btn-console btn-console-primary w-full py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 cursor-pointer"
>
<Play className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* 进度与终端日志展示 */}
{(batchStatus.active || batchStatus.total > 0) && (
<div className="space-y-4 pt-4 border-t border-slate-200">
<div className="space-y-1.5">
<div className="flex justify-between text-xs font-bold text-slate-600 select-none">
<span className="flex items-center gap-1.5">
{batchStatus.action === 'download' && <Download className="w-3.5 h-3.5 text-sky-600" />}
{batchStatus.action === 'parse' && <FileText className="w-3.5 h-3.5 text-emerald-650" />}
{batchStatus.action === 'translate' && <RefreshCw className="w-3.5 h-3.5 text-indigo-600 animate-spin" />}
{batchStatus.action === 'embed' && <SlidersHorizontal className="w-3.5 h-3.5 text-amber-600" />}
{batchStatus.action === 'target' && <RefreshCw className="w-3.5 h-3.5 text-sky-600" />}
{batchStatus.action === 'download'
? '正文离线下载进度'
: batchStatus.action === 'parse'
? '结构化排版解析进度'
: batchStatus.action === 'translate'
? '中英双语对照翻译进度'
: batchStatus.action === 'embed'
? '段落向量分块入库进度'
: '天体识别与缓存进度'}
</span>
<span>
{batchStatus.action === 'download' ? batchStatus.downloaded : batchStatus.parsed} / {batchStatus.total}
{((batchStatus.action === 'download' && batchStatus.download_failed > 0) ||
(batchStatus.action !== 'download' && batchStatus.parse_failed > 0)) && (
<span className="text-red-500 ml-2 font-bold">
( {batchStatus.action === 'download' ? batchStatus.download_failed : batchStatus.parse_failed} )
</span>
)}
</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-100 overflow-hidden border border-slate-200 select-none">
<div
className={`h-full transition-all duration-300 ${
batchStatus.action === 'download'
? 'bg-sky-600'
: batchStatus.action === 'parse'
? 'bg-emerald-600'
: batchStatus.action === 'translate'
? 'bg-indigo-600'
: batchStatus.action === 'embed'
? 'bg-amber-600'
: 'bg-sky-600'
}`}
style={{
width: `${
batchStatus.total > 0
? Math.min(
100,
Math.round(
((batchStatus.action === 'download'
? batchStatus.downloaded + batchStatus.download_failed
: batchStatus.parsed + batchStatus.parse_failed) /
batchStatus.total) *
100
)
)
: 0
}%`,
}}
/>
</div>
</div>
{batchStatus.active && batchStatus.current_bibcode && (
<div className="text-xs font-bold text-slate-600 flex items-center gap-2">
<Loader className="w-3.5 h-3.5 text-sky-600 animate-spin" />
<span>: <code className="bg-slate-100 px-2 py-0.5 rounded font-mono font-bold text-slate-800 border border-slate-200 select-all">{batchStatus.current_bibcode}</code></span>
</div>
)}
{/* 滚动日志终端 */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block select-none"></label>
<div ref={logsContainerRef} className="bg-slate-50 text-slate-800 font-mono text-[11px] p-4 rounded-lg h-48 overflow-y-auto border border-slate-250 space-y-1 scrollbar-thin scrollbar-thumb-slate-300 relative select-all">
{batchStatus.logs.length === 0 ? (
<div className="text-slate-400 italic select-none">...</div>
) : (
batchStatus.logs.map((log: string, idx: number) => (
<div key={idx} className="whitespace-pre-wrap leading-relaxed">
{log}
</div>
))
)}
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,74 @@
// dashboard/src/components/sync/BookmarkletToolCard.tsx
import { useRef, useEffect } from 'react';
// 书签代码常量
const BOOKMARKLET_CODE = `javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode',{credentials:'include'});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘 file://。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`;
export function BookmarkletToolCard() {
const linkRef = useRef<HTMLAnchorElement | null>(null);
useEffect(() => {
if (linkRef.current) {
linkRef.current.setAttribute('href', BOOKMARKLET_CODE);
}
}, []);
const handleCopyCode = () => {
navigator.clipboard.writeText(BOOKMARKLET_CODE);
alert('书签代码已成功复制到剪贴板!');
};
return (
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4 shadow-xs">
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2 select-none">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-sky-500 animate-pulse" />
<span></span>
</h3>
<p className="text-slate-500 text-xs mt-1 leading-relaxed">
Cloudflare/WAF PDF/HTML AstroResearch
</p>
</div>
<div className="space-y-3.5 text-xs text-slate-700">
<div className="space-y-1.5 font-bold select-none">
<span className="text-slate-500"></span>
<p className="text-[11px] text-slate-400 font-normal mt-1 leading-relaxed">
URL JavaScript
</p>
<div className="flex flex-wrap items-center gap-3 mt-2 select-none">
{/* 可直接拖拽的链接按钮 */}
<a
ref={linkRef}
className="px-4 py-2 bg-gradient-to-r from-sky-500 to-indigo-500 hover:from-sky-650 hover:to-indigo-650 text-white rounded-lg text-xs font-bold transition-all shadow-sm cursor-move flex items-center gap-1.5"
title="拖拽我到您的书签栏中"
onClick={(e) => e.preventDefault()}
>
<span style={{ position: 'absolute', width: '1px', height: '1px', overflow: 'hidden', opacity: 0, pointerEvents: 'none' }}>
AstroResearch
</span>
<span></span>
</a>
<button
onClick={handleCopyCode}
className="px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-[11px] font-bold transition-all cursor-pointer"
>
JavaScript
</button>
</div>
</div>
<div className="space-y-1.5 font-bold mt-4 select-none">
<span className="text-slate-500">使</span>
<ol className="list-decimal list-inside text-[11px] text-slate-500 font-normal pl-1 space-y-1.5 mt-1 leading-relaxed">
<li> <span className="font-bold text-slate-750">BIBCODE / DOI / arXiv ID </span></li>
<li><span className="font-bold text-slate-750"></span></li>
<li><span className="font-bold text-slate-750"> Bibcode</span></li>
</ol>
</div>
</div>
</div>
);
}
@@ -0,0 +1,270 @@
// dashboard/src/components/sync/MetadataSyncCard.tsx
import { SlidersHorizontal, RefreshCw, Loader, Play, Info, CheckCircle, Lightbulb } from 'lucide-react';
import { CustomSelect } from '../CustomSelect';
import type { HarvestStatus } from '../../hooks/useSyncState';
interface MetadataSyncCardProps {
query: string;
setQuery: (q: string) => void;
source: 'all' | 'ads' | 'arxiv';
setSource: (s: 'all' | 'ads' | 'arxiv') => void;
limit: number;
setLimit: (l: number) => void;
estimating: boolean;
estimatedCount: number | null;
status: HarvestStatus;
showBuilder: boolean;
setShowBuilder: (show: boolean) => void;
rules: Array<{ field: string; op: string; val: string }>;
handleAddRule: () => void;
handleRemoveRule: (idx: number) => void;
handleRuleChange: (idx: number, key: 'field' | 'op' | 'val', value: string) => void;
handleEstimate: () => Promise<void>;
handleStartHarvest: () => Promise<void>;
}
export function MetadataSyncCard({
query,
setQuery,
source,
setSource,
limit,
setLimit,
estimating,
estimatedCount,
status,
showBuilder,
setShowBuilder,
rules,
handleAddRule,
handleRemoveRule,
handleRuleChange,
handleEstimate,
handleStartHarvest,
}: MetadataSyncCardProps) {
const percent = status.total > 0 ? Math.min(100, Math.round((status.synced / status.total) * 100)) : 0;
return (
<div className="space-y-6">
{/* 控制面板卡片 */}
<div className="console-panel p-6 rounded-xl space-y-6 relative overflow-hidden bg-white border border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block flex justify-between items-center">
<span> (Query)</span>
<button
type="button"
onClick={() => setShowBuilder(!showBuilder)}
className="text-xs text-sky-600 hover:text-sky-750 font-bold flex items-center gap-1 cursor-pointer"
>
<SlidersHorizontal className="w-3.5 h-3.5" />
{showBuilder ? '隐藏高级构造' : '高级检索构造'}
</button>
</label>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
disabled={status.active}
placeholder="例如: hot subdwarf, Gaia BH1..."
className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-xs font-medium"
/>
<div className="text-[11px] text-slate-450 flex flex-wrap gap-x-2.5 px-0.5 mt-1 select-none">
<span>:</span>
<span><code className="text-slate-700 bg-slate-100 px-1 py-0.2 rounded font-mono">author:"Althaus" AND year:2020-2023</code></span>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<div className="flex gap-2">
{[
{ id: 'all', label: '全部' },
{ id: 'ads', label: 'NASA ADS' },
{ id: 'arxiv', label: 'arXiv 预印本' },
].map(src => (
<button
key={src.id}
type="button"
disabled={status.active}
onClick={() => setSource(src.id as any)}
className={`flex-1 py-2 rounded-lg text-xs font-bold border transition-all cursor-pointer ${
source === src.id
? 'bg-sky-50 border-sky-300 text-sky-700 shadow-sm'
: 'btn-console btn-console-secondary'
}`}
>
{src.label}
</button>
))}
</div>
</div>
</div>
{/* 动态表单生成器 */}
{showBuilder && (
<div className="p-4 rounded-lg bg-slate-50 border border-slate-200 space-y-3.5 transition-all">
<div className="text-xs font-bold text-slate-700 flex justify-between items-center select-none">
<span></span>
<button
type="button"
onClick={handleAddRule}
className="text-xs text-sky-600 hover:text-sky-750 font-bold cursor-pointer"
>
+
</button>
</div>
<div className="space-y-2.5">
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
{idx > 0 ? (
<CustomSelect
value={rule.op}
onChange={val => handleRuleChange(idx, 'op', val)}
className="w-24"
options={[
{ value: 'AND', label: '并且 (AND)' },
{ value: 'OR', label: '或者 (OR)' },
{ value: 'NOT', label: '排除 (NOT)' },
]}
/>
) : (
<div className="w-24 text-center text-xs text-slate-500 font-semibold select-none"></div>
)}
<CustomSelect
value={rule.field}
onChange={val => handleRuleChange(idx, 'field', val)}
className="w-32"
options={[
{ value: 'all', label: '任意字段' },
{ value: 'title', label: '标题' },
{ value: 'author', label: '作者' },
{ value: 'abs', label: '摘要' },
{ value: 'year', label: '年份' },
]}
/>
<input
type="text"
value={rule.val}
onChange={e => handleRuleChange(idx, 'val', e.target.value)}
placeholder={
rule.field === 'year'
? '例如: 2020-2023 或 2022'
: rule.field === 'author'
? '例如: Althaus'
: '请输入检索词...'
}
className="flex-1 px-3 py-1.5 rounded-lg bg-white border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 text-xs font-medium"
/>
{rules.length > 1 && (
<button
type="button"
onClick={() => handleRemoveRule(idx)}
className="text-red-600 hover:text-red-750 text-xs font-bold px-2 py-1.5 cursor-pointer"
>
</button>
)}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-2 border-t border-slate-200">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block flex items-center justify-between">
<span></span>
<span className="text-[11px] text-slate-450 font-normal"> API</span>
</label>
<input
type="number"
value={limit}
disabled={status.active}
onChange={e => setLimit(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium"
/>
</div>
<div className="flex items-end gap-3">
<button
type="button"
disabled={status.active || estimating}
onClick={handleEstimate}
className="flex-1 py-2 rounded-lg bg-white border border-slate-300 hover:bg-slate-50 text-slate-750 text-xs font-bold flex items-center justify-center gap-2 transition-all disabled:opacity-40 cursor-pointer"
>
{estimating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
</button>
<button
type="button"
disabled={status.active || !query.trim()}
onClick={handleStartHarvest}
className="btn-console btn-console-primary flex-1 py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 disabled:opacity-40 cursor-pointer"
>
<Play className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 预估结果 */}
{estimatedCount !== null && !status.active && (
<div className="p-4 rounded-lg bg-sky-50 border border-sky-200 flex gap-3 text-xs text-sky-850 items-center select-none animate-in fade-in duration-250">
<Info className="w-4 h-4 shrink-0 text-sky-600" />
<div>
<strong className="text-sky-900 font-bold">{estimatedCount}</strong>
{estimatedCount > limit ? ` 限制最大同步前 ${limit} 篇元数据。` : ' 将全部进行同步。'}
</div>
</div>
)}
</div>
{/* 实时同步进度 */}
{(status.active || status.synced > 0) && (
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4 shadow-xs">
<div className="flex justify-between items-center">
<div>
<h3 className="text-xs font-bold text-slate-850 flex items-center gap-2">
{status.active ? (
<>
<Loader className="w-4 h-4 text-sky-600 animate-spin" />
<span>...</span>
</>
) : (
<>
<CheckCircle className="w-4 h-4 text-emerald-655" />
<span></span>
</>
)}
</h3>
<p className="text-slate-500 text-[11px] mt-1.5 font-semibold leading-relaxed select-all">
: <code className="bg-slate-100 px-1 py-0.5 rounded text-slate-700 font-mono">{status.query}</code> : {status.source === 'all' ? '全部' : status.source === 'ads' ? 'NASA ADS' : 'arXiv'}
</p>
</div>
<span className="text-xs font-bold text-sky-700">{status.synced} / {status.total} </span>
</div>
<div className="w-full h-3 rounded-full bg-slate-100 overflow-hidden border border-slate-200 select-none">
<div
className="h-full bg-sky-600 transition-all duration-500 shadow-sm"
style={{ width: `${percent}%` }}
/>
</div>
{status.active && (status.source === 'all' || status.source === 'arxiv') ? (
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-700 flex items-start gap-1.5 select-none">
<Lightbulb className="w-4 h-4 shrink-0 mt-0.5" />
<span> arXiv 3000 </span>
</div>
) : null}
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-921
View File
@@ -1,921 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import { RefreshCw, Play, Info, AlertTriangle, CheckCircle, Loader, StopCircle, Download, FileText, SlidersHorizontal, Lightbulb } from 'lucide-react';
import type { SavedSyncQuery } from '../../types';
import { CustomSelect } from '../../components/CustomSelect';
interface BatchStatus {
active: boolean;
total: number;
downloaded: number;
parsed: number;
download_failed: number;
parse_failed: number;
current_bibcode: string;
logs: string[];
action?: 'download' | 'parse' | 'translate' | 'embed' | 'target';
}
interface HarvestStatus {
active: boolean;
query: string;
source: string;
synced: number;
total: number;
}
export function SyncPanel() {
const [query, setQuery] = useState('');
const [source, setSource] = useState<'all' | 'ads' | 'arxiv'>('all');
const [limit, setLimit] = useState<number>(200);
const [estimating, setEstimating] = useState(false);
const [estimatedCount, setEstimatedCount] = useState<number | null>(null);
const [status, setStatus] = useState<HarvestStatus>({
active: false,
query: '',
source: '',
synced: 0,
total: 0,
});
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [syncQueries, setSyncQueries] = useState<SavedSyncQuery[]>([]);
const pollIntervalRef = useRef<any>(null);
// 批量下载与解析相关状态
const [targetPhase, setTargetPhase] = useState<'download' | 'parse' | 'translate' | 'embed' | 'target'>('download');
const [batchLimitCount, setBatchLimitCount] = useState<number>(100);
const [sortOrder, setSortOrder] = useState<'default' | 'pub_year_desc' | 'created_at_desc'>('default');
const [skipCompleted, setSkipCompleted] = useState<boolean>(true);
const [skipFailed, setSkipFailed] = useState<boolean>(false);
const [skipPrecedingFailed, setSkipPrecedingFailed] = useState<boolean>(false);
const [skipPrecedingUncompleted, setSkipPrecedingUncompleted] = useState<boolean>(false);
const [batchStatus, setBatchStatus] = useState<BatchStatus>({
active: false,
total: 0,
downloaded: 0,
parsed: 0,
download_failed: 0,
parse_failed: 0,
current_bibcode: '',
logs: [],
});
const [batchError, setBatchError] = useState<string | null>(null);
const batchPollIntervalRef = useRef<any>(null);
const logsContainerRef = useRef<HTMLDivElement | null>(null);
const [showBuilder, setShowBuilder] = useState(false);
const [rules, setRules] = useState<Array<{ field: string; op: string; val: string }>>([
{ field: 'all', op: 'AND', val: '' }
]);
// 当高级表单规则变化时,自动更新同步输入框的检索式
const updateQueryFromRules = (currentRules: typeof rules) => {
let qParts: string[] = [];
currentRules.forEach((rule, idx) => {
if (!rule.val.trim()) return;
let valStr = rule.val.trim();
if (valStr.includes(' ') && !valStr.startsWith('"') && !valStr.startsWith('(')) {
valStr = `"${valStr}"`;
}
let fieldPart = '';
if (rule.field !== 'all') {
fieldPart = `${rule.field}:${valStr}`;
} else {
fieldPart = valStr;
}
if (idx === 0) {
qParts.push(fieldPart);
} else {
qParts.push(`${rule.op} ${fieldPart}`);
}
});
setQuery(qParts.join(' '));
};
const handleAddRule = () => {
setRules(prev => [...prev, { field: 'all', op: 'AND', val: '' }]);
};
const handleRemoveRule = (idx: number) => {
const next = rules.filter((_, i) => i !== idx);
setRules(next);
updateQueryFromRules(next);
};
const handleRuleChange = (idx: number, key: 'field' | 'op' | 'val', value: string) => {
const next = rules.map((r, i) => i === idx ? { ...r, [key]: value } : r);
setRules(next);
updateQueryFromRules(next);
};
// 获取历史检索配置
const fetchSyncQueries = async () => {
try {
const res = await axios.get<SavedSyncQuery[]>(`/api/sync/queries?t=${Date.now()}`);
setSyncQueries(res.data);
} catch (e) {
console.error('获取检索配置列表失败', e);
}
};
const handleDeleteQuery = async (id: number) => {
try {
await axios.delete(`/api/sync/queries/${id}`);
fetchSyncQueries();
} catch (e) {
console.error('删除配置失败', e);
}
};
const handleReuseQuery = (sq: SavedSyncQuery) => {
setQuery(sq.query);
setSource(sq.source as any);
setLimit(sq.limit_count);
};
const handleQuickSync = async (sq: SavedSyncQuery) => {
setErrorMsg(null);
// 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态
setStatus(prev => ({
...prev,
active: true,
query: sq.query,
source: sq.source as any,
synced: 0,
total: sq.limit_count,
}));
startPolling();
try {
await axios.post('/api/sync/meta/run', {
q: sq.query,
source: sq.source,
limit: sq.limit_count,
});
fetchStatus();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动快速同步失败。');
fetchStatus();
}
};
// 获取当前的元数据同步状态
const fetchStatus = async () => {
try {
const res = await axios.get<HarvestStatus>(`/api/sync/meta/status?t=${Date.now()}`);
setStatus(res.data);
if (res.data.active) {
startPolling();
} else {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
fetchSyncQueries();
}
}
} catch (e) {
console.error('获取同步状态失败', e);
}
};
// 开始轮询
const startPolling = () => {
if (pollIntervalRef.current) return;
pollIntervalRef.current = setInterval(fetchStatus, 1000);
};
useEffect(() => {
fetchStatus();
fetchSyncQueries();
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
// 批量下载与解析相关的网络操作
const fetchBatchStatus = async () => {
try {
const res = await axios.get<BatchStatus>(`/api/batch/asset/status?t=${Date.now()}`);
setBatchStatus(res.data);
if (res.data.active) {
startBatchPolling();
} else if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
batchPollIntervalRef.current = null;
}
} catch (e) {
console.error('获取处理状态失败', e);
}
};
const startBatchPolling = () => {
if (batchPollIntervalRef.current) return;
batchPollIntervalRef.current = setInterval(fetchBatchStatus, 1000);
};
const handleStartBatch = async () => {
setBatchError(null);
try {
await axios.post('/api/batch/asset/run', {
target_phase: targetPhase,
limit_count: batchLimitCount,
sort_order: sortOrder,
skip_completed: skipCompleted,
skip_failed: skipFailed,
skip_preceding_failed: skipPrecedingFailed,
skip_preceding_uncompleted: skipPrecedingUncompleted,
});
fetchBatchStatus();
startBatchPolling();
} catch (e: any) {
console.error(e);
setBatchError(e.response?.data || '启动批量任务失败。');
}
};
const handleStopBatch = async () => {
try {
await axios.post('/api/batch/asset/stop');
fetchBatchStatus();
} catch (e: any) {
console.error(e);
setBatchError(e.response?.data || '停止任务失败。');
}
};
useEffect(() => {
fetchBatchStatus();
return () => {
if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
}
};
}, []);
// 日志终端自动滚动到底部 (仅限定滚动容器内部,不影响整个网页的滚动)
useEffect(() => {
const container = logsContainerRef.current;
if (container) {
// 阈值设为 80px。如果用户滚动到离底部距离小于 80px,则视为保持跟踪底部的模式
const isCloseToBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 80;
if (isCloseToBottom) {
container.scrollTop = container.scrollHeight;
}
}
}, [batchStatus.logs]);
// 估算文献总量
const handleEstimate = async () => {
if (!query.trim()) {
setErrorMsg('请输入检索关键词!');
return;
}
setErrorMsg(null);
setEstimating(true);
setEstimatedCount(null);
try {
const res = await axios.get<{ total: number }>('/api/sync/meta/count', {
params: { q: query.trim(), source }
});
setEstimatedCount(res.data.total);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '估算文献总量失败,请检查 API 密钥或网络。');
} finally {
setEstimating(false);
}
};
// 启动任务
const handleStartHarvest = async () => {
if (!query.trim()) {
setErrorMsg('请输入检索关键词!');
return;
}
setErrorMsg(null);
// 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态
setStatus(prev => ({
...prev,
active: true,
query: query.trim(),
source,
synced: 0,
total: 0,
}));
startPolling();
try {
await axios.post('/api/sync/meta/run', {
q: query.trim(),
source,
limit: limit,
});
fetchStatus();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动元数据同步任务失败。');
fetchStatus();
}
};
const percent = status.total > 0 ? Math.min(100, Math.round((status.synced / status.total) * 100)) : 0;
return (
<div className="space-y-6 w-full max-w-3xl mx-auto">
{/* 标题 */}
<div className="flex flex-col gap-1.5 border-b border-slate-200 pb-3">
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase"></h2>
<p className="text-slate-500 text-xs"> NASA ADS arXiv 线</p>
</div>
{errorMsg && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div className="font-semibold">{errorMsg}</div>
</div>
)}
{/* 控制面板卡片 */}
<div className="console-panel p-6 rounded-xl space-y-6 relative overflow-hidden">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block flex justify-between items-center">
<span> (Query)</span>
<button
type="button"
onClick={() => setShowBuilder(!showBuilder)}
className="text-xs text-sky-600 hover:text-sky-750 font-bold flex items-center gap-1"
>
<SlidersHorizontal className="w-3.5 h-3.5" />
{showBuilder ? '隐藏高级构造' : '高级检索构造'}
</button>
</label>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
disabled={status.active}
placeholder="例如: hot subdwarf, Gaia BH1..."
className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-xs font-medium"
/>
<div className="text-[11px] text-slate-450 flex flex-wrap gap-x-2.5 px-0.5 mt-1">
<span>:</span>
<span><code className="text-slate-700 bg-slate-100 px-1 py-0.2 rounded font-mono">author:"Althaus" AND year:2020-2023</code></span>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<div className="flex gap-2">
{[
{ id: 'all', label: '全部' },
{ id: 'ads', label: 'NASA ADS' },
{ id: 'arxiv', label: 'arXiv 预印本' },
].map(src => (
<button
key={src.id}
type="button"
disabled={status.active}
onClick={() => setSource(src.id as any)}
className={`flex-1 py-2 rounded-lg text-xs font-bold border transition-all ${
source === src.id
? 'bg-sky-50 border-sky-300 text-sky-700 shadow-sm'
: 'btn-console btn-console-secondary'
}`}
>
{src.label}
</button>
))}
</div>
</div>
</div>
{/* 动态表单生成器 */}
{showBuilder && (
<div className="p-4 rounded-lg bg-slate-50 border border-slate-200 space-y-3.5 transition-all">
<div className="text-xs font-bold text-slate-700 flex justify-between items-center">
<span></span>
<button
type="button"
onClick={handleAddRule}
className="text-xs text-sky-600 hover:text-sky-700 font-bold"
>
+
</button>
</div>
<div className="space-y-2.5">
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
{idx > 0 ? (
<CustomSelect
value={rule.op}
onChange={val => handleRuleChange(idx, 'op', val)}
className="w-24"
options={[
{ value: 'AND', label: '并且 (AND)' },
{ value: 'OR', label: '或者 (OR)' },
{ value: 'NOT', label: '排除 (NOT)' },
]}
/>
) : (
<div className="w-24 text-center text-xs text-slate-500 font-semibold"></div>
)}
<CustomSelect
value={rule.field}
onChange={val => handleRuleChange(idx, 'field', val)}
className="w-32"
options={[
{ value: 'all', label: '任意字段' },
{ value: 'title', label: '标题' },
{ value: 'author', label: '作者' },
{ value: 'abs', label: '摘要' },
{ value: 'year', label: '年份' },
]}
/>
<input
type="text"
value={rule.val}
onChange={e => handleRuleChange(idx, 'val', e.target.value)}
placeholder={
rule.field === 'year'
? '例如: 2020-2023 或 2022'
: rule.field === 'author'
? '例如: Althaus'
: '请输入检索词...'
}
className="flex-1 px-3 py-1.5 rounded-lg bg-white border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 text-xs font-medium"
/>
{rules.length > 1 && (
<button
type="button"
onClick={() => handleRemoveRule(idx)}
className="text-red-655 hover:text-red-700 text-xs font-bold px-2 py-1.5"
>
</button>
)}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-2 border-t border-slate-200">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block flex items-center justify-between">
<span></span>
<span className="text-[11px] text-slate-450 font-normal"> API</span>
</label>
<input
type="number"
value={limit}
disabled={status.active}
onChange={e => setLimit(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium"
/>
</div>
<div className="flex items-end gap-3">
<button
type="button"
disabled={status.active || estimating}
onClick={handleEstimate}
className="flex-1 py-2 rounded-lg bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 text-xs font-bold flex items-center justify-center gap-2 transition-all disabled:opacity-40"
>
{estimating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
</button>
<button
type="button"
disabled={status.active || !query.trim()}
onClick={handleStartHarvest}
className="btn-console btn-console-primary flex-1 py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 disabled:opacity-40"
>
<Play className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 预估结果 */}
{estimatedCount !== null && !status.active && (
<div className="p-4 rounded-lg bg-sky-50 border border-sky-200 flex gap-3 text-xs text-sky-850 items-center">
<Info className="w-4 h-4 shrink-0 text-sky-600" />
<div>
<strong className="text-sky-900 font-bold">{estimatedCount}</strong>
{estimatedCount > limit ? ` 限制最大同步前 ${limit} 篇元数据。` : ' 将全部进行同步。'}
</div>
</div>
)}
</div>
{/* 实时同步进度 */}
{(status.active || status.synced > 0) && (
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4">
<div className="flex justify-between items-center">
<div>
<h3 className="text-xs font-bold text-slate-800 flex items-center gap-2">
{status.active ? (
<>
<Loader className="w-4 h-4 text-sky-600 animate-spin" />
<span>...</span>
</>
) : (
<>
<CheckCircle className="w-4 h-4 text-emerald-600" />
<span></span>
</>
)}
</h3>
<p className="text-slate-500 text-[11px] mt-1.5 font-semibold">
: <code className="bg-slate-100 px-1 py-0.5 rounded text-slate-700">{status.query}</code> : {status.source === 'all' ? '全部' : status.source === 'ads' ? 'NASA ADS' : 'arXiv'}
</p>
</div>
<span className="text-xs font-bold text-sky-700">{status.synced} / {status.total} </span>
</div>
<div className="w-full h-3 rounded-full bg-slate-100 overflow-hidden border border-slate-200">
<div
className="h-full bg-sky-600 transition-all duration-500 shadow-sm"
style={{ width: `${percent}%` }}
/>
</div>
{status.active && (status.source === 'all' || status.source === 'arxiv') ? (
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-700 flex items-start gap-1.5">
<Lightbulb className="w-4 h-4 shrink-0 mt-0.5" />
<span> arXiv 3000 </span>
</div>
) : null}
</div>
)}
{/* 批量任务 */}
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-6 relative overflow-hidden">
<div className="flex flex-col gap-1">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<Download className="w-4 h-4 text-sky-600" />
<span>线</span>
</h3>
<p className="text-slate-500 text-xs">
PDF/HTML Markdown
</p>
</div>
{batchError && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>{batchError}</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<CustomSelect
value={targetPhase}
disabled={batchStatus.active}
onChange={val => setTargetPhase(val as any)}
className="w-full"
options={[
{ value: 'download', label: '下载' },
{ value: 'parse', label: '解析' },
{ value: 'translate', label: '翻译' },
{ value: 'embed', label: '向量化' },
{ value: 'target', label: '天体识别' },
]}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<input
type="number"
value={batchLimitCount}
disabled={batchStatus.active}
onChange={e => setBatchLimitCount(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<CustomSelect
value={sortOrder}
disabled={batchStatus.active}
onChange={val => setSortOrder(val)}
className="w-full"
options={[
{ value: 'default', label: '默认(不指定)' },
{ value: 'pub_year_desc', label: '按出版年份降序' },
{ value: 'created_at_desc', label: '按入库时间降序' },
]}
/>
</div>
</div>
<div className="space-y-2 border-t border-slate-100 pt-4">
<label className="text-xs font-bold text-slate-700 block"></label>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipCompleted}
disabled={batchStatus.active}
onChange={e => setSkipCompleted(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipFailed}
disabled={batchStatus.active}
onChange={e => setSkipFailed(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipPrecedingFailed}
disabled={batchStatus.active}
onChange={e => setSkipPrecedingFailed(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipPrecedingUncompleted}
disabled={batchStatus.active}
onChange={e => setSkipPrecedingUncompleted(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
</div>
</div>
<div className="flex justify-end pt-2 border-t border-slate-100">
<div className="w-full md:w-1/3 flex">
{batchStatus.active ? (
<button
type="button"
onClick={handleStopBatch}
className="w-full py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-xs font-bold flex items-center justify-center gap-2 transition-all shadow-sm"
>
<StopCircle className="w-3.5 h-3.5" />
</button>
) : (
<button
type="button"
onClick={handleStartBatch}
className="btn-console btn-console-primary w-full py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2"
>
<Play className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* 进度与终端日志展示 */}
{(batchStatus.active || batchStatus.total > 0) && (
<div className="space-y-4 pt-4 border-t border-slate-200">
<div className="space-y-1.5">
<div className="flex justify-between text-xs font-bold text-slate-600">
<span className="flex items-center gap-1">
{batchStatus.action === 'download' && <Download className="w-3.5 h-3.5 text-sky-600" />}
{batchStatus.action === 'parse' && <FileText className="w-3.5 h-3.5 text-emerald-600" />}
{batchStatus.action === 'translate' && <RefreshCw className="w-3.5 h-3.5 text-indigo-600" />}
{batchStatus.action === 'embed' && <SlidersHorizontal className="w-3.5 h-3.5 text-amber-600" />}
{batchStatus.action === 'target' && <RefreshCw className="w-3.5 h-3.5 text-sky-600" />}
{batchStatus.action === 'download'
? '正文离线下载进度'
: batchStatus.action === 'parse'
? '结构化排版解析进度'
: batchStatus.action === 'translate'
? '中英双语对照翻译进度'
: batchStatus.action === 'embed'
? '段落向量分块入库进度'
: '天体识别与缓存进度'}
</span>
<span>
{batchStatus.action === 'download' ? batchStatus.downloaded : batchStatus.parsed} / {batchStatus.total}
{((batchStatus.action === 'download' && batchStatus.download_failed > 0) ||
(batchStatus.action !== 'download' && batchStatus.parse_failed > 0)) && (
<span className="text-red-500 ml-2 font-bold">
( {batchStatus.action === 'download' ? batchStatus.download_failed : batchStatus.parse_failed} )
</span>
)}
</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-100 overflow-hidden border border-slate-200">
<div
className={`h-full transition-all duration-300 ${
batchStatus.action === 'download'
? 'bg-sky-600'
: batchStatus.action === 'parse'
? 'bg-emerald-600'
: batchStatus.action === 'translate'
? 'bg-indigo-600'
: batchStatus.action === 'embed'
? 'bg-amber-600'
: 'bg-sky-600'
}`}
style={{
width: `${
batchStatus.total > 0
? Math.min(
100,
Math.round(
((batchStatus.action === 'download'
? batchStatus.downloaded + batchStatus.download_failed
: batchStatus.parsed + batchStatus.parse_failed) /
batchStatus.total) *
100
)
)
: 0
}%`,
}}
/>
</div>
</div>
{batchStatus.active && batchStatus.current_bibcode && (
<div className="text-xs font-bold text-slate-600 flex items-center gap-2">
<Loader className="w-3.5 h-3.5 text-sky-600 animate-spin" />
<span>: <code className="bg-slate-100 px-2 py-0.5 rounded font-mono font-bold text-slate-800 border border-slate-200">{batchStatus.current_bibcode}</code></span>
</div>
)}
{/* 滚动日志终端 */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<div ref={logsContainerRef} className="bg-slate-50 text-slate-800 font-mono text-xs p-4 rounded-lg h-48 overflow-y-auto border border-slate-250 space-y-1 scrollbar-thin scrollbar-thumb-slate-300 relative">
{batchStatus.logs.length === 0 ? (
<div className="text-slate-400 italic">...</div>
) : (
batchStatus.logs.map((log, idx) => (
<div key={idx} className="whitespace-pre-wrap leading-relaxed">
{log}
</div>
))
)}
</div>
</div>
</div>
)}
</div>
{/* 已存同步检索配置 */}
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4">
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-sky-600 animate-pulse" />
<span></span>
</h3>
<p className="text-slate-500 text-xs">
</p>
</div>
{syncQueries.length === 0 ? (
<div className="text-center py-6 text-xs text-slate-400 italic">
</div>
) : (
<div className="divide-y divide-slate-100 max-h-80 overflow-y-auto pr-1 scrollbar-thin">
{syncQueries.map(sq => (
<div key={sq.id} className="py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs">
<div className="space-y-1">
<div className="font-bold text-slate-800 break-all select-all font-mono">
{sq.query}
</div>
<div className="text-[10px] text-slate-500 flex items-center gap-2.5 font-semibold">
<span>: <strong className="text-slate-700">{sq.source === 'all' ? '全部' : sq.source === 'ads' ? 'NASA ADS' : 'arXiv'}</strong></span>
<span>: <strong className="text-slate-700">{sq.limit_count}</strong></span>
<span>: <strong className="text-slate-700">{sq.last_run}</strong></span>
</div>
</div>
<div className="flex gap-2 shrink-0 self-end sm:self-center">
<button
type="button"
onClick={() => handleReuseQuery(sq)}
className="px-2.5 py-1.5 rounded-lg bg-slate-50 border border-slate-250 text-slate-650 hover:bg-slate-100 hover:text-slate-800 transition-all text-xs font-bold cursor-pointer"
>
</button>
<button
type="button"
disabled={status.active}
onClick={() => handleQuickSync(sq)}
className="px-2.5 py-1.5 rounded-lg bg-sky-50 border border-sky-200 text-sky-700 hover:bg-sky-100 transition-all text-xs font-bold cursor-pointer disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => handleDeleteQuery(sq.id)}
className="px-2.5 py-1.5 rounded-lg bg-red-50 border border-red-200 text-red-750 hover:bg-red-100 transition-all text-xs font-bold cursor-pointer"
>
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* 浏览器快捷直推书签工具 */}
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4">
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-sky-500 animate-pulse" />
<span></span>
</h3>
<p className="text-slate-500 text-xs mt-1">
Cloudflare/WAF PDF/HTML AstroResearch
</p>
</div>
<div className="space-y-3.5 text-xs text-slate-700">
<div className="space-y-1.5 font-bold">
<span className="text-slate-500"></span>
<p className="text-[11px] text-slate-400 font-normal mt-1">
URL JavaScript
</p>
<div className="flex flex-wrap items-center gap-3 mt-2">
{/* 可直接拖拽的链接按钮 */}
<a
ref={(el) => {
if (el) {
el.setAttribute(
'href',
`javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode',{credentials:'include'});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`
);
}
}}
className="px-4 py-2 bg-gradient-to-r from-sky-500 to-indigo-500 hover:from-sky-600 hover:to-indigo-600 text-white rounded-lg text-xs font-bold transition-all shadow-sm cursor-move flex items-center gap-1.5"
title="拖拽我到您的书签栏中"
onClick={(e) => e.preventDefault()}
>
{/* 浏览器拖拽时读取全部 textContentsr-only span 的文字成为书签名 */}
<span style={{position:'absolute',width:'1px',height:'1px',overflow:'hidden',opacity:0,pointerEvents:'none'}}>AstroResearch</span>
<span aria-hidden="true"></span>
</a>
<button
onClick={() => {
const bookmarkletCode = `javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode',{credentials:'include'});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示:对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`;
navigator.clipboard.writeText(bookmarkletCode);
alert('书签代码已成功复制到剪贴板!');
}}
className="px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-[11px] font-bold transition-all cursor-pointer"
>
JavaScript
</button>
</div>
</div>
<div className="space-y-1.5 font-bold mt-4">
<span className="text-slate-500">使</span>
<ol className="list-decimal list-inside text-[11px] text-slate-500 font-normal pl-1 space-y-1.5 mt-1">
<li> <span className="font-bold">BIBCODE / DOI / arXiv ID </span></li>
<li><span className="font-bold"></span></li>
<li><span className="font-bold"> Bibcode</span></li>
</ol>
</div>
</div>
</div>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
// dashboard/src/hooks/useAuth.ts
import { useState, useEffect } from 'react';
import axios from 'axios';
export function useAuth() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [password, setPassword] = useState('');
const [loginError, setLoginError] = useState<string | null>(null);
const [loggingIn, setLoggingIn] = useState(false);
// 全局启用 Axios 跨域凭证
useEffect(() => {
axios.defaults.withCredentials = true;
}, []);
// 校验登录状态
useEffect(() => {
axios.get('/api/auth/check')
.then(() => {
setIsAuthenticated(true);
})
.catch(() => {
setIsAuthenticated(false);
});
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) {
setLoginError('请输入访问密码!');
return;
}
setLoggingIn(true);
setLoginError(null);
try {
await axios.post('/api/auth/login', { password });
setIsAuthenticated(true);
setLoginError(null);
} catch (err: any) {
console.error('登录校验失败:', err);
const errMsg = err.response?.data || '密码错误,请重试。';
setLoginError(errMsg);
} finally {
setLoggingIn(false);
}
};
const handleLogout = async () => {
try {
await axios.post('/api/auth/logout');
} catch (e) {
console.error('登出失败:', e);
}
setIsAuthenticated(false);
setPassword('');
};
return {
isAuthenticated,
password,
setPassword,
loginError,
loggingIn,
handleLogin,
handleLogout,
};
}
+46
View File
@@ -0,0 +1,46 @@
// dashboard/src/hooks/useCitations.ts
import { useState, useCallback } from 'react';
import axios from 'axios';
import type { CitationNetwork } from '../types';
export function useCitations() {
const [citationNetwork, setCitationNetwork] = useState<CitationNetwork | null>(null);
const [loadingCitations, setLoadingCitations] = useState(false);
const [citationHistory, setCitationHistory] = useState<CitationNetwork[]>([]);
const [uncachedBibcode, setUncachedBibcode] = useState<string | null>(null);
const loadCitations = useCallback(async (bibcode: string, reset = false) => {
setLoadingCitations(true);
try {
const res = await axios.get<CitationNetwork>('/api/citations', {
params: { bibcode }
});
setCitationNetwork(res.data);
setCitationHistory(prev => {
if (reset) {
return [res.data];
}
if (prev.some(net => net.bibcode === res.data.bibcode)) {
return prev;
}
return [...prev, res.data];
});
} catch (e) {
console.error('加载引用拓扑失败', e);
} finally {
setLoadingCitations(false);
}
}, []);
return {
citationNetwork,
setCitationNetwork,
loadingCitations,
setLoadingCitations,
citationHistory,
setCitationHistory,
uncachedBibcode,
setUncachedBibcode,
loadCitations,
};
}
+216
View File
@@ -0,0 +1,216 @@
// dashboard/src/hooks/useLibrary.ts
import { useState, useEffect } from 'react';
import axios from 'axios';
import type { StandardPaper } from '../types';
interface UseLibraryProps {
isAuthenticated: boolean | null;
showAlert: (message: string, title?: string) => void;
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
openReader: (paper: StandardPaper, skipTabSwitch?: boolean) => void;
}
export function useLibrary({
isAuthenticated,
showAlert,
showConfirm,
openReader,
}: UseLibraryProps) {
const [library, setLibrary] = useState<StandardPaper[]>([]);
const [selectedPaper, setSelectedPaper] = useState<StandardPaper | null>(null);
const [detailBibcode, setDetailBibcode] = useState<string | null>(null);
const [recentlySelected, setRecentlySelected] = useState<StandardPaper[]>(() => {
try {
const saved = localStorage.getItem('astro_recently_selected');
return saved ? JSON.parse(saved) : [];
} catch (e) {
console.error('Failed to parse recently selected papers', e);
return [];
}
});
const [downloadingBibcodes, setDownloadingBibcodes] = useState<Record<string, boolean>>({});
const [uploadingBibcode, setUploadingBibcode] = useState<string | null>(null);
useEffect(() => {
localStorage.setItem('astro_recently_selected', JSON.stringify(recentlySelected));
}, [recentlySelected]);
const addPaperToRecent = (paper: StandardPaper) => {
setRecentlySelected(prev => {
const filtered = prev.filter(p => p.bibcode !== paper.bibcode);
return [paper, ...filtered].slice(0, 5);
});
};
const openCitation = (
paper: StandardPaper,
setActiveTab: (tab: any) => void,
loadCitations: (bibcode: string, reset?: boolean) => void
) => {
setSelectedPaper(paper);
setActiveTab('citation');
loadCitations(paper.bibcode, true);
addPaperToRecent(paper);
};
const fetchLibrary = async () => {
try {
const res = await axios.get<StandardPaper[]>('/api/library');
setLibrary(res.data);
const lastReadBibcode = localStorage.getItem('last_read_bibcode');
if (lastReadBibcode) {
const lastReadPaper = res.data.find(p => p.bibcode === lastReadBibcode);
if (lastReadPaper) {
const initialTab = localStorage.getItem('astro_active_tab') || 'search';
openReader(lastReadPaper, initialTab !== 'reader');
}
}
} catch (e) {
console.error('加载本地文献库失败', e);
}
};
// 初始化加载本地文献库
useEffect(() => {
if (isAuthenticated === true) {
fetchLibrary();
}
}, [isAuthenticated]);
// 触发文献双格式下载
const handleDownload = async (bibcode: string, force = false, searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>) => {
setDownloadingBibcodes(prev => ({ ...prev, [bibcode]: true }));
try {
const res = await axios.post<StandardPaper>('/api/download', { bibcode, force });
if (searchResultsSetter) {
searchResultsSetter(prev => prev.map(p => p.bibcode === bibcode ? res.data : p));
}
setLibrary(prev => {
if (prev.some(p => p.bibcode === bibcode)) {
return prev.map(p => p.bibcode === bibcode ? res.data : p);
} else {
return [res.data, ...prev];
}
});
if (selectedPaper?.bibcode === bibcode) {
setSelectedPaper(res.data);
}
} catch (e) {
console.error('下载文献失败', e);
showAlert('文献下载失败,请检查 ADS 网络限制与网络代理!', '下载失败');
} finally {
setDownloadingBibcodes(prev => ({ ...prev, [bibcode]: false }));
}
};
// 手动上传文献文件以绕过反爬/人机验证
const handleManualUpload = async (bibcode: string, type: 'pdf' | 'html', file: File, searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>) => {
setUploadingBibcode(bibcode);
const formData = new FormData();
formData.append('bibcode', bibcode);
formData.append('type', type);
formData.append('file', file);
try {
const res = await axios.post<StandardPaper>('/api/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
if (searchResultsSetter) {
searchResultsSetter(prev => prev.map(p => p.bibcode === bibcode ? res.data : p));
}
setLibrary(prev => {
if (prev.some(p => p.bibcode === bibcode)) {
return prev.map(p => p.bibcode === bibcode ? res.data : p);
} else {
return [res.data, ...prev];
}
});
if (selectedPaper?.bibcode === bibcode) {
setSelectedPaper(res.data);
}
showAlert('手动文献文件上传导入成功!', '上传成功');
} catch (e: any) {
console.error('手动文件上传失败', e);
const errMsg = e.response?.data || '请确保上传的是合法且完整的文件。';
showAlert(`文件上传失败: ${errMsg}`, '上传出错');
} finally {
setUploadingBibcode(null);
}
};
// 手动标记文献为“无有效全文资源”
const handleMarkNoResource = async (bibcode: string, clear = false, searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>) => {
const performMark = async () => {
try {
const res = await axios.post<StandardPaper>('/api/no_resource', { bibcode, clear });
if (searchResultsSetter) {
searchResultsSetter(prev => prev.map(p => p.bibcode === bibcode ? res.data : p));
}
setLibrary(prev => {
if (prev.some(p => p.bibcode === bibcode)) {
return prev.map(p => p.bibcode === bibcode ? res.data : p);
} else {
return [res.data, ...prev];
}
});
if (selectedPaper?.bibcode === bibcode) {
setSelectedPaper(res.data);
}
showAlert(
clear
? '已成功清除“无全文资源”标记,该文献已被重新允许重载!'
: '已成功将文献标记为“无全文资源”,后续批量任务将自动跳过!',
'标记更新'
);
} catch (e: any) {
console.error('标记更新失败', e);
const errMsg = e.response?.data || '请稍后重试。';
showAlert(`标记失败: ${errMsg}`, '操作出错');
}
};
if (clear) {
performMark();
} else {
showConfirm(
'确认将此文献标记为“无有效全文资源”吗?\n标记后,未来的批量下载/解析重试任务将自动跳过此文献。',
performMark,
'标记无有效全文资源'
);
}
};
// 衍生状态计算
const getDetailPaper = (searchResults: StandardPaper[]) => {
return library.find(p => p.bibcode === detailBibcode) || searchResults.find(p => p.bibcode === detailBibcode);
};
return {
library,
setLibrary,
selectedPaper,
setSelectedPaper,
detailBibcode,
setDetailBibcode,
recentlySelected,
setRecentlySelected,
downloadingBibcodes,
setDownloadingBibcodes,
uploadingBibcode,
addPaperToRecent,
openCitation,
fetchLibrary,
handleDownload,
handleManualUpload,
handleMarkNoResource,
getDetailPaper,
};
}
+69
View File
@@ -0,0 +1,69 @@
// dashboard/src/hooks/useNotes.ts
import { useState } from 'react';
import axios from 'axios';
import type { NoteRecord, StandardPaper } from '../types';
export function useNotes() {
const [notes, setNotes] = useState<NoteRecord[]>([]);
const [showNotesPanel, setShowNotesPanel] = useState(false);
const [newNoteText, setNewNoteText] = useState('');
const [newNoteColor, setNewNoteColor] = useState('yellow');
const [selectedParagraphIdx, setSelectedParagraphIdx] = useState<number | null>(null);
const [selectedText, setSelectedText] = useState('');
const handleCreateNote = async (selectedPaper: StandardPaper | null) => {
if (!selectedPaper || selectedParagraphIdx === null || !newNoteText.trim()) return;
try {
const res = await axios.post<NoteRecord>('/api/notes', {
bibcode: selectedPaper.bibcode,
paragraph_index: selectedParagraphIdx,
note_text: newNoteText.trim(),
highlight_color: newNoteColor,
selected_text: selectedText,
});
setNotes(prev => [...prev, res.data]);
setNewNoteText('');
setSelectedParagraphIdx(null);
setSelectedText('');
} catch (e) {
console.error('保存笔记失败', e);
}
};
const handleDeleteNote = async (id: number) => {
try {
await axios.delete('/api/notes', { params: { id } });
setNotes(prev => prev.filter(n => n.id !== id));
} catch (e) {
console.error('删除笔记失败', e);
}
};
// 选中文本时弹出笔记添加面板
const handleTextSelection = (paragraphIdx: number) => {
const sel = window.getSelection();
if (sel && sel.toString().trim().length > 3) {
setSelectedText(sel.toString().trim());
setSelectedParagraphIdx(paragraphIdx);
setShowNotesPanel(true);
}
};
return {
notes,
setNotes,
showNotesPanel,
setShowNotesPanel,
newNoteText,
setNewNoteText,
newNoteColor,
setNewNoteColor,
selectedParagraphIdx,
setSelectedParagraphIdx,
selectedText,
setSelectedText,
handleCreateNote,
handleDeleteNote,
handleTextSelection,
};
}
+317
View File
@@ -0,0 +1,317 @@
// dashboard/src/hooks/useReaderState.ts
import { useState, useRef, useEffect, useMemo } from 'react';
import axios from 'axios';
import { defaultSchema } from 'rehype-sanitize';
import type { StandardPaper } from '../types';
import { getHighlightCandidates, type TargetInfo, ENABLE_LEGACY_UNICODE_SPACE_COMPAT } from '../utils/celestial';
import { useSyncScroll } from './useSyncScroll';
// LaTeX 及 HTML 过滤白名单配置
export const safeSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
'*': (defaultSchema.attributes?.['*'] || []).concat(['className', 'style', 'mathvariant', 'display', 'data-target-name']),
'span': (defaultSchema.attributes?.['span'] || []).concat(['className', 'style', 'data-target-name']),
},
tagNames: (defaultSchema.tagNames || []).concat([
'math', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'msubsup', 'mfrac', 'mover', 'munder', 'munderover', 'mspace', 'mtext', 'annotation'
]),
};
// 读书笔记高亮配色映射表
export const NOTE_COLORS: Record<string, { bg: string; border: string; label: string; text: string }> = {
cyan: { bg: 'bg-cyan-50 border-cyan-200', border: 'border-cyan-300', label: '天蓝色', text: 'text-cyan-800' },
amber: { bg: 'bg-amber-50 border-amber-200', border: 'border-amber-300', label: '星光金', text: 'text-amber-800' },
purple: { bg: 'bg-purple-50 border-purple-200', border: 'border-purple-300', label: '淡紫色', text: 'text-purple-800' },
green: { bg: 'bg-emerald-50 border-emerald-200', border: 'border-emerald-300', label: '荧光绿', text: 'text-emerald-800' },
};
interface UseReaderStateProps {
selectedPaper: StandardPaper;
englishText: string;
viewMode: 'bilingual' | 'english' | 'chinese';
setViewMode: (mode: 'bilingual' | 'english' | 'chinese') => void;
}
export function useReaderState({
selectedPaper,
englishText,
viewMode,
setViewMode,
}: UseReaderStateProps) {
const [showSwitchMenu, setShowSwitchMenu] = useState(false);
const [showPdf, setShowPdf] = useState(false);
const [syncScroll, setSyncScroll] = useState(true);
const hasMarkdown = !!englishText;
// 绑定双语自适应双轨同步滚动 (方案三)
const {
englishRef,
chineseRef,
handleEnglishScroll,
handleChineseScroll,
} = useSyncScroll(syncScroll, viewMode, showPdf, hasMarkdown, () => {
if (hoveredTarget) setHoveredTarget(null);
});
// 天体识别与 RAG 助手状态
const [targets, setTargets] = useState<TargetInfo[]>([]);
const [loadingTargets, setLoadingTargets] = useState(false);
const [manualTargetName, setManualTargetName] = useState('');
const [associatingTarget, setAssociatingTarget] = useState(false);
const [identifyingTargets, setIdentifyingTargets] = useState(false);
const [sidebarTab, setSidebarTab] = useState<'notes' | 'ai'>('notes');
const [hoveredTarget, setHoveredTarget] = useState<TargetInfo | null>(null);
const [hoverCardPos, setHoverCardPos] = useState<{ x: number; y: number; isAbove?: boolean } | null>(null);
const hoverTimeout = useRef<any>(null);
// 预先计算并缓存天体高亮匹配项
const highlightCandidates = useMemo(() => {
return getHighlightCandidates(targets);
}, [targets]);
// 获取天体关联列表
useEffect(() => {
const fetchTargets = async () => {
setLoadingTargets(true);
try {
const res = await axios.get<TargetInfo[]>('/api/target/list', {
params: { bibcode: selectedPaper.bibcode }
});
setTargets(res.data);
} catch (e) {
console.error('获取天体关联列表失败:', e);
} finally {
setLoadingTargets(false);
}
};
if (selectedPaper?.bibcode) {
fetchTargets();
setSidebarTab('notes');
}
}, [selectedPaper?.bibcode]);
// 自动从正文中提取天体并查询 CDS Sesame 缓存
const handleIdentifyTargets = async (bibcode: string) => {
setIdentifyingTargets(true);
try {
const res = await axios.post<{ targets: TargetInfo[] }>('/api/target/extract', { bibcode });
setTargets(res.data.targets);
} catch (e) {
console.error('天体识别失败:', e);
alert('从文献提取天体识别失败,请检查网络或后端连接。');
} finally {
setIdentifyingTargets(false);
}
};
// 手动关联天体
const handleAssociateTarget = async (e: React.FormEvent) => {
e.preventDefault();
if (!manualTargetName.trim() || associatingTarget) return;
setAssociatingTarget(true);
try {
const res = await axios.post<{ status: string; target: TargetInfo }>('/api/target/associate', {
bibcode: selectedPaper.bibcode,
object_name: manualTargetName.trim()
});
if (!targets.some(t => t.target_name.toLowerCase() === res.data.target.target_name.toLowerCase())) {
setTargets(prev => [...prev, res.data.target]);
}
setManualTargetName('');
} catch (e) {
console.error('手动关联天体失败:', e);
alert('关联天体失败,请确认天体名称是否正确,或者 CDS 接口可访问。');
} finally {
setAssociatingTarget(false);
}
};
const clearHoverTimeout = () => {
if (hoverTimeout.current) {
clearTimeout(hoverTimeout.current);
hoverTimeout.current = null;
}
};
// 悬浮显示气泡
const handleMouseOver = (e: React.MouseEvent<HTMLDivElement>) => {
const element = e.target as HTMLElement;
const targetName = element.getAttribute('data-target-name');
if (targetName) {
clearHoverTimeout();
const matchedTarget = targets.find(t =>
t.target_name.toLowerCase() === targetName.toLowerCase() ||
t.aliases.some(a => a.toLowerCase() === targetName.toLowerCase())
);
if (matchedTarget) {
if (!hoveredTarget || hoveredTarget.target_name !== matchedTarget.target_name) {
const rect = element.getBoundingClientRect();
// 使用 fixed 视口定位防止父级 overflow-y-auto 裁切
let x = rect.left;
if (x + 288 > window.innerWidth) {
x = window.innerWidth - 288 - 16;
}
// 像素级高内聚自适应翻转定位 (transform: translateY(-100%) 方案)
const estimatedHeight = 400; // 考虑参数与别名全满时的最大安全预估高度
let isAbove = false;
let y = rect.bottom + 2;
if (y + estimatedHeight > window.innerHeight) {
// 当天体文字靠下时,向上翻转定位在文字上方,并通过 isAbove 标记应用 CSS 平移
isAbove = true;
y = rect.top - 2;
}
setHoverCardPos({ x, y, isAbove });
setHoveredTarget(matchedTarget);
}
}
} else {
if (hoveredTarget && !hoverTimeout.current) {
hoverTimeout.current = setTimeout(() => {
setHoveredTarget(null);
hoverTimeout.current = null;
}, 500);
}
}
};
const handleContainerMouseLeave = () => {
if (hoveredTarget && !hoverTimeout.current) {
hoverTimeout.current = setTimeout(() => {
setHoveredTarget(null);
hoverTimeout.current = null;
}, 500);
}
};
const handleMouseLeaveTarget = () => {
if (!hoverTimeout.current) {
hoverTimeout.current = setTimeout(() => {
setHoveredTarget(null);
hoverTimeout.current = null;
}, 500);
}
};
// 点击侧边栏天体时,跳转并高亮正文中对应的天体
const handleJumpToTarget = (target: TargetInfo) => {
if (!englishText) return;
// 如果当前处于仅中文视图,自动切换为对照视图(移动端切换为英文视图)
if (viewMode === 'chinese') {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
setViewMode(isMobile ? 'english' : 'bilingual');
}
// 生成所有可能匹配的名称变体(包含空格与无空格变体、别名)
const cleanNames = new Set<string>();
// 历史存量数据 Unicode 异型空格兼容清洗函数 (根据 ENABLE_LEGACY_UNICODE_SPACE_COMPAT 开关自适应切换)
const normalizeSpace = (str: string) => {
if (ENABLE_LEGACY_UNICODE_SPACE_COMPAT) {
// 兼容模式:将所有特殊的 Unicode 不换行空格、窄空格、连续多空格统一替换为标准的单半角空格
return str.replace(/[\s\u00a0\u2000-\u200a\u202f\u205f\u3000]+/g, ' ').trim().toLowerCase();
}
// 纯净模式:仅进行标准的首尾空格清理与小写转换(无正则消耗)
return str.trim().toLowerCase();
};
const addNameAndVariants = (name: string) => {
const normalized = normalizeSpace(name);
if (!normalized) return;
cleanNames.add(normalized);
const spaceRegex = /^(ngc|ic|m|hd|hip|gaia|wd|gd|tyc|kic|tic|psr)\s+(\d+.*)$/i;
const match = normalized.match(spaceRegex);
if (match) {
cleanNames.add(`${match[1]}${match[2]}`);
}
const noSpaceRegex = /^(ngc|ic|m|hd|hip|gaia|wd|gd|tyc|kic|tic|psr)(\d+.*)$/i;
const matchNoSpace = normalized.match(noSpaceRegex);
if (matchNoSpace) {
cleanNames.add(`${matchNoSpace[1]} ${matchNoSpace[2]}`);
}
};
addNameAndVariants(target.target_name);
if (target.aliases) {
target.aliases.forEach(addNameAndVariants);
}
const paragraphs = englishText.split('\n\n');
const foundIdx = paragraphs.findIndex(para => {
const paraClean = normalizeSpace(para);
return Array.from(cleanNames).some(name => paraClean.includes(name));
});
if (foundIdx !== -1) {
setTimeout(() => {
const element = document.getElementById(`paragraph-block-${foundIdx}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
// 闪烁高亮段落背景
element.classList.add('bg-sky-50', 'ring-2', 'ring-sky-500/20', 'transition-all');
// 闪烁高亮段落内具体的天体标签
const targetSpans = element.querySelectorAll(`[data-target-name]`);
targetSpans.forEach(span => {
const attrVal = span.getAttribute('data-target-name')?.toLowerCase();
if (attrVal && Array.from(cleanNames).some(name => name === attrVal)) {
span.classList.add('bg-amber-200', 'scale-105', 'px-1', 'rounded', 'transition-all');
setTimeout(() => {
span.classList.remove('bg-amber-200', 'scale-105', 'px-1', 'rounded');
}, 3000);
}
});
setTimeout(() => {
element.classList.remove('bg-sky-50', 'ring-2', 'ring-sky-500/20');
}, 3000);
}
}, 150);
}
};
return {
showSwitchMenu,
setShowSwitchMenu,
showPdf,
setShowPdf,
syncScroll,
setSyncScroll,
targets,
loadingTargets,
manualTargetName,
setManualTargetName,
associatingTarget,
identifyingTargets,
sidebarTab,
setSidebarTab,
hoveredTarget,
setHoveredTarget,
hoverCardPos,
highlightCandidates,
englishRef,
chineseRef,
handleIdentifyTargets,
handleAssociateTarget,
handleMouseOver,
clearHoverTimeout,
handleContainerMouseLeave,
handleMouseLeaveTarget,
handleEnglishScroll,
handleChineseScroll,
handleJumpToTarget,
};
}
+971
View File
@@ -0,0 +1,971 @@
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import {
useAutoScroll,
routeThought,
routeToolCall,
routeToolResult,
routeTextDelta,
} from '../components/agent';
import type { TimelineItem } from '../components/agent';
export interface SessionSummary {
session_id: string;
title: string;
model: string;
mode: string;
turn_count: number;
summary?: string | null;
created_at: string;
updated_at: string;
}
export interface SearchResult {
result_type: string;
session_id: string;
title?: string | null;
snippet: string;
created_at?: string | null;
}
export interface MessageRecord {
id: number;
agent_name: string;
turn_index: number;
step_index: number;
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
thought?: string | null;
tool_calls?: ToolCall[] | null;
tool_call_id?: string | null;
token_count: number;
metadata?: any | null;
created_at: string;
}
export interface ToolCall {
id: string;
type: string;
function: {
name: string;
arguments: string; // JSON string
};
}
export interface ActiveTurn {
question: string;
imagePath?: string;
timeline: TimelineItem[];
finalAnswer: string;
error?: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export interface ProcessedTurn {
turn_index: number;
question: string;
questionMessageId?: number;
imagePath?: string;
timeline: TimelineItem[];
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
createdAt: string;
}
export interface RewindResult {
rewound_count: number;
target_preview: string;
new_turn_index: number;
session_id: string;
}
export interface BranchResult {
branch_session_id: string;
forked_at_message_id: number;
copied_count: number;
}
export interface RetryResult {
retried_message: string;
new_turn_index: number;
deleted_count: number;
session_id: string;
image_path?: string | null;
}
export interface AgentMode {
id: string;
name: string;
description: string;
icon: string;
}
interface UseResearchAgentProps {
showConfirm?: (message: string, onConfirm: () => void, title?: string) => void;
showAlert?: (message: string, title?: string) => void;
}
export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentProps = {}) {
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<MessageRecord[]>([]);
const [activeTurn, setActiveTurn] = useState<ActiveTurn | null>(null);
const [streaming, setStreaming] = useState(false);
const [input, setInput] = useState('');
const [agentMode, setAgentMode] = useState('default');
const [agentModes, setAgentModes] = useState<AgentMode[]>([]);
const [thinking, setThinking] = useState(false);
const [pendingImage, setPendingImage] = useState<{ data?: string; path?: string; mime_type: string; name: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [loadingSessions, setLoadingSessions] = useState(false);
const [loadingHistory, setLoadingHistory] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// 全文搜索历史记录状态
const [searchQuery, setSearchQuery] = useState('');
const [searchScopeOnlyCurrent, setSearchScopeOnlyCurrent] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [loadingSearch, setLoadingSearch] = useState(false);
// 展开折叠控制
const [expandedThoughts, setExpandedThoughts] = useState<Record<string, boolean>>({});
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>({});
const [expandedResults, setExpandedResults] = useState<Record<string, boolean>>({});
const [collapsedSubAgents, setCollapsedSubAgents] = useState<Record<string, boolean>>({});
// 新功能面板状态
const [showMetrics, setShowMetrics] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false);
const [hasPendingPermission, setHasPendingPermission] = useState(false);
const [hasPendingQuestion, setHasPendingQuestion] = useState(false);
const skipNextHistoryLoadRef = useRef(false);
const {
chatEndRef,
scrollContainerRef,
setShouldAutoScroll,
scrollToBottom,
handleScroll,
} = useAutoScroll([messages, activeTurn, streaming]);
// 将 File 转为 base64 并设置为 pendingImage
const attachImage = (file: File) => {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const commaIdx = result.indexOf(',');
const data = commaIdx > -1 ? result.substring(commaIdx + 1) : result;
setPendingImage({ data, mime_type: file.type, name: file.name });
};
reader.readAsDataURL(file);
};
// 粘贴事件:从剪贴板获取图片
const handlePaste = (e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) attachImage(file);
return;
}
}
};
// 获取会话列表
const fetchSessions = async (autoSelectFirst = false) => {
setLoadingSessions(true);
try {
const res = await axios.get<SessionSummary[]>('/api/chat/sessions');
setSessions(res.data);
// 如果有会话且当前没有选中,默认选中第一个
if (autoSelectFirst && res.data.length > 0 && !currentSessionId) {
setCurrentSessionId(res.data[0].session_id);
}
} catch (e) {
console.error('获取智能体会话列表失败:', e);
} finally {
setLoadingSessions(false);
}
};
// 初始化加载
useEffect(() => {
fetchSessions(false);
const fetchModes = async () => {
try {
const res = await axios.get<AgentMode[]>('/api/chat/modes');
setAgentModes(res.data);
} catch (e) {
console.error('获取 Agent 运行模式列表失败:', e);
// Fallback in case of API failure for resilience
setAgentModes([
{ id: 'default', name: '默认', icon: 'Brain', description: '通用天体物理学研究助手,自主判断搜索、阅读或计算' },
{ id: 'deep-research', name: '深度', icon: 'Compass', description: '多来源系统性文献调研与交叉验证,适合撰写综述' },
{ id: 'literature-reader', name: '精读', icon: 'BookOpen', description: '专注论文精读、翻译与笔记提炼,强制只读安全模式' },
]);
}
};
fetchModes();
}, []);
// 加载选中的会话历史消息
const loadSessionHistory = async (sessionId: string, silent: boolean = false, onSuccess?: () => void) => {
if (!silent) {
setLoadingHistory(true);
}
setShouldAutoScroll(false);
try {
const res = await axios.get<{ session: SessionSummary; messages: MessageRecord[] }>(`/api/chat/sessions/${sessionId}`);
const newMessages = res.data.messages;
if (res.data.session && res.data.session.mode) {
setAgentMode(res.data.session.mode);
}
if (onSuccess) {
onSuccess();
}
setMessages(newMessages);
// 加载历史后瞬时跳到底部(无动画),并在渲染稳定后恢复自动滚动状态
requestAnimationFrame(() => {
scrollToBottom('auto');
setTimeout(() => {
setShouldAutoScroll(true);
}, 50);
});
// 自动迁移流式工具调用的展开/折叠状态至历史视图
const newTurnIndex = newMessages.length > 0 ? newMessages[newMessages.length - 1].turn_index : 0;
newMessages.forEach(msg => {
if (msg.turn_index === newTurnIndex && msg.role === 'assistant' && msg.tool_calls) {
msg.tool_calls.forEach(tc => {
setExpandedArgs(prev => {
if (prev[tc.id] !== undefined) return prev;
return { ...prev, [tc.id]: false };
});
setExpandedResults(prev => {
if (prev[tc.id] !== undefined) return prev;
return { ...prev, [tc.id]: false };
});
});
}
});
} catch (e) {
console.error('加载会话消息历史失败:', e);
} finally {
if (!silent) {
setLoadingHistory(false);
}
}
};
// 会话切换监听
useEffect(() => {
if (currentSessionId) {
if (skipNextHistoryLoadRef.current) {
skipNextHistoryLoadRef.current = false;
} else {
loadSessionHistory(currentSessionId);
}
} else {
setMessages([]);
}
}, [currentSessionId]);
// 跨会话历史检索防抖逻辑
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults([]);
return;
}
const delayDebounceFn = setTimeout(async () => {
setLoadingSearch(true);
try {
const params: Record<string, any> = {
q: searchQuery,
scope: 'all',
limit: 30,
};
if (searchScopeOnlyCurrent && currentSessionId) {
params.session_id = currentSessionId;
}
const res = await axios.get<SearchResult[]>('/api/search/history', {
params,
});
setSearchResults(res.data);
} catch (e) {
console.error('搜索会话历史失败:', e);
} finally {
setLoadingSearch(false);
}
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [searchQuery, searchScopeOnlyCurrent, currentSessionId]);
// 新建会话
const handleNewSession = () => {
setCurrentSessionId(null);
setMessages([]);
setActiveTurn(null);
};
// 删除会话
const handleDeleteSession = async (sessionId: string, e: React.MouseEvent) => {
e.stopPropagation();
const performDelete = async () => {
try {
await axios.delete(`/api/chat/sessions/${sessionId}`);
setSessions(prev => prev.filter(s => s.session_id !== sessionId));
if (currentSessionId === sessionId) {
setCurrentSessionId(null);
setMessages([]);
}
} catch (err) {
console.error('删除会话失败:', err);
if (showAlert) {
showAlert('删除会话失败,请稍后重试。', '删除出错');
} else {
alert('删除会话失败,请稍后重试。');
}
}
};
if (showConfirm) {
showConfirm('确认删除此研讨会话吗?删除后将无法恢复。', performDelete, '确认删除');
} else {
if (window.confirm('确认删除此研讨会话吗?删除后将无法恢复。')) {
performDelete();
}
}
};
// 回退会话到指定消息
const handleRewind = async (messageId?: number, n?: number) => {
if (!currentSessionId) return;
const msg = messageId
? '确定要回退到此消息之前吗?此后的对话将被移除(可通过“恢复”按钮撤销)。'
: `确定要回退最近 ${n || 1} 个对话轮次吗?`;
const performRewind = async () => {
try {
const body: { n?: number; message_id?: number } = {};
if (messageId) body.message_id = messageId;
else body.n = n || 1;
const res = await axios.post<RewindResult>(
`/api/chat/sessions/${currentSessionId}/rewind`,
body
);
if (showAlert) {
showAlert(`已回退 ${res.data.rewound_count} 条消息`, '成功');
} else {
alert(`已回退 ${res.data.rewound_count} 条消息`);
}
// 重新加载会话(静默刷新,避免闪烁)
loadSessionHistory(currentSessionId, true);
fetchSessions(); // 更新侧栏 turn_count
} catch (e: any) {
const errMsg = `回退失败: ${e.response?.data || e.message}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
alert(errMsg);
}
}
};
if (showConfirm) {
showConfirm(msg, performRewind, '确认回退');
} else {
if (window.confirm(msg)) {
performRewind();
}
}
};
// 恢复上次回退(undo-of-undo
const handleRestoreRewind = async () => {
if (!currentSessionId) return;
try {
const res = await axios.post<{ restored_count: number }>(
`/api/chat/sessions/${currentSessionId}/rewind/restore`
);
if (res.data.restored_count > 0) {
const msg = `已恢复 ${res.data.restored_count} 条被回退的消息`;
if (showAlert) {
showAlert(msg, '成功');
} else {
alert(msg);
}
loadSessionHistory(currentSessionId, true);
fetchSessions();
} else {
const msg = '没有可恢复的回退操作';
if (showAlert) {
showAlert(msg, '提示');
} else {
alert(msg);
}
}
} catch (e: any) {
const errMsg = `恢复失败: ${e.response?.data || e.message}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
alert(errMsg);
}
}
};
// 分叉当前会话
const handleBranch = async () => {
if (!currentSessionId) return;
const performBranch = async () => {
try {
const res = await axios.post<BranchResult>(
`/api/chat/sessions/${currentSessionId}/branch`
);
if (showAlert) {
showAlert(`成功分叉会话!已复制 ${res.data.copied_count} 条消息`, '成功');
} else {
alert(`成功分叉会话!已复制 ${res.data.copied_count} 条消息`);
}
// 刷新列表并选中新的分叉会话
await fetchSessions();
setCurrentSessionId(res.data.branch_session_id);
} catch (e: any) {
const errMsg = `分叉失败: ${e.response?.data || e.message}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
alert(errMsg);
}
}
};
const msg = '确定要将当前活跃的消息分叉到一个新会话吗?';
if (showConfirm) {
showConfirm(msg, performBranch, '确认分叉会话');
} else {
if (window.confirm(msg)) {
performBranch();
}
}
};
// 重试最后一轮
const handleRetry = async () => {
if (!currentSessionId || streaming) return;
const performRetry = async () => {
try {
const res = await axios.post<RetryResult>(
`/api/chat/sessions/${currentSessionId}/retry`
);
const questionText = res.data.retried_message;
if (!questionText) {
if (showAlert) {
showAlert('没有找到可重试的上一轮问题', '提示');
} else {
alert('没有找到可重试的上一轮问题');
}
return;
}
// 重新加载会话以移除已被硬删除的消息,然后自动触发发送
await loadSessionHistory(currentSessionId, true);
await fetchSessions();
// 如果原消息附带了图片,通过 path 复用已有文件,避免重新上传
if (res.data.image_path) {
const ext = res.data.image_path.split('.').pop() || 'png';
const mimeType = { jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', png: 'image/png' }[ext] || 'image/png';
handleSend(questionText, { path: res.data.image_path, mime_type: mimeType, name: res.data.image_path.split('/').pop() || 'image' });
return;
}
// 触发自动重发
handleSend(questionText);
} catch (e: any) {
const errMsg = `重试失败: ${e.response?.data || e.message}`;
if (showAlert) {
showAlert(errMsg, '错误');
} else {
alert(errMsg);
}
}
};
const msg = '确定要重试最后一轮对话吗?原回答将被删除。';
if (showConfirm) {
showConfirm(msg, performRetry, '确认重试对话');
} else {
if (window.confirm(msg)) {
performRetry();
}
}
};
// 手动停止智能体执行
const handleStop = async () => {
if (!currentSessionId) return;
try {
await axios.post(`/api/chat/sessions/${currentSessionId}/stop`);
} catch (e) {
console.error('停止智能体执行失败:', e);
}
};
// 发送消息与流式响应处理
const handleSend = async (questionText: string, imageOverride?: { data?: string; path?: string; mime_type: string; name: string } | null) => {
if (!questionText.trim() || streaming) return;
if (!currentSessionId) {
skipNextHistoryLoadRef.current = true;
}
setInput('');
const image = imageOverride !== undefined ? imageOverride : pendingImage;
setPendingImage(null);
setStreaming(true);
setShouldAutoScroll(true);
const active: ActiveTurn = {
question: questionText,
imagePath: image
? (image.path ? `/api/files/${image.path}` : `data:${image.mime_type};base64,${image.data}`)
: undefined,
timeline: [],
finalAnswer: '',
};
setActiveTurn(active);
try {
const response = await fetch('/api/chat/agent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
question: questionText,
session_id: currentSessionId,
mode: agentMode,
thinking,
...(image ? {
image: image.path
? { path: image.path, mime_type: image.mime_type }
: { data: image.data, mime_type: image.mime_type },
} : {}),
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('ReadableStream not supported');
}
const decoder = new TextDecoder('utf-8');
let buffer = '';
let activeSessionId = currentSessionId;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('data:')) {
const dataStr = trimmed.slice(5).trim();
if (!dataStr) continue;
try {
const event = JSON.parse(dataStr);
switch (event.type) {
case 'session':
if (!activeSessionId) {
activeSessionId = event.session_id;
setCurrentSessionId(event.session_id);
fetchSessions();
} else if (event.title) {
setSessions(prev => prev.map(s =>
s.session_id === event.session_id ? { ...s, title: event.title } : s
));
}
break;
case 'thought':
setActiveTurn(prev => {
if (!prev) return prev;
return { ...prev, timeline: routeThought(prev.timeline, event.step, event.content) };
});
break;
case 'tool_call':
setActiveTurn(prev => {
if (!prev) return prev;
return {
...prev,
timeline: routeToolCall(prev.timeline, event.step, event.id, event.name, event.arguments),
};
});
break;
case 'tool_result':
setExpandedResults(prev => ({ ...prev, [event.tool_call_id]: true }));
setActiveTurn(prev => {
if (!prev) return prev;
return {
...prev,
timeline: routeToolResult(prev.timeline, event.tool_call_id, event.name, event.output, event.is_error),
};
});
break;
case 'text_delta':
if (event.tool_call_id) {
setExpandedResults(prev => ({ ...prev, [event.tool_call_id]: true }));
}
setActiveTurn(prev => {
if (!prev) return prev;
if (event.tool_call_id) {
const hasToolCall = prev.timeline.some(
t => t.type === 'tool_call' && 'id' in t && t.id === event.tool_call_id,
);
if (!hasToolCall) return prev;
}
const { timeline, answerDelta } = routeTextDelta(
prev.timeline, event.content, event.tool_call_id,
);
return {
...prev,
timeline,
finalAnswer: prev.finalAnswer + answerDelta,
};
});
break;
case 'usage':
setActiveTurn(prev => {
if (!prev) return prev;
return { ...prev, usage: event };
});
break;
case 'error':
setActiveTurn(prev => {
if (!prev) return prev;
return { ...prev, error: event.message };
});
break;
}
} catch (err) {
console.error('解析 SSE 数据包失败:', err, trimmed);
}
}
}
}
setStreaming(false);
if (activeSessionId) {
loadSessionHistory(activeSessionId, true, () => {
setActiveTurn(null);
});
fetchSessions();
} else {
setActiveTurn(null);
}
} catch (e: any) {
console.error('智能体对话请求失败:', e);
setActiveTurn(prev => prev ? { ...prev, error: e.message || '网络连接错误,请稍后重试' } : null);
setStreaming(false);
}
};
const turns = groupMessagesIntoTurns(messages);
const toggleThought = (key: string) => setExpandedThoughts(prev => ({ ...prev, [key]: !prev[key] }));
const toggleArgs = (tcId: string) => setExpandedArgs(prev => ({ ...prev, [tcId]: !prev[tcId] }));
const toggleResult = (tcId: string) => setExpandedResults(prev => ({ ...prev, [tcId]: !prev[tcId] }));
const toggleSubAgent = (id: string) => setCollapsedSubAgents(prev => ({ ...prev, [id]: !prev[id] }));
return {
sessions,
currentSessionId,
setCurrentSessionId,
messages,
activeTurn,
streaming,
input,
setInput,
agentMode,
setAgentMode,
agentModes,
thinking,
setThinking,
pendingImage,
setPendingImage,
loadingSessions,
loadingHistory,
sidebarCollapsed,
setSidebarCollapsed,
searchQuery,
setSearchQuery,
searchScopeOnlyCurrent,
setSearchScopeOnlyCurrent,
searchResults,
loadingSearch,
expandedThoughts,
expandedArgs,
expandedResults,
collapsedSubAgents,
showMetrics,
setShowMetrics,
showAuditLog,
setShowAuditLog,
hasPendingPermission,
setHasPendingPermission,
hasPendingQuestion,
setHasPendingQuestion,
fileInputRef,
chatEndRef,
scrollContainerRef,
handleScroll,
attachImage,
handlePaste,
fetchSessions,
loadSessionHistory,
handleNewSession,
handleDeleteSession,
handleRewind,
handleRestoreRewind,
handleBranch,
handleRetry,
handleStop,
handleSend,
turns,
toggleThought,
toggleArgs,
toggleResult,
toggleSubAgent,
};
}
// 辅助函数:将消息列表还原为以 TimelineItem[] 组织的 turns
function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
const turnsMap = new Map<number, ProcessedTurn>();
const nonSystemMessages = messages.filter(m => m.role !== 'system');
const leadMessages = nonSystemMessages.filter(m => !m.metadata?.is_subagent && !m.agent_name?.startsWith('sub_'));
const subMessages = nonSystemMessages.filter(m => m.metadata?.is_subagent || m.agent_name?.startsWith('sub_'));
const subByAgent = new Map<string, MessageRecord[]>();
for (const sm of subMessages) {
const key = sm.metadata?.agent || sm.agent_name || 'unknown';
if (!subByAgent.has(key)) subByAgent.set(key, []);
subByAgent.get(key)!.push(sm);
}
for (const msg of leadMessages) {
let turn = turnsMap.get(msg.turn_index);
if (!turn) {
turn = {
turn_index: msg.turn_index,
question: '',
timeline: [],
createdAt: msg.created_at,
};
turnsMap.set(msg.turn_index, turn);
}
if (msg.token_count > 0) {
if (!turn.usage) {
turn.usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
}
if (msg.role === 'assistant') {
turn.usage.completion_tokens += msg.token_count;
} else {
turn.usage.prompt_tokens += msg.token_count;
}
turn.usage.total_tokens = turn.usage.prompt_tokens + turn.usage.completion_tokens;
}
if (msg.role === 'user') {
turn.question = msg.content;
turn.questionMessageId = msg.id;
if (msg.metadata?.image_path) {
turn.imagePath = `/api/files/${msg.metadata.image_path}`;
}
} else if (msg.role === 'assistant') {
const stepNum = msg.step_index;
const hasToolCalls = msg.tool_calls && msg.tool_calls.length > 0;
const hasThought = !!msg.thought;
const hasContent = !!msg.content;
if (hasToolCalls) {
if (hasThought) {
const alreadyExists = turn.timeline.some(
t => t.type === 'thought' && t.step === stepNum
);
if (!alreadyExists) {
turn.timeline.push({ type: 'thought', step: stepNum, content: msg.thought! });
}
}
for (const tc of msg.tool_calls!) {
let parsedArgs = {};
try {
parsedArgs = typeof tc.function.arguments === 'string'
? JSON.parse(tc.function.arguments)
: tc.function.arguments;
} catch { /* ignore */ }
turn.timeline.push({
type: 'tool_call',
step: stepNum,
id: tc.id,
name: tc.function.name,
arguments: parsedArgs,
});
}
} else if (hasThought && hasContent) {
const alreadyExists = turn.timeline.some(
t => t.type === 'thought' && t.step === stepNum
);
if (!alreadyExists) {
turn.timeline.push({ type: 'thought', step: stepNum, content: msg.thought! });
}
turn.timeline.push({ type: 'answer', content: msg.content! });
} else if (hasContent) {
turn.timeline.push({ type: 'answer', content: msg.content! });
}
} else if (msg.role === 'tool') {
for (const item of turn.timeline) {
if (item.type === 'tool_call' && item.id === msg.tool_call_id && !item.result) {
let isError = false;
if (msg.content.startsWith('错误:') || msg.content.includes('fail') || msg.content.startsWith('Error:')) {
isError = true;
}
item.result = { output: msg.content, isError };
break;
}
}
}
}
for (const [, turn] of turnsMap) {
for (let i = 0; i < turn.timeline.length; i++) {
const item = turn.timeline[i];
if (item.type !== 'tool_call' || (item.name !== 'subagent' && item.name !== 'delegate_research')) continue;
const delegateResult = item.result?.output || '';
const delegateTcId = item.id;
let subChildren: TimelineItem[] = [];
for (const [agentName, msgs] of subByAgent) {
if (msgs.length === 0) continue;
const filtered = msgs.filter(m => m.role !== 'system' && m.role !== 'user');
if (filtered.length === 0) continue;
const children: TimelineItem[] = [];
for (const msg of filtered) {
if (msg.role === 'assistant') {
const stepNum = msg.step_index;
const hasToolCalls = msg.tool_calls && msg.tool_calls.length > 0;
const hasThought = !!msg.thought;
const hasContent = !!msg.content;
if (hasToolCalls) {
if (hasThought) {
const alreadyExists = children.some(
c => c.type === 'thought' && c.step === stepNum
);
if (!alreadyExists) {
children.push({ type: 'thought', step: stepNum, content: msg.thought! });
}
}
for (const tc of msg.tool_calls!) {
let parsedArgs = {};
try {
parsedArgs = typeof tc.function.arguments === 'string'
? JSON.parse(tc.function.arguments)
: tc.function.arguments;
} catch { /* ignore */ }
children.push({
type: 'tool_call',
step: stepNum,
id: tc.id,
name: tc.function.name,
arguments: parsedArgs,
});
}
} else if (hasThought && hasContent) {
const alreadyExists = children.some(
c => c.type === 'thought' && c.step === stepNum
);
if (!alreadyExists) {
children.push({ type: 'thought', step: stepNum, content: msg.thought! });
}
children.push({ type: 'answer', content: msg.content! });
} else if (hasContent) {
children.push({ type: 'answer', content: msg.content! });
}
} else if (msg.role === 'tool') {
for (const child of children) {
if (child.type === 'tool_call' && child.id === msg.tool_call_id && !child.result) {
let isError = false;
if (msg.content.startsWith('错误:') || msg.content.includes('fail') || msg.content.startsWith('Error:')) {
isError = true;
}
child.result = { output: msg.content, isError };
break;
}
}
}
}
subChildren = children;
subByAgent.delete(agentName);
break;
}
turn.timeline[i] = {
type: 'subagent_container',
id: delegateTcId,
status: 'complete',
children: subChildren,
summary: delegateResult,
};
}
}
const sortedTurns = Array.from(turnsMap.values()).sort((a, b) => a.turn_index - b.turn_index);
return sortedTurns;
}
+123
View File
@@ -0,0 +1,123 @@
// dashboard/src/hooks/useSearch.ts
import { useState } from 'react';
import axios from 'axios';
import type { StandardPaper } from '../types';
interface UseSearchProps {
showAlert: (message: string, title?: string) => void;
}
export function useSearch({ showAlert }: UseSearchProps) {
const [searchQuery, setSearchQuery] = useState('');
const [searchSource, setSearchSource] = useState<'all' | 'ads' | 'arxiv'>('all');
const [searchResults, setSearchResults] = useState<StandardPaper[]>([]);
const [searching, setSearching] = useState(false);
const [exportingList, setExportingList] = useState<string[]>([]);
const [bibtexContent, setBibtexContent] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const [searchRows, setSearchRows] = useState(15);
const [searchStart, setSearchStart] = useState(0);
const [searchSort, setSearchSort] = useState('relevance');
const [searchCache, setSearchCache] = useState<Record<string, StandardPaper[]>>({});
const executeSearch = async (start: number, rows: number, sort: string) => {
if (!searchQuery.trim()) return;
// 构造缓存 Key
const cacheKey = `${searchQuery.trim()}_${searchSource}_${start}_${rows}_${sort}`;
if (searchCache[cacheKey]) {
setSearchResults(searchCache[cacheKey]);
return;
}
setSearching(true);
setBibtexContent(null);
try {
const res = await axios.get<StandardPaper[]>('/api/search', {
params: { q: searchQuery, source: searchSource, rows, start, sort }
});
setSearchResults(res.data);
// 写入缓存
setSearchCache(prev => ({ ...prev, [cacheKey]: res.data }));
} catch (e) {
console.error('检索文献失败', e);
showAlert('检索失败,请确认后端连接及 API 密钥配置。', '检索出错');
} finally {
setSearching(false);
}
};
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
setSearchStart(0);
executeSearch(0, searchRows, searchSort);
};
const handlePageChange = (newStart: number) => {
setSearchStart(newStart);
executeSearch(newStart, searchRows, searchSort);
};
const handleSortChange = (newSort: string) => {
setSearchSort(newSort);
setSearchStart(0);
executeSearch(0, searchRows, newSort);
};
const handleRowsChange = (newRows: number) => {
setSearchRows(newRows);
setSearchStart(0);
executeSearch(0, newRows, searchSort);
};
const handleExportBibtex = async () => {
if (exportingList.length === 0) return;
setExporting(true);
try {
const res = await axios.post<{ bibtex: string }>('/api/export', { bibcodes: exportingList });
setBibtexContent(res.data.bibtex);
} catch (e) {
console.error('导出 BibTeX 失败', e);
showAlert('导出 BibTeX 失败,请检查 ADS Token。', '导出失败');
} finally {
setExporting(false);
}
};
const toggleExportItem = (bibcode: string) => {
setExportingList(prev =>
prev.includes(bibcode) ? prev.filter(b => b !== bibcode) : [...prev, bibcode]
);
};
return {
searchQuery,
setSearchQuery,
searchSource,
setSearchSource,
searchResults,
setSearchResults,
searching,
setSearching,
exportingList,
setExportingList,
bibtexContent,
setBibtexContent,
exporting,
setExporting,
searchRows,
setSearchRows,
searchStart,
setSearchStart,
searchSort,
setSearchSort,
searchCache,
executeSearch,
handleSearch,
handlePageChange,
handleSortChange,
handleRowsChange,
handleExportBibtex,
toggleExportItem,
};
}
+210
View File
@@ -0,0 +1,210 @@
// dashboard/src/hooks/useSyncScroll.ts
// 双语分栏对照阅读器的高精度双轨同步滚动引擎 (段落物理锚定与全局比例自适应回退)
import { useRef, useCallback } from 'react';
export function useSyncScroll(
syncScroll: boolean,
viewMode: 'bilingual' | 'english' | 'chinese',
showPdf: boolean,
hasMarkdown: boolean,
onScrollStart?: () => void
) {
const englishRef = useRef<HTMLDivElement>(null);
const chineseRef = useRef<HTMLDivElement>(null);
const scrollLock = useRef(false);
// 英文滚动同步到中文
const handleEnglishScroll = useCallback(() => {
if (!syncScroll || viewMode !== 'bilingual') return;
if (scrollLock.current) return;
if (onScrollStart) onScrollStart();
const eng = englishRef.current;
const chn = chineseRef.current;
if (!eng || !chn) return;
scrollLock.current = true;
// ── 判定是否执行全局比例映射 ──
if (showPdf || !hasMarkdown) {
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
requestAnimationFrame(() => {
scrollLock.current = false;
});
return;
}
// ── 执行高精度段落锚定与局部微调算法 ──
try {
const engParas = eng.querySelectorAll('[id^="paragraph-block-"]');
if (engParas.length === 0) {
// 鲁棒性退后兜底:若没有获取到任何段落 DOM,执行全局比例映射
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
} else {
// 1. 计算 15% 虚拟视窗分界线的高度位置
const threshold = eng.scrollTop + eng.clientHeight * 0.15;
// 2. 遍历源端段落,寻找当前穿过分界线的活动锚点段落
let anchorIndex = -1;
let anchorElement: HTMLElement | null = null;
for (let i = 0; i < engParas.length; i++) {
const el = engParas[i] as HTMLElement;
if (el.offsetTop <= threshold && el.offsetTop + el.offsetHeight >= threshold) {
anchorIndex = i;
anchorElement = el;
break;
}
}
// 3. 边界兜底:若没有完美契合的活动段落
if (anchorIndex === -1) {
if (threshold < (engParas[0] as HTMLElement).offsetTop) {
// 比第一段还靠上,锚定第 0 段
anchorIndex = 0;
anchorElement = engParas[0] as HTMLElement;
} else {
// 已经滚过了最后一段,锚定最后一段
anchorIndex = engParas.length - 1;
anchorElement = engParas[engParas.length - 1] as HTMLElement;
}
}
// 4. 计算该段落的局部滚出比例 (LocalRatio)
let localRatio = 0;
if (anchorElement) {
const offsetTop = anchorElement.offsetTop;
const offsetHeight = anchorElement.offsetHeight || 1;
localRatio = (threshold - offsetTop) / offsetHeight;
localRatio = Math.max(0, Math.min(1, localRatio)); // 限制在安全浮点数 0~1
}
// 5. 目标端(中文)寻找同索引段落并进行物理锚定
const chnParas = chn.querySelectorAll('[id^="paragraph-block-"]');
const targetPara = chnParas[anchorIndex] as HTMLElement | undefined;
if (targetPara) {
const targetOffsetTop = targetPara.offsetTop;
const targetOffsetHeight = targetPara.offsetHeight;
// 目标 scrollTop = 目标段落顶部位置 - 目标容器 15% 分界线偏置 + 局部拉伸偏移
const targetScrollTop = targetOffsetTop - chn.clientHeight * 0.15 + localRatio * targetOffsetHeight;
const maxScroll = chn.scrollHeight - chn.clientHeight;
chn.scrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
} else {
// 极端边界:若两侧段落数不一致导致越界,以全局比例兜底
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
}
}
} catch (err) {
console.error('段落对齐滚动计算失败:', err);
} finally {
requestAnimationFrame(() => {
scrollLock.current = false;
});
}
}, [syncScroll, viewMode, showPdf, hasMarkdown, onScrollStart]);
// 中文滚动同步到英文
const handleChineseScroll = useCallback(() => {
if (!syncScroll || viewMode !== 'bilingual') return;
if (scrollLock.current) return;
if (onScrollStart) onScrollStart();
const eng = englishRef.current;
const chn = chineseRef.current;
if (!eng || !chn) return;
scrollLock.current = true;
// ── 判定是否执行全局比例映射 ──
if (showPdf || !hasMarkdown) {
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
requestAnimationFrame(() => {
scrollLock.current = false;
});
return;
}
// ── 执行高精度段落锚定与局部微调算法 ──
try {
const chnParas = chn.querySelectorAll('[id^="paragraph-block-"]');
if (chnParas.length === 0) {
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
} else {
// 1. 计算 15% 虚拟视窗分界线的高度位置
const threshold = chn.scrollTop + chn.clientHeight * 0.15;
// 2. 遍历源端段落,寻找当前活动锚点段落
let anchorIndex = -1;
let anchorElement: HTMLElement | null = null;
for (let i = 0; i < chnParas.length; i++) {
const el = chnParas[i] as HTMLElement;
if (el.offsetTop <= threshold && el.offsetTop + el.offsetHeight >= threshold) {
anchorIndex = i;
anchorElement = el;
break;
}
}
// 3. 边界兜底
if (anchorIndex === -1) {
if (threshold < (chnParas[0] as HTMLElement).offsetTop) {
anchorIndex = 0;
anchorElement = chnParas[0] as HTMLElement;
} else {
anchorIndex = chnParas.length - 1;
anchorElement = chnParas[chnParas.length - 1] as HTMLElement;
}
}
// 4. 计算该段落的局部滚出比例 (LocalRatio)
let localRatio = 0;
if (anchorElement) {
const offsetTop = anchorElement.offsetTop;
const offsetHeight = anchorElement.offsetHeight || 1;
localRatio = (threshold - offsetTop) / offsetHeight;
localRatio = Math.max(0, Math.min(1, localRatio));
}
// 5. 目标端(英文)寻找同索引段落并进行物理锚定
const engParas = eng.querySelectorAll('[id^="paragraph-block-"]');
const targetPara = engParas[anchorIndex] as HTMLElement | undefined;
if (targetPara) {
const targetOffsetTop = targetPara.offsetTop;
const targetOffsetHeight = targetPara.offsetHeight;
const targetScrollTop = targetOffsetTop - eng.clientHeight * 0.15 + localRatio * targetOffsetHeight;
const maxScroll = eng.scrollHeight - eng.clientHeight;
eng.scrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
} else {
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
}
}
} catch (err) {
console.error('段落对齐滚动计算失败:', err);
} finally {
requestAnimationFrame(() => {
scrollLock.current = false;
});
}
}, [syncScroll, viewMode, showPdf, hasMarkdown, onScrollStart]);
return {
englishRef,
chineseRef,
handleEnglishScroll,
handleChineseScroll,
};
}
+375
View File
@@ -0,0 +1,375 @@
// dashboard/src/hooks/useSyncState.ts
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import type { SavedSyncQuery } from '../types';
export interface BatchStatus {
active: boolean;
total: number;
downloaded: number;
parsed: number;
download_failed: number;
parse_failed: number;
current_bibcode: string;
logs: string[];
action?: 'download' | 'parse' | 'translate' | 'embed' | 'target';
}
export interface HarvestStatus {
active: boolean;
query: string;
source: string;
synced: number;
total: number;
}
export function useSyncState() {
const [query, setQuery] = useState('');
const [source, setSource] = useState<'all' | 'ads' | 'arxiv'>('all');
const [limit, setLimit] = useState<number>(200);
const [estimating, setEstimating] = useState(false);
const [estimatedCount, setEstimatedCount] = useState<number | null>(null);
const [status, setStatus] = useState<HarvestStatus>({
active: false,
query: '',
source: '',
synced: 0,
total: 0,
});
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [syncQueries, setSyncQueries] = useState<SavedSyncQuery[]>([]);
const pollIntervalRef = useRef<any>(null);
// 批量下载与解析相关状态
const [targetPhase, setTargetPhase] = useState<'download' | 'parse' | 'translate' | 'embed' | 'target'>('download');
const [batchLimitCount, setBatchLimitCount] = useState<number>(100);
const [sortOrder, setSortOrder] = useState<'default' | 'pub_year_desc' | 'created_at_desc'>('default');
const [skipCompleted, setSkipCompleted] = useState<boolean>(true);
const [skipFailed, setSkipFailed] = useState<boolean>(false);
const [skipPrecedingFailed, setSkipPrecedingFailed] = useState<boolean>(false);
const [skipPrecedingUncompleted, setSkipPrecedingUncompleted] = useState<boolean>(false);
const [batchStatus, setBatchStatus] = useState<BatchStatus>({
active: false,
total: 0,
downloaded: 0,
parsed: 0,
download_failed: 0,
parse_failed: 0,
current_bibcode: '',
logs: [],
});
const [batchError, setBatchError] = useState<string | null>(null);
const batchPollIntervalRef = useRef<any>(null);
const logsContainerRef = useRef<HTMLDivElement | null>(null);
const [showBuilder, setShowBuilder] = useState(false);
const [rules, setRules] = useState<Array<{ field: string; op: string; val: string }>>([
{ field: 'all', op: 'AND', val: '' }
]);
// 当高级表单规则变化时,自动更新同步输入框的检索式
const updateQueryFromRules = (currentRules: typeof rules) => {
let qParts: string[] = [];
currentRules.forEach((rule, idx) => {
if (!rule.val.trim()) return;
let valStr = rule.val.trim();
if (valStr.includes(' ') && !valStr.startsWith('"') && !valStr.startsWith('(')) {
valStr = `"${valStr}"`;
}
let fieldPart = '';
if (rule.field !== 'all') {
fieldPart = `${rule.field}:${valStr}`;
} else {
fieldPart = valStr;
}
if (idx === 0) {
qParts.push(fieldPart);
} else {
qParts.push(`${rule.op} ${fieldPart}`);
}
});
setQuery(qParts.join(' '));
};
const handleAddRule = () => {
setRules(prev => [...prev, { field: 'all', op: 'AND', val: '' }]);
};
const handleRemoveRule = (idx: number) => {
const next = rules.filter((_, i) => i !== idx);
setRules(next);
updateQueryFromRules(next);
};
const handleRuleChange = (idx: number, key: 'field' | 'op' | 'val', value: string) => {
const next = rules.map((r, i) => i === idx ? { ...r, [key]: value } : r);
setRules(next);
updateQueryFromRules(next);
};
// 获取历史检索配置
const fetchSyncQueries = async () => {
try {
const res = await axios.get<SavedSyncQuery[]>(`/api/sync/queries?t=${Date.now()}`);
setSyncQueries(res.data);
} catch (e) {
console.error('获取检索配置列表失败', e);
}
};
const handleDeleteQuery = async (id: number) => {
try {
await axios.delete(`/api/sync/queries/${id}`);
fetchSyncQueries();
} catch (e) {
console.error('删除配置失败', e);
}
};
const handleReuseQuery = (sq: SavedSyncQuery) => {
setQuery(sq.query);
setSource(sq.source as any);
setLimit(sq.limit_count);
};
const handleQuickSync = async (sq: SavedSyncQuery) => {
setErrorMsg(null);
// 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态
setStatus(prev => ({
...prev,
active: true,
query: sq.query,
source: sq.source as any,
synced: 0,
total: sq.limit_count,
}));
startPolling();
try {
await axios.post('/api/sync/meta/run', {
q: sq.query,
source: sq.source,
limit: sq.limit_count,
});
fetchStatus();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动快速同步失败。');
fetchStatus();
}
};
// 获取当前的元数据同步状态
const fetchStatus = async () => {
try {
const res = await axios.get<HarvestStatus>(`/api/sync/meta/status?t=${Date.now()}`);
setStatus(res.data);
if (res.data.active) {
startPolling();
} else {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
fetchSyncQueries();
}
}
} catch (e) {
console.error('获取同步状态失败', e);
}
};
// 开始轮询元数据同步状态
const startPolling = () => {
if (pollIntervalRef.current) return;
pollIntervalRef.current = setInterval(fetchStatus, 1000);
};
useEffect(() => {
fetchStatus();
fetchSyncQueries();
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
// 批量下载与解析相关的网络操作
const fetchBatchStatus = async () => {
try {
const res = await axios.get<BatchStatus>(`/api/batch/asset/status?t=${Date.now()}`);
setBatchStatus(res.data);
if (res.data.active) {
startBatchPolling();
} else if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
batchPollIntervalRef.current = null;
}
} catch (e) {
console.error('获取处理状态失败', e);
}
};
const startBatchPolling = () => {
if (batchPollIntervalRef.current) return;
batchPollIntervalRef.current = setInterval(fetchBatchStatus, 1000);
};
const handleStartBatch = async () => {
setBatchError(null);
try {
await axios.post('/api/batch/asset/run', {
target_phase: targetPhase,
limit_count: batchLimitCount,
sort_order: sortOrder,
skip_completed: skipCompleted,
skip_failed: skipFailed,
skip_preceding_failed: skipPrecedingFailed,
skip_preceding_uncompleted: skipPrecedingUncompleted,
});
fetchBatchStatus();
startBatchPolling();
} catch (e: any) {
console.error(e);
setBatchError(e.response?.data || '启动批量任务失败。');
}
};
const handleStopBatch = async () => {
try {
await axios.post('/api/batch/asset/stop');
fetchBatchStatus();
} catch (e: any) {
console.error(e);
setBatchError(e.response?.data || '停止任务失败。');
}
};
useEffect(() => {
fetchBatchStatus();
return () => {
if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
}
};
}, []);
// 日志终端自动滚动到底部
useEffect(() => {
const container = logsContainerRef.current;
if (container) {
const isCloseToBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 80;
if (isCloseToBottom) {
container.scrollTop = container.scrollHeight;
}
}
}, [batchStatus.logs]);
// 估算文献总量
const handleEstimate = async () => {
if (!query.trim()) {
setErrorMsg('请输入检索关键词!');
return;
}
setErrorMsg(null);
setEstimating(true);
setEstimatedCount(null);
try {
const res = await axios.get<{ total: number }>('/api/sync/meta/count', {
params: { q: query.trim(), source }
});
setEstimatedCount(res.data.total);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '估算文献总量失败,请检查 API 密钥或网络。');
} finally {
setEstimating(false);
}
};
// 启动任务
const handleStartHarvest = async () => {
if (!query.trim()) {
setErrorMsg('请输入检索关键词!');
return;
}
setErrorMsg(null);
setStatus(prev => ({
...prev,
active: true,
query: query.trim(),
source,
synced: 0,
total: 0,
}));
startPolling();
try {
await axios.post('/api/sync/meta/run', {
q: query.trim(),
source,
limit: limit,
});
fetchStatus();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动元数据同步任务失败。');
fetchStatus();
}
};
return {
query,
setQuery,
source,
setSource,
limit,
setLimit,
estimating,
estimatedCount,
status,
errorMsg,
setErrorMsg,
syncQueries,
targetPhase,
setTargetPhase,
batchLimitCount,
setBatchLimitCount,
sortOrder,
setSortOrder,
skipCompleted,
setSkipCompleted,
skipFailed,
setSkipFailed,
skipPrecedingFailed,
setSkipPrecedingFailed,
skipPrecedingUncompleted,
setSkipPrecedingUncompleted,
batchStatus,
batchError,
setBatchError,
showBuilder,
setShowBuilder,
rules,
logsContainerRef,
handleAddRule,
handleRemoveRule,
handleRuleChange,
handleDeleteQuery,
handleReuseQuery,
handleQuickSync,
handleEstimate,
handleStartHarvest,
handleStartBatch,
handleStopBatch,
};
}
@@ -1,8 +1,8 @@
// dashboard/src/features/citation/CitationPanel.tsx
import { useState } from 'react';
import { Loader, GitFork, RotateCcw, ChevronDown, History, FileText } from 'lucide-react';
import { CitationGalaxyCanvas } from '../../components/CitationGalaxyCanvas';
import type { StandardPaper, CitationNetwork } from '../../types';
import { CitationGalaxyCanvas } from '../components/CitationGalaxyCanvas';
import type { StandardPaper, CitationNetwork } from '../types';
interface CitationPanelProps {
selectedPaper: StandardPaper | null;
@@ -1,9 +1,9 @@
// dashboard/src/features/library/LibraryPanel.tsx
import { useState, useCallback } from 'react';
import { Library, RotateCw, Search, SlidersHorizontal, X, CheckCircle, AlertTriangle, BookOpen, GitFork } from 'lucide-react';
import type { StandardPaper } from '../../types';
import { getDoctypeBadge } from '../search/SearchPanel';
import { CustomSelect } from '../../components/CustomSelect';
import type { StandardPaper } from '../types';
import { getDoctypeBadge } from '../utils/paper';
import { CustomSelect } from '../components/CustomSelect';
interface LibraryPanelProps {
library: StandardPaper[];
+174
View File
@@ -0,0 +1,174 @@
// dashboard/src/features/reader/ReaderPanel.tsx
import { useState } from 'react';
import type { StandardPaper, NoteRecord } from '../types';
import { useReaderState } from '../hooks/useReaderState';
import { ReaderToolbar } from '../components/reader/ReaderToolbar';
import { BilingualViewer } from '../components/reader/BilingualViewer';
import { ReaderNotesSidebar } from '../components/reader/ReaderNotesSidebar';
interface ReaderPanelProps {
selectedPaper: StandardPaper;
library: StandardPaper[];
recentlySelected: StandardPaper[];
onSwitchPaper: (paper: StandardPaper) => void;
parsing: boolean;
handleParse: (bibcode: string, force?: boolean) => void;
translating: boolean;
handleTranslate: (bibcode: string, force?: boolean) => void;
vectorizing: boolean;
handleVectorize: (bibcode: string) => void;
showNotesPanel: boolean;
setShowNotesPanel: (show: boolean) => void;
notes: NoteRecord[];
englishText: string;
chineseText: string;
handleTextSelection: (paragraphIdx: number) => void;
selectedParagraphIdx: number | null;
setSelectedParagraphIdx: (idx: number | null) => void;
selectedText: string;
setSelectedText: (text: string) => void;
newNoteColor: string;
setNewNoteColor: (color: string) => void;
newNoteText: string;
setNewNoteText: (text: string) => void;
handleCreateNote: () => void;
handleDeleteNote: (id: number) => void;
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
onJumpToSource: (bibcode: string, paragraphIndex: number) => void;
}
export function ReaderPanel(props: ReaderPanelProps) {
const {
selectedPaper,
library,
recentlySelected,
onSwitchPaper,
parsing,
handleParse,
translating,
handleTranslate,
vectorizing,
handleVectorize,
showNotesPanel,
setShowNotesPanel,
notes,
englishText,
chineseText,
handleTextSelection,
selectedParagraphIdx,
setSelectedParagraphIdx,
selectedText,
setSelectedText,
newNoteColor,
setNewNoteColor,
newNoteText,
setNewNoteText,
handleCreateNote,
handleDeleteNote,
showConfirm,
onJumpToSource,
} = props;
// 局部管理阅读视角模式(原文/中文/对照)
const [viewMode, setViewMode] = useState<'bilingual' | 'english' | 'chinese'>(() => {
if (typeof window !== 'undefined' && window.innerWidth < 768) {
return 'english';
}
return 'bilingual';
});
// 调用封装后的阅读器逻辑状态 Hook
const state = useReaderState({
selectedPaper,
englishText,
viewMode,
setViewMode,
});
return (
<div className="w-full flex-1 flex flex-col space-y-4 min-h-0">
{/* 顶部控制栏 */}
<ReaderToolbar
selectedPaper={selectedPaper}
library={library}
recentlySelected={recentlySelected}
onSwitchPaper={onSwitchPaper}
parsing={parsing}
handleParse={handleParse}
translating={translating}
handleTranslate={handleTranslate}
vectorizing={vectorizing}
handleVectorize={handleVectorize}
showNotesPanel={showNotesPanel}
setShowNotesPanel={setShowNotesPanel}
notesCount={notes.length}
showConfirm={showConfirm}
englishText={englishText}
chineseText={chineseText}
showSwitchMenu={state.showSwitchMenu}
setShowSwitchMenu={state.setShowSwitchMenu}
viewMode={viewMode}
setViewMode={setViewMode}
syncScroll={state.syncScroll}
setSyncScroll={state.setSyncScroll}
showPdf={state.showPdf}
/>
{/* 并排对比视窗与侧边栏 */}
<BilingualViewer
selectedPaper={selectedPaper}
parsing={parsing}
translating={translating}
englishText={englishText}
chineseText={chineseText}
notes={notes}
handleTextSelection={handleTextSelection}
showNotesPanel={showNotesPanel}
viewMode={viewMode}
showPdf={state.showPdf}
setShowPdf={state.setShowPdf}
englishRef={state.englishRef}
chineseRef={state.chineseRef}
handleEnglishScroll={state.handleEnglishScroll}
handleChineseScroll={state.handleChineseScroll}
handleMouseOver={state.handleMouseOver}
clearHoverTimeout={state.clearHoverTimeout}
handleContainerMouseLeave={state.handleContainerMouseLeave}
handleMouseLeaveTarget={state.handleMouseLeaveTarget}
highlightCandidates={state.highlightCandidates}
hoveredTarget={state.hoveredTarget}
hoverCardPos={state.hoverCardPos}
>
{/* 学术助手侧边栏 */}
<ReaderNotesSidebar
selectedPaper={selectedPaper}
showNotesPanel={showNotesPanel}
setShowNotesPanel={setShowNotesPanel}
notes={notes}
selectedParagraphIdx={selectedParagraphIdx}
setSelectedParagraphIdx={setSelectedParagraphIdx}
selectedText={selectedText}
setSelectedText={setSelectedText}
newNoteColor={newNoteColor}
setNewNoteColor={setNewNoteColor}
newNoteText={newNoteText}
setNewNoteText={setNewNoteText}
handleCreateNote={handleCreateNote}
handleDeleteNote={handleDeleteNote}
onJumpToSource={onJumpToSource}
sidebarTab={state.sidebarTab}
setSidebarTab={state.setSidebarTab}
targets={state.targets}
loadingTargets={state.loadingTargets}
identifyingTargets={state.identifyingTargets}
associatingTarget={state.associatingTarget}
manualTargetName={state.manualTargetName}
setManualTargetName={state.setManualTargetName}
handleIdentifyTargets={state.handleIdentifyTargets}
handleAssociateTarget={state.handleAssociateTarget}
handleJumpToTarget={state.handleJumpToTarget}
/>
</BilingualViewer>
</div>
);
}
@@ -0,0 +1,93 @@
import { useResearchAgent } from '../hooks/useResearchAgent';
import { AgentSessionSidebar } from '../components/agent/AgentSessionSidebar';
import { AgentMessageList } from '../components/agent/AgentMessageList';
import { AgentInputArea } from '../components/agent/AgentInputArea';
interface ResearchAgentPanelProps {
showConfirm?: (message: string, onConfirm: () => void, title?: string) => void;
showAlert?: (message: string, title?: string) => void;
}
export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPanelProps) {
const state = useResearchAgent({ showConfirm, showAlert });
return (
<div className="w-full flex-1 flex overflow-hidden bg-slate-100 rounded-2xl border border-slate-200 h-[calc(100vh-130px)] shadow-xs">
{/* 左侧会话列表侧栏 */}
<AgentSessionSidebar
sessions={state.sessions}
currentSessionId={state.currentSessionId}
setCurrentSessionId={state.setCurrentSessionId}
loadingSessions={state.loadingSessions}
searchQuery={state.searchQuery}
setSearchQuery={state.setSearchQuery}
searchScopeOnlyCurrent={state.searchScopeOnlyCurrent}
setSearchScopeOnlyCurrent={state.setSearchScopeOnlyCurrent}
searchResults={state.searchResults}
loadingSearch={state.loadingSearch}
collapsed={state.sidebarCollapsed}
onToggleCollapse={state.setSidebarCollapsed}
onNewSession={state.handleNewSession}
onDeleteSession={state.handleDeleteSession}
/>
{/* 右侧会话对话展示及输入面板 */}
<div className="flex-1 flex flex-col overflow-hidden bg-white relative">
<AgentMessageList
turns={state.turns}
activeTurn={state.activeTurn}
streaming={state.streaming}
currentSessionId={state.currentSessionId}
expandedThoughts={state.expandedThoughts}
expandedArgs={state.expandedArgs}
expandedResults={state.expandedResults}
collapsedSubAgents={state.collapsedSubAgents}
toggleThought={state.toggleThought}
toggleArgs={state.toggleArgs}
toggleResult={state.toggleResult}
toggleSubAgent={state.toggleSubAgent}
handleRewind={state.handleRewind}
handleRestoreRewind={state.handleRestoreRewind}
handleRetry={state.handleRetry}
handleBranch={state.handleBranch}
handleSend={state.handleSend}
scrollContainerRef={state.scrollContainerRef}
chatEndRef={state.chatEndRef}
handleScroll={state.handleScroll}
showMetrics={state.showMetrics}
setShowMetrics={state.setShowMetrics}
showAuditLog={state.showAuditLog}
setShowAuditLog={state.setShowAuditLog}
hasPendingPermission={state.hasPendingPermission}
setHasPendingPermission={state.setHasPendingPermission}
hasPendingQuestion={state.hasPendingQuestion}
setHasPendingQuestion={state.setHasPendingQuestion}
showAlert={showAlert}
sidebarCollapsed={state.sidebarCollapsed}
setSidebarCollapsed={state.setSidebarCollapsed}
sessions={state.sessions}
handleDeleteSession={state.handleDeleteSession}
loadSessionHistory={state.loadSessionHistory}
/>
<AgentInputArea
input={state.input}
setInput={state.setInput}
streaming={state.streaming}
thinking={state.thinking}
setThinking={state.setThinking}
agentMode={state.agentMode}
setAgentMode={state.setAgentMode}
agentModes={state.agentModes}
pendingImage={state.pendingImage}
setPendingImage={state.setPendingImage}
onSend={state.handleSend}
onStop={state.handleStop}
fileInputRef={state.fileInputRef}
attachImage={state.attachImage}
handlePaste={state.handlePaste}
/>
</div>
</div>
);
}
@@ -1,40 +1,10 @@
// dashboard/src/features/search/SearchPanel.tsx
import React from 'react';
import { Search, Loader, CheckCircle, Copy, Download, ChevronLeft, ChevronRight, SlidersHorizontal, AlertTriangle, Lightbulb } from 'lucide-react';
import type { StandardPaper } from '../../types';
import { CustomSelect } from '../../components/CustomSelect';
import type { StandardPaper } from '../types';
import { CustomSelect } from '../components/CustomSelect';
export const getDoctypeBadge = (doctype: string) => {
const typeMap: Record<string, { label: string; style: string }> = {
article: { label: '期刊文章', style: 'bg-blue-50 text-blue-700 border-blue-200' },
eprint: { label: '预印本', style: 'bg-purple-50 text-purple-700 border-purple-200' },
inproceedings: { label: '会议论文', style: 'bg-amber-50 text-amber-700 border-amber-200' },
proceedings: { label: '会议集', style: 'bg-amber-50 text-amber-700 border-amber-200' },
proposal: { label: '观测提案', style: 'bg-rose-50 text-rose-700 border-rose-200' },
abstract: { label: '会议摘要', style: 'bg-slate-50 text-slate-700 border-slate-200' },
catalog: { label: '星表数据', style: 'bg-indigo-50 text-indigo-700 border-indigo-200' },
dataset: { label: '星表数据', style: 'bg-indigo-50 text-indigo-700 border-indigo-200' },
software: { label: '软件代码', style: 'bg-teal-50 text-teal-700 border-teal-200' },
phdthesis: { label: '博士论文', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
mastersthesis: { label: '硕士论文', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
circular: { label: '天文电报', style: 'bg-orange-50 text-orange-700 border-orange-200' },
inbook: { label: '图书章节', style: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
book: { label: '学术专著', style: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
editorial: { label: '期刊社论', style: 'bg-slate-50 text-slate-700 border-slate-200' },
erratum: { label: '勘误说明', style: 'bg-red-50 text-red-700 border-red-200' },
misc: { label: '其他文献', style: 'bg-slate-50 text-slate-700 border-slate-200' },
newsletter: { label: '简报新闻', style: 'bg-slate-50 text-slate-700 border-slate-200' },
techreport: { label: '技术报告', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
obituary: { label: '讣告', style: 'bg-slate-50 text-slate-700 border-slate-200' },
};
const val = doctype ? doctype.toLowerCase() : 'article';
const match = typeMap[val] || { label: '文献', style: 'bg-slate-50 text-slate-700 border-slate-200' };
return (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold border ${match.style} mr-2 align-middle`}>
{match.label}
</span>
);
};
import { getDoctypeBadge } from '../utils/paper';
interface SearchPanelProps {
searchQuery: string;
+137
View File
@@ -0,0 +1,137 @@
// dashboard/src/features/sync/SyncPanel.tsx
import { AlertTriangle, SlidersHorizontal } from 'lucide-react';
import { useSyncState } from '../hooks/useSyncState';
import { MetadataSyncCard } from '../components/sync/MetadataSyncCard';
import { BatchPipelineCard } from '../components/sync/BatchPipelineCard';
import { BookmarkletToolCard } from '../components/sync/BookmarkletToolCard';
export function SyncPanel() {
const state = useSyncState();
return (
<div className="space-y-6 w-full max-w-3xl mx-auto">
{/* 标题 */}
<div className="flex flex-col gap-1.5 border-b border-slate-200 pb-3 select-none">
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase"></h2>
<p className="text-slate-500 text-xs">
NASA ADS arXiv 线
</p>
</div>
{/* 全局错误显示 */}
{state.errorMsg && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div className="font-semibold">{state.errorMsg}</div>
</div>
)}
{/* 1. 元数据同步控制卡片 */}
<MetadataSyncCard
query={state.query}
setQuery={state.setQuery}
source={state.source}
setSource={state.setSource}
limit={state.limit}
setLimit={state.setLimit}
estimating={state.estimating}
estimatedCount={state.estimatedCount}
status={state.status}
showBuilder={state.showBuilder}
setShowBuilder={state.setShowBuilder}
rules={state.rules}
handleAddRule={state.handleAddRule}
handleRemoveRule={state.handleRemoveRule}
handleRuleChange={state.handleRuleChange}
handleEstimate={state.handleEstimate}
handleStartHarvest={state.handleStartHarvest}
/>
{/* 2. 馆藏批量流水线学术任务卡片 */}
<BatchPipelineCard
targetPhase={state.targetPhase}
setTargetPhase={state.setTargetPhase}
batchLimitCount={state.batchLimitCount}
setBatchLimitCount={state.setBatchLimitCount}
sortOrder={state.sortOrder}
setSortOrder={state.setSortOrder}
skipCompleted={state.skipCompleted}
setSkipCompleted={state.setSkipCompleted}
skipFailed={state.skipFailed}
setSkipFailed={state.setSkipFailed}
skipPrecedingFailed={state.skipPrecedingFailed}
setSkipPrecedingFailed={state.setSkipPrecedingFailed}
skipPrecedingUncompleted={state.skipPrecedingUncompleted}
setSkipPrecedingUncompleted={state.setSkipPrecedingUncompleted}
batchStatus={state.batchStatus}
batchError={state.batchError}
logsContainerRef={state.logsContainerRef}
handleStartBatch={state.handleStartBatch}
handleStopBatch={state.handleStopBatch}
/>
{/* 3. 常用批量同步检索配置 */}
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4 shadow-xs">
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2 select-none">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-sky-600" />
<span></span>
</h3>
<p className="text-slate-500 text-xs mt-0.5">
</p>
</div>
{state.syncQueries.length === 0 ? (
<div className="text-center py-6 text-xs text-slate-405 italic select-none">
</div>
) : (
<div className="divide-y divide-slate-100 max-h-80 overflow-y-auto pr-1 scrollbar-thin">
{state.syncQueries.map(sq => (
<div key={sq.id} className="py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs">
<div className="space-y-1">
<div className="font-bold text-slate-800 break-all select-all font-mono">
{sq.query}
</div>
<div className="text-[10px] text-slate-500 flex items-center gap-2.5 font-semibold">
<span>: <strong className="text-slate-700">{sq.source === 'all' ? '全部' : sq.source === 'ads' ? 'NASA ADS' : 'arXiv'}</strong></span>
<span>: <strong className="text-slate-700">{sq.limit_count}</strong></span>
<span>: <strong className="text-slate-700">{sq.last_run}</strong></span>
</div>
</div>
<div className="flex gap-2 shrink-0 self-end sm:self-center select-none">
<button
type="button"
onClick={() => state.handleReuseQuery(sq)}
className="px-2.5 py-1.5 rounded-lg bg-slate-50 border border-slate-250 text-slate-650 hover:bg-slate-100 hover:text-slate-800 transition-all text-xs font-bold cursor-pointer"
>
</button>
<button
type="button"
disabled={state.status.active}
onClick={() => state.handleQuickSync(sq)}
className="px-2.5 py-1.5 rounded-lg bg-sky-50 border border-sky-200 text-sky-700 hover:bg-sky-100 transition-all text-xs font-bold cursor-pointer disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => state.handleDeleteQuery(sq.id)}
className="px-2.5 py-1.5 rounded-lg bg-red-50 border border-red-200 text-red-755 hover:bg-red-100 transition-all text-xs font-bold cursor-pointer"
>
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* 4. 浏览器直推书签工具 */}
<BookmarkletToolCard />
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
// dashboard/src/utils/celestial.ts
// 天体物理文本高亮与候选词生成通用工具函数 (Pure Functions)
/**
* Unicode
*
* Markdown \u2009 () \u00a0 ()
* 2026-06-25 \u0020
*
*
* - (true) Unicode
* - (false)
* - false
*/
export const ENABLE_LEGACY_UNICODE_SPACE_COMPAT = true;
export interface TargetInfo {
target_name: string;
ra: string | null;
dec: string | null;
parallax: number | null;
parallax_err: number | null;
spectral_type: string | null;
v_magnitude: number | null;
otype: string | null;
oname: string | null;
pm_ra: number | null;
pm_de: number | null;
radial_velocity: number | null;
photometry: Record<string, number> | null;
aliases: string[];
}
export interface HighlightCandidate {
name: string;
preferredName: string;
}
// 生成天体的匹配词条列表
export function getHighlightCandidates(targets: TargetInfo[]): HighlightCandidate[] {
const candidates: HighlightCandidate[] = [];
const seen = new Set<string>();
const addCandidate = (name: string, preferredName: string) => {
const trimmed = name.trim();
if (!trimmed) return;
// 过滤无效或太短的名称以防止在正文中误伤普通单词
if (trimmed.length < 3) return;
if (/^\d+$/.test(trimmed)) return; // 纯数字
if (/^[a-zA-Z\s]+$/.test(trimmed) && trimmed.length <= 4) return; // 纯字母且长度小于等于4
const lower = trimmed.toLowerCase();
if (seen.has(lower)) return;
seen.add(lower);
candidates.push({ name: trimmed, preferredName });
// 自动为带有空格的常见星表前缀添加无空格变体
const spaceRegex = /^(NGC|IC|M|HD|HIP|Gaia|WD|GD|TYC|KIC|TIC|PSR)\s+(\d+)$/i;
const match = trimmed.match(spaceRegex);
if (match) {
const variant = `${match[1]}${match[2]}`;
const variantLower = variant.toLowerCase();
if (!seen.has(variantLower)) {
seen.add(variantLower);
candidates.push({ name: variant, preferredName });
}
}
// 自动为无空格的常见星表前缀添加有空格变体
const noSpaceRegex = /^(NGC|IC|M|HD|HIP|Gaia|WD|GD|TYC|KIC|TIC|PSR)(\d+)$/i;
const matchNoSpace = trimmed.match(noSpaceRegex);
if (matchNoSpace) {
const variant = `${matchNoSpace[1]} ${matchNoSpace[2]}`;
const variantLower = variant.toLowerCase();
if (!seen.has(variantLower)) {
seen.add(variantLower);
candidates.push({ name: variant, preferredName });
}
}
};
for (const t of targets) {
// 1. 首选名称
addCandidate(t.target_name, t.target_name);
// 2. 别名
if (t.aliases) {
for (const alias of t.aliases) {
addCandidate(alias, t.target_name);
}
}
}
// 按长度降序排列,保证最长匹配优先
return candidates.sort((a, b) => b.name.length - a.name.length);
}
// 辅助函数:在 Markdown 文本中安全高亮天体名称,跳过 HTML 标签、LaTeX公式和 Markdown 链接
export function highlightTargetsInMarkdown(text: string, candidates: HighlightCandidate[]): string {
if (!candidates || candidates.length === 0) return text;
let result = text;
for (const cand of candidates) {
const escaped = cand.name.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
// 根据兼容开关决定是否启用宽容的 Unicode 空格正则通配
const regexStr = ENABLE_LEGACY_UNICODE_SPACE_COMPAT
? escaped.replace(/\s+/g, '[\\s\\u00a0\\u2000-\\u200a\\u202f\\u205f\\u3000]+')
: escaped;
const regex = new RegExp(`\\b(${regexStr})\\b`, 'gi');
// 按 HTML 标签、LaTeX 行内/块级公式、Markdown 链接进行切片
const parts = result.split(/(<[^>]+>|\$\$[\s\S]*?\ $\$|\$[\s\S]*?\$|\[[^\]]*\]\([^\)]*\))/g);
result = parts.map((part) => {
// 若是标签、公式或 Markdown 链接,则原样返回
if (
part.startsWith('<') ||
part.startsWith('$') ||
(part.startsWith('[') && part.includes(']('))
) {
return part;
}
// plain text 部分执行天体正则高亮替换
return part.replace(regex, `[$1](#celestial-${encodeURIComponent(cand.preferredName)})`);
}).join('');
}
return result;
}
+152
View File
@@ -0,0 +1,152 @@
// dashboard/src/utils/paper.tsx
// 文献相关的通用辅助和视图徽章渲染函数
// 根据文献类型 (doctype) 渲染统一的 Astro 样式卡片分类徽章 (Pure View Helper)
export const getDoctypeBadge = (doctype: string) => {
const typeMap: Record<string, { label: string; style: string }> = {
article: { label: '期刊文章', style: 'bg-blue-50 text-blue-700 border-blue-200' },
eprint: { label: '预印本', style: 'bg-purple-50 text-purple-700 border-purple-200' },
inproceedings: { label: '会议论文', style: 'bg-amber-50 text-amber-700 border-amber-200' },
proceedings: { label: '会议集', style: 'bg-amber-50 text-amber-700 border-amber-200' },
proposal: { label: '观测提案', style: 'bg-rose-50 text-rose-700 border-rose-200' },
abstract: { label: '会议摘要', style: 'bg-slate-50 text-slate-700 border-slate-200' },
catalog: { label: '星表数据', style: 'bg-indigo-50 text-indigo-700 border-indigo-200' },
dataset: { label: '星表数据', style: 'bg-indigo-50 text-indigo-700 border-indigo-200' },
software: { label: '软件代码', style: 'bg-teal-50 text-teal-700 border-teal-200' },
phdthesis: { label: '博士论文', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
mastersthesis: { label: '硕士论文', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
circular: { label: '天文电报', style: 'bg-orange-50 text-orange-700 border-orange-200' },
inbook: { label: '图书章节', style: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
book: { label: '学术专著', style: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
editorial: { label: '期刊社论', style: 'bg-slate-50 text-slate-700 border-slate-200' },
erratum: { label: '勘误说明', style: 'bg-red-50 text-red-700 border-red-200' },
misc: { label: '其他文献', style: 'bg-slate-50 text-slate-700 border-slate-200' },
newsletter: { label: '简报新闻', style: 'bg-slate-50 text-slate-700 border-slate-200' },
techreport: { label: '技术报告', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
obituary: { label: '讣告', style: 'bg-slate-50 text-slate-700 border-slate-200' },
};
const val = doctype ? doctype.toLowerCase() : 'article';
const match = typeMap[val] || { label: '文献', style: 'bg-slate-50 text-slate-700 border-slate-200' };
return (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold border ${match.style} mr-2 align-middle`}>
{match.label}
</span>
);
};
export interface PaperMetadata {
title?: string;
author?: string[];
publisher?: string;
source?: string;
date?: string;
tags?: string;
}
/**
* Markdown YAML Front Matter
* YAML Markdown
*/
export function parseMarkdownFrontMatter(markdown: string): {
metadata: PaperMetadata | null;
pureMarkdown: string;
} {
if (!markdown) {
return { metadata: null, pureMarkdown: '' };
}
// 匹配文首被双 --- 包裹的 YAML 元数据区块
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) {
return { metadata: null, pureMarkdown: markdown };
}
const yamlText = match[1];
const pureMarkdown = match[2];
const metadata: PaperMetadata = {};
const lines = yamlText.split('\n');
for (const line of lines) {
// 寻找第一个英文冒号或中文冒号作为键值分隔符
let sepIdx = line.indexOf(':');
if (sepIdx === -1) {
sepIdx = line.indexOf('');
}
if (sepIdx !== -1) {
let rawKey = line.slice(0, sepIdx).trim();
let val = line.slice(sepIdx + 1).trim();
// 清理 key 中的 Markdown 粗体/斜体等标记,如 **, *, __, _
rawKey = rawKey.replace(/[\*_]/g, '').trim().toLowerCase();
// 清理 val 首尾的 Markdown 粗体/斜体标记(大模型翻译时常在值末尾加加粗符号)
val = val.replace(/^[\*_]+|[\*_]+$/g, '').trim();
// 去除包裹的字符串双引号或单引号
if (val.startsWith('"') && val.endsWith('"')) {
val = val.slice(1, -1);
} else if (val.startsWith("'") && val.endsWith("'")) {
val = val.slice(1, -1);
}
// 将常见的中文 Key 映射为标准的英文元数据字段键
let key = rawKey;
if (rawKey === '标题' || rawKey === 'title') {
key = 'title';
} else if (rawKey === '作者' || rawKey === 'author' || rawKey === 'authors') {
key = 'author';
} else if (rawKey === '出版社' || rawKey === '出版商' || rawKey === 'publisher') {
key = 'publisher';
} else if (rawKey === '来源' || rawKey === 'source' || rawKey === 'url') {
key = 'source';
} else if (rawKey === '日期' || rawKey === '年份' || rawKey === 'date' || rawKey === 'year') {
key = 'date';
} else if (rawKey === '标签' || rawKey === 'tags') {
key = 'tags';
}
if (key === 'title') {
metadata.title = val;
} else if (key === 'publisher') {
metadata.publisher = val;
} else if (key === 'source') {
metadata.source = val;
} else if (key === 'date') {
metadata.date = val;
} else if (key === 'tags') {
metadata.tags = val;
} else if (key === 'author') {
// 解析 JSON 格式数组: ["U. Heber", "S. Geier", ...] 或中文翻译保留的 [Heber, Ulrich]
if (val.startsWith('[') && val.endsWith(']')) {
const authors: string[] = [];
const quoteRegex = /"([^"]+)"|'([^']+)'/g;
let m;
let hasQuotes = false;
while ((m = quoteRegex.exec(val)) !== null) {
authors.push(m[1] || m[2]);
hasQuotes = true;
}
if (!hasQuotes) {
// 如果没有引号,如 [Heber, Ulrich],直接去掉括号按逗号切分
const inside = val.slice(1, -1).trim();
metadata.author = inside.split(/[,]/).map(a => a.trim()).filter(Boolean);
} else {
metadata.author = authors;
}
} else {
// 兼容中文逗号和英文逗号的分隔
metadata.author = val.split(/[,]/).map(a => a.trim()).filter(Boolean);
}
}
}
}
// 只要解析出任一有效元数据字段,就认为解析成功;否则返回空
const hasKeys = Object.keys(metadata).length > 0;
return {
metadata: hasKeys ? metadata : null,
pureMarkdown
};
}
-580
View File
@@ -1,580 +0,0 @@
# 前端开发规范\-React篇
React 开发实践指南
V1\.0 \| 2026年5月 \
# 一、概述与总则
本文档旨在统一前端开发团队的技术实践标准,确保代码质量、可维护性和团队协作效率。规范覆盖 React 技术栈的核心开发场景,同时涵盖 HTML/CSS、JavaScript/TypeScript 等基础层面的通用准则。
## 1\.1 适用范围
本规范适用于所有使用 React 技术栈的前端项目,包括但不限于:
1. 使用 React 18\+ 的 Web 应用项目
2. 基于 Next\.js、Remix 等元框架的服务端渲染项目
3. 使用 React Native 的移动端跨平台项目(部分适用)
## 1\.2 规范层级
规范条目按强制程度分为三个层级,开发者应根据项目实际情况合理遵循:
|**层级**|**标识**|**说明**|
|---|---|---|
|必须(Must)|\[M\]|所有项目必须严格遵守,Code Review 中必检项|
|推荐(Should)|\[S\]|强烈建议遵循,特殊场景经评估后可调整|
|可选(May)|\[O\]|根据项目实际情况选择性采纳|
## 1\.3 技术栈版本要求
新项目应优先采用以下技术栈版本,已有项目应在迭代周期内逐步升级:
|**技术项**|**推荐版本**|**说明**|
|---|---|---|
|React|18\.x / 19\.x|使用最新稳定版|
|TypeScript|5\.5\+|strict 模式启用|
|Vite|6\.x|构建工具首选|
|Next\.js|15\.x|SSR/SSG 场景|
|Tailwind CSS|4\.x|原子化 CSS 方案|
|ESLint|9\.x|Flat Config 格式|
# 二、React 开发规范
React 是本规范的核心关注领域。本章从组件设计、Hooks 使用、状态管理、TypeScript 类型约束和性能优化五个维度,系统性地定义 React 开发的最佳实践。
## 2\.1 组件设计规范
### 2\.1\.1 组件分类与组织
React 组件应按职责明确划分为以下类别,并在项目目录中保持清晰的组织结构:
|**组件类型**|**存放路径**|**职责说明**|
|---|---|---|
|Page 组件|app/ 或 pages/|路由级别的页面组件,负责数据获取和页面级布局|
|Layout 组件|components/layout/|页面布局框架,如 Header、Sidebar、Footer|
|UI 组件|components/ui/|基础 UI 元素,Button、Input、Modal 等纯展示组件|
|Feature 组件|features/\*/components/|业务功能组件,与特定功能域紧耦合|
|HOC / 工具|components/hoc/|高阶组件和渲染工具(render props|
### 2\.1\.2 函数组件优先
自 React 16\.8 引入 Hooks 以来,函数组件已成为官方推荐的标准写法。所有新开发组件必须使用函数组件,类组件仅在维护遗留代码时允许存在。
```TypeScript
// 推荐:函数组件 + Hooks
import { useState, useCallback } from 'react';
interface UserCardProps {
user: User;
onSelect: (id: string) => void;
}
export function UserCard({ user, onSelect }: UserCardProps) {
const [expanded, setExpanded] = useState(false);
const handleClick = useCallback(() => {
onSelect(user.id);
setExpanded(prev => !prev);
}, [onSelect, user.id]);
return (
<Card onClick={handleClick}>
<Avatar src={user.avatar} />
<UserName>{user.name}</UserName>
{expanded && <UserDetail user={user} />}
</Card>
);
}
```
### 2\.1\.3 Props 设计原则
组件的 Props 接口设计直接影响组件的可复用性和可维护性。遵循以下原则:
4. 单一职责:每个组件只接收其渲染所需的最小数据集合,避免传递冗余数据
5. 显式接口:使用 TypeScript interface 定义 Props,禁止隐式 any 类型
6. 默认值策略:对可选 Props 提供合理的默认值,或使用解构赋值简化处理
7. 事件命名:自定义事件处理器以 on 为前缀(如 onSelect、onValueChange),遵循 React 原生事件命名惯例
8. 避免过度透传:不要简单地将父组件的 Props 全部展开传递给子组件,应显式声明所需属性
### 2\.1\.4 组件文件结构
每个组件应按以下结构组织,确保关注点分离和可测试性:
```TypeScript
// components/UserCard/index.tsx
export { UserCard } from './UserCard';
export type { UserCardProps } from './types';
// components/UserCard/UserCard.tsx
import { useState } from 'react';
import type { UserCardProps } from './types';
import { useUserCard } from './useUserCard';
import * as S from './styles';
export function UserCard({ user, onSelect }: UserCardProps) {
const { expanded, handleClick } = useUserCard(user, onSelect);
return (
<S.Card onClick={handleClick}>...</S.Card>
);
}
// components/UserCard/types.ts
export interface UserCardProps {
user: User;
onSelect: (id: string) => void;
}
// components/UserCard/useUserCard.ts
export function useUserCard(user: User, onSelect: (id: string) => void) {
// 业务逻辑抽离到自定义 Hook
}
// components/UserCard/styles.ts (styled-components / CSS Modules)
```
## 2\.2 Hooks 使用规范
### 2\.2\.1 Hooks 基础规则
Hooks 是 React 16\.8 引入的革命性特性,必须严格遵循以下使用规则,否则可能导致不可预期的行为:
9. 只在最顶层调用 Hooks:不要在循环、条件判断或嵌套函数中调用 Hooks
10. 只在 React 函数中调用 Hooks:在函数组件或自定义 Hook 中调用,不要在普通 JavaScript 函数中调用
11. 以 use 开头命名:自定义 Hook 必须以 use 开头命名,以便 ESLint 插件识别
12. 依赖数组诚实原则:useEffect、useMemo、useCallback 的依赖数组必须完整列出所有依赖项
### 2\.2\.2 useEffect 最佳实践
useEffect 是最常用的 Hook 之一,也是最容易滥用的。遵循以下实践:
```JavaScript
// 推荐:单一职责的 Effect
useEffect(() => {
const controller = new AbortController();
fetchUser(userId, { signal: controller.signal })
.then(setUser)
.catch(setError);
return () => controller.abort();
}, [userId]); // 依赖数组必须完整
// 推荐:逻辑拆分到独立 Effect
useEffect(() => {
// 数据获取逻辑
}, [params]);
useEffect(() => {
// DOM 操作或订阅逻辑
return () => { /* 清理逻辑 */ };
}, []);
// 禁止:缺失依赖项
useEffect(() => {
fetchData(page); // page 未在依赖数组中!
}, []); // eslint-disable-line 是临时方案,应尽快修复
```
### 2\.2\.3 useMemo 与 useCallback
性能优化 Hooks 应在有明确性能问题时使用,避免过早优化。遵循以下准则:
13. useMemo:用于缓存昂贵的计算结果,仅在计算成本显著高于缓存开销时使用
14. useCallback:用于缓存事件处理函数,主要配合 React\.memo 使用,避免子组件不必要的重渲染
15. 避免滥用:简单的计算和事件处理不需要 memoization,React 的渲染性能通常优于预期
16. 依赖数组完整性:与 useEffect 同样,必须确保依赖数组的完整性
```JavaScript
// 推荐:复杂数据转换使用 useMemo
const filteredUsers = useMemo(() => {
return users
.filter(u => u.active)
.sort((a, b) => b.score - a.score)
.slice(0, 100);
}, [users]);
// 推荐:配合 React.memo 使用 useCallback
const handleSubmit = useCallback((values: FormData) => {
api.submit(values).then(onSuccess);
}, [onSuccess]);
// 禁止:对简单值使用 useMemo
const fullName = useMemo(
() => `$${firstName} $${lastName}`,
[firstName, lastName] // 字符串拼接成本极低,无需缓存
);
```
## 2\.3 状态管理规范
### 2\.3\.1 状态管理策略
React 应用的状态管理应按状态的作用域和复杂度选择适当的方案,避免过度工程化:
|**状态类型**|**管理方案**|**适用场景**|
|---|---|---|
|本地 UI 状态|useState|组件内部的临时状态,如表单输入、展开/收起|
|派生状态|useMemo / 计算|可从已有状态计算得出的值|
|共享状态|Context / Props|跨 2\-3 层组件传递的状态|
|全局状态|Zustand / Jotai|应用级共享状态,如用户信息、主题设置|
|服务端状态|TanStack Query|服务器数据缓存和同步|
|表单状态|React Hook Form|复杂表单的状态和验证管理|
### 2\.3\.2 Context 使用规范
React Context 适用于跨组件层级的数据传递,但不当使用会导致性能问题:
17. 拆分 Context:将高频变化和低频变化的状态拆分到独立的 Context,避免不必要的重渲染
18. 避免过度使用:仅在真正需要跨多级组件传递数据时使用,简单的父子组件通信仍应通过 Props
19. 结合 useReducer:对于复杂状态逻辑,Context 配合 useReducer 可以实现轻量级的 Redux\-like 方案
```TypeScript
// 推荐:拆分 Context 避免重渲染
const ThemeContext = createContext<Theme>('light');
const UserContext = createContext<User | null>(null);
// ThemeProvider 更新时,只消费 ThemeContext 的组件重渲染
// UserProvider 更新时,只消费 UserContext 的组件重渲染
// 推荐:Context + useReducer 组合
type Action = { type: 'increment' } | { type: 'decrement' };
const CounterContext = createContext<{
state: number;
dispatch: React.Dispatch<Action>;
} | null>(null);
```
### 2\.3\.3 外部状态管理(Zustand/Jotai
对于中大型企业级应用,推荐使用轻量级的原子化状态管理方案,如 Zustand 或 Jotai
20. Zustand:适合模块化的 Store 架构,API 极简,无 Provider 包裹问题
21. Jotai:适合原子化的细粒度状态管理,状态依赖自动追踪
22. 避免 Redux 过度使用:仅在需要 Redux DevTools 时间旅行调试、复杂中间件链时考虑 Redux Toolkit
## 2\.4 TypeScript 类型规范
### 2\.4\.1 组件 Props 类型
所有组件 Props 必须使用 TypeScript 接口显式定义,禁止使用 any 类型绕过类型检查:
```TypeScript
// 推荐:显式 Props 接口
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
children: React.ReactNode;
}
export function Button({
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
onClick,
children,
}: ButtonProps) {
// 实现...
}
// 禁止:隐式 any 或缺少类型
// function Button(props) { // 错误!props 为 any
// return <button>{props.label}</button>;
// }
```
### 2\.4\.2 泛型组件
对于数据展示类组件,使用泛型实现类型安全的通用组件:
```TypeScript
// 推荐:泛型表格组件
interface DataTableProps<T> {
data: T[];
columns: ColumnDef<T>[];
keyExtractor: (item: T) => string;
onRowClick?: (item: T) => void;
}
export function DataTable<T>({
data, columns, keyExtractor, onRowClick,
}: DataTableProps<T>) {
return (
<table>
<tbody>
{data.map(item => (
<tr key={keyExtractor(item)}
onClick={() => onRowClick?.(item)}>
{columns.map(col => (
<td key={col.key}>{col.render(item)}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
```
### 2\.4\.3 事件类型
React 事件处理函数应使用 React 提供的泛型事件类型,而非原生 DOM 事件类型:
|**事件类型**|**React 类型**|
|---|---|
|点击事件|React\.MouseEvent\<HTMLButtonElement\>|
|输入事件|React\.ChangeEvent\<HTMLInputElement\>|
|表单提交|React\.FormEvent\<HTMLFormElement\>|
|键盘事件|React\.KeyboardEvent\<HTMLInputElement\>|
|拖拽事件|React\.DragEvent\<HTMLDivElement\>|
|触摸事件|React\.TouchEvent\<HTMLDivElement\>|
|通用事件|React\.SyntheticEvent|
## 2\.5 性能优化规范
### 2\.5\.1 渲染优化
React 的渲染优化应从以下维度系统化地进行:
23. React\.memo:对纯展示组件使用 React\.memo 进行浅比较优化,避免不必要的重渲染
24. useMemo / useCallback:对昂贵的计算和传递给子组件的回调进行缓存
25. 虚拟列表:长列表使用 react\-window 或 react\-virtualized 实现虚拟滚动
26. 代码分割:使用 React\.lazy \+ Suspense 实现路由级别和组件级别的懒加载
```JavaScript
// 推荐:React.memo + 自定义比较函数
export const UserList = React.memo(function UserList({
users,
onSelect,
}: UserListProps) {
return (
<ul>
{users.map(user => (
<UserItem key={user.id} user={user} onSelect={onSelect} />
))}
</ul>
);
}, (prev, next) => prev.users === next.users);
// 推荐:React.lazy 代码分割
const Dashboard = React.lazy(() => import('./Dashboard'));
const Settings = React.lazy(() => import('./Settings'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
```
### 2\.5\.2 状态更新优化
状态更新方式直接影响渲染性能,应遵循以下最佳实践:
27. 批量更新:React 18 自动批处理所有状态更新,无需手动合并
28. 不可变数据:始终使用不可变更新模式,配合 useMemo/React\.memo 进行引用比较
29. 状态拆分:将独立变化的状态拆分为多个 useState,避免不必要的联合更新
30. 派生状态:优先使用 useMemo 计算派生状态,避免在状态中存储可计算的值
# 三、HTML/CSS 规范
HTML 和 CSS 是前端开发的基础,良好的标记和样式实践是构建可维护应用的前提。
## 3\.1 HTML 语义化
语义化的 HTML 不仅有利于可访问性(A11y),也有助于 SEO 和代码的可读性:
31. 使用恰当的语义化标签:header、nav、main、article、section、aside、footer
32. 表单元素必须关联 label,使用 aria\-label 或 aria\-labelledby 补充描述
33. 图片必须提供有意义的 alt 文本,装饰性图片使用 alt=""
34. 遵循标题层级顺序(h1 → h2 → h3),不要跳级使用
## 3\.2 CSS 架构
推荐采用CSS Modules,避免全局命名空间污染:
```JavaScript
// 推荐:CSS Modules
import styles from './Button.module.css';
export function Button({ children }) {
return <button className={styles.button}>{children}</button>;
}
/* Button.module.css */
.button {
padding: 8px 16px;
border-radius: 4px;
background: var(--color-primary);
color: white;
}
.button:hover {
background: var(--color-primary-dark);
}
```
## 3\.3 响应式设计
所有界面必须适配至少三种断点,采用移动优先的设计策略:
|**断点名**|**尺寸范围**|**适配策略**|
|---|---|---|
|Mobile|\< 768px|单列布局、触摸友好的交互、简化导航|
|Tablet|768px \- 1024px|双列布局、侧边栏可收起、适配触控|
|Desktop|\> 1024px|完整多列布局、 hover 交互、固定侧边栏|
# 四、JavaScript/TypeScript 通用规范
除 React 特定规范外,团队应遵循以下 JavaScript/TypeScript 通用编码规范。
## 4\.1 命名规范
35. 文件名:PascalCase 用于组件文件(UserCard\.tsx),camelCase 用于工具文件(formatDate\.ts
36. 组件名:PascalCase,与文件名保持一致
37. Hook 名:以 use 开头,后跟 PascalCaseuseUserData
38. 常量:UPPER\_SNAKE\_CASEMAX\_RETRY\_COUNT
39. 布尔变量:使用 is、has、should 等前缀(isLoading、hasError
## 4\.2 代码风格
统一使用 ESLint \+ Prettier 进行代码格式化和质量检查,配置文件纳入版本控制:
```Java
// .eslintrc.cjs
module.exports = {
extends: [
'eslint:recommended',
'@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/explicit-function-return-type': 'warn',
'react-hooks/exhaustive-deps': 'error',
},
};
// .prettierrc
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all"
}
```
## 4\.3 类型安全
40. strict 模式:TypeScript 配置必须启用 strict: true
41. 禁止 any:原则上禁止使用 any 类型,必要时应使用 unknown 并配合类型收窄
42. 返回值类型:公共函数应显式声明返回值类型,利用类型推断的边界情况除外
43. 类型导出:组件 Props 接口应随组件一起导出,便于复用
# 五、工程化与项目结构
良好的项目结构和工程化配置是团队协作的基石。
## 5\.1 目录结构
推荐采用以下目录组织方式,Feature\-based 结构优先:
```Python
src/
├── app/ # 路由页面(Next.js / React Router
│ ├── layout.tsx
│ ├── page.tsx
│ └── dashboard/
│ └── page.tsx
├── components/ # 共享组件
│ ├── ui/ # 基础 UI 组件(Button, Input, Modal
│ └── layout/ # 布局组件(Header, Sidebar, Footer
├── features/ # 功能模块
│ └── auth/ # 认证功能
│ ├── api/ # API 请求
│ ├── components/ # 功能组件
│ ├── hooks/ # 功能 Hooks
│ ├── stores/ # 状态管理
│ └── types.ts # 功能类型
├── hooks/ # 全局共享 Hooks
├── lib/ # 工具库和配置
│ ├── api.ts # Axios 实例配置
│ └── utils.ts # 通用工具函数
├── types/ # 全局类型定义
└── styles/ # 全局样式和主题配置
```
## 5\.2 开发工作流
44. Git 分支策略:采用 Git Flow 或 Trunk\-based 开发,功能分支命名格式 feature/描述 或 fix/描述
45. 代码审查:所有代码变更必须通过 Pull Request 审查,至少 1 人 approving 后方可合并
46. 提交规范:遵循 Conventional Commits 规范(feat:、fix:、docs:、refactor:、test: 等前缀)
47. CI/CD:集成自动化测试、代码质量检查(ESLint、TypeScript 编译检查)到 CI 流水线
# 六、性能优化与最佳实践
性能优化是前端开发的重要环节,应贯穿整个开发周期。
## 6\.1 加载性能
48. 资源压缩:启用 Gzip/Brotli 压缩,图片使用 WebP/AVIF 格式
49. 懒加载:路由、图片、非首屏组件均使用懒加载策略
50. 预加载:对关键资源使用 rel=preload,对后续路由使用 rel=prefetch
51. Bundle 分析:定期使用 @next/bundle\-analyzer 或 webpack\-bundle\-analyzer 分析打包体积
## 6\.2 运行时性能
52. 避免频繁的状态更新:使用防抖(debounce)和节流(throttle)控制高频事件
53. Web Workers:将复杂计算 offload 到 Web Worker,避免阻塞主线程
54. 内存管理:及时清理定时器、事件监听器和订阅,防止内存泄漏
55. 虚拟化:长列表使用虚拟滚动,大数据集使用分页或虚拟表格