diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d037bf..c62f6bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # 变更记录 +## 2026-08-20(夜,再续)— h3-console 任务列表加删除/回收站 + +**结果**:任务列表每行右侧加「⋯」菜单(含「删除」),左下角加「🗑 回收站」入口,点开是删除任务的 +列表,每条带「恢复」按钮。软删除——数据库里加一列 `deleted_at`,删除/恢复就是给它写时间戳/清空, +不是真的从 sqlite 里 `DELETE`,也不碰 gateway/api.py 上的任务本身(该跑的还继续跑,只是从用户 +看到的列表里挪走)。 + +`server.py`:`jobs` 表加 `deleted_at REAL` 列(`ALTER TABLE` 包一层 try/except 兼容老库, +不会破坏已有数据);`GET /v1/videos` 加 `?trash=true` 参数切换"主列表"和"回收站"两个视图 +(分别过滤 `deleted_at IS NULL` / `IS NOT NULL`);新增 `DELETE /v1/videos/{id}`(软删除)和 +`POST /v1/videos/{id}/restore`(恢复),都是幂等的 `UPDATE ... WHERE` + `rowcount` 判 404。 +`_pending_counts()`(弹性调度器读的排队/运行中计数)没有跟着按 `deleted_at` 过滤——删除只是 +从用户列表里隐藏,任务如果还在 gateway/api.py 上真跑着,该占的显存和该算的调度需求不变,这里 +过滤了反而会让统计和实际资源占用对不上。 + +前端:`renderSidebar()` 加了 `viewingTrash` 模式分支(回收站视图复用同一个 `#job-list` 容器, +不是新开一个面板),任务行右侧「⋯」按钮弹出小菜单(点击外部自动收起),回收站行是「恢复」按钮。 +纯新增 UI 状态和几个 fetch 调用,没碰现有的 `refreshDetail`/`prefillCreate` 等逻辑。 + +**验证**:浏览器里跑了一遍完整链路(删除任务 → 从主列表消失、出现在回收站,带"删除于 X 秒前" +时间 → 点恢复 → 回收站清空、任务原样回到主列表,创建时间没被恢复操作重置),以及命令行测过 +`DELETE`/`restore` 的幂等性(重复调用 404,不是 500)。改的是 `server.py`(有状态的后端逻辑), +按安全重启法用 `ss` 按端口找 PID kill、重启,重启前确认了没有正在轮询详情页的关键操作会被打断 +(console 是独立进程,重启不影响 gateway.py/api.py 上正在跑的生成任务)。 + +--- + ## 2026-08-20(夜,续)— console 修复"任务卡在假运行中":后端重启丢任务时正确判失败 **背景**:用户报告任务 `b515f90547be` 详情页显示"运行中",但 `/v1/backends` 明明 diff --git a/h3-console/server.py b/h3-console/server.py index 5b2eddf..539cb5b 100644 --- a/h3-console/server.py +++ b/h3-console/server.py @@ -64,6 +64,12 @@ def init_db(): last_known_json TEXT ) """) + # 老库升级:CREATE TABLE IF NOT EXISTS 不会给已存在的表加列,只能单独 ALTER。 + # 第二次启动起这行必然报"列已存在",吞掉就行,不影响其它列。 + try: + con.execute("ALTER TABLE jobs ADD COLUMN deleted_at REAL") + except sqlite3.OperationalError: + pass init_db() @@ -130,6 +136,7 @@ def _row_view(row) -> dict: view["backend"] = row["backend"] view.setdefault("req", json.loads(row["req_json"])) view["created_at"] = row["created_at"] + view["deleted_at"] = row["deleted_at"] # gateway.py/api.py 原样带过来的 `poll`/`download` 字段用的是 gateway 内部 id(不是 console # 自己的 id),照抄会指向一个 console 自己的 sqlite 里根本查不到的路径,直接 404——这两个 # 字段本来就该跟 `id` 一样,统一改成用 console 的 id 重新拼。 @@ -296,15 +303,38 @@ async def download_video(jid: str, request: Request): @app.get("/v1/videos") -async def list_videos(limit: int = 50): +async def list_videos(limit: int = 50, trash: bool = False): # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在, # 没法从 gateway 反查出来)。实时进度由后台 reconciler_loop 持续刷新进 last_known_json, # 这里直接读,不在请求路径里再打一遍 gateway,避免列表接口被拖慢。 + # 软删除:deleted_at 非空的任务默认不出现在列表里,?trash=true 反过来只看这些—— + # 数据还在同一张表里,删除/恢复就是 UPDATE 一个时间戳,不用挪表、不用真删文件。 + where = "deleted_at IS NOT NULL" if trash else "deleted_at IS NULL" + order = "deleted_at DESC" if trash else "created_at DESC" with db() as con: - rows = con.execute("SELECT * FROM jobs ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall() + rows = con.execute(f"SELECT * FROM jobs WHERE {where} ORDER BY {order} LIMIT ?", (limit,)).fetchall() return {"jobs": [_row_view(r) for r in rows]} +@app.delete("/v1/videos/{jid}") +async def delete_video(jid: str): + """软删除:只挪出主列表,不碰 gateway/api.py 上的任务本身(可能还在跑),不删磁盘文件。""" + with db() as con: + cur = con.execute("UPDATE jobs SET deleted_at=? WHERE id=? AND deleted_at IS NULL", (time.time(), jid)) + if cur.rowcount == 0: + raise HTTPException(404, "无此任务,或已经在回收站里") + return {"id": jid, "deleted": True} + + +@app.post("/v1/videos/{jid}/restore") +async def restore_video(jid: str): + with db() as con: + cur = con.execute("UPDATE jobs SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (jid,)) + if cur.rowcount == 0: + raise HTTPException(404, "无此任务,或不在回收站里") + return {"id": jid, "deleted": False} + + @app.get("/v1/backends") async def backends(): resp = await client.get(f"{GATEWAY_URL}/health") diff --git a/h3-console/static/index.html b/h3-console/static/index.html index 90790f3..fa7e168 100644 --- a/h3-console/static/index.html +++ b/h3-console/static/index.html @@ -13,14 +13,26 @@ #sidebar-header { padding: 16px; border-bottom: 1px solid #ddd; } #sidebar-header h1 { font-size: 16px; margin: 0; } #job-list { flex: 1; overflow-y: auto; } - .job-row { padding: 10px 16px; border-bottom: 1px solid #eee; cursor: pointer; } + .job-row { position: relative; padding: 10px 16px; border-bottom: 1px solid #eee; cursor: pointer; } .job-row:hover { background: rgba(128,128,128,.08); } .job-row.selected { background: rgba(74,170,119,.15); border-left: 3px solid #4a7; } .job-row.new-row { display: flex; align-items: center; gap: 6px; font-weight: 600; color: #4a7; } .job-row .jr-top { display: flex; justify-content: space-between; align-items: center; font-size: 12px; } + .job-row .jr-actions { display: flex; align-items: center; gap: 6px; } .job-row .jr-prompt { font-size: 13px; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .job-row .jr-time { font-size: 11px; opacity: .55; margin-top: 2px; } + .jr-menu-btn { cursor: pointer; padding: 0 4px; opacity: .55; font-size: 14px; line-height: 1; } + .jr-menu-btn:hover { opacity: 1; } + .jr-menu { position: absolute; top: 22px; right: 12px; background: Canvas; color: CanvasText; + border: 1px solid #888; border-radius: 6px; box-shadow: 0 2px 10px rgba(0,0,0,.25); z-index: 10; min-width: 76px; } + .jr-menu-item { padding: 8px 12px; font-size: 13px; cursor: pointer; } + .jr-menu-item:hover { background: rgba(128,128,128,.15); } + .jr-restore { padding: 3px 10px; font-size: 12px; border: 1px solid #4a7; color: #4a7; background: transparent; border-radius: 4px; cursor: pointer; } + .jr-restore:hover { background: rgba(74,170,119,.15); } #backends { font-size: 11px; opacity: .75; padding: 10px 16px; border-top: 1px solid #ddd; white-space: pre-wrap; } + #sidebar-footer { padding: 8px 16px; border-top: 1px solid #ddd; } + #trash-btn { font-size: 17px; background: transparent; border: none; cursor: pointer; opacity: .6; padding: 4px; } + #trash-btn:hover { opacity: 1; } /* ---- 右侧主区 ---- */ #main { flex: 1; overflow-y: auto; padding: 24px 32px; } @@ -61,6 +73,9 @@
+
@@ -163,6 +178,9 @@