```
feat(scheduler): 添加定时任务功能并集成前端配置界面 - 引入 node-cron 依赖以支持定时任务调度 - 新增定时任务相关 API 接口:获取配置、更新配置、查询状态、手动触发任务 - 前端新增“定时任务”标签页,支持 Cron 表达式配置与友好时间展示 - 支持通过 Web 界面启用/禁用定时任务、设置执行计划和金额阈值 - 定时任务可自动采集数据并发送邮件报告,无需重启服务即可生效新配置 - 优化配置保存逻辑,避免敏感信息泄露 ```
This commit is contained in:
298
public/app.js
298
public/app.js
@@ -479,11 +479,6 @@ async function exportReport() {
|
||||
|
||||
// ========== 邮件功能 ==========
|
||||
|
||||
// 页面加载时加载邮件配置
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadEmailConfig();
|
||||
});
|
||||
|
||||
// 保存邮件配置到localStorage
|
||||
function saveEmailConfig() {
|
||||
const config = {
|
||||
@@ -670,3 +665,296 @@ function showEmailStatus(message, type) {
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 定时任务功能 ==========
|
||||
|
||||
// 将Cron表达式转换为友好的时间描述
|
||||
function cronToFriendlyText(cronTime) {
|
||||
// 常见的预设值映射
|
||||
const cronMap = {
|
||||
'0 9 * * *': '每天上午9点',
|
||||
'0 6 * * *': '每天上午6点',
|
||||
'0 12 * * *': '每天中午12点',
|
||||
'0 18 * * *': '每天下午18点',
|
||||
'0 9,18 * * *': '每天9点和18点',
|
||||
'0 */6 * * *': '每6小时',
|
||||
'0 */12 * * *': '每12小时',
|
||||
'0 9 * * 1': '每周一上午9点',
|
||||
'0 9 1 * *': '每月1日上午9点'
|
||||
};
|
||||
|
||||
// 如果是预设值,直接返回
|
||||
if (cronMap[cronTime]) {
|
||||
return cronMap[cronTime];
|
||||
}
|
||||
|
||||
// 尝试解析自定义时间 "分 时 * * *" 格式
|
||||
const cronParts = cronTime.split(/\s+/);
|
||||
if (cronParts.length === 5 && cronParts[2] === '*' && cronParts[3] === '*' && cronParts[4] === '*') {
|
||||
const minute = cronParts[0];
|
||||
const hour = cronParts[1];
|
||||
|
||||
// 检查是否是整点
|
||||
if (minute === '0') {
|
||||
return `每天${hour}点`;
|
||||
} else {
|
||||
return `每天${hour}点${minute}分`;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果无法解析,返回原始值
|
||||
return cronTime;
|
||||
}
|
||||
|
||||
// 加载定时任务配置
|
||||
async function loadSchedulerConfig() {
|
||||
try {
|
||||
// 从服务器获取配置
|
||||
const response = await fetch(`${API_BASE}/config`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
const config = data.data;
|
||||
|
||||
// 填充表单
|
||||
if (config.scheduler) {
|
||||
document.getElementById('schedulerEnabled').checked = config.scheduler.enabled || false;
|
||||
const cronTime = config.scheduler.cronTime || '0 9 * * *';
|
||||
document.getElementById('schedulerCronInput').value = cronTime;
|
||||
document.getElementById('schedulerThresholdInput').value = config.scheduler.threshold || 100000;
|
||||
document.getElementById('schedulerDescription').value = config.scheduler.description || '';
|
||||
|
||||
// 时间段配置
|
||||
document.getElementById('schedulerTimeRange').value = config.scheduler.timeRange || 'thisMonth';
|
||||
|
||||
// 反向映射Cron表达式到预设选择器
|
||||
const presetSelector = document.getElementById('schedulerCronPreset');
|
||||
const customGroup = document.getElementById('customCronGroup');
|
||||
|
||||
// 预设值列表
|
||||
const presets = [
|
||||
'0 9 * * *',
|
||||
'0 6 * * *',
|
||||
'0 12 * * *',
|
||||
'0 18 * * *',
|
||||
'0 9,18 * * *',
|
||||
'0 */6 * * *',
|
||||
'0 */12 * * *',
|
||||
'0 9 * * 1',
|
||||
'0 9 1 * *'
|
||||
];
|
||||
|
||||
// 检查是否匹配预设值
|
||||
if (presets.includes(cronTime)) {
|
||||
presetSelector.value = cronTime;
|
||||
customGroup.style.display = 'none';
|
||||
} else {
|
||||
// 自定义时间 - 尝试解析为 "分 时 * * *" 格式
|
||||
presetSelector.value = 'custom';
|
||||
customGroup.style.display = 'block';
|
||||
|
||||
const cronParts = cronTime.split(/\s+/);
|
||||
if (cronParts.length >= 2) {
|
||||
document.getElementById('customMinute').value = cronParts[0];
|
||||
document.getElementById('customHour').value = cronParts[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新状态显示
|
||||
await updateSchedulerStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载定时任务配置失败:', error);
|
||||
showSchedulerStatus('加载配置失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 处理Cron预设选择器变化
|
||||
function handleCronPresetChange() {
|
||||
const preset = document.getElementById('schedulerCronPreset').value;
|
||||
const customGroup = document.getElementById('customCronGroup');
|
||||
const cronInput = document.getElementById('schedulerCronInput');
|
||||
|
||||
if (preset === 'custom') {
|
||||
// 显示自定义时间选择器
|
||||
customGroup.style.display = 'block';
|
||||
updateCustomCron(); // 根据自定义时间生成Cron表达式
|
||||
} else {
|
||||
// 隐藏自定义时间选择器,使用预设Cron表达式
|
||||
customGroup.style.display = 'none';
|
||||
cronInput.value = preset;
|
||||
}
|
||||
}
|
||||
|
||||
// 根据自定义小时和分钟生成Cron表达式
|
||||
function updateCustomCron() {
|
||||
const hour = document.getElementById('customHour').value;
|
||||
const minute = document.getElementById('customMinute').value;
|
||||
const cronInput = document.getElementById('schedulerCronInput');
|
||||
|
||||
// 生成格式: 分 时 * * * (每天指定时间执行)
|
||||
cronInput.value = `${minute} ${hour} * * *`;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadEmailConfig();
|
||||
loadSchedulerConfig();
|
||||
|
||||
// 添加自定义时间输入框的事件监听
|
||||
const customHour = document.getElementById('customHour');
|
||||
const customMinute = document.getElementById('customMinute');
|
||||
if (customHour) {
|
||||
customHour.addEventListener('change', updateCustomCron);
|
||||
}
|
||||
if (customMinute) {
|
||||
customMinute.addEventListener('change', updateCustomCron);
|
||||
}
|
||||
});
|
||||
|
||||
// 更新定时任务状态显示
|
||||
async function updateSchedulerStatus() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/scheduler/status`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
const status = data.data;
|
||||
|
||||
// 更新运行状态
|
||||
const statusText = status.isRunning ? '✓ 运行中' : '✗ 未运行';
|
||||
const statusColor = status.isRunning ? '#28a745' : '#dc3545';
|
||||
document.getElementById('schedulerRunningStatus').innerHTML = `<span style="color: ${statusColor}">${statusText}</span>`;
|
||||
|
||||
// 更新执行计划
|
||||
if (status.config) {
|
||||
document.getElementById('schedulerCronTime').textContent = cronToFriendlyText(status.config.cronTime);
|
||||
const thresholdBillion = (status.config.threshold / 10000).toFixed(1);
|
||||
document.getElementById('schedulerThreshold').textContent = `${status.config.threshold}万元 (${thresholdBillion}亿)`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取定时任务状态失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存定时任务配置
|
||||
async function saveSchedulerConfig() {
|
||||
const schedulerConfig = {
|
||||
enabled: document.getElementById('schedulerEnabled').checked,
|
||||
cronTime: document.getElementById('schedulerCronInput').value,
|
||||
threshold: parseInt(document.getElementById('schedulerThresholdInput').value),
|
||||
description: document.getElementById('schedulerDescription').value,
|
||||
timeRange: document.getElementById('schedulerTimeRange').value
|
||||
};
|
||||
|
||||
// 验证Cron表达式格式(简单验证)
|
||||
const cronParts = schedulerConfig.cronTime.trim().split(/\s+/);
|
||||
if (cronParts.length !== 5) {
|
||||
showSchedulerStatus('Cron表达式格式错误,应为5个部分(分 时 日 月 周)', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 从localStorage获取邮件配置
|
||||
const emailConfigStr = localStorage.getItem('emailConfig');
|
||||
let emailConfig = {};
|
||||
|
||||
if (emailConfigStr) {
|
||||
try {
|
||||
emailConfig = JSON.parse(emailConfigStr);
|
||||
} catch (e) {
|
||||
console.error('解析邮件配置失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果邮件配置为空,提示用户
|
||||
if (!emailConfig.smtpHost || !emailConfig.smtpUser) {
|
||||
if (confirm('检测到邮件配置未完成,定时任务需要邮件配置才能发送报告。\n\n是否继续保存定时任务配置(不保存邮件配置)?')) {
|
||||
// 继续保存,但不包含邮件配置
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 构建完整配置对象
|
||||
const fullConfig = {
|
||||
scheduler: schedulerConfig,
|
||||
email: emailConfig
|
||||
};
|
||||
|
||||
showSchedulerStatus('正在保存配置...', 'info');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fullConfig)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showSchedulerStatus('配置已保存,定时任务已重新加载!', 'success');
|
||||
// 刷新状态显示
|
||||
await updateSchedulerStatus();
|
||||
} else {
|
||||
showSchedulerStatus(`保存失败: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showSchedulerStatus(`请求失败: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 立即测试运行定时任务
|
||||
async function testSchedulerNow() {
|
||||
if (!confirm('确定要立即执行定时任务吗?\n\n这将采集本月大于阈值的项目并发送邮件,可能需要几分钟时间。')) {
|
||||
return;
|
||||
}
|
||||
|
||||
showSchedulerStatus('正在后台执行定时任务,请稍候...', 'info');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/run-scheduled-task`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showSchedulerStatus('定时任务已在后台开始执行,完成后将发送邮件。请查看服务器控制台日志了解进度。', 'success');
|
||||
} else {
|
||||
showSchedulerStatus(`执行失败: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showSchedulerStatus(`请求失败: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示定时任务配置状态
|
||||
function showSchedulerStatus(message, type) {
|
||||
const statusDiv = document.getElementById('schedulerConfigStatus');
|
||||
const bgColors = {
|
||||
success: '#d4edda',
|
||||
error: '#f8d7da',
|
||||
info: '#d1ecf1'
|
||||
};
|
||||
const textColors = {
|
||||
success: '#155724',
|
||||
error: '#721c24',
|
||||
info: '#0c5460'
|
||||
};
|
||||
|
||||
statusDiv.innerHTML = `
|
||||
<div style="background: ${bgColors[type]}; color: ${textColors[type]}; padding: 15px; border-radius: 8px;">
|
||||
${message}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 3秒后自动隐藏成功消息
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
statusDiv.innerHTML = '';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user