后端: - 权限系统重写: 全局→按 Session 隔离, 新增规则查询 API - 安全加固: 登录 IP 限流, Token 仅存 Cookie, bibcode 白名单校验 - SSE 超时保护, 异步 I/O 迁移, 10+ 处静默 DB 错误改为显式日志 - ar5iv 下标解析修复, parse_paper_row 去重, 优雅关闭 前端: - useSyncScroll 重写: 段落 ID 映射修复中英错位 - 全局竞态修复 (active 标志), libraryRef 闭包过期修复 - ErrorBoundary + vitest 测试基础设施 - Logo 组件提取, CustomSelect 泛型化, TabId 类型统一 - ReaderPanel 自动视图模式, AIAssistantPanel 状态批处理
271 lines
12 KiB
TypeScript
271 lines
12 KiB
TypeScript
// 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-lg 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-slate-655 hover:text-slate-850 font-bold flex items-center gap-1 cursor-pointer underline underline-offset-2"
|
|
>
|
|
<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-md bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint 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 'all' | 'ads' | 'arxiv')}
|
|
className={`flex-1 py-2 rounded-md text-xs font-bold border transition-all cursor-pointer ${
|
|
source === src.id
|
|
? 'bg-slate-100 border-slate-350 text-slate-855 shadow-2xs'
|
|
: 'btn-console btn-console-secondary'
|
|
}`}
|
|
>
|
|
{src.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 动态表单生成器 */}
|
|
{showBuilder && (
|
|
<div className="p-4 rounded-md 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-slate-655 hover:text-slate-855 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-md bg-white border border-slate-300 text-slate-900 placeholder-slate-450 focus:outline-none focus:border-blueprint text-xs font-medium"
|
|
/>
|
|
|
|
{rules.length > 1 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveRule(idx)}
|
|
className="text-slate-500 hover:text-slate-800 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-md bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-blueprint 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-md 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-md 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-md bg-slate-50 border border-slate-200 flex gap-3 text-xs text-slate-800 items-center select-none animate-in fade-in duration-250">
|
|
<Info className="w-4 h-4 shrink-0 text-slate-500" />
|
|
<div>
|
|
检索库中估计有约 <strong className="text-slate-900 font-bold">{estimatedCount}</strong> 篇文献记录。
|
|
{estimatedCount > limit ? ` 限制最大同步前 ${limit} 篇元数据。` : ' 将全部进行同步。'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 实时同步进度 */}
|
|
{(status.active || status.synced > 0) && (
|
|
<div className="console-panel p-6 rounded-lg border border-slate-200 bg-white space-y-4 shadow-sm">
|
|
<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-blueprint animate-spin" />
|
|
<span>正在同步学术元数据...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircle className="w-4 h-4 text-emerald-700" />
|
|
<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-slate-800">{status.synced} / {status.total} 篇</span>
|
|
</div>
|
|
|
|
<div className="w-full h-3 rounded-md bg-slate-100 overflow-hidden border border-slate-200 select-none">
|
|
<div
|
|
className="h-full bg-blueprint transition-all duration-500 shadow-sm"
|
|
style={{ width: `${percent}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{status.active && (status.source === 'all' || status.source === 'arxiv') ? (
|
|
<div className="p-3 rounded-md bg-slate-50 border border-slate-200 border-l-4 border-l-amber-500 text-xs text-slate-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>
|
|
);
|
|
}
|