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 `