Commit 44dd52c26259e5cb40ca43b7fb777725781e0515

Authored by yibo
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>
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) -&gt; 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 || [];
... ...