后端: - 权限系统重写: 全局→按 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 状态批处理
418 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|