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 | 30 | ## 2026-08-20(夜,续)— console 修复"任务卡在假运行中":后端重启丢任务时正确判失败 |
| 4 | 31 | |
| 5 | 32 | **背景**:用户报告任务 `b515f90547be` 详情页显示"运行中",但 `/v1/backends` 明明 | ... | ... |
h3-console/server.py
| ... | ... | @@ -64,6 +64,12 @@ def init_db(): |
| 64 | 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 | 75 | init_db() |
| ... | ... | @@ -130,6 +136,7 @@ def _row_view(row) -> dict: |
| 130 | 136 | view["backend"] = row["backend"] |
| 131 | 137 | view.setdefault("req", json.loads(row["req_json"])) |
| 132 | 138 | view["created_at"] = row["created_at"] |
| 139 | + view["deleted_at"] = row["deleted_at"] | |
| 133 | 140 | # gateway.py/api.py 原样带过来的 `poll`/`download` 字段用的是 gateway 内部 id(不是 console |
| 134 | 141 | # 自己的 id),照抄会指向一个 console 自己的 sqlite 里根本查不到的路径,直接 404——这两个 |
| 135 | 142 | # 字段本来就该跟 `id` 一样,统一改成用 console 的 id 重新拼。 |
| ... | ... | @@ -296,15 +303,38 @@ async def download_video(jid: str, request: Request): |
| 296 | 303 | |
| 297 | 304 | |
| 298 | 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 | 307 | # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在, |
| 301 | 308 | # 没法从 gateway 反查出来)。实时进度由后台 reconciler_loop 持续刷新进 last_known_json, |
| 302 | 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 | 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 | 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 | 338 | @app.get("/v1/backends") |
| 309 | 339 | async def backends(): |
| 310 | 340 | resp = await client.get(f"{GATEWAY_URL}/health") | ... | ... |
h3-console/static/index.html
| ... | ... | @@ -13,14 +13,26 @@ |
| 13 | 13 | #sidebar-header { padding: 16px; border-bottom: 1px solid #ddd; } |
| 14 | 14 | #sidebar-header h1 { font-size: 16px; margin: 0; } |
| 15 | 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 | 17 | .job-row:hover { background: rgba(128,128,128,.08); } |
| 18 | 18 | .job-row.selected { background: rgba(74,170,119,.15); border-left: 3px solid #4a7; } |
| 19 | 19 | .job-row.new-row { display: flex; align-items: center; gap: 6px; font-weight: 600; color: #4a7; } |
| 20 | 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 | 22 | .job-row .jr-prompt { font-size: 13px; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 22 | 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 | 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 | 38 | #main { flex: 1; overflow-y: auto; padding: 24px 32px; } |
| ... | ... | @@ -61,6 +73,9 @@ |
| 61 | 73 | </div> |
| 62 | 74 | <div id="job-list"></div> |
| 63 | 75 | <div id="backends"></div> |
| 76 | + <div id="sidebar-footer"> | |
| 77 | + <button id="trash-btn" title="回收站" onclick="toggleTrash()">🗑 回收站</button> | |
| 78 | + </div> | |
| 64 | 79 | </div> |
| 65 | 80 | |
| 66 | 81 | <div id="main"> |
| ... | ... | @@ -163,6 +178,9 @@ |
| 163 | 178 | <script> |
| 164 | 179 | const uploaded = { 'fl2va-first': null, 'fl2va-last': null, 'ref2va-refs': [] }; |
| 165 | 180 | let jobsCache = []; |
| 181 | +let trashCache = []; | |
| 182 | +let viewingTrash = false; | |
| 183 | +let openMenuFor = null; | |
| 166 | 184 | |
| 167 | 185 | // URL 是 /task.html/<id> 或 /newtask.html(或裸 /,等同 newtask)——不是两个页面文件, |
| 168 | 186 | // server.py 里 /task.html/{tid} 和 /newtask.html 两条路由都返回这同一份 index.html, |
| ... | ... | @@ -414,6 +432,33 @@ function fmtTime(ts) { |
| 414 | 432 | function renderSidebar() { |
| 415 | 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 | 462 | const newRow = document.createElement('div'); |
| 418 | 463 | newRow.className = 'job-row new-row' + (selectedId === null ? ' selected' : ''); |
| 419 | 464 | newRow.onclick = () => showCreate(); |
| ... | ... | @@ -425,14 +470,70 @@ function renderSidebar() { |
| 425 | 470 | row.className = 'job-row' + (j.id === selectedId ? ' selected' : ''); |
| 426 | 471 | row.onclick = () => selectJob(j.id); |
| 427 | 472 | const prompt = j.req ? (j.req.prompt || '(无 prompt)') : ''; |
| 473 | + const menuOpen = openMenuFor === j.id; | |
| 428 | 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 | 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 | 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 | 537 | async function selectJob(id, pushUrl = true) { |
| 437 | 538 | selectedId = id; |
| 438 | 539 | if (pushUrl) history.pushState(null, '', '/task.html/' + encodeURIComponent(id)); |
| ... | ... | @@ -498,6 +599,7 @@ async function refreshDetail() { |
| 498 | 599 | } |
| 499 | 600 | |
| 500 | 601 | async function refreshJobs() { |
| 602 | + if (viewingTrash) { await loadTrash(); renderSidebar(); return; } | |
| 501 | 603 | try { |
| 502 | 604 | const r = await fetch('/v1/videos?limit=50'); const d = await r.json(); |
| 503 | 605 | jobsCache = d.jobs || []; | ... | ... |