feat(web): 定时任务执行历史列表(右栏 Tab + 分页)(bump 0.29.0)

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) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-26 12:53:56 +08:00
parent e7a86fb00c
commit d633949a66
5 changed files with 178 additions and 14 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `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) ### 2026-06-26 / 渠道卡片收拢绑定管理 + 删 rail 按钮(bump 0.28.1)
- 把渠道绑定/对话/管理全部收进「新建任务」下方的卡片,删掉左下角 rail「微信」按钮(精简页面)。 - 把渠道绑定/对话/管理全部收进「新建任务」下方的卡片,删掉左下角 rail「微信」按钮(精简页面)。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.28.1" __version__ = "0.29.0"

View File

@ -1966,6 +1966,63 @@ def create_app() -> FastAPI:
except scheduler.JobError as e: except scheduler.JobError as e:
raise HTTPException(404, str(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"]) @app.delete("/v1/tasks/{task_id}", status_code=204, tags=["tasks"])
def delete_task(task_id: str, user_id: UUID = Depends(require_user)): def delete_task(task_id: str, user_id: UUID = Depends(require_user)):
"""软删除:置 deleted_at=now(),从任务列表隐藏。 """软删除:置 deleted_at=now(),从任务列表隐藏。

View File

@ -343,6 +343,16 @@
.cr-d-row .k { width: 84px; flex-shrink: 0; color: var(--muted); } .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-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-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) { @media (max-width: 760px) {
#crons-modal .card { width: 96vw; height: 88vh; max-height: 88vh; } #crons-modal .card { width: 96vw; height: 88vh; max-height: 88vh; }
#cr-cols { flex-direction: column; } #cr-cols { flex-direction: column; }

View File

@ -78,12 +78,11 @@ function row(k, v) {
return `<div class="cr-d-row"><span class="k">${k}</span><span class="v">${v}</span></div>`; return `<div class="cr-d-row"><span class="k">${k}</span><span class="v">${v}</span></div>`;
} }
function selectJob(id, itemEl) { // 当前选中 job —— tab 切换 / 历史分页都靠它定位(详情切走即失效)。
$("cr-list").querySelectorAll(".sk-item.active").forEach((el) => el.classList.remove("active")); let _selJobId = null;
if (itemEl) itemEl.classList.add("active");
const j = _jobs.find((x) => x.job_id === id);
if (!j) return;
// 详情 tab 的字段行(纯展示,不含动作按钮 —— 按钮已提到顶部 head)。
function detailRowsHtml(j) {
const notify = j.notify && j.notify.to const notify = j.notify && j.notify.to
? `必达邮件 → ${escapeHtml(j.notify.to)}` : "无(结果进任务线程 / 由指令自行投递)"; ? `必达邮件 → ${escapeHtml(j.notify.to)}` : "无(结果进任务线程 / 由指令自行投递)";
const modeDesc = j.mode === "persistent" ? "持续(同一任务线程,有连续性)" : "独立(每次新建任务,省 token)"; const modeDesc = j.mode === "persistent" ? "持续(同一任务线程,有连续性)" : "独立(每次新建任务,省 token)";
@ -97,17 +96,96 @@ function selectJob(id, itemEl) {
row("上次执行", `${ts(j.last_run_at)} · ${escapeHtml(j.last_status || "—")}`); row("上次执行", `${ts(j.last_run_at)} · ${escapeHtml(j.last_status || "—")}`);
if (j.last_error) rows += row("上次错误", `<span style="color:#c0392b">${escapeHtml(j.last_error)}</span>`); if (j.last_error) rows += row("上次错误", `<span style="color:#c0392b">${escapeHtml(j.last_error)}</span>`);
rows += row("累计运行", `${j.run_count}` + (j.consecutive_failures ? ` · 连续失败 ${j.consecutive_failures}` : "")); 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 =
`<div id="cr-hist" data-job="${escapeHtml(j.job_id)}"><div class="muted" style="padding:6px;font-size:12px;">加载中…</div></div>`;
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
? `<button class="small" data-open-task="${escapeHtml(j.last_task_id)}">打开它跑的任务</button>` : "";
$("cr-detail").innerHTML = $("cr-detail").innerHTML =
`<div class="sk-d-head"><span class="sk-d-name">${escapeHtml(j.name)}</span> ${statusBadge(j)}<span class="spacer"></span></div>` + `<div class="sk-d-head">
rows + <span class="sk-d-name">${escapeHtml(j.name)}</span> ${statusBadge(j)}<span class="spacer"></span>
`<div class="cr-acts">
<button class="small" data-toggle="${escapeHtml(j.job_id)}">${j.enabled ? "停用" : "启用"}</button> <button class="small" data-toggle="${escapeHtml(j.job_id)}">${j.enabled ? "停用" : "启用"}</button>
<button class="small danger" data-del="${escapeHtml(j.job_id)}">删除</button> <button class="small danger" data-del="${escapeHtml(j.job_id)}">删除</button>
${openTask} </div>` +
`<div class="cr-tabs">
<button class="cr-tab active" data-tab="detail">详情</button>
<button class="cr-tab" data-tab="history">执行记录</button>
</div>` +
`<div id="cr-tab-body"></div>`;
renderTab("detail");
}
const HIST_PAGE_SIZE = 10;
function runStatusLabel(rs) {
if (rs === "running") return '<span class="cr-st ok">运行中</span>';
if (rs === "cancelling") return '<span class="cr-st paused">取消中</span>';
if (rs === "error") return '<span class="cr-st error">失败</span>';
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 = `<div class="err" style="padding:6px;font-size:12px;">加载失败: ${escapeHtml(e.message)}</div>`;
return;
}
const box = $("cr-hist");
if (!box || box.getAttribute("data-job") !== jobId) return; // 已切走,丢弃
const results = data.results || [];
if (!data.count) {
box.innerHTML = '<div class="muted" style="padding:6px;font-size:12px;">还没有执行记录(尚未触发过)。</div>';
return;
}
const items = results.map((t) => {
const st = runStatusLabel(t.run_status);
return `<div class="cr-hist-item" data-open-task="${escapeHtml(t.task_id)}">
<span class="cr-hist-ts">${ts(t.created_at)}</span>
<span class="cr-hist-name">${escapeHtml(t.name || "(未命名)")}</span>
<span class="cr-hist-meta">${st}${t.n_messages ? ` ${t.n_messages}` : ""}</span>
</div>`; </div>`;
}).join("");
const totalPages = Math.max(1, Math.ceil(data.count / HIST_PAGE_SIZE));
const pager = totalPages > 1
? `<div class="cr-hist-pager">
<button class="small" data-hist-page="${page - 1}" ${page <= 1 ? "disabled" : ""}>上一页</button>
<span class="muted"> ${page} / ${totalPages} · ${data.count}</span>
<button class="small" data-hist-page="${page + 1}" ${page >= totalPages ? "disabled" : ""}>下一页</button>
</div>`
: `<div class="cr-hist-pager"><span class="muted">共 ${data.count} 次</span></div>`;
box.innerHTML = items + pager;
} }
// ───── 顶层绑定 ───── // ───── 顶层绑定 ─────
@ -126,7 +204,18 @@ $("cr-detail").addEventListener("click", async (e) => {
const toggle = e.target.closest("[data-toggle]"); const toggle = e.target.closest("[data-toggle]");
const del = e.target.closest("[data-del]"); const del = e.target.closest("[data-del]");
const openTask = e.target.closest("[data-open-task]"); 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) { if (openTask) {
closeCronsModal(); closeCronsModal();
selectTask(openTask.getAttribute("data-open-task")); selectTask(openTask.getAttribute("data-open-task"));