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:
+4
-7
@@ -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
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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
@@ -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()}
|
||||
>
|
||||
{/* 浏览器拖拽时读取全部 textContent,sr-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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
+3
-3
@@ -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[];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+3
-33
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 开头,后跟 PascalCase(useUserData)
|
||||
|
||||
38. 常量:UPPER\_SNAKE\_CASE(MAX\_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. 虚拟化:长列表使用虚拟滚动,大数据集使用分页或虚拟表格
|
||||
|
||||
Reference in New Issue
Block a user