AstroResearch/dashboard/src/features/sync/SyncPanel.tsx
Asfmq cec4b8cf7b feat: Docker 容器化、Cookie 鉴权、Coordinator 编排、FTS5 搜索与 P1-P3 全面收尾
Docker 容器化部署
  - 提供 Mode A (Alpine musl, ~23MB) 和 Mode B (Distroless glibc, ~87MB)
    两种镜像,Docker Compose 一键启动
  - build.rs 支持 SKIP_DASHBOARD_BUILD 跳过前端构建
  - 国内镜像加速 (npm/apt/apk) 通过 USE_MIRRORS build-arg 控制

  安全:Cookie-Based 鉴权系统
  - HttpOnly/SameSite=Strict Cookie 会话管理(24h 过期自动清理)
  - 登录/登出/验证接口 + 中间件注入
  - 前端登录页面 + 退出按钮
  - 三层 CORS:localhost 鉴权 / 全放通 bookmarklet / 受保护路由
  - 书签脚本 fetch 添加 credentials:'include'

  Coordinator 模式 (P2)
  - 4 个 meta-tool (delegate_task/check_task/task_stop/synthesize)
  - WorkerPool + Semaphore 并发控制 + 超时保护
  - 前端协调者模式开关

  Hook 系统:UserPromptSubmit 事件 (P2)
  - 第 13 个生命周期事件,fire-and-forget 审计

  FTS5 全文搜索 (P3)
  - agent_sessions_fts + agent_messages_fts 虚拟表
  - search_history Agent 工具 + /api/search/history HTTP 接口
  - 前端防抖搜索框 + 仅当前会话筛选

  工具加载优化 (P3)
  - defer_loading 延迟加载 (7 个重型工具)
  - is_readonly 只读标记 (9 个查询工具)
  - classifier_summary 工具目录供 LLM 按需判断

  模型回退策略 (P3)
  - LLM_FALLBACK_MODEL 优先回退 + LLM_FALLBACK_CHAIN 链式轮换
  - LlmClient model 改为 Arc<RwLock> 支持运行时切换
  - 连续 3 次过载后自动切换

  压缩记忆桥接 (P3)
  - 压缩丢弃消息 → 子代理提取持久记忆 (extract_memories_from_compaction)

  git2 依赖修复
  - 切换到 vendored-libgit2,消除 OpenSSL 系统依赖
2026-06-23 20:22:06 +08:00

922 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import { RefreshCw, Play, Info, AlertTriangle, CheckCircle, Loader, StopCircle, Download, FileText, SlidersHorizontal, Lightbulb } from 'lucide-react';
import type { SavedSyncQuery } from '../../types';
import { CustomSelect } from '../../components/CustomSelect';
interface BatchStatus {
active: boolean;
total: number;
downloaded: number;
parsed: number;
download_failed: number;
parse_failed: number;
current_bibcode: string;
logs: string[];
action?: 'download' | 'parse' | 'translate' | 'embed' | 'target';
}
interface HarvestStatus {
active: boolean;
query: string;
source: string;
synced: number;
total: number;
}
export function SyncPanel() {
const [query, setQuery] = useState('');
const [source, setSource] = useState<'all' | 'ads' | 'arxiv'>('all');
const [limit, setLimit] = useState<number>(200);
const [estimating, setEstimating] = useState(false);
const [estimatedCount, setEstimatedCount] = useState<number | null>(null);
const [status, setStatus] = useState<HarvestStatus>({
active: false,
query: '',
source: '',
synced: 0,
total: 0,
});
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [syncQueries, setSyncQueries] = useState<SavedSyncQuery[]>([]);
const pollIntervalRef = useRef<any>(null);
// 批量下载与解析相关状态
const [targetPhase, setTargetPhase] = useState<'download' | 'parse' | 'translate' | 'embed' | 'target'>('download');
const [batchLimitCount, setBatchLimitCount] = useState<number>(100);
const [sortOrder, setSortOrder] = useState<'default' | 'pub_year_desc' | 'created_at_desc'>('default');
const [skipCompleted, setSkipCompleted] = useState<boolean>(true);
const [skipFailed, setSkipFailed] = useState<boolean>(false);
const [skipPrecedingFailed, setSkipPrecedingFailed] = useState<boolean>(false);
const [skipPrecedingUncompleted, setSkipPrecedingUncompleted] = useState<boolean>(false);
const [batchStatus, setBatchStatus] = useState<BatchStatus>({
active: false,
total: 0,
downloaded: 0,
parsed: 0,
download_failed: 0,
parse_failed: 0,
current_bibcode: '',
logs: [],
});
const [batchError, setBatchError] = useState<string | null>(null);
const batchPollIntervalRef = useRef<any>(null);
const logsContainerRef = useRef<HTMLDivElement | null>(null);
const [showBuilder, setShowBuilder] = useState(false);
const [rules, setRules] = useState<Array<{ field: string; op: string; val: string }>>([
{ field: 'all', op: 'AND', val: '' }
]);
// 当高级表单规则变化时,自动更新同步输入框的检索式
const updateQueryFromRules = (currentRules: typeof rules) => {
let qParts: string[] = [];
currentRules.forEach((rule, idx) => {
if (!rule.val.trim()) return;
let valStr = rule.val.trim();
if (valStr.includes(' ') && !valStr.startsWith('"') && !valStr.startsWith('(')) {
valStr = `"${valStr}"`;
}
let fieldPart = '';
if (rule.field !== 'all') {
fieldPart = `${rule.field}:${valStr}`;
} else {
fieldPart = valStr;
}
if (idx === 0) {
qParts.push(fieldPart);
} else {
qParts.push(`${rule.op} ${fieldPart}`);
}
});
setQuery(qParts.join(' '));
};
const handleAddRule = () => {
setRules(prev => [...prev, { field: 'all', op: 'AND', val: '' }]);
};
const handleRemoveRule = (idx: number) => {
const next = rules.filter((_, i) => i !== idx);
setRules(next);
updateQueryFromRules(next);
};
const handleRuleChange = (idx: number, key: 'field' | 'op' | 'val', value: string) => {
const next = rules.map((r, i) => i === idx ? { ...r, [key]: value } : r);
setRules(next);
updateQueryFromRules(next);
};
// 获取历史检索配置
const fetchSyncQueries = async () => {
try {
const res = await axios.get<SavedSyncQuery[]>(`/api/sync/queries?t=${Date.now()}`);
setSyncQueries(res.data);
} catch (e) {
console.error('获取检索配置列表失败', e);
}
};
const handleDeleteQuery = async (id: number) => {
try {
await axios.delete(`/api/sync/queries/${id}`);
fetchSyncQueries();
} catch (e) {
console.error('删除配置失败', e);
}
};
const handleReuseQuery = (sq: SavedSyncQuery) => {
setQuery(sq.query);
setSource(sq.source as any);
setLimit(sq.limit_count);
};
const handleQuickSync = async (sq: SavedSyncQuery) => {
setErrorMsg(null);
// 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态
setStatus(prev => ({
...prev,
active: true,
query: sq.query,
source: sq.source as any,
synced: 0,
total: sq.limit_count,
}));
startPolling();
try {
await axios.post('/api/sync/meta/run', {
q: sq.query,
source: sq.source,
limit: sq.limit_count,
});
fetchStatus();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动快速同步失败。');
fetchStatus();
}
};
// 获取当前的元数据同步状态
const fetchStatus = async () => {
try {
const res = await axios.get<HarvestStatus>(`/api/sync/meta/status?t=${Date.now()}`);
setStatus(res.data);
if (res.data.active) {
startPolling();
} else {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
fetchSyncQueries();
}
}
} catch (e) {
console.error('获取同步状态失败', e);
}
};
// 开始轮询
const startPolling = () => {
if (pollIntervalRef.current) return;
pollIntervalRef.current = setInterval(fetchStatus, 1000);
};
useEffect(() => {
fetchStatus();
fetchSyncQueries();
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
// 批量下载与解析相关的网络操作
const fetchBatchStatus = async () => {
try {
const res = await axios.get<BatchStatus>(`/api/batch/asset/status?t=${Date.now()}`);
setBatchStatus(res.data);
if (res.data.active) {
startBatchPolling();
} else if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
batchPollIntervalRef.current = null;
}
} catch (e) {
console.error('获取处理状态失败', e);
}
};
const startBatchPolling = () => {
if (batchPollIntervalRef.current) return;
batchPollIntervalRef.current = setInterval(fetchBatchStatus, 1000);
};
const handleStartBatch = async () => {
setBatchError(null);
try {
await axios.post('/api/batch/asset/run', {
target_phase: targetPhase,
limit_count: batchLimitCount,
sort_order: sortOrder,
skip_completed: skipCompleted,
skip_failed: skipFailed,
skip_preceding_failed: skipPrecedingFailed,
skip_preceding_uncompleted: skipPrecedingUncompleted,
});
fetchBatchStatus();
startBatchPolling();
} catch (e: any) {
console.error(e);
setBatchError(e.response?.data || '启动批量任务失败。');
}
};
const handleStopBatch = async () => {
try {
await axios.post('/api/batch/asset/stop');
fetchBatchStatus();
} catch (e: any) {
console.error(e);
setBatchError(e.response?.data || '停止任务失败。');
}
};
useEffect(() => {
fetchBatchStatus();
return () => {
if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
}
};
}, []);
// 日志终端自动滚动到底部 (仅限定滚动容器内部,不影响整个网页的滚动)
useEffect(() => {
const container = logsContainerRef.current;
if (container) {
// 阈值设为 80px。如果用户滚动到离底部距离小于 80px则视为保持跟踪底部的模式
const isCloseToBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 80;
if (isCloseToBottom) {
container.scrollTop = container.scrollHeight;
}
}
}, [batchStatus.logs]);
// 估算文献总量
const handleEstimate = async () => {
if (!query.trim()) {
setErrorMsg('请输入检索关键词!');
return;
}
setErrorMsg(null);
setEstimating(true);
setEstimatedCount(null);
try {
const res = await axios.get<{ total: number }>('/api/sync/meta/count', {
params: { q: query.trim(), source }
});
setEstimatedCount(res.data.total);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '估算文献总量失败,请检查 API 密钥或网络。');
} finally {
setEstimating(false);
}
};
// 启动任务
const handleStartHarvest = async () => {
if (!query.trim()) {
setErrorMsg('请输入检索关键词!');
return;
}
setErrorMsg(null);
// 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态
setStatus(prev => ({
...prev,
active: true,
query: query.trim(),
source,
synced: 0,
total: 0,
}));
startPolling();
try {
await axios.post('/api/sync/meta/run', {
q: query.trim(),
source,
limit: limit,
});
fetchStatus();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动元数据同步任务失败。');
fetchStatus();
}
};
const percent = status.total > 0 ? Math.min(100, Math.round((status.synced / status.total) * 100)) : 0;
return (
<div className="space-y-6 w-full max-w-3xl mx-auto">
{/* 标题 */}
<div className="flex flex-col gap-1.5 border-b border-slate-200 pb-3">
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase"></h2>
<p className="text-slate-500 text-xs"> NASA ADS arXiv 线</p>
</div>
{errorMsg && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div className="font-semibold">{errorMsg}</div>
</div>
)}
{/* 控制面板卡片 */}
<div className="console-panel p-6 rounded-xl space-y-6 relative overflow-hidden">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block flex justify-between items-center">
<span> (Query)</span>
<button
type="button"
onClick={() => setShowBuilder(!showBuilder)}
className="text-xs text-sky-600 hover:text-sky-750 font-bold flex items-center gap-1"
>
<SlidersHorizontal className="w-3.5 h-3.5" />
{showBuilder ? '隐藏高级构造' : '高级检索构造'}
</button>
</label>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
disabled={status.active}
placeholder="例如: hot subdwarf, Gaia BH1..."
className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 focus:bg-white transition-all text-xs font-medium"
/>
<div className="text-[11px] text-slate-450 flex flex-wrap gap-x-2.5 px-0.5 mt-1">
<span>:</span>
<span><code className="text-slate-700 bg-slate-100 px-1 py-0.2 rounded font-mono">author:"Althaus" AND year:2020-2023</code></span>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<div className="flex gap-2">
{[
{ id: 'all', label: '全部' },
{ id: 'ads', label: 'NASA ADS' },
{ id: 'arxiv', label: 'arXiv 预印本' },
].map(src => (
<button
key={src.id}
type="button"
disabled={status.active}
onClick={() => setSource(src.id as any)}
className={`flex-1 py-2 rounded-lg text-xs font-bold border transition-all ${
source === src.id
? 'bg-sky-50 border-sky-300 text-sky-700 shadow-sm'
: 'btn-console btn-console-secondary'
}`}
>
{src.label}
</button>
))}
</div>
</div>
</div>
{/* 动态表单生成器 */}
{showBuilder && (
<div className="p-4 rounded-lg bg-slate-50 border border-slate-200 space-y-3.5 transition-all">
<div className="text-xs font-bold text-slate-700 flex justify-between items-center">
<span></span>
<button
type="button"
onClick={handleAddRule}
className="text-xs text-sky-600 hover:text-sky-700 font-bold"
>
+
</button>
</div>
<div className="space-y-2.5">
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
{idx > 0 ? (
<CustomSelect
value={rule.op}
onChange={val => handleRuleChange(idx, 'op', val)}
className="w-24"
options={[
{ value: 'AND', label: '并且 (AND)' },
{ value: 'OR', label: '或者 (OR)' },
{ value: 'NOT', label: '排除 (NOT)' },
]}
/>
) : (
<div className="w-24 text-center text-xs text-slate-500 font-semibold"></div>
)}
<CustomSelect
value={rule.field}
onChange={val => handleRuleChange(idx, 'field', val)}
className="w-32"
options={[
{ value: 'all', label: '任意字段' },
{ value: 'title', label: '标题' },
{ value: 'author', label: '作者' },
{ value: 'abs', label: '摘要' },
{ value: 'year', label: '年份' },
]}
/>
<input
type="text"
value={rule.val}
onChange={e => handleRuleChange(idx, 'val', e.target.value)}
placeholder={
rule.field === 'year'
? '例如: 2020-2023 或 2022'
: rule.field === 'author'
? '例如: Althaus'
: '请输入检索词...'
}
className="flex-1 px-3 py-1.5 rounded-lg bg-white border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-sky-500 text-xs font-medium"
/>
{rules.length > 1 && (
<button
type="button"
onClick={() => handleRemoveRule(idx)}
className="text-red-655 hover:text-red-700 text-xs font-bold px-2 py-1.5"
>
</button>
)}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-2 border-t border-slate-200">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block flex items-center justify-between">
<span></span>
<span className="text-[11px] text-slate-450 font-normal"> API</span>
</label>
<input
type="number"
value={limit}
disabled={status.active}
onChange={e => setLimit(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-4 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium"
/>
</div>
<div className="flex items-end gap-3">
<button
type="button"
disabled={status.active || estimating}
onClick={handleEstimate}
className="flex-1 py-2 rounded-lg bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 text-xs font-bold flex items-center justify-center gap-2 transition-all disabled:opacity-40"
>
{estimating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
</button>
<button
type="button"
disabled={status.active || !query.trim()}
onClick={handleStartHarvest}
className="btn-console btn-console-primary flex-1 py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 disabled:opacity-40"
>
<Play className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 预估结果 */}
{estimatedCount !== null && !status.active && (
<div className="p-4 rounded-lg bg-sky-50 border border-sky-200 flex gap-3 text-xs text-sky-850 items-center">
<Info className="w-4 h-4 shrink-0 text-sky-600" />
<div>
<strong className="text-sky-900 font-bold">{estimatedCount}</strong>
{estimatedCount > limit ? ` 限制最大同步前 ${limit} 篇元数据。` : ' 将全部进行同步。'}
</div>
</div>
)}
</div>
{/* 实时同步进度 */}
{(status.active || status.synced > 0) && (
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4">
<div className="flex justify-between items-center">
<div>
<h3 className="text-xs font-bold text-slate-800 flex items-center gap-2">
{status.active ? (
<>
<Loader className="w-4 h-4 text-sky-600 animate-spin" />
<span>...</span>
</>
) : (
<>
<CheckCircle className="w-4 h-4 text-emerald-600" />
<span></span>
</>
)}
</h3>
<p className="text-slate-500 text-[11px] mt-1.5 font-semibold">
: <code className="bg-slate-100 px-1 py-0.5 rounded text-slate-700">{status.query}</code> : {status.source === 'all' ? '全部' : status.source === 'ads' ? 'NASA ADS' : 'arXiv'}
</p>
</div>
<span className="text-xs font-bold text-sky-700">{status.synced} / {status.total} </span>
</div>
<div className="w-full h-3 rounded-full bg-slate-100 overflow-hidden border border-slate-200">
<div
className="h-full bg-sky-600 transition-all duration-500 shadow-sm"
style={{ width: `${percent}%` }}
/>
</div>
{status.active && (status.source === 'all' || status.source === 'arxiv') ? (
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-700 flex items-start gap-1.5">
<Lightbulb className="w-4 h-4 shrink-0 mt-0.5" />
<span> arXiv 3000 </span>
</div>
) : null}
</div>
)}
{/* 批量任务 */}
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-6 relative overflow-hidden">
<div className="flex flex-col gap-1">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<Download className="w-4 h-4 text-sky-600" />
<span>线</span>
</h3>
<p className="text-slate-500 text-xs">
PDF/HTML Markdown
</p>
</div>
{batchError && (
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex gap-3 text-xs text-red-750 items-start">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>{batchError}</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<CustomSelect
value={targetPhase}
disabled={batchStatus.active}
onChange={val => setTargetPhase(val as any)}
className="w-full"
options={[
{ value: 'download', label: '下载' },
{ value: 'parse', label: '解析' },
{ value: 'translate', label: '翻译' },
{ value: 'embed', label: '向量化' },
{ value: 'target', label: '天体识别' },
]}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<input
type="number"
value={batchLimitCount}
disabled={batchStatus.active}
onChange={e => setBatchLimitCount(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-sky-500 transition-all text-xs font-medium"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<CustomSelect
value={sortOrder}
disabled={batchStatus.active}
onChange={val => setSortOrder(val)}
className="w-full"
options={[
{ value: 'default', label: '默认(不指定)' },
{ value: 'pub_year_desc', label: '按出版年份降序' },
{ value: 'created_at_desc', label: '按入库时间降序' },
]}
/>
</div>
</div>
<div className="space-y-2 border-t border-slate-100 pt-4">
<label className="text-xs font-bold text-slate-700 block"></label>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipCompleted}
disabled={batchStatus.active}
onChange={e => setSkipCompleted(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipFailed}
disabled={batchStatus.active}
onChange={e => setSkipFailed(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipPrecedingFailed}
disabled={batchStatus.active}
onChange={e => setSkipPrecedingFailed(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="checkbox"
checked={skipPrecedingUncompleted}
disabled={batchStatus.active}
onChange={e => setSkipPrecedingUncompleted(e.target.checked)}
className="rounded text-sky-650 focus:ring-sky-500"
/>
<span></span>
</label>
</div>
</div>
<div className="flex justify-end pt-2 border-t border-slate-100">
<div className="w-full md:w-1/3 flex">
{batchStatus.active ? (
<button
type="button"
onClick={handleStopBatch}
className="w-full py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-xs font-bold flex items-center justify-center gap-2 transition-all shadow-sm"
>
<StopCircle className="w-3.5 h-3.5" />
</button>
) : (
<button
type="button"
onClick={handleStartBatch}
className="btn-console btn-console-primary w-full py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2"
>
<Play className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* 进度与终端日志展示 */}
{(batchStatus.active || batchStatus.total > 0) && (
<div className="space-y-4 pt-4 border-t border-slate-200">
<div className="space-y-1.5">
<div className="flex justify-between text-xs font-bold text-slate-600">
<span className="flex items-center gap-1">
{batchStatus.action === 'download' && <Download className="w-3.5 h-3.5 text-sky-600" />}
{batchStatus.action === 'parse' && <FileText className="w-3.5 h-3.5 text-emerald-600" />}
{batchStatus.action === 'translate' && <RefreshCw className="w-3.5 h-3.5 text-indigo-600" />}
{batchStatus.action === 'embed' && <SlidersHorizontal className="w-3.5 h-3.5 text-amber-600" />}
{batchStatus.action === 'target' && <RefreshCw className="w-3.5 h-3.5 text-sky-600" />}
{batchStatus.action === 'download'
? '正文离线下载进度'
: batchStatus.action === 'parse'
? '结构化排版解析进度'
: batchStatus.action === 'translate'
? '中英双语对照翻译进度'
: batchStatus.action === 'embed'
? '段落向量分块入库进度'
: '天体识别与缓存进度'}
</span>
<span>
{batchStatus.action === 'download' ? batchStatus.downloaded : batchStatus.parsed} / {batchStatus.total}
{((batchStatus.action === 'download' && batchStatus.download_failed > 0) ||
(batchStatus.action !== 'download' && batchStatus.parse_failed > 0)) && (
<span className="text-red-500 ml-2 font-bold">
( {batchStatus.action === 'download' ? batchStatus.download_failed : batchStatus.parse_failed} )
</span>
)}
</span>
</div>
<div className="w-full h-2 rounded-full bg-slate-100 overflow-hidden border border-slate-200">
<div
className={`h-full transition-all duration-300 ${
batchStatus.action === 'download'
? 'bg-sky-600'
: batchStatus.action === 'parse'
? 'bg-emerald-600'
: batchStatus.action === 'translate'
? 'bg-indigo-600'
: batchStatus.action === 'embed'
? 'bg-amber-600'
: 'bg-sky-600'
}`}
style={{
width: `${
batchStatus.total > 0
? Math.min(
100,
Math.round(
((batchStatus.action === 'download'
? batchStatus.downloaded + batchStatus.download_failed
: batchStatus.parsed + batchStatus.parse_failed) /
batchStatus.total) *
100
)
)
: 0
}%`,
}}
/>
</div>
</div>
{batchStatus.active && batchStatus.current_bibcode && (
<div className="text-xs font-bold text-slate-600 flex items-center gap-2">
<Loader className="w-3.5 h-3.5 text-sky-600 animate-spin" />
<span>: <code className="bg-slate-100 px-2 py-0.5 rounded font-mono font-bold text-slate-800 border border-slate-200">{batchStatus.current_bibcode}</code></span>
</div>
)}
{/* 滚动日志终端 */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<div ref={logsContainerRef} className="bg-slate-50 text-slate-800 font-mono text-xs p-4 rounded-lg h-48 overflow-y-auto border border-slate-250 space-y-1 scrollbar-thin scrollbar-thumb-slate-300 relative">
{batchStatus.logs.length === 0 ? (
<div className="text-slate-400 italic">...</div>
) : (
batchStatus.logs.map((log, idx) => (
<div key={idx} className="whitespace-pre-wrap leading-relaxed">
{log}
</div>
))
)}
</div>
</div>
</div>
)}
</div>
{/* 已存同步检索配置 */}
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4">
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4 text-sky-600 animate-pulse" />
<span></span>
</h3>
<p className="text-slate-500 text-xs">
</p>
</div>
{syncQueries.length === 0 ? (
<div className="text-center py-6 text-xs text-slate-400 italic">
</div>
) : (
<div className="divide-y divide-slate-100 max-h-80 overflow-y-auto pr-1 scrollbar-thin">
{syncQueries.map(sq => (
<div key={sq.id} className="py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs">
<div className="space-y-1">
<div className="font-bold text-slate-800 break-all select-all font-mono">
{sq.query}
</div>
<div className="text-[10px] text-slate-500 flex items-center gap-2.5 font-semibold">
<span>: <strong className="text-slate-700">{sq.source === 'all' ? '全部' : sq.source === 'ads' ? 'NASA ADS' : 'arXiv'}</strong></span>
<span>: <strong className="text-slate-700">{sq.limit_count}</strong></span>
<span>: <strong className="text-slate-700">{sq.last_run}</strong></span>
</div>
</div>
<div className="flex gap-2 shrink-0 self-end sm:self-center">
<button
type="button"
onClick={() => handleReuseQuery(sq)}
className="px-2.5 py-1.5 rounded-lg bg-slate-50 border border-slate-250 text-slate-650 hover:bg-slate-100 hover:text-slate-800 transition-all text-xs font-bold cursor-pointer"
>
</button>
<button
type="button"
disabled={status.active}
onClick={() => handleQuickSync(sq)}
className="px-2.5 py-1.5 rounded-lg bg-sky-50 border border-sky-200 text-sky-700 hover:bg-sky-100 transition-all text-xs font-bold cursor-pointer disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => handleDeleteQuery(sq.id)}
className="px-2.5 py-1.5 rounded-lg bg-red-50 border border-red-200 text-red-750 hover:bg-red-100 transition-all text-xs font-bold cursor-pointer"
>
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* 浏览器快捷直推书签工具 */}
<div className="console-panel p-6 rounded-xl border border-slate-200 bg-white space-y-4">
<div className="flex flex-col gap-1 border-b border-slate-100 pb-2">
<h3 className="text-xs font-bold text-slate-900 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-sky-500 animate-pulse" />
<span></span>
</h3>
<p className="text-slate-500 text-xs mt-1">
Cloudflare/WAF PDF/HTML AstroResearch
</p>
</div>
<div className="space-y-3.5 text-xs text-slate-700">
<div className="space-y-1.5 font-bold">
<span className="text-slate-500"></span>
<p className="text-[11px] text-slate-400 font-normal mt-1">
URL JavaScript
</p>
<div className="flex flex-wrap items-center gap-3 mt-2">
{/* 可直接拖拽的链接按钮 */}
<a
ref={(el) => {
if (el) {
el.setAttribute(
'href',
`javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode',{credentials:'include'});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`
);
}
}}
className="px-4 py-2 bg-gradient-to-r from-sky-500 to-indigo-500 hover:from-sky-600 hover:to-indigo-600 text-white rounded-lg text-xs font-bold transition-all shadow-sm cursor-move flex items-center gap-1.5"
title="拖拽我到您的书签栏中"
onClick={(e) => e.preventDefault()}
>
{/* 浏览器拖拽时读取全部 textContentsr-only span 的文字成为书签名 */}
<span style={{position:'absolute',width:'1px',height:'1px',overflow:'hidden',opacity:0,pointerEvents:'none'}}>AstroResearch</span>
<span aria-hidden="true"></span>
</a>
<button
onClick={() => {
const bookmarkletCode = `javascript:(async function(){try{let defaultBib='';try{const res=await fetch('http://localhost:8000/api/active_bibcode',{credentials:'include'});if(res.ok){const data=await res.json();if(data&&data.bibcode)defaultBib=data.bibcode;}}catch(e){}const b=prompt('请输入文献的 Bibcode / doi / arxiv_id :',defaultBib);if(!b||!b.trim())return;const bib=b.trim();if(window.location.protocol==='file:'){alert('[ERR] 浏览器安全策略限制:书签脚本无法直接读取本地磁盘文件 (file://)。\\n\\n提示对于本地 PDF/HTML 文件,请直接在 AstroResearch 的文献详情页点击“上传 PDF/HTML”按钮导入。');return;}let blob,type='html',ext='.html';const isPDF=document.contentType==='application/pdf'||window.location.pathname.toLowerCase().endsWith('.pdf')||document.title.toLowerCase().endsWith('.pdf');if(isPDF){try{const res=await fetch(window.location.href);if(!res.ok)throw new Error('HTTP '+res.status);blob=await res.blob();type='pdf';ext='.pdf';}catch(err){alert('[ERR] 无法读取该 PDF 数据。\\n(错误: '+err.message+')');return;}}else{blob=new Blob([document.documentElement.outerHTML],{type:'text/html'});}const fd=new FormData();fd.append('bibcode',bib);fd.append('type',type);fd.append('file',blob,bib+ext);const r=await fetch('http://localhost:8000/api/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){const d=await r.json();alert('[OK] '+(d.title||bib));}else{const t=await r.text();alert('[ERR '+r.status+'] '+t);}}catch(e){alert('[FAIL] '+e.message);}})();void(0);`;
navigator.clipboard.writeText(bookmarkletCode);
alert('书签代码已成功复制到剪贴板!');
}}
className="px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-[11px] font-bold transition-all cursor-pointer"
>
JavaScript
</button>
</div>
</div>
<div className="space-y-1.5 font-bold mt-4">
<span className="text-slate-500">使</span>
<ol className="list-decimal list-inside text-[11px] text-slate-500 font-normal pl-1 space-y-1.5 mt-1">
<li> <span className="font-bold">BIBCODE / DOI / arXiv ID </span></li>
<li><span className="font-bold"></span></li>
<li><span className="font-bold"> Bibcode</span></li>
</ol>
</div>
</div>
</div>
</div>
);
}