From 794f40a880c102cd1aba668f8e0fbc6ed31ff890 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Tue, 11 Aug 2026 08:27:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B0=83=E5=BA=A6=E7=AE=80=E5=8D=95?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=EF=BC=88=E9=A2=91=E7=8E=87=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=99=A8=EF=BC=89+=20=E8=BF=90=E8=A1=8C=E7=AA=97=E5=8F=A3=20+?= =?UTF-8?q?=20=E4=B8=8B=E6=AC=A1=E8=BF=90=E8=A1=8C=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/task_manager.py | 73 +++++++++++++++++- static/admin/monitor.js | 144 ++++++++++++++++++++++++++++++----- templates/admin/monitor.html | 4 +- web_server.py | 27 +++++-- 4 files changed, 222 insertions(+), 26 deletions(-) diff --git a/core/task_manager.py b/core/task_manager.py index f83a5cd..f801231 100644 --- a/core/task_manager.py +++ b/core/task_manager.py @@ -21,6 +21,7 @@ import json import time import uuid import threading +from datetime import datetime from concurrent.futures import ThreadPoolExecutor, as_completed from apscheduler.schedulers.background import BackgroundScheduler @@ -45,6 +46,61 @@ _log = get_logger("core.tm") _START_STAGGER_SEC = 0.2 +def _in_run_window(schedule, now=None): + """是否在当前运行窗口内。 + + schedule.window = {"start": "HH:MM", "end": "HH:MM"},每天重复; + 窗口外(定时触发 + 手动执行)任务不会启动。未配置/非法配置视为不限制。 + 支持跨午夜(如 21:00-09:00 = 晚上 9 点运行到次日早 9 点)。 + """ + win = (schedule or {}).get("window") or {} + start, end = win.get("start", ""), win.get("end", "") + if not start or not end: + return True + try: + cur = (now or datetime.now()).hour * 60 + (now or datetime.now()).minute + s = int(start.split(":")[0]) * 60 + int(start.split(":")[1]) + e = int(end.split(":")[0]) * 60 + int(end.split(":")[1]) + except (ValueError, AttributeError, IndexError): + return True # 配置非法按不限制处理 + if s == e: + return True # 起止相同视为不限制 + if s < e: + return s <= cur < e + return cur >= s or cur < e # 跨午夜 + + +def _next_run_time(schedule, now=None): + """任务下次真正执行的时间(考虑运行窗口)。 + + 用 CronTrigger.get_next_fire_time 从当前时间向后找触发点, + 跳过运行窗口外的触发点(最多找 200 次防死循环)。 + 返回 datetime 或 None(无 cron / 配置非法)。 + """ + sched = schedule or {} + if sched.get("mode") not in ("cron", "cron_stop"): + return None + cron = sched.get("cron", "") + if not cron: + return None + try: + trigger = CronTrigger.from_crontab(cron) + except Exception: + return None + now = now or datetime.now() + win = sched.get("window") or {} + if not win.get("start") or not win.get("end"): + return trigger.get_next_fire_time(None, now) + fire = trigger.get_next_fire_time(None, now) + for _ in range(200): + if fire is None: + return None + if _in_run_window({"window": win}, fire): + return fire + fire = trigger.get_next_fire_time(fire, fire) + return None + + # ================== 设备分组 ================== class DeviceGroup: def __init__(self, name, serials=None, description=""): @@ -504,6 +560,11 @@ class TaskManager: job = self.jobs.get(job_id) if not job: return + if not _in_run_window(job.schedule): + win = job.schedule.get("window") or {} + _log.info(f"定时触发 {job.name}({job.id}) 跳过:当前不在运行窗口内 " + f"({win.get('start','')}-{win.get('end','')})") + return _log.info(f"定时触发: {job.name}({job.id})") self._run_job(job) @@ -526,11 +587,21 @@ class TaskManager: stopped.append(serial) _log.info(f"定时停止 {job.name}({job.id}): 停止 {len(stopped)} 台设备 {stopped}") + def next_run_of(self, job): + """任务下次真正执行的时间(考虑运行窗口)。停用/无 cron 返回 None。""" + if not job or not job.enabled: + return None + return _next_run_time(job.schedule) + def run_job_now(self, job_id): - """立即执行任务(手动触发)。""" + """立即执行任务(手动触发)。运行窗口外拒绝启动。""" job = self.jobs.get(job_id) if not job: return {"ok": False, "error": "任务不存在"} + if not _in_run_window(job.schedule): + win = job.schedule.get("window") or {} + return {"ok": False, + "error": f"当前不在运行窗口内({win.get('start', '')}-{win.get('end', '')}),任务未启动"} # 在独立线程跑,不阻塞调用方 t = threading.Thread(target=self._run_job, args=(job,), daemon=True) t.start() diff --git a/static/admin/monitor.js b/static/admin/monitor.js index e92ef90..9a7570c 100644 --- a/static/admin/monitor.js +++ b/static/admin/monitor.js @@ -498,6 +498,13 @@ async function loadJobsForMonitor(){ // 通用进度展示:解析 progress = {done, total, unit, action_counts, elapsed} // 任意 app 都能用:显示进度条 + "done/total unit" + 运行时长 + 操作计数徽章 +// 下次运行时间格式化:"2026-08-12 09:00:00" → "08-12 09:00" +function fmtNextRun(iso){ + if(!iso)return ''; + const s=String(iso).replace('T',' '); + return s.length>=16?s.slice(5,16):s; +} + function fmtProgress(progress, workerStatus){ if(!progress||!Object.keys(progress).length){ // 空闲/未运行 → 显示"-" @@ -622,7 +629,7 @@ function renderTaskTable(){ filter:(j,kw)=>((j.name||'').toLowerCase().includes(kw)||(j.task_type||'').toLowerCase().includes(kw)||(j.id||'').toLowerCase().includes(kw)), render:renderTaskRow, tbody:'tb-tasks',pager:'pager-tasks',pageSize:10, - empty:'暂无任务,点击"新建任务"创建' + empty:'暂无任务,点击"新建任务"创建' }); applyListPager('tasks'); initListSearch('tasks','search-tasks'); @@ -630,8 +637,14 @@ function renderTaskTable(){ function renderTaskRow(j){ const tgt=TARGET_MAP[j.target.mode]||j.target.mode; const tgtDetail=j.target.mode==='group'?(j.target.group_name||''):(j.target.mode==='serial'?(j.target.serial||''):''); - const sched=j.schedule.mode==='cron_stop'?('启停 '+esc(j.schedule.cron||'')+'→'+esc(j.schedule.stop_cron||'')): - j.schedule.mode==='cron'?('定时 '+esc(j.schedule.cron||'')):'手动'; + const sched=j.schedule.mode==='cron_stop'?('启停 '+cronToText(j.schedule.cron||'')+' → '+cronToText(j.schedule.stop_cron||'')): + j.schedule.mode==='cron'?('定时 '+cronToText(j.schedule.cron||'')):'手动'; + const schedTxt=sched+((j.schedule.window&&j.schedule.window.start&&j.schedule.window.end) + ?' [窗口 '+esc(j.schedule.window.start)+'-'+esc(j.schedule.window.end)+']':''); + const schedOut=''+schedTxt+''; + // 下次运行时间(后端已按运行窗口跳过窗口外触发点) + const nextRun=fmtNextRun(j.next_run); + const nextOut=''+(nextRun||'-')+''; const retry=j.retry.max_attempts>1?(j.retry.max_attempts+'次/'+j.retry.delay+'s'):'不重试'; const en=j.enabled?'启用':'停用'; return ''+ @@ -640,13 +653,16 @@ function renderTaskRow(j){ ''+esc(j.task_type)+''+ ''+en+''+ ''+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+''+ - ''+sched+''+ + schedOut+ + nextOut+ ''+retry+''+ ''+ - ''+ - ''+ - ''+ - ''+ + (_can('tasks')?( + ''+ + ''+ + ''+ + '' + ):'只读')+ ''+ ''; } @@ -663,6 +679,15 @@ function openTaskModal(jobId){ const sm=j.schedule.mode; const startCron=(sm==='cron'||sm==='cron_stop')?(j.schedule.cron||''):''; const stopCron=sm==='cron_stop'?(j.schedule.stop_cron||''):''; + // 运行窗口(每天时间段,窗口外不启动) + const win=j.schedule.window||null; + const winStart=win?(win.start||'09:00'):'09:00'; + const winEnd=win?(win.end||'18:00'):'18:00'; + // 简单调度:把已有 cron 解析回"频率+时间",编辑时回填;解析不了落到自定义 + const sc=parseCronToFreq(startCron); + const stopF=parseCronToFreq(stopCron).time; + const weekdayOpts=[0,1,2,3,4,5,6].map(w=> + '').join(''); document.getElementById('modal-body').innerHTML= '
'+ @@ -686,14 +711,32 @@ function openTaskModal(jobId){ '
'+ '
'+ ''+ '
'+ - '
格式:分 时 日 月 周
'+ - '
到点自动停止所有运行中的 worker
'+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
窗口外任务不会启动(定时触发和手动执行都受约束)。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行
'+ '
'+ '
'+ '
'+ @@ -728,10 +771,69 @@ function toggleTargetFields(){ document.getElementById('f-target_serial_wrap').style.display=mode==='serial'?'block':'none'; } +// ===== 简单调度:cron(5段) ↔ 频率+时间 互转 ===== +const WEEKDAY_NAMES=['周日','周一','周二','周三','周四','周五','周六']; +// cron → 简单设置 {freq: daily|hourly|everyN|weekly|custom, time:"HH:MM", interval, weekday(0-6)} +function parseCronToFreq(cron){ + const r={freq:'custom',time:'',interval:2,weekday:1}; + const p=(cron||'').trim().split(/\s+/); + if(p.length!==5)return r; + const [mi,h,d,m,w]=p; + if(mi==='0'&&h==='*'&&d==='*'&&m==='*'&&w==='*'){r.freq='hourly';return r;} + const n=(h.match(/^\*\/(\d+)$/)||[])[1]; // 每隔N小时:时位是 */N + if(n&&mi==='0'&&d==='*'&&m==='*'&&w==='*'){r.freq='everyN';r.interval=parseInt(n)||2;return r;} + if(/^\d{1,2}$/.test(mi)&&/^\d{1,2}$/.test(h)&&d==='*'&&m==='*'&&w==='*'){ + r.freq='daily';r.time=h.padStart(2,'0')+':'+mi.padStart(2,'0');return r; + } + if(/^\d{1,2}$/.test(mi)&&/^\d{1,2}$/.test(h)&&d==='*'&&m==='*'&&/^[0-7]$/.test(w)){ + r.freq='weekly';r.time=h.padStart(2,'0')+':'+mi.padStart(2,'0');r.weekday=parseInt(w)%7;return r; + } + return r; // 解析不了 → 落到自定义 +} +// 表单 → cron(5段) +function buildCronFromFreq(){ + const freq=document.getElementById('f-schedule_freq').value; + if(freq==='custom')return document.getElementById('f-schedule_cron').value.trim(); + if(freq==='hourly')return '0 * * * *'; + if(freq==='everyN'){ + const n=parseInt(document.getElementById('f-schedule_interval').value)||2; + return '0 */'+Math.min(Math.max(n,1),12)+' * * *'; + } + const t=(document.getElementById('f-schedule_time').value||'09:00').split(':'); + const mi=parseInt(t[1])||0, h=parseInt(t[0])||0; + if(freq==='weekly'){ + const w=parseInt(document.getElementById('f-schedule_weekday').value)||0; + return mi+' '+h+' * * '+w; + } + return mi+' '+h+' * * *'; // daily +} +// cron(5段) → 中文描述(任务列表显示) +function cronToText(cron){ + const r=parseCronToFreq(cron||''); + if(r.freq==='hourly')return '每小时'; + if(r.freq==='everyN')return '每'+r.interval+'小时'; + if(r.freq==='daily')return '每天 '+r.time; + if(r.freq==='weekly')return WEEKDAY_NAMES[r.weekday]+' '+r.time; + return 'cron: '+(cron||'-'); +} function toggleScheduleFields(){ const mode=document.getElementById('f-schedule_mode').value; - document.getElementById('f-cron_wrap').style.display=(mode==='cron'||mode==='cron_stop')?'block':'none'; - document.getElementById('f-stop_cron_wrap').style.display=mode==='cron_stop'?'block':'none'; + const showSched=(mode==='cron'||mode==='cron_stop'); + document.getElementById('f-freq_wrap').style.display=showSched?'block':'none'; + document.getElementById('f-stop_wrap').style.display=mode==='cron_stop'?'block':'none'; + if(showSched)showFreqFields(); +} +function toggleWindowFields(){ + const on=document.getElementById('f-window_enable').checked; + document.getElementById('f-window_wrap').style.display=on?'flex':'none'; + document.getElementById('f-window_help').style.display=on?'block':'none'; +} +function showFreqFields(){ + const freq=document.getElementById('f-schedule_freq').value; + document.getElementById('f-time_wrap').style.display=(freq==='daily'||freq==='weekly')?'block':'none'; + document.getElementById('f-interval_wrap').style.display=freq==='everyN'?'block':'none'; + document.getElementById('f-weekday_wrap').style.display=freq==='weekly'?'block':'none'; + document.getElementById('f-cron_wrap').style.display=freq==='custom'?'block':'none'; } function onTaskTypeChange(){ @@ -1726,11 +1828,19 @@ async function saveTask(jobId){ const schedMode=document.getElementById('f-schedule_mode').value; let schedule={mode:'once'}; if(schedMode==='cron'){ - schedule={mode:'cron',cron:document.getElementById('f-schedule_cron').value.trim()}; + schedule={mode:'cron',cron:buildCronFromFreq()}; }else if(schedMode==='cron_stop'){ + const st=(document.getElementById('f-schedule_stop_time').value||'18:00').split(':'); schedule={mode:'cron_stop', - cron:document.getElementById('f-schedule_cron').value.trim(), - stop_cron:document.getElementById('f-schedule_stop_cron').value.trim()}; + cron:buildCronFromFreq(), + stop_cron:(parseInt(st[1])||0)+' '+(parseInt(st[0])||0)+' * * *'}; + } + // 运行窗口:窗口外(定时触发 + 手动执行)不启动 + if(document.getElementById('f-window_enable').checked){ + schedule.window={ + start:document.getElementById('f-window_start').value||'09:00', + end:document.getElementById('f-window_end').value||'18:00', + }; } const data={ diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 2231671..c22121e 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -337,13 +337,13 @@ select.form-control{cursor:pointer}
任务计划管理
新建、编辑、删除任务计划,支持立即执行和定时调度
- +
- +
ID任务名类型启用目标调度重试操作ID任务名类型启用目标调度下次运行重试操作
diff --git a/web_server.py b/web_server.py index e709f02..077b03a 100644 --- a/web_server.py +++ b/web_server.py @@ -247,15 +247,26 @@ def api_devices(): # ================== API:任务计划 CRUD ================== +def _job_next_run(job): + """任务下次执行时间,格式化为 "YYYY-MM-DD HH:MM"(Flask 序列化 aware datetime 会变 GMT 格式)。""" + nr = mgr.next_run_of(job) + return nr.strftime("%Y-%m-%d %H:%M") if nr else None + + @app.route("/api/jobs") @login_required def api_jobs_list(): - return jsonify({"ok": True, "jobs": [j.to_dict() for j in mgr.jobs.values()], + jobs = [] + for j in mgr.jobs.values(): + d = j.to_dict() + d["next_run"] = _job_next_run(j) + jobs.append(d) + return jsonify({"ok": True, "jobs": jobs, "task_types": list_task_types()}) @app.route("/api/jobs", methods=["POST"]) -@login_required +@perm_required(PERM_TASKS) def api_jobs_create(): data = request.json or {} name = (data.get("name") or "").strip() @@ -271,11 +282,13 @@ def api_jobs_create(): schedule=data.get("schedule", {"mode": "once"}), retry=data.get("retry", {"max_attempts": 1, "delay": 60}), enabled=data.get("enabled", True)) - return jsonify({"ok": True, "msg": "任务已创建", "job": job.to_dict()}) + d = job.to_dict() + d["next_run"] = _job_next_run(job) + return jsonify({"ok": True, "msg": "任务已创建", "job": d}) @app.route("/api/jobs/", methods=["PUT"]) -@login_required +@perm_required(PERM_TASKS) def api_jobs_update(job_id): job = mgr.jobs.get(job_id) if not job: @@ -288,11 +301,13 @@ def api_jobs_update(job_id): if "task_type" in fields and not get_task_class(fields["task_type"]): return jsonify({"ok": False, "error": f"未知任务类型: {fields['task_type']}"}), 400 mgr.update_job(job_id, **fields) - return jsonify({"ok": True, "msg": "任务已更新", "job": job.to_dict()}) + d = job.to_dict() + d["next_run"] = _job_next_run(job) + return jsonify({"ok": True, "msg": "任务已更新", "job": d}) @app.route("/api/jobs/", methods=["DELETE"]) -@login_required +@perm_required(PERM_TASKS) def api_jobs_delete(job_id): if mgr.delete_job(job_id): return jsonify({"ok": True, "msg": "任务已删除"})