From d633949a66ac11074bb0b1caa4ad917ce3d6ef23 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 26 Jun 2026 12:53:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=AE=9A=E6=97=B6=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=89=A7=E8=A1=8C=E5=8E=86=E5=8F=B2=E5=88=97=E8=A1=A8?= =?UTF-8?q?(=E5=8F=B3=E6=A0=8F=20Tab=20+=20=E5=88=86=E9=A1=B5)(bump=200.29?= =?UTF-8?q?.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isolated 模式每次触发新建 task,旧的带 scheduled_job_id 被普通列表过滤、 UI 够不到,原来只有「打开它跑的任务」单按钮指向 last_task_id(最近一次)。 - 后端新增 GET /v1/schedules/{job_id}/tasks?page=&page_size=:按 scheduled_job_id 归属 + user_id 隔离,created_at desc 分页,复用 _task_dict,标准分页壳返回。 - 前端定时弹框右栏改 Tab(详情 / 执行记录),动作按钮提到顶部 head; 执行记录是分页列表,点某条打开那次对话。await 后重查 #cr-hist 防切换串显。 - 决策(与用户对齐):历史全部保留不剪枝;布局选 Tab 而非三栏。 Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 10 +++- core/__init__.py | 2 +- web/app.py | 57 +++++++++++++++++++++ web/static/dev.html | 10 ++++ web/static/js/crons.js | 113 ++++++++++++++++++++++++++++++++++++----- 5 files changed, 178 insertions(+), 14 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index c97b5e3..4752275 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-06-26(定时任务对话归属 + push 统一记录到渠道对话 + bump 0.28.0) +最后更新:2026-06-26(定时任务执行历史列表(分页)+ bump 0.29.0) --- @@ -21,6 +21,14 @@ ## 已完成关键能力 +### 2026-06-26 / 定时任务执行历史列表(分页)(bump 0.29.0) + +- 背景:isolated 模式每次触发新建一个 task,旧的带 `scheduled_job_id` 被普通列表过滤掉、UI 够不到,只有详情里单个「打开它跑的任务」按钮指向 `last_task_id`(最近一次)。历史 task 一直在库里(不删除),但访问不到。 +- 改:把单按钮换成右栏 **Tab 布局(详情 / 执行记录)**,动作按钮(停用/删除)提到右栏顶部 head;执行记录 tab 是**带分页的列表**。决策(与用户对齐):**保留全部历史不剪枝**(以后再清),列表做好分页;布局选 Tab 而非三栏(固定宽 modal 三栏每栏太窄、长文本难读)。 +- 后端:新增 `GET /v1/schedules/{job_id}/tasks?page=&page_size=` —— 查 `scheduled_job_id == job AND user_id == 自己 AND deleted_at IS NULL`,`created_at desc` 分页,复用 `_task_dict`(带消息数/用量),返回标准分页壳 `{page, page_size, count, results}`。user_id 过滤天然隔离他人 job;非法/非本人 job_id 返回空。 +- 前端 `crons.js`:`selectJob` 渲染 head(名+状态+按钮)+ tab 条 + `#cr-tab-body`;`renderTab` 切详情/历史;`loadHistory(jobId, page)` 拉一页渲染进 `#cr-hist`(时间·名称·状态/消息数,点某条 → 关弹框 + `selectTask` 打开那次对话),底部「上一页/下一页」+ 页码;await 后**重查** `#cr-hist` 校验 `data-job`,防切 job/切 tab 的迟到响应串显。persistent 模式天然只显一条。 +- 文件:`web/app.py`(新端点)、`web/static/js/crons.js`(tab+历史+分页)、`web/static/dev.html`(`.cr-tabs/.cr-tab/.cr-hist-*` 样式)。 + ### 2026-06-26 / 渠道卡片收拢绑定管理 + 删 rail 按钮(bump 0.28.1) - 把渠道绑定/对话/管理全部收进「新建任务」下方的卡片,删掉左下角 rail「微信」按钮(精简页面)。 diff --git a/core/__init__.py b/core/__init__.py index d288f13..0be642f 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.28.1" +__version__ = "0.29.0" diff --git a/web/app.py b/web/app.py index dff66c3..26756be 100644 --- a/web/app.py +++ b/web/app.py @@ -1966,6 +1966,63 @@ def create_app() -> FastAPI: except scheduler.JobError as e: raise HTTPException(404, str(e)) + @app.get("/v1/schedules/{job_id}/tasks", tags=["schedules"]) + def list_schedule_tasks( + job_id: str, + page: int = 1, + page_size: int = 20, + user_id: UUID = Depends(require_user), + ): + """列某定时任务的历史执行 task(归属 scheduled_job_id),按 created_at 倒序分页。 + + isolated 模式每次触发新建一个 task,这些 task 不进普通 /v1/tasks 列表 + (scheduled_job_id 过滤),只能经此端点回看;persistent 模式始终只有绑定的那一条。 + 返回标准分页壳 `{page, page_size, count, results}`(results 同 /v1/tasks,复用 _task_dict)。 + user_id 过滤天然隔离他人 job —— 非本人 / 非法 job_id 一律返回空列表。 + """ + page = max(1, page) + page_size = max(1, min(page_size, 100)) + try: + jid = UUID(str(job_id).strip()) + except (ValueError, AttributeError): + return {"page": page, "page_size": page_size, "count": 0, "results": []} + + conditions = [ + Task.user_id == user_id, + Task.deleted_at.is_(None), + Task.scheduled_job_id == jid, + ] + offset = (page - 1) * page_size + with session_scope() as s: + cnt = s.execute( + select(func.count()).select_from(Task).where(*conditions) + ).scalar_one() or 0 + rows = s.execute( + select(Task).where(*conditions) + .order_by(Task.created_at.desc()) + .limit(page_size).offset(offset) + ).scalars().all() + tids = [r.task_id for r in rows] + msg_counts = ( + dict(s.execute( + select(Message.task_id, func.count()) + .where(Message.task_id.in_(tids)) + .group_by(Message.task_id) + ).all()) + if tids else {} + ) + usage = _usage_aggregates(s, tids) + + return { + "page": page, + "page_size": page_size, + "count": int(cnt), + "results": [ + _task_dict(r, n_messages=msg_counts.get(r.task_id, 0), usage=usage.get(r.task_id)) + for r in rows + ], + } + @app.delete("/v1/tasks/{task_id}", status_code=204, tags=["tasks"]) def delete_task(task_id: str, user_id: UUID = Depends(require_user)): """软删除:置 deleted_at=now(),从任务列表隐藏。 diff --git a/web/static/dev.html b/web/static/dev.html index 031fa8c..77a60bf 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -343,6 +343,16 @@ .cr-d-row .k { width: 84px; flex-shrink: 0; color: var(--muted); } .cr-d-row .v { flex: 1; min-width: 0; word-break: break-word; white-space: pre-wrap; } .cr-acts { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; } + .cr-tabs { display: flex; gap: 4px; margin-bottom: 12px; border-bottom: 1px solid var(--border); } + .cr-tab { appearance: none; background: none; border: none; border-bottom: 2px solid transparent; padding: 6px 12px; margin-bottom: -1px; font-size: 13px; color: var(--muted); cursor: pointer; } + .cr-tab:hover { color: var(--fg, inherit); } + .cr-tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; } + .cr-hist-item { display: flex; align-items: center; gap: 8px; padding: 6px 4px; border-bottom: 1px solid var(--border); font-size: 12px; cursor: pointer; border-radius: 4px; } + .cr-hist-item:hover { background: var(--hover, rgba(127,127,127,0.08)); } + .cr-hist-ts { width: 92px; flex-shrink: 0; color: var(--muted); } + .cr-hist-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .cr-hist-meta { flex-shrink: 0; display: flex; align-items: center; gap: 6px; color: var(--muted); } + .cr-hist-pager { display: flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 12px; } @media (max-width: 760px) { #crons-modal .card { width: 96vw; height: 88vh; max-height: 88vh; } #cr-cols { flex-direction: column; } diff --git a/web/static/js/crons.js b/web/static/js/crons.js index 1c35af5..66589ee 100644 --- a/web/static/js/crons.js +++ b/web/static/js/crons.js @@ -78,12 +78,11 @@ function row(k, v) { return `
${k}${v}
`; } -function selectJob(id, itemEl) { - $("cr-list").querySelectorAll(".sk-item.active").forEach((el) => el.classList.remove("active")); - if (itemEl) itemEl.classList.add("active"); - const j = _jobs.find((x) => x.job_id === id); - if (!j) return; +// 当前选中 job —— tab 切换 / 历史分页都靠它定位(详情切走即失效)。 +let _selJobId = null; +// 详情 tab 的字段行(纯展示,不含动作按钮 —— 按钮已提到顶部 head)。 +function detailRowsHtml(j) { const notify = j.notify && j.notify.to ? `必达邮件 → ${escapeHtml(j.notify.to)}` : "无(结果进任务线程 / 由指令自行投递)"; const modeDesc = j.mode === "persistent" ? "持续(同一任务线程,有连续性)" : "独立(每次新建任务,省 token)"; @@ -97,17 +96,96 @@ function selectJob(id, itemEl) { row("上次执行", `${ts(j.last_run_at)} · ${escapeHtml(j.last_status || "—")}`); if (j.last_error) rows += row("上次错误", `${escapeHtml(j.last_error)}`); rows += row("累计运行", `${j.run_count} 次` + (j.consecutive_failures ? ` · 连续失败 ${j.consecutive_failures}` : "")); + return rows; +} + +// 切换右栏 tab:详情 = 字段行;执行记录 = 历史列表(异步拉)。 +function renderTab(tab) { + const j = _jobs.find((x) => x.job_id === _selJobId); + if (!j) return; + $("cr-detail").querySelectorAll(".cr-tab").forEach((el) => + el.classList.toggle("active", el.getAttribute("data-tab") === tab)); + const body = $("cr-tab-body"); + if (tab === "history") { + body.innerHTML = + `
加载中…
`; + loadHistory(j.job_id, 1); + } else { + body.innerHTML = detailRowsHtml(j); + } +} + +function selectJob(id, itemEl) { + $("cr-list").querySelectorAll(".sk-item.active").forEach((el) => el.classList.remove("active")); + if (itemEl) itemEl.classList.add("active"); + const j = _jobs.find((x) => x.job_id === id); + if (!j) return; + _selJobId = id; - const openTask = j.last_task_id - ? `` : ""; $("cr-detail").innerHTML = - `
${escapeHtml(j.name)} ${statusBadge(j)}
` + - rows + - `
+ `
+ ${escapeHtml(j.name)} ${statusBadge(j)} - ${openTask} -
`; +
` + + `
+ + +
` + + `
`; + renderTab("detail"); +} + +const HIST_PAGE_SIZE = 10; + +function runStatusLabel(rs) { + if (rs === "running") return '运行中'; + if (rs === "cancelling") return '取消中'; + if (rs === "error") return '失败'; + return ""; // idle:跑完的常态,不加噪 +} + +// 拉某 job 的历史执行 task 一页,渲染进 #cr-hist(含上一页/下一页)。 +// 切 job / 切 tab 时容器会被换掉,故 await 后**重新**查 #cr-hist 并校验 data-job, +// 避免迟到的响应往已切走的视图里串显。 +async function loadHistory(jobId, page) { + if (!$("cr-hist")) return; + let data; + try { + data = await api( + "GET", + `/v1/schedules/${encodeURIComponent(jobId)}/tasks?page=${page}&page_size=${HIST_PAGE_SIZE}`, + ); + } catch (e) { + const b = $("cr-hist"); + if (b && b.getAttribute("data-job") === jobId) + b.innerHTML = `
加载失败: ${escapeHtml(e.message)}
`; + return; + } + const box = $("cr-hist"); + if (!box || box.getAttribute("data-job") !== jobId) return; // 已切走,丢弃 + const results = data.results || []; + if (!data.count) { + box.innerHTML = '
还没有执行记录(尚未触发过)。
'; + return; + } + const items = results.map((t) => { + const st = runStatusLabel(t.run_status); + return `
+ ${ts(t.created_at)} + ${escapeHtml(t.name || "(未命名)")} + ${st}${t.n_messages ? ` ${t.n_messages} 条` : ""} +
`; + }).join(""); + const totalPages = Math.max(1, Math.ceil(data.count / HIST_PAGE_SIZE)); + const pager = totalPages > 1 + ? `
+ + 第 ${page} / ${totalPages} 页 · 共 ${data.count} + +
` + : `
共 ${data.count} 次
`; + box.innerHTML = items + pager; } // ───── 顶层绑定 ───── @@ -126,7 +204,18 @@ $("cr-detail").addEventListener("click", async (e) => { const toggle = e.target.closest("[data-toggle]"); const del = e.target.closest("[data-del]"); const openTask = e.target.closest("[data-open-task]"); + const histPage = e.target.closest("[data-hist-page]"); + const tab = e.target.closest("[data-tab]"); + if (tab) { + renderTab(tab.getAttribute("data-tab")); + return; + } + if (histPage) { + const box = $("cr-hist"); + if (box) loadHistory(box.getAttribute("data-job"), Number(histPage.getAttribute("data-hist-page"))); + return; + } if (openTask) { closeCronsModal(); selectTask(openTask.getAttribute("data-open-task"));