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