AstroResearch/dashboard/src/hooks/useSyncState.ts
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

418 lines
12 KiB
TypeScript

// dashboard/src/hooks/useSyncState.ts
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import type { SavedSyncQuery } from '../types';
export interface BatchStatus {
active: boolean;
total: number;
downloaded: number;
parsed: number;
download_failed: number;
parse_failed: number;
current_bibcode: string;
logs: string[];
action?: 'download' | 'parse' | 'translate' | 'embed' | 'target';
}
export interface HarvestStatus {
active: boolean;
query: string;
source: string;
synced: number;
total: number;
}
export function useSyncState() {
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<ReturnType<typeof setInterval> | null>(null);
// 批量下载与解析相关状态
const [targetPhase, setTargetPhase] = useState<'download' | 'parse' | 'translate' | 'embed' | 'target'>('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 [batchStatus, setBatchStatus] = useState<BatchStatus>({
active: false,
total: 0,
downloaded: 0,
parsed: 0,
download_failed: 0,
parse_failed: 0,
current_bibcode: '',
logs: [],
});
const [batchError, setBatchError] = useState<string | null>(null);
const batchPollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const logsContainerRef = 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) => {
const 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}"`;
}
const fieldPart = rule.field !== 'all'
? `${rule.field}:${valStr}`
: 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);
const validSources = ['all', 'ads', 'arxiv'] as const;
setSource(validSources.includes(sq.source as typeof validSources[number]) ? sq.source as typeof validSources[number] : 'all');
setLimit(sq.limit_count);
};
const handleQuickSync = async (sq: SavedSyncQuery) => {
setErrorMsg(null);
// 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态
setStatus(prev => ({
...prev,
active: true,
query: sq.query,
source: sq.source,
synced: 0,
total: sq.limit_count,
}));
startPolling();
try {
await axios.post('/api/sync/meta/run', {
q: sq.query,
source: sq.source,
limit: sq.limit_count,
});
fetchStatus();
setTimeout(fetchSyncQueries, 500);
} catch (e: unknown) {
console.error(e);
const axiosError = e as { response?: { data?: string } };
setErrorMsg(axiosError.response?.data || '启动快速同步失败。');
fetchStatus();
}
};
// 获取当前的元数据同步状态
const fetchStatus = async () => {
// eslint-disable-next-line react-hooks/purity -- Date.now() is called at invocation time (setInterval/event handlers), not during render
const ts = Date.now();
try {
const res = await axios.get<HarvestStatus>(`/api/sync/meta/status?t=${ts}`);
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(() => {
let active = true;
(async () => {
try {
const ts = Date.now();
const [statusRes, queriesRes] = await Promise.all([
axios.get<HarvestStatus>(`/api/sync/meta/status?t=${ts}`),
axios.get<SavedSyncQuery[]>(`/api/sync/queries?t=${ts}`),
]);
if (!active) return;
setStatus(statusRes.data);
setSyncQueries(queriesRes.data);
if (statusRes.data.active) {
startPolling();
} else if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
} catch (e) {
console.error('初始化同步状态失败', e);
}
})();
return () => {
active = false;
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
// 批量下载与解析相关的网络操作
const fetchBatchStatus = async () => {
// eslint-disable-next-line react-hooks/purity -- Date.now() is called at invocation time (setInterval/event handlers), not during render
const ts = Date.now();
try {
const res = await axios.get<BatchStatus>(`/api/batch/asset/status?t=${ts}`);
setBatchStatus(res.data);
if (res.data.active) {
startBatchPolling();
} else if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
batchPollIntervalRef.current = null;
}
} catch (e) {
console.error('获取处理状态失败', e);
}
};
const startBatchPolling = () => {
if (batchPollIntervalRef.current) return;
batchPollIntervalRef.current = setInterval(fetchBatchStatus, 1000);
};
const handleStartBatch = async () => {
setBatchError(null);
try {
await axios.post('/api/batch/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,
});
fetchBatchStatus();
startBatchPolling();
} catch (e: unknown) {
console.error(e);
const axiosError = e as { response?: { data?: string } };
setBatchError(axiosError.response?.data || '启动批量任务失败。');
}
};
const handleStopBatch = async () => {
try {
await axios.post('/api/batch/asset/stop');
fetchBatchStatus();
} catch (e: unknown) {
console.error(e);
const axiosError = e as { response?: { data?: string } };
setBatchError(axiosError.response?.data || '停止任务失败。');
}
};
useEffect(() => {
let active = true;
(async () => {
try {
const ts = Date.now();
const res = await axios.get<BatchStatus>(`/api/batch/asset/status?t=${ts}`);
if (!active) return;
setBatchStatus(res.data);
if (res.data.active) {
startBatchPolling();
} else if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
batchPollIntervalRef.current = null;
}
} catch (e) {
console.error('初始化批量状态失败', e);
}
})();
return () => {
active = false;
if (batchPollIntervalRef.current) {
clearInterval(batchPollIntervalRef.current);
}
};
}, []);
// 日志终端自动滚动到底部
useEffect(() => {
const container = logsContainerRef.current;
if (container) {
const isCloseToBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 80;
if (isCloseToBottom) {
container.scrollTop = container.scrollHeight;
}
}
}, [batchStatus.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: unknown) {
console.error(e);
const axiosError = e as { response?: { data?: string } };
setErrorMsg(axiosError.response?.data || '估算文献总量失败,请检查 API 密钥或网络。');
} finally {
setEstimating(false);
}
};
// 启动任务
const handleStartHarvest = async () => {
if (!query.trim()) {
setErrorMsg('请输入检索关键词!');
return;
}
setErrorMsg(null);
setStatus(prev => ({
...prev,
active: true,
query: query.trim(),
source,
synced: 0,
total: 0,
}));
startPolling();
try {
await axios.post('/api/sync/meta/run', {
q: query.trim(),
source,
limit: limit,
});
fetchStatus();
setTimeout(fetchSyncQueries, 500);
} catch (e: unknown) {
console.error(e);
const axiosError = e as { response?: { data?: string } };
setErrorMsg(axiosError.response?.data || '启动元数据同步任务失败。');
fetchStatus();
}
};
return {
query,
setQuery,
source,
setSource,
limit,
setLimit,
estimating,
estimatedCount,
status,
errorMsg,
setErrorMsg,
syncQueries,
targetPhase,
setTargetPhase,
batchLimitCount,
setBatchLimitCount,
sortOrder,
setSortOrder,
skipCompleted,
setSkipCompleted,
skipFailed,
setSkipFailed,
skipPrecedingFailed,
setSkipPrecedingFailed,
skipPrecedingUncompleted,
setSkipPrecedingUncompleted,
batchStatus,
batchError,
setBatchError,
showBuilder,
setShowBuilder,
rules,
logsContainerRef,
handleAddRule,
handleRemoveRule,
handleRuleChange,
handleDeleteQuery,
handleReuseQuery,
handleQuickSync,
handleEstimate,
handleStartHarvest,
handleStartBatch,
handleStopBatch,
};
}