Commit 44dd52c26259e5cb40ca43b7fb777725781e0515
1 parent
e4a5d5cf
h3-console 任务列表加删除/回收站(软删除)
任务行右侧「⋯」菜单可删除,左下角回收站入口可查看/恢复已删除任务。
server.py:jobs 表加 deleted_at 列,DELETE /v1/videos/{id} 软删除、
POST .../restore 恢复,GET /v1/videos?trash=true 切换视图。
不影响弹性调度器的 pending 计数(删除只隐藏,不取消底层任务)。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
3 changed files
with
164 additions
and
5 deletions
CHANGELOG.md
| 1 | # 变更记录 | 1 | # 变更记录 |
| 2 | 2 | ||
| 3 | +## 2026-08-20(夜,再续)— h3-console 任务列表加删除/回收站 | ||
| 4 | + | ||
| 5 | +**结果**:任务列表每行右侧加「⋯」菜单(含「删除」),左下角加「🗑 回收站」入口,点开是删除任务的 | ||
| 6 | +列表,每条带「恢复」按钮。软删除——数据库里加一列 `deleted_at`,删除/恢复就是给它写时间戳/清空, | ||
| 7 | +不是真的从 sqlite 里 `DELETE`,也不碰 gateway/api.py 上的任务本身(该跑的还继续跑,只是从用户 | ||
| 8 | +看到的列表里挪走)。 | ||
| 9 | + | ||
| 10 | +`server.py`:`jobs` 表加 `deleted_at REAL` 列(`ALTER TABLE` 包一层 try/except 兼容老库, | ||
| 11 | +不会破坏已有数据);`GET /v1/videos` 加 `?trash=true` 参数切换"主列表"和"回收站"两个视图 | ||
| 12 | +(分别过滤 `deleted_at IS NULL` / `IS NOT NULL`);新增 `DELETE /v1/videos/{id}`(软删除)和 | ||
| 13 | +`POST /v1/videos/{id}/restore`(恢复),都是幂等的 `UPDATE ... WHERE` + `rowcount` 判 404。 | ||
| 14 | +`_pending_counts()`(弹性调度器读的排队/运行中计数)没有跟着按 `deleted_at` 过滤——删除只是 | ||
| 15 | +从用户列表里隐藏,任务如果还在 gateway/api.py 上真跑着,该占的显存和该算的调度需求不变,这里 | ||
| 16 | +过滤了反而会让统计和实际资源占用对不上。 | ||
| 17 | + | ||
| 18 | +前端:`renderSidebar()` 加了 `viewingTrash` 模式分支(回收站视图复用同一个 `#job-list` 容器, | ||
| 19 | +不是新开一个面板),任务行右侧「⋯」按钮弹出小菜单(点击外部自动收起),回收站行是「恢复」按钮。 | ||
| 20 | +纯新增 UI 状态和几个 fetch 调用,没碰现有的 `refreshDetail`/`prefillCreate` 等逻辑。 | ||
| 21 | + | ||
| 22 | +**验证**:浏览器里跑了一遍完整链路(删除任务 → 从主列表消失、出现在回收站,带"删除于 X 秒前" | ||
| 23 | +时间 → 点恢复 → 回收站清空、任务原样回到主列表,创建时间没被恢复操作重置),以及命令行测过 | ||
| 24 | +`DELETE`/`restore` 的幂等性(重复调用 404,不是 500)。改的是 `server.py`(有状态的后端逻辑), | ||
| 25 | +按安全重启法用 `ss` 按端口找 PID kill、重启,重启前确认了没有正在轮询详情页的关键操作会被打断 | ||
| 26 | +(console 是独立进程,重启不影响 gateway.py/api.py 上正在跑的生成任务)。 | ||
| 27 | + | ||
| 28 | +--- | ||
| 29 | + | ||
| 3 | ## 2026-08-20(夜,续)— console 修复"任务卡在假运行中":后端重启丢任务时正确判失败 | 30 | ## 2026-08-20(夜,续)— console 修复"任务卡在假运行中":后端重启丢任务时正确判失败 |
| 4 | 31 | ||
| 5 | **背景**:用户报告任务 `b515f90547be` 详情页显示"运行中",但 `/v1/backends` 明明 | 32 | **背景**:用户报告任务 `b515f90547be` 详情页显示"运行中",但 `/v1/backends` 明明 |
h3-console/server.py
| @@ -64,6 +64,12 @@ def init_db(): | @@ -64,6 +64,12 @@ def init_db(): | ||
| 64 | last_known_json TEXT | 64 | last_known_json TEXT |
| 65 | ) | 65 | ) |
| 66 | """) | 66 | """) |
| 67 | + # 老库升级:CREATE TABLE IF NOT EXISTS 不会给已存在的表加列,只能单独 ALTER。 | ||
| 68 | + # 第二次启动起这行必然报"列已存在",吞掉就行,不影响其它列。 | ||
| 69 | + try: | ||
| 70 | + con.execute("ALTER TABLE jobs ADD COLUMN deleted_at REAL") | ||
| 71 | + except sqlite3.OperationalError: | ||
| 72 | + pass | ||
| 67 | 73 | ||
| 68 | 74 | ||
| 69 | init_db() | 75 | init_db() |
| @@ -130,6 +136,7 @@ def _row_view(row) -> dict: | @@ -130,6 +136,7 @@ def _row_view(row) -> dict: | ||
| 130 | view["backend"] = row["backend"] | 136 | view["backend"] = row["backend"] |
| 131 | view.setdefault("req", json.loads(row["req_json"])) | 137 | view.setdefault("req", json.loads(row["req_json"])) |
| 132 | view["created_at"] = row["created_at"] | 138 | view["created_at"] = row["created_at"] |
| 139 | + view["deleted_at"] = row["deleted_at"] | ||
| 133 | # gateway.py/api.py 原样带过来的 `poll`/`download` 字段用的是 gateway 内部 id(不是 console | 140 | # gateway.py/api.py 原样带过来的 `poll`/`download` 字段用的是 gateway 内部 id(不是 console |
| 134 | # 自己的 id),照抄会指向一个 console 自己的 sqlite 里根本查不到的路径,直接 404——这两个 | 141 | # 自己的 id),照抄会指向一个 console 自己的 sqlite 里根本查不到的路径,直接 404——这两个 |
| 135 | # 字段本来就该跟 `id` 一样,统一改成用 console 的 id 重新拼。 | 142 | # 字段本来就该跟 `id` 一样,统一改成用 console 的 id 重新拼。 |
| @@ -296,15 +303,38 @@ async def download_video(jid: str, request: Request): | @@ -296,15 +303,38 @@ async def download_video(jid: str, request: Request): | ||
| 296 | 303 | ||
| 297 | 304 | ||
| 298 | @app.get("/v1/videos") | 305 | @app.get("/v1/videos") |
| 299 | -async def list_videos(limit: int = 50): | 306 | +async def list_videos(limit: int = 50, trash: bool = False): |
| 300 | # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在, | 307 | # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在, |
| 301 | # 没法从 gateway 反查出来)。实时进度由后台 reconciler_loop 持续刷新进 last_known_json, | 308 | # 没法从 gateway 反查出来)。实时进度由后台 reconciler_loop 持续刷新进 last_known_json, |
| 302 | # 这里直接读,不在请求路径里再打一遍 gateway,避免列表接口被拖慢。 | 309 | # 这里直接读,不在请求路径里再打一遍 gateway,避免列表接口被拖慢。 |
| 310 | + # 软删除:deleted_at 非空的任务默认不出现在列表里,?trash=true 反过来只看这些—— | ||
| 311 | + # 数据还在同一张表里,删除/恢复就是 UPDATE 一个时间戳,不用挪表、不用真删文件。 | ||
| 312 | + where = "deleted_at IS NOT NULL" if trash else "deleted_at IS NULL" | ||
| 313 | + order = "deleted_at DESC" if trash else "created_at DESC" | ||
| 303 | with db() as con: | 314 | with db() as con: |
| 304 | - rows = con.execute("SELECT * FROM jobs ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall() | 315 | + rows = con.execute(f"SELECT * FROM jobs WHERE {where} ORDER BY {order} LIMIT ?", (limit,)).fetchall() |
| 305 | return {"jobs": [_row_view(r) for r in rows]} | 316 | return {"jobs": [_row_view(r) for r in rows]} |
| 306 | 317 | ||
| 307 | 318 | ||
| 319 | +@app.delete("/v1/videos/{jid}") | ||
| 320 | +async def delete_video(jid: str): | ||
| 321 | + """软删除:只挪出主列表,不碰 gateway/api.py 上的任务本身(可能还在跑),不删磁盘文件。""" | ||
| 322 | + with db() as con: | ||
| 323 | + cur = con.execute("UPDATE jobs SET deleted_at=? WHERE id=? AND deleted_at IS NULL", (time.time(), jid)) | ||
| 324 | + if cur.rowcount == 0: | ||
| 325 | + raise HTTPException(404, "无此任务,或已经在回收站里") | ||
| 326 | + return {"id": jid, "deleted": True} | ||
| 327 | + | ||
| 328 | + | ||
| 329 | +@app.post("/v1/videos/{jid}/restore") | ||
| 330 | +async def restore_video(jid: str): | ||
| 331 | + with db() as con: | ||
| 332 | + cur = con.execute("UPDATE jobs SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (jid,)) | ||
| 333 | + if cur.rowcount == 0: | ||
| 334 | + raise HTTPException(404, "无此任务,或不在回收站里") | ||
| 335 | + return {"id": jid, "deleted": False} | ||
| 336 | + | ||
| 337 | + | ||
| 308 | @app.get("/v1/backends") | 338 | @app.get("/v1/backends") |
| 309 | async def backends(): | 339 | async def backends(): |
| 310 | resp = await client.get(f"{GATEWAY_URL}/health") | 340 | resp = await client.get(f"{GATEWAY_URL}/health") |
h3-console/static/index.html
| @@ -13,14 +13,26 @@ | @@ -13,14 +13,26 @@ | ||
| 13 | #sidebar-header { padding: 16px; border-bottom: 1px solid #ddd; } | 13 | #sidebar-header { padding: 16px; border-bottom: 1px solid #ddd; } |
| 14 | #sidebar-header h1 { font-size: 16px; margin: 0; } | 14 | #sidebar-header h1 { font-size: 16px; margin: 0; } |
| 15 | #job-list { flex: 1; overflow-y: auto; } | 15 | #job-list { flex: 1; overflow-y: auto; } |
| 16 | - .job-row { padding: 10px 16px; border-bottom: 1px solid #eee; cursor: pointer; } | 16 | + .job-row { position: relative; padding: 10px 16px; border-bottom: 1px solid #eee; cursor: pointer; } |
| 17 | .job-row:hover { background: rgba(128,128,128,.08); } | 17 | .job-row:hover { background: rgba(128,128,128,.08); } |
| 18 | .job-row.selected { background: rgba(74,170,119,.15); border-left: 3px solid #4a7; } | 18 | .job-row.selected { background: rgba(74,170,119,.15); border-left: 3px solid #4a7; } |
| 19 | .job-row.new-row { display: flex; align-items: center; gap: 6px; font-weight: 600; color: #4a7; } | 19 | .job-row.new-row { display: flex; align-items: center; gap: 6px; font-weight: 600; color: #4a7; } |
| 20 | .job-row .jr-top { display: flex; justify-content: space-between; align-items: center; font-size: 12px; } | 20 | .job-row .jr-top { display: flex; justify-content: space-between; align-items: center; font-size: 12px; } |
| 21 | + .job-row .jr-actions { display: flex; align-items: center; gap: 6px; } | ||
| 21 | .job-row .jr-prompt { font-size: 13px; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | 22 | .job-row .jr-prompt { font-size: 13px; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 22 | .job-row .jr-time { font-size: 11px; opacity: .55; margin-top: 2px; } | 23 | .job-row .jr-time { font-size: 11px; opacity: .55; margin-top: 2px; } |
| 24 | + .jr-menu-btn { cursor: pointer; padding: 0 4px; opacity: .55; font-size: 14px; line-height: 1; } | ||
| 25 | + .jr-menu-btn:hover { opacity: 1; } | ||
| 26 | + .jr-menu { position: absolute; top: 22px; right: 12px; background: Canvas; color: CanvasText; | ||
| 27 | + border: 1px solid #888; border-radius: 6px; box-shadow: 0 2px 10px rgba(0,0,0,.25); z-index: 10; min-width: 76px; } | ||
| 28 | + .jr-menu-item { padding: 8px 12px; font-size: 13px; cursor: pointer; } | ||
| 29 | + .jr-menu-item:hover { background: rgba(128,128,128,.15); } | ||
| 30 | + .jr-restore { padding: 3px 10px; font-size: 12px; border: 1px solid #4a7; color: #4a7; background: transparent; border-radius: 4px; cursor: pointer; } | ||
| 31 | + .jr-restore:hover { background: rgba(74,170,119,.15); } | ||
| 23 | #backends { font-size: 11px; opacity: .75; padding: 10px 16px; border-top: 1px solid #ddd; white-space: pre-wrap; } | 32 | #backends { font-size: 11px; opacity: .75; padding: 10px 16px; border-top: 1px solid #ddd; white-space: pre-wrap; } |
| 33 | + #sidebar-footer { padding: 8px 16px; border-top: 1px solid #ddd; } | ||
| 34 | + #trash-btn { font-size: 17px; background: transparent; border: none; cursor: pointer; opacity: .6; padding: 4px; } | ||
| 35 | + #trash-btn:hover { opacity: 1; } | ||
| 24 | 36 | ||
| 25 | /* ---- 右侧主区 ---- */ | 37 | /* ---- 右侧主区 ---- */ |
| 26 | #main { flex: 1; overflow-y: auto; padding: 24px 32px; } | 38 | #main { flex: 1; overflow-y: auto; padding: 24px 32px; } |
| @@ -61,6 +73,9 @@ | @@ -61,6 +73,9 @@ | ||
| 61 | </div> | 73 | </div> |
| 62 | <div id="job-list"></div> | 74 | <div id="job-list"></div> |
| 63 | <div id="backends"></div> | 75 | <div id="backends"></div> |
| 76 | + <div id="sidebar-footer"> | ||
| 77 | + <button id="trash-btn" title="回收站" onclick="toggleTrash()">🗑 回收站</button> | ||
| 78 | + </div> | ||
| 64 | </div> | 79 | </div> |
| 65 | 80 | ||
| 66 | <div id="main"> | 81 | <div id="main"> |
| @@ -163,6 +178,9 @@ | @@ -163,6 +178,9 @@ | ||
| 163 | <script> | 178 | <script> |
| 164 | const uploaded = { 'fl2va-first': null, 'fl2va-last': null, 'ref2va-refs': [] }; | 179 | const uploaded = { 'fl2va-first': null, 'fl2va-last': null, 'ref2va-refs': [] }; |
| 165 | let jobsCache = []; | 180 | let jobsCache = []; |
| 181 | +let trashCache = []; | ||
| 182 | +let viewingTrash = false; | ||
| 183 | +let openMenuFor = null; | ||
| 166 | 184 | ||
| 167 | // URL 是 /task.html/<id> 或 /newtask.html(或裸 /,等同 newtask)——不是两个页面文件, | 185 | // URL 是 /task.html/<id> 或 /newtask.html(或裸 /,等同 newtask)——不是两个页面文件, |
| 168 | // server.py 里 /task.html/{tid} 和 /newtask.html 两条路由都返回这同一份 index.html, | 186 | // server.py 里 /task.html/{tid} 和 /newtask.html 两条路由都返回这同一份 index.html, |
| @@ -414,6 +432,33 @@ function fmtTime(ts) { | @@ -414,6 +432,33 @@ function fmtTime(ts) { | ||
| 414 | function renderSidebar() { | 432 | function renderSidebar() { |
| 415 | const box = document.getElementById('job-list'); box.innerHTML = ''; | 433 | const box = document.getElementById('job-list'); box.innerHTML = ''; |
| 416 | 434 | ||
| 435 | + if (viewingTrash) { | ||
| 436 | + const backRow = document.createElement('div'); | ||
| 437 | + backRow.className = 'job-row new-row'; | ||
| 438 | + backRow.onclick = () => backToList(); | ||
| 439 | + backRow.innerHTML = `<span>←</span><span>返回任务列表</span>`; | ||
| 440 | + box.appendChild(backRow); | ||
| 441 | + | ||
| 442 | + if (!trashCache.length) { | ||
| 443 | + const empty = document.createElement('div'); | ||
| 444 | + empty.style.cssText = 'padding:16px;font-size:13px;opacity:.6'; | ||
| 445 | + empty.textContent = '回收站是空的'; | ||
| 446 | + box.appendChild(empty); | ||
| 447 | + } | ||
| 448 | + trashCache.forEach(j => { | ||
| 449 | + const row = document.createElement('div'); | ||
| 450 | + row.className = 'job-row'; | ||
| 451 | + const prompt = j.req ? (j.req.prompt || '(无 prompt)') : ''; | ||
| 452 | + row.innerHTML = ` | ||
| 453 | + <div class="jr-top"><span>${j.id}</span> | ||
| 454 | + <button class="jr-restore" onclick="event.stopPropagation(); restoreJob('${j.id}')">恢复</button></div> | ||
| 455 | + <div class="jr-prompt" title="${prompt}">${prompt}</div> | ||
| 456 | + <div class="jr-time">删除于 ${timeAgo(j.deleted_at)}</div>`; | ||
| 457 | + box.appendChild(row); | ||
| 458 | + }); | ||
| 459 | + return; | ||
| 460 | + } | ||
| 461 | + | ||
| 417 | const newRow = document.createElement('div'); | 462 | const newRow = document.createElement('div'); |
| 418 | newRow.className = 'job-row new-row' + (selectedId === null ? ' selected' : ''); | 463 | newRow.className = 'job-row new-row' + (selectedId === null ? ' selected' : ''); |
| 419 | newRow.onclick = () => showCreate(); | 464 | newRow.onclick = () => showCreate(); |
| @@ -425,14 +470,70 @@ function renderSidebar() { | @@ -425,14 +470,70 @@ function renderSidebar() { | ||
| 425 | row.className = 'job-row' + (j.id === selectedId ? ' selected' : ''); | 470 | row.className = 'job-row' + (j.id === selectedId ? ' selected' : ''); |
| 426 | row.onclick = () => selectJob(j.id); | 471 | row.onclick = () => selectJob(j.id); |
| 427 | const prompt = j.req ? (j.req.prompt || '(无 prompt)') : ''; | 472 | const prompt = j.req ? (j.req.prompt || '(无 prompt)') : ''; |
| 473 | + const menuOpen = openMenuFor === j.id; | ||
| 428 | row.innerHTML = ` | 474 | row.innerHTML = ` |
| 429 | - <div class="jr-top"><span>${j.id}</span>${statusLabel(j.status)}</div> | 475 | + <div class="jr-top"><span>${j.id}</span> |
| 476 | + <div class="jr-actions"> | ||
| 477 | + ${statusLabel(j.status)} | ||
| 478 | + <span class="jr-menu-btn" onclick="event.stopPropagation(); toggleJobMenu('${j.id}')">⋯</span> | ||
| 479 | + </div> | ||
| 480 | + </div> | ||
| 430 | <div class="jr-prompt" title="${prompt}">${prompt}</div> | 481 | <div class="jr-prompt" title="${prompt}">${prompt}</div> |
| 431 | - <div class="jr-time">${timeAgo(j.created_at)}</div>`; | 482 | + <div class="jr-time">${timeAgo(j.created_at)}</div> |
| 483 | + ${menuOpen ? `<div class="jr-menu" onclick="event.stopPropagation()"> | ||
| 484 | + <div class="jr-menu-item" onclick="deleteJob('${j.id}')">删除</div> | ||
| 485 | + </div>` : ''}`; | ||
| 432 | box.appendChild(row); | 486 | box.appendChild(row); |
| 433 | }); | 487 | }); |
| 434 | } | 488 | } |
| 435 | 489 | ||
| 490 | +// 任意点击落在某条任务的「⋯」菜单外面,就把菜单收起来——不然点开一个菜单后点别处它会一直挂着 | ||
| 491 | +document.addEventListener('click', () => { if (openMenuFor !== null) { openMenuFor = null; renderSidebar(); } }); | ||
| 492 | + | ||
| 493 | +function toggleJobMenu(id) { | ||
| 494 | + openMenuFor = (openMenuFor === id) ? null : id; | ||
| 495 | + renderSidebar(); | ||
| 496 | +} | ||
| 497 | + | ||
| 498 | +async function deleteJob(id) { | ||
| 499 | + openMenuFor = null; | ||
| 500 | + try { | ||
| 501 | + const r = await fetch(`/v1/videos/${id}`, { method: 'DELETE' }); | ||
| 502 | + if (!r.ok) throw new Error(await r.text()); | ||
| 503 | + jobsCache = jobsCache.filter(j => j.id !== id); | ||
| 504 | + if (selectedId === id) showCreate(); | ||
| 505 | + renderSidebar(); | ||
| 506 | + } catch (e) { alert('删除失败: ' + e.message); } | ||
| 507 | +} | ||
| 508 | + | ||
| 509 | +async function restoreJob(id) { | ||
| 510 | + try { | ||
| 511 | + const r = await fetch(`/v1/videos/${id}/restore`, { method: 'POST' }); | ||
| 512 | + if (!r.ok) throw new Error(await r.text()); | ||
| 513 | + trashCache = trashCache.filter(j => j.id !== id); | ||
| 514 | + renderSidebar(); | ||
| 515 | + } catch (e) { alert('恢复失败: ' + e.message); } | ||
| 516 | +} | ||
| 517 | + | ||
| 518 | +async function loadTrash() { | ||
| 519 | + try { | ||
| 520 | + const r = await fetch('/v1/videos?trash=true&limit=50'); | ||
| 521 | + trashCache = (await r.json()).jobs || []; | ||
| 522 | + } catch (e) { /* 下一轮轮询再试 */ } | ||
| 523 | +} | ||
| 524 | + | ||
| 525 | +async function toggleTrash() { | ||
| 526 | + viewingTrash = true; | ||
| 527 | + await loadTrash(); | ||
| 528 | + renderSidebar(); | ||
| 529 | +} | ||
| 530 | + | ||
| 531 | +function backToList() { | ||
| 532 | + viewingTrash = false; | ||
| 533 | + refreshJobs(); // 立即拉一次主列表,不用等下一轮 5 秒轮询 | ||
| 534 | + renderSidebar(); | ||
| 535 | +} | ||
| 536 | + | ||
| 436 | async function selectJob(id, pushUrl = true) { | 537 | async function selectJob(id, pushUrl = true) { |
| 437 | selectedId = id; | 538 | selectedId = id; |
| 438 | if (pushUrl) history.pushState(null, '', '/task.html/' + encodeURIComponent(id)); | 539 | if (pushUrl) history.pushState(null, '', '/task.html/' + encodeURIComponent(id)); |
| @@ -498,6 +599,7 @@ async function refreshDetail() { | @@ -498,6 +599,7 @@ async function refreshDetail() { | ||
| 498 | } | 599 | } |
| 499 | 600 | ||
| 500 | async function refreshJobs() { | 601 | async function refreshJobs() { |
| 602 | + if (viewingTrash) { await loadTrash(); renderSidebar(); return; } | ||
| 501 | try { | 603 | try { |
| 502 | const r = await fetch('/v1/videos?limit=50'); const d = await r.json(); | 604 | const r = await fetch('/v1/videos?limit=50'); const d = await r.json(); |
| 503 | jobsCache = d.jobs || []; | 605 | jobsCache = d.jobs || []; |