AstroResearch/dashboard/src/components/sync/BatchPipelineCard.tsx
Asfmq eaf85707b5 refactor: 全栈架构重构与质量硬化
核心架构重构:
- Config 按职责拆分为 LlmConfig/EmbeddingConfig/VisionConfig/CdsConfig/StorageConfig 五个子结构
- AppState 拆分为 LlmState/DataSourceState/SessionState 三个子结构,消除 50+ 平铺字段
- 新增 ServiceError 结构化错误类型替代 handler 中的 msg.contains() 字符串匹配
- 移除 api::handlers 兼容命名空间,路由直接引用 agent/auth/papers 等模块

认证性能优化:
- login_rate_limiter/upload_rate_limiter 从 Mutex<HashMap> 迁移为 DashMap(无锁)
- 新增 session_last_active: DashMap<String, AtomicU64>,auth 中间件快速路径免写锁
- 会话过期清理改为按间隔触发(300s),避免每次请求全表扫描
- MAX_SESSIONS 1000→10000,SSE 广播通道 256→1024

Agent 工具增强:
- AgentTool trait 新增 is_internal()/display_name(),SSE 事件携带工具元数据
- 新增 GET /chat/tools 端点暴露注册工具列表
- pending_questions/pending_permissions 增加 created_at 时间戳,自动清理过期条目(10min TTL)
- Agent 超时现在正确 abort 后台任务并设置取消令牌

观测数据源修复:
- FITS 解析: APOGEE/DESI 改用 read_image+切片替代 read_rows(修复 fitsio panic)
- ZTF: CIRCLE 参数分隔符 +→空格(修复 IRSA 400),半径自动裁剪至硬上限
- MAST TESS: parse_tic_json 兼容数组/对象两种 API 响应格式
- 统一检索: per_target_limit 默认 50→1,sources 支持 per-source release/version
- Gaia 测光从 VizieR 镜像切换至官方 TAP 服务

RAG 并发优化:
- 向量化降级从串行改为并发 5 条/批(buffer_unordered)
- 混合检索 RRF 合并从借用改为 owned RetrievalResult

安全加固:
- PDF 中间件: URL 解码 %2F/%2E 后判扩展名;文件名过滤非 ASCII + 禁 \ 防头注入
- chat_agent 日志截断问题内容至 50 字符;list_sessions 强制 limit clamp

前端双主题:
- 设计令牌三层架构: primitive→semantic→component,浅色暖纸张学术/暗色 Night Indigo
- useTheme hook + ThemeToggle 侧边栏组件 + main.tsx 防 FOUC 初始化
- 全组件从硬编码 slate 色迁移至语义令牌(bg-surface/text-content/border-subtle 等)
- 新增 ToastContainer 非阻塞通知系统

部署优化:
- deploy.sh 引入 SSH ControlMaster 单次密码复用
2026-07-11 14:57:40 +08:00

346 lines
13 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.

// dashboard/src/components/sync/BatchPipelineCard.tsx
import React from 'react';
import {
Download,
AlertTriangle,
Play,
StopCircle,
Loader,
FileText,
RefreshCw,
SlidersHorizontal,
} from 'lucide-react';
import { CustomSelect } from '../CustomSelect';
import type { BatchStatus } from '../../hooks/useSyncState';
interface BatchPipelineCardProps {
targetPhase: 'download' | 'parse' | 'translate' | 'embed' | 'target';
setTargetPhase: (
p: 'download' | 'parse' | 'translate' | 'embed' | 'target'
) => void;
batchLimitCount: number;
setBatchLimitCount: (c: number) => void;
sortOrder: 'default' | 'pub_year_desc' | 'created_at_desc';
setSortOrder: (o: 'default' | 'pub_year_desc' | 'created_at_desc') => void;
skipCompleted: boolean;
setSkipCompleted: (s: boolean) => void;
skipFailed: boolean;
setSkipFailed: (s: boolean) => void;
skipPrecedingFailed: boolean;
setSkipPrecedingFailed: (s: boolean) => void;
skipPrecedingUncompleted: boolean;
setSkipPrecedingUncompleted: (s: boolean) => void;
batchStatus: BatchStatus;
batchError: string | null;
logsContainerRef: React.RefObject<HTMLDivElement | null>;
handleStartBatch: () => Promise<void>;
handleStopBatch: () => Promise<void>;
}
export function BatchPipelineCard({
targetPhase,
setTargetPhase,
batchLimitCount,
setBatchLimitCount,
sortOrder,
setSortOrder,
skipCompleted,
setSkipCompleted,
skipFailed,
setSkipFailed,
skipPrecedingFailed,
setSkipPrecedingFailed,
skipPrecedingUncompleted,
setSkipPrecedingUncompleted,
batchStatus,
batchError,
logsContainerRef,
handleStartBatch,
handleStopBatch,
}: BatchPipelineCardProps) {
return (
<div className="console-panel p-6 rounded-lg bg-surface space-y-6 relative overflow-hidden shadow-sm">
<div className="flex flex-col gap-1 select-none">
<h3 className="text-xs font-bold text-content flex items-center gap-2">
<Download className="w-4 h-4 text-secondary" />
<span>线</span>
</h3>
<p className="text-secondary text-xs mt-0.5">
PDF/HTML Markdown
</p>
</div>
{batchError && (
<div className="p-4 rounded-md bg-rose-50 border border-rose-200/60 dark:bg-rose-500/10 dark:border-rose-500/30 flex gap-3 text-xs text-rose-800 dark:text-rose-300 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-secondary block">
</label>
<CustomSelect
value={targetPhase}
disabled={batchStatus.active}
onChange={(val) =>
setTargetPhase(
val as 'download' | 'parse' | 'translate' | 'embed' | 'target'
)
}
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-secondary 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-md bg-sunken text-content focus:outline-none focus:border-blueprint transition-all text-xs font-medium"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-secondary block">
</label>
<CustomSelect
value={sortOrder}
disabled={batchStatus.active}
onChange={(val) =>
setSortOrder(
val as 'default' | 'pub_year_desc' | 'created_at_desc'
)
}
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-subtle pt-4">
<label className="text-xs font-bold text-secondary block">
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 select-none">
<label className="flex items-center gap-2 text-xs font-medium text-secondary cursor-pointer">
<input
type="checkbox"
checked={skipCompleted}
disabled={batchStatus.active}
onChange={(e) => setSkipCompleted(e.target.checked)}
className="rounded text-blueprint focus:ring-blueprint/25"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-secondary cursor-pointer">
<input
type="checkbox"
checked={skipFailed}
disabled={batchStatus.active}
onChange={(e) => setSkipFailed(e.target.checked)}
className="rounded text-blueprint focus:ring-blueprint/25"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-secondary cursor-pointer">
<input
type="checkbox"
checked={skipPrecedingFailed}
disabled={batchStatus.active}
onChange={(e) => setSkipPrecedingFailed(e.target.checked)}
className="rounded text-blueprint focus:ring-blueprint/25"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-secondary cursor-pointer">
<input
type="checkbox"
checked={skipPrecedingUncompleted}
disabled={batchStatus.active}
onChange={(e) => setSkipPrecedingUncompleted(e.target.checked)}
className="rounded text-blueprint focus:ring-blueprint/25"
/>
<span></span>
</label>
</div>
</div>
{/* 控制按钮 */}
<div className="flex justify-end pt-2 border-t border-subtle">
<div className="w-full md:w-1/3 flex">
{batchStatus.active ? (
<button
type="button"
onClick={handleStopBatch}
className="w-full py-2 rounded-md bg-danger hover:opacity-90 text-white text-xs font-bold flex items-center justify-center gap-2 transition-all shadow-sm cursor-pointer"
>
<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-md text-xs font-bold flex items-center justify-center gap-2 cursor-pointer"
>
<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-subtle">
<div className="space-y-1.5">
<div className="flex justify-between text-xs font-bold text-secondary select-none">
<span className="flex items-center gap-1.5">
{batchStatus.action === 'download' && (
<Download className="w-3.5 h-3.5 text-blueprint" />
)}
{batchStatus.action === 'parse' && (
<FileText className="w-3.5 h-3.5 text-emerald-700" />
)}
{batchStatus.action === 'translate' && (
<RefreshCw className="w-3.5 h-3.5 text-slate-700 animate-spin" />
)}
{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-blueprint" />
)}
{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-danger ml-2 font-bold">
({' '}
{batchStatus.action === 'download'
? batchStatus.download_failed
: batchStatus.parse_failed}{' '}
)
</span>
)}
</span>
</div>
<div className="w-full h-2 rounded-md bg-sunken overflow-hidden select-none">
<div
className={`h-full transition-all duration-300 ${
batchStatus.action === 'download'
? 'bg-blueprint'
: batchStatus.action === 'parse'
? 'bg-emerald-600'
: batchStatus.action === 'translate'
? 'bg-slate-700'
: batchStatus.action === 'embed'
? 'bg-amber-600'
: 'bg-blueprint'
}`}
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-secondary flex items-center gap-2">
<Loader className="w-3.5 h-3.5 text-blueprint animate-spin" />
<span>
:{' '}
<code className="bg-sunken px-2 py-0.5 rounded-md font-mono font-bold text-content select-all">
{batchStatus.current_bibcode}
</code>
</span>
</div>
)}
{/* 滚动日志终端 */}
<div className="space-y-2">
<label className="text-xs font-bold text-secondary block select-none">
</label>
<div
ref={logsContainerRef}
className="bg-sunken text-content font-mono text-[11px] p-4 rounded-md h-48 overflow-y-auto space-y-1 scrollbar-thin relative select-all"
>
{batchStatus.logs.length === 0 ? (
<div className="text-tertiary italic select-none">
...
</div>
) : (
batchStatus.logs.map((log: string, idx: number) => (
<div
key={idx}
className="whitespace-pre-wrap leading-relaxed"
>
{log}
</div>
))
)}
</div>
</div>
</div>
)}
</div>
);
}