feat: 新增后台任务系统,支持抓取任务在后台持续运行

- 新增后台任务API (/api/tasks)
- 抓取任务在后台独立运行,不受页面刷新影响
- 支持任务状态查询和手动停止
- 新增后台任务管理界面
- 数据库新增 background_tasks 表
- 前端使用轮询方式更新任务进度
This commit is contained in:
2026-07-14 10:59:18 +08:00
parent 4f47bf2b91
commit 6534c3315b
8 changed files with 973 additions and 43 deletions
+116
View File
@@ -454,4 +454,120 @@
.failed-url-actions {
display: flex;
gap: 8px;
}
/* 后台任务区域 */
.background-tasks-section {
background: white;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
margin-top: 20px;
}
.background-tasks-section .panel-header {
background: #e0e7ff;
}
.background-tasks-section .panel-header h2 {
color: #3730a3;
}
.background-tasks-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.background-task-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 15px;
border: 1px solid #c7d2fe;
border-radius: 8px;
background: #f5f3ff;
}
.background-task-item.status-running {
background: #fef3c7;
border-color: #fcd34d;
}
.background-task-item.status-success {
background: #d1fae5;
border-color: #6ee7b7;
}
.background-task-item.status-error {
background: #fee2e2;
border-color: #fca5a5;
}
.background-task-item.status-warning {
background: #fef3c7;
border-color: #fcd34d;
}
.task-info {
flex: 1;
}
.task-id {
font-size: 12px;
color: #666;
margin-bottom: 5px;
}
.task-status {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 5px;
}
.task-progress {
color: #667eea;
font-weight: bold;
}
.status-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-badge.status-pending {
background: #e0e7ff;
color: #3730a3;
}
.status-badge.status-running {
background: #fef3c7;
color: #92400e;
}
.status-badge.status-success {
background: #d1fae5;
color: #065f46;
}
.status-badge.status-error {
background: #fee2e2;
color: #991b1b;
}
.status-badge.status-warning {
background: #fef3c7;
color: #92400e;
}
.task-time {
font-size: 11px;
color: #999;
}
.task-actions {
display: flex;
gap: 8px;
}
+283 -43
View File
@@ -5,8 +5,9 @@ const API_BASE = '';
let searchResults = [];
let currentResultIndex = -1;
// 自动流程控制
let shouldStop = false;
// 后台任务相关
let currentTaskId = null;
let taskPollInterval = null;
// 页面加载初始化
document.addEventListener('DOMContentLoaded', () => {
@@ -17,8 +18,9 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// 加载失败URL
// 加载失败URL和后台任务
loadFailedUrls();
loadBackgroundTasks();
});
// 执行搜索
@@ -95,7 +97,7 @@ async function doSearch() {
document.getElementById('progress-text').textContent = '搜索完成!';
document.getElementById('stop-btn').style.display = 'none'; // 隐藏停止按钮
// 自动抓取和保存
// 自动抓取和保存(后台任务方式)
if (autoFetch && searchResults.length > 0) {
setTimeout(() => fetchAllResults(autoSave), 500);
} else {
@@ -358,60 +360,170 @@ async function saveAllResults() {
showToast(`成功保存 ${saved} 条结果`, 'success');
}
// 抓取所有结果
// 抓取所有结果(后台任务方式)
async function fetchAllResults(autoSave) {
shouldStop = false; // 重置停止标志
const progress = document.getElementById('search-progress');
progress.style.display = 'block';
document.getElementById('stop-btn').style.display = 'inline-flex'; // 显示停止按钮
document.getElementById('stop-btn').style.display = 'inline-flex';
document.getElementById('progress-fill').style.width = '0%';
document.getElementById('progress-text').textContent = '正在启动后台任务...';
const total = searchResults.length;
let fetched = 0;
// 准备结果数据
const resultsToSend = searchResults.filter(r => !r.fetched).map(r => ({
title: r.title,
url: r.url
}));
for (let i = 0; i < searchResults.length; i++) {
// 检查是否停止
if (shouldStop) {
document.getElementById('progress-text').textContent = `已停止(已抓取 ${fetched}/${total}`;
document.getElementById('stop-btn').style.display = 'none';
setTimeout(() => {
progress.style.display = 'none';
}, 2000);
showToast('自动抓取已停止', 'warning');
return;
}
if (searchResults[i].fetched) {
fetched++;
continue;
}
document.getElementById('progress-text').textContent =
`正在抓取 ${fetched + 1}/${total}...`;
document.getElementById('progress-fill').style.width =
`${(fetched / total) * 100}%`;
await fetchResult(i);
fetched++;
if (resultsToSend.length === 0) {
document.getElementById('progress-text').textContent = '所有结果已抓取';
document.getElementById('stop-btn').style.display = 'none';
setTimeout(() => progress.style.display = 'none', 1000);
return;
}
try {
// 启动后台任务
const response = await fetch(`${API_BASE}/api/tasks/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
results: resultsToSend,
auto_save: autoSave,
category: '',
keywords: []
})
});
const data = await response.json();
if (data.success) {
currentTaskId = data.task_id;
showToast('后台任务已启动', 'success');
// 开始轮询任务状态
startTaskPolling(autoSave);
} else {
document.getElementById('stop-btn').style.display = 'none';
showToast('启动后台任务失败: ' + data.error, 'error');
progress.style.display = 'none';
}
} catch (error) {
document.getElementById('stop-btn').style.display = 'none';
showToast('启动任务出错', 'error');
progress.style.display = 'none';
console.error(error);
}
}
// 开始轮询任务状态
function startTaskPolling(autoSave) {
if (taskPollInterval) {
clearInterval(taskPollInterval);
}
taskPollInterval = setInterval(async () => {
try {
const response = await fetch(`${API_BASE}/api/tasks/${currentTaskId}/status`);
const data = await response.json();
if (data.success) {
updateTaskProgress(data.task);
// 任务完成
if (data.task.status === 'completed') {
clearInterval(taskPollInterval);
taskPollInterval = null;
onTaskCompleted(data.task);
} else if (data.task.status === 'failed') {
clearInterval(taskPollInterval);
taskPollInterval = null;
showToast('任务失败: ' + data.task.error_message, 'error');
document.getElementById('stop-btn').style.display = 'none';
} else if (data.task.status === 'stopped') {
clearInterval(taskPollInterval);
taskPollInterval = null;
showToast('任务已停止', 'warning');
onTaskStopped(data.task);
}
}
} catch (error) {
console.error('轮询任务状态失败:', error);
}
}, 1000); // 每秒轮询一次
}
// 更新任务进度显示
function updateTaskProgress(task) {
const progress = document.getElementById('search-progress');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const total = task.total || 0;
const current = task.progress || 0;
const currentItem = task.current_item || '';
progressFill.style.width = total > 0 ? `${(current / total) * 100}%` : '0%';
progressText.textContent = `正在抓取 ${current}/${total}... ${currentItem ? currentItem.substring(0, 30) : ''}`;
}
// 任务完成处理
function onTaskCompleted(task) {
const progress = document.getElementById('search-progress');
document.getElementById('progress-fill').style.width = '100%';
document.getElementById('progress-text').textContent = '抓取完成!';
const result = task.result || {};
document.getElementById('progress-text').textContent =
`抓取完成!成功 ${result.success || 0},失败 ${result.failed || 0},已保存 ${result.saved || 0}`;
document.getElementById('stop-btn').style.display = 'none';
setTimeout(() => {
progress.style.display = 'none';
}, 1000);
}, 2000);
// 自动保存
if (autoSave) {
setTimeout(() => saveAllResults(), 500);
}
showToast(`抓取完成:成功 ${result.success || 0},失败 ${result.failed || 0}`, 'success');
// 刷新失败URL列表
loadFailedUrls();
// 标记所有结果为已抓取
searchResults.forEach(r => r.fetched = true);
displayResults();
}
// 停止自动处理
function stopAutoProcess() {
shouldStop = true;
// 任务停止处理
function onTaskStopped(task) {
const progress = document.getElementById('search-progress');
document.getElementById('progress-fill').style.width =
task.total > 0 ? `${(task.progress / task.total) * 100}%` : '0%';
document.getElementById('progress-text').textContent =
`已停止(已处理 ${task.progress || 0}/${task.total || 0}`;
document.getElementById('stop-btn').style.display = 'none';
setTimeout(() => {
progress.style.display = 'none';
}, 2000);
loadFailedUrls();
}
// 停止自动处理(后台任务版本)
async function stopAutoProcess() {
if (!currentTaskId) return;
try {
const response = await fetch(`${API_BASE}/api/tasks/${currentTaskId}/stop`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showToast('正在停止任务...', 'warning');
} else {
showToast('停止任务失败', 'error');
}
} catch (error) {
showToast('停止任务出错', 'error');
}
}
// 显示结果详情
@@ -621,4 +733,132 @@ async function clearFailedUrls() {
} catch (error) {
showToast('清空失败', 'error');
}
}
// ========== 后台任务管理 ==========
// 加载后台任务列表
async function loadBackgroundTasks() {
try {
const response = await fetch(`${API_BASE}/api/tasks/recent?limit=10`);
const data = await response.json();
if (data.success) {
const container = document.getElementById('background-tasks-list');
if (data.tasks.length === 0) {
container.innerHTML = '<div class="empty-text">暂无后台任务</div>';
} else {
container.innerHTML = data.tasks.map(task => {
const statusClass = {
'pending': 'status-pending',
'running': 'status-running',
'completed': 'status-success',
'failed': 'status-error',
'stopped': 'status-warning'
}[task.status] || '';
const statusText = {
'pending': '等待中',
'running': '运行中',
'completed': '已完成',
'failed': '失败',
'stopped': '已停止'
}[task.status] || task.status;
const progressPercent = task.total > 0 ?
Math.round((task.progress / task.total) * 100) : 0;
const result = task.result ? JSON.parse(task.result) : {};
return `
<div class="background-task-item ${statusClass}">
<div class="task-info">
<div class="task-id">${escapeHtml(task.task_id)}</div>
<div class="task-status">
<span class="status-badge ${statusClass}">${statusText}</span>
${task.status === 'running' ?
`<span class="task-progress">${task.progress || 0}/${task.total || 0}</span>` : ''}
${task.status === 'completed' && result.success ?
`<span>成功 ${result.success},失败 ${result.failed},已保存 ${result.saved}</span>` : ''}
</div>
<div class="task-time">${task.created_at || ''}
${task.finished_at ? ' → ' + task.finished_at : ''}
</div>
</div>
<div class="task-actions">
${task.status === 'running' ?
`<button onclick="stopBackgroundTask('${task.task_id}')" class="btn btn-sm btn-danger">
<i class="ri-stop-line"></i> 停止
</button>` : ''}
${task.status !== 'running' && task.status !== 'pending' ?
`<button onclick="deleteBackgroundTask('${task.task_id}')" class="btn btn-sm btn-secondary">
<i class="ri-delete-bin-line"></i>
</button>` : ''}
</div>
</div>
`;
}).join('');
}
}
} catch (error) {
console.error('加载后台任务出错:', error);
}
}
// 停止后台任务
async function stopBackgroundTask(taskId) {
try {
const response = await fetch(`${API_BASE}/api/tasks/${taskId}/stop`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showToast('任务已停止', 'success');
loadBackgroundTasks();
} else {
showToast('停止失败: ' + data.error, 'error');
}
} catch (error) {
showToast('停止任务出错', 'error');
}
}
// 删除后台任务记录
async function deleteBackgroundTask(taskId) {
try {
const response = await fetch(`${API_BASE}/api/tasks/${taskId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showToast('任务已删除', 'success');
loadBackgroundTasks();
} else {
showToast('删除失败: ' + data.error, 'error');
}
} catch (error) {
showToast('删除任务出错', 'error');
}
}
// 清理已完成的任务
async function clearCompletedTasks() {
try {
const response = await fetch(`${API_BASE}/api/tasks/clear`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
loadBackgroundTasks();
} else {
showToast('清理失败', 'error');
}
} catch (error) {
showToast('清理任务出错', 'error');
}
}