feat: 新增后台任务系统,支持抓取任务在后台持续运行
- 新增后台任务API (/api/tasks) - 抓取任务在后台独立运行,不受页面刷新影响 - 支持任务状态查询和手动停止 - 新增后台任务管理界面 - 数据库新增 background_tasks 表 - 前端使用轮询方式更新任务进度
This commit is contained in:
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user