AstroResearch/dashboard/src/components/sync/MetadataSyncCard.tsx
Asfmq c5fd5b0d66 refactor: 全栈质量硬化
后端:
  - 权限系统重写: 全局→按 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 状态批处理
2026-06-28 14:40:26 +08:00

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>
);
}