From 86c65236a2d054fab7bcac1bf9691587672d0b41 Mon Sep 17 00:00:00 2001
From: butubb <1422726308@qq.com>
Date: Thu, 10 Sep 2026 21:15:23 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E7=9B=91=E6=8E=A7=E9=A1=B5=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=E6=A6=82=E5=86=B5=E5=8E=BB=E6=8E=89=E3=80=8C=E7=BC=96?=
=?UTF-8?q?=E8=BE=91/=E5=88=A0=E9=99=A4=E3=80=8D=EF=BC=8C=E5=8F=AA?=
=?UTF-8?q?=E7=95=99=E6=89=A7=E8=A1=8C/=E5=81=9C=E7=94=A8=20+=20=E6=98=BE?=
=?UTF-8?q?=E7=A4=BA=E8=A6=86=E7=9B=96=E8=AE=BE=E5=A4=87?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
监控页的任务卡片原来带「编辑」,但 openTaskModal 依赖 _taskTypes/_groupsList/
_devicesList,这三个列表只在任务 Tab 的 loadTasks() 里加载——从监控页直接点编辑,
下拉框是空的(这就是"编辑有问题")。按需求把编辑/删除从监控页移除(编辑统一去任务 Tab),
每张卡片只留:执行任务 + 停用任务/启用任务。
并给卡片加「覆盖设备」一行:
- 后端 /api/jobs(含 POST/PUT 返回的 job)新增 coverage = {mode, serials, total}:
按 target 定义解析(all=设备池全量 / group=分组∩设备池 / serial=该设备),
不做离线过滤、不写调度日志(该接口每 5s 轮询),与调度用的 resolve_serials 口径差异已写入文档
- 前端用已在手的 /api/status 标注在线状态,渲染成彩色 chip:
在线=蓝、运行中=黄+⏳、离线或不在池=灰划线+✕,完整 serial/型号放 tooltip
- 文档:API.md §5 补 coverage 字段与三种模式口径、ARCHITECTURE §5.1 补卡片说明
自测:coverage 三种模式端到端(含分组过滤池外 serial)全通过;Edge headless 验卡片
(2 个按钮、无编辑/删除、chip 文案/样式/tooltip)全通过;离线分支与任务 Tab 编辑器回归通过
---
doc/API.md | 17 ++++++++++++++++-
doc/ARCHITECTURE.md | 7 +++++++
static/admin/monitor.js | 35 +++++++++++++++++++++++++++++++----
templates/admin/monitor.html | 8 ++++++++
web/tasks_api.py | 36 ++++++++++++++++++++++++++++++++++++
5 files changed, 98 insertions(+), 5 deletions(-)
diff --git a/doc/API.md b/doc/API.md
index 48c4090..ad3fd4f 100644
--- a/doc/API.md
+++ b/doc/API.md
@@ -266,12 +266,27 @@
{
"ok": true,
"jobs": [{"id": "abc123", "name": "抖音养号", "task_type": "douyin_nurture",
- "next_run": "2026-08-12 09:00", "...": "..."}],
+ "next_run": "2026-08-12 09:00",
+ "coverage": {"mode": "all", "total": 3,
+ "serials": ["192.168.20.206:5555", "..."]},
+ "...": "..."}],
"task_types": [...]
}
```
`next_run`:下次真正执行时间(已按运行窗口跳过窗口外触发点,格式 `YYYY-MM-DD HH:MM`);手动任务/已停用为 `null`。
+`coverage`:**任务覆盖的设备**(监控页「任务运行概况」展示用,每次请求现算)。按 `target` 定义解析,
+不因设备当前是否空闲而变(在线/运行中由前端拿 `/api/status` 标注),也不做离线过滤、不写调度日志:
+
+| target.mode | coverage.serials |
+|------|------|
+| `all` | 设备池全部启用设备 |
+| `group` | 该分组的 serial ∩ 设备池启用设备(池外的手填 IP 不计入,与调度口径一致) |
+| `serial` | 仅该 serial(即使不在池中也照实返回) |
+
+> 口径差异:真正调度时走 `TaskJob.resolve_serials()`(`all` 取"池内∩在线"、并跳过离线设备);
+> `coverage` 是**定义层**的覆盖面,供人看"这台任务管哪些设备"。POST/PUT 的 `job` 里同样带 `coverage`。
+
### POST /api/jobs
创建任务计划。
diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md
index 47d7799..42a8c3d 100644
--- a/doc/ARCHITECTURE.md
+++ b/doc/ARCHITECTURE.md
@@ -320,6 +320,13 @@ tasks/douyin/actions/like.py — @register_action(ACTIONS) LikeAction
- **数据获取**:`fetch()` 调 JSON API,5 秒轮询 `/api/status`
- **状态渲染**:设备表格、任务卡片、进度条、徽章,纯 DOM 操作
+**监控页「任务运行概况」卡片(2026-09-10 收敛)**:每张卡片只保留两个操作——**执行任务**(`POST /api/jobs/:id/run`)
+与**停用任务/启用任务**(`POST /api/jobs/:id/toggle`,按当前状态切换文案);原"编辑/删除"已移除
+(编辑与删除统一去「任务」Tab 操作,避免监控页误删)。卡片新增**覆盖设备**一行:设备清单来自
+`/api/jobs` 的 `coverage`(后端按 target 定义解析,见 [API.md](API.md) §5),在线/运行中状态由前端
+用已在手的 `/api/status` 数据标注成彩色 chip(在线=蓝、运行中=黄+⏳、离线或不在池=灰划线+✕,
+完整 serial 与型号放 tooltip)。
+
### 5.2 步骤编辑器
`generic_steps` 任务的步骤编辑器(任务弹窗已加宽到 1000px):
diff --git a/static/admin/monitor.js b/static/admin/monitor.js
index 529b9f0..8b18d1a 100644
--- a/static/admin/monitor.js
+++ b/static/admin/monitor.js
@@ -445,6 +445,34 @@ function fmtParams(params){
return parts.join('');
}
+// 任务覆盖的设备:后端给 serial 清单(coverage),这里用实时状态标注在线/运行中
+function _jobCoverageHtml(j){
+ const cov=j.coverage||{}, serials=cov.serials||[];
+ const st={};
+ if(_statusData)for(const d of (_statusData.devices||[]))st[d.serial]=d;
+ if(!serials.length){
+ return '
覆盖设备 0 台'
+ +'该目标下暂无可调度设备(检查设备池/分组)
';
+ }
+ let onlineN=0,busyN=0;
+ const chips=serials.map(s=>{
+ const d=st[s]||{};
+ const online=!!d.present;
+ const busy=(d.worker_status==='running'||d.worker_status==='connecting');
+ if(online)onlineN++;
+ if(busy)busyN++;
+ const cls=busy?'running':(online?'online':'offline');
+ const title=s+(d.model?' · '+d.model:'')
+ +(busy?' · 任务中:'+(d.task_job||''):'')
+ +(online?'':' · 离线或不在设备池');
+ return ''
+ +esc(s.replace(/:5555$/,''))+(busy?' ⏳':'')+(online?'':' ✕')+'';
+ }).join('');
+ return '覆盖 '+serials.length+' 台 · 在线 '+onlineN
+ +(busyN?' · 运行中 '+busyN:'')+''
+ +'
'+chips+'
';
+}
+
function renderJobCards(){
const jobs=_jobsData||[];
const running={};
@@ -470,12 +498,11 @@ function renderJobCards(){
'调度:'+esc(sched)+''+
'重试:'+esc(retry)+''+
''+fmtParams(j.params)+'
'+
+ _jobCoverageHtml(j)+
''+
''+
- ''+
- ''+
- ''+
- ''+
+ ''+
+ ''+
'
'+
'';
}).join(''):'暂无任务,到"任务"Tab 新建
';
diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html
index a4d5c80..03f666e 100644
--- a/templates/admin/monitor.html
+++ b/templates/admin/monitor.html
@@ -169,6 +169,14 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
.job-card .job-body{font-size:12px;color:var(--text-muted);margin-bottom:11px;line-height:1.65}
.job-card .job-body .kv{display:inline-block;background:#faf9f5;border:1px solid var(--border);border-radius:6px;padding:1px 7px;margin:2px 3px 0 0;font-family:var(--mono);font-size:11px}
.job-card .job-actions{display:flex;gap:6px;flex-wrap:wrap}
+/* 任务覆盖的设备(监控页任务概况) */
+.job-cov{margin-top:6px}
+.cov-chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}
+.cov-chip{font-family:var(--mono);font-size:11px;padding:1px 7px;border-radius:9px;border:1px solid;white-space:nowrap;cursor:default}
+.cov-chip.online{color:#0e7490;background:#e0f2fe;border-color:#bae6fd}
+.cov-chip.running{color:#b45309;background:#fef3c7;border-color:#fde68a}
+.cov-chip.offline{color:#9ca3af;background:#f3f4f6;border-color:#e5e7eb;text-decoration:line-through}
+.cov-note{font-size:11px;color:#94a3b8;margin-left:4px}
/* 空状态 / 辅助文本 */
.empty{text-align:center;padding:42px;color:var(--text-light);font-size:13px}
diff --git a/web/tasks_api.py b/web/tasks_api.py
index 0628c21..f512e87 100644
--- a/web/tasks_api.py
+++ b/web/tasks_api.py
@@ -54,13 +54,47 @@ def _job_next_run(job):
nr = context.mgr.next_run_of(job)
return nr.strftime("%Y-%m-%d %H:%M") if nr else None
+def _job_coverage(job, configured):
+ """任务覆盖的设备(监控页「任务运行概况」展示用)。
+
+ 与 TaskJob.resolve_serials 的差别:这里按 **target 定义**给出「这台任务会覆盖
+ 哪些设备」,不因设备当前是否空闲而变(在线/运行中由前端拿实时状态标注),
+ 也不做离线过滤、不写调度日志——它是每 5s 轮询的展示接口。
+ """
+ tgt = job.target or {}
+ mode = tgt.get("mode", "all")
+ try:
+ if mode == "serial":
+ serials = [tgt.get("serial") or ""]
+ elif mode == "group":
+ g = context.mgr.groups.get(tgt.get("group_name"))
+ serials = [s for s in (list(g.serials) if g else []) if s in configured]
+ else:
+ serials = sorted(configured)
+ serials = [s for s in serials if s]
+ return {"mode": mode, "serials": serials, "total": len(serials)}
+ except Exception as e:
+ _log.warning(f"任务「{job.name}」覆盖设备解析失败: {e}")
+ return {"mode": mode, "serials": [], "total": 0, "error": str(e)[:80]}
+
+
+def _configured_set():
+ """设备池清单(coverage 计算用,取一次给所有任务复用)。"""
+ try:
+ return set(device_pool.list_configured())
+ except Exception:
+ return set()
+
+
@bp.route("/api/jobs")
@login_required
def api_jobs_list():
+ configured = _configured_set()
jobs = []
for j in context.mgr.jobs.values():
d = j.to_dict()
d["next_run"] = _job_next_run(j)
+ d["coverage"] = _job_coverage(j, configured)
jobs.append(d)
return jsonify({"ok": True, "jobs": jobs,
"task_types": list_task_types()})
@@ -84,6 +118,7 @@ def api_jobs_create():
enabled=data.get("enabled", True))
d = job.to_dict()
d["next_run"] = _job_next_run(job)
+ d["coverage"] = _job_coverage(job, _configured_set())
return jsonify({"ok": True, "msg": "任务已创建", "job": d})
@bp.route("/api/jobs/", methods=["PUT"])
@@ -102,6 +137,7 @@ def api_jobs_update(job_id):
context.mgr.update_job(job_id, **fields)
d = job.to_dict()
d["next_run"] = _job_next_run(job)
+ d["coverage"] = _job_coverage(job, _configured_set())
return jsonify({"ok": True, "msg": "任务已更新", "job": d})
@bp.route("/api/jobs/", methods=["DELETE"])