AstroResearch/dashboard/src/features/sync/SyncPanel.tsx
Asfmq cd6af4f995 feat: 重构 PDF/文献检索同步机制、升级引力图交互与控制台 UI 样式
- [后端/PDF解析] 重构 MinerU PDF 解析流程:引入预签名两阶段直传机制,解决大文件 API 传输限制问题;支持轮询机制与本地 images 备用目录存储。
- [后端/同步与下载] 新增经典 ADS SCAN 扫描件 PDF 和 ADS_PDF 直接通道的下载逻辑;新增常用同步检索配置的持久化存储与去重管理 API。
- [后端/日志] 重构日志系统,支持控制台 pretty 输出与每日滚动文件日志(使用上海 +08:00 时区),引入 HTTP 路由请求链路追踪。
- [前端/引力图] 升级引用星系图 canvas 交互:支持平移拖拽与滚轮缩放,添加引力圈轨道装饰及未导入文献的半透明视觉区分。
- [前端/控制台] 统一重构为扁平高对比度浅色纯中文控制台样式;重新设计文献详情弹窗与状态进度条。
- [数据库] 新增 papers 表的 doctype 字段及 sync_queries 检索配置表。
2026-06-10 17:29:07 +08:00

814 lines
33 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 } from 'lucide-react';
import type { SavedSyncQuery } from '../../types';
interface ProcessStatus {
active: boolean;
total: number;
downloaded: number;
parsed: number;
download_failed: number;
parse_failed: number;
current_bibcode: string;
logs: string[];
action?: 'download' | 'parse' | 'translate';
}
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'>('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 [processStatus, setProcessStatus] = useState<ProcessStatus>({
active: false,
total: 0,
downloaded: 0,
parsed: 0,
download_failed: 0,
parse_failed: 0,
current_bibcode: '',
logs: [],
});
const [processError, setProcessError] = useState<string | null>(null);
const processPollIntervalRef = useRef<any>(null);
const logsEndRef = 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);
try {
await axios.post('/api/sync/meta/run', {
q: sq.query,
source: sq.source,
limit: sq.limit_count,
});
fetchStatus();
startPolling();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动快速同步失败。');
}
};
// 获取当前的收割状态
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 fetchProcessStatus = async () => {
try {
const res = await axios.get<ProcessStatus>(`/api/sync/asset/status?t=${Date.now()}`);
setProcessStatus(res.data);
if (res.data.active) {
startProcessPolling();
} else if (processPollIntervalRef.current) {
clearInterval(processPollIntervalRef.current);
processPollIntervalRef.current = null;
}
} catch (e) {
console.error('获取处理状态失败', e);
}
};
const startProcessPolling = () => {
if (processPollIntervalRef.current) return;
processPollIntervalRef.current = setInterval(fetchProcessStatus, 1000);
};
const handleStartProcess = async () => {
setProcessError(null);
try {
await axios.post('/api/sync/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,
});
fetchProcessStatus();
startProcessPolling();
} catch (e: any) {
console.error(e);
setProcessError(e.response?.data || '启动批量任务失败。');
}
};
const handleStopProcess = async () => {
try {
await axios.post('/api/sync/asset/stop');
fetchProcessStatus();
} catch (e: any) {
console.error(e);
setProcessError(e.response?.data || '停止任务失败。');
}
};
useEffect(() => {
fetchProcessStatus();
return () => {
if (processPollIntervalRef.current) {
clearInterval(processPollIntervalRef.current);
}
};
}, []);
// 日志终端自动滚动到底部
useEffect(() => {
if (logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [processStatus.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);
try {
await axios.post('/api/sync/meta/run', {
q: query.trim(),
source,
limit: limit,
});
fetchStatus();
startPolling();
setTimeout(fetchSyncQueries, 500);
} catch (e: any) {
console.error(e);
setErrorMsg(e.response?.data || '启动收割任务失败。');
}
};
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 ? (
<select
value={rule.op}
onChange={e => handleRuleChange(idx, 'op', e.target.value)}
className="bg-white border border-slate-300 rounded-lg px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:border-sky-500 w-24"
>
<option value="AND"> (AND)</option>
<option value="OR"> (OR)</option>
<option value="NOT"> (NOT)</option>
</select>
) : (
<div className="w-24 text-center text-xs text-slate-500 font-semibold"></div>
)}
<select
value={rule.field}
onChange={e => handleRuleChange(idx, 'field', e.target.value)}
className="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs text-slate-700 focus:outline-none focus:border-sky-500 w-32"
>
<option value="all"></option>
<option value="title"></option>
<option value="author"></option>
<option value="abs"></option>
<option value="year"></option>
</select>
<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">
💡 arXiv 3000
</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>
{processError && (
<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>{processError}</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>
<div className="flex flex-col gap-1.5">
{[
{ id: 'download', label: '下载' },
{ id: 'parse', label: '解析' },
{ id: 'translate', label: '翻译' },
].map(phase => (
<label key={phase.id} className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
<input
type="radio"
name="targetPhase"
checked={targetPhase === phase.id}
disabled={processStatus.active}
onChange={() => setTargetPhase(phase.id as any)}
className="text-sky-650 focus:ring-sky-500"
/>
<span>{phase.label}</span>
</label>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"> (DOCS)</label>
<input
type="number"
value={batchLimitCount}
disabled={processStatus.active}
onChange={e => setBatchLimitCount(Math.max(1, parseInt(e.target.value) || 0))}
className="w-full px-3 py-1.5 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>
<select
value={sortOrder}
disabled={processStatus.active}
onChange={e => setSortOrder(e.target.value as any)}
className="w-full px-3 py-1.5 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"
>
<option value="default"></option>
<option value="pub_year_desc"></option>
<option value="created_at_desc"></option>
</select>
</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={processStatus.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={processStatus.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={processStatus.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={processStatus.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">
{processStatus.active ? (
<button
type="button"
onClick={handleStopProcess}
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={handleStartProcess}
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>
{/* 进度与终端日志展示 */}
{(processStatus.active || processStatus.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">
{processStatus.action === 'download' && <Download className="w-3.5 h-3.5 text-sky-600" />}
{processStatus.action === 'parse' && <FileText className="w-3.5 h-3.5 text-emerald-600" />}
{processStatus.action === 'translate' && <RefreshCw className="w-3.5 h-3.5 text-indigo-600" />}
{processStatus.action === 'download' ? '正文离线下载进度' : processStatus.action === 'parse' ? '结构化排版解析进度' : '中英双语对照翻译进度'}
</span>
<span>
{processStatus.action === 'download' ? processStatus.downloaded : processStatus.parsed} / {processStatus.total}
{((processStatus.action === 'download' && processStatus.download_failed > 0) ||
(processStatus.action !== 'download' && processStatus.parse_failed > 0)) && (
<span className="text-red-500 ml-2 font-bold">
( {processStatus.action === 'download' ? processStatus.download_failed : processStatus.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 ${
processStatus.action === 'download' ? 'bg-sky-600' : processStatus.action === 'parse' ? 'bg-emerald-600' : 'bg-indigo-600'
}`}
style={{
width: `${
processStatus.total > 0
? Math.min(
100,
Math.round(
((processStatus.action === 'download'
? processStatus.downloaded + processStatus.download_failed
: processStatus.parsed + processStatus.parse_failed) /
processStatus.total) *
100
)
)
: 0
}%`,
}}
/>
</div>
</div>
{processStatus.active && processStatus.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">{processStatus.current_bibcode}</code></span>
</div>
)}
{/* 滚动日志终端 */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block"></label>
<div 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">
{processStatus.logs.length === 0 ? (
<div className="text-slate-400 italic">...</div>
) : (
processStatus.logs.map((log, idx) => (
<div key={idx} className="whitespace-pre-wrap leading-relaxed">
{log}
</div>
))
)}
<div ref={logsEndRef} />
</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>
);
}