Commit ce4f6b204fa81e1958dff99e0eafa098b38debd0
1 parent
ebcef114
h3-console: 任务列表加搜索/筛选/排序,修复只能看到最近50条任务
/v1/videos 一直硬编码 limit=50,服务器上实际232条未删除任务,超出的 根本没请求回来——不是滚动条的问题。改成滚到底部自动翻页加载;同时给 /v1/videos 加 q/status/kind/sort 参数(向后兼容),左侧栏加对应的 搜索框/状态筛选/类型筛选/排序切换。已部署到服务器并用真实数据验证过。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JDWoVwpqrsyzv9LRszTw9d
Showing
2 changed files
with
111 additions
and
5 deletions
h3-console/server.py
| ... | ... | @@ -465,16 +465,36 @@ async def download_video(jid: str, request: Request): |
| 465 | 465 | |
| 466 | 466 | |
| 467 | 467 | @router.get("/v1/videos") |
| 468 | -async def list_videos(limit: int = 50, trash: bool = False): | |
| 468 | +async def list_videos(limit: int = 50, trash: bool = False, q: str = "", status: str = "", | |
| 469 | + kind: str = "", sort: str = "desc"): | |
| 469 | 470 | # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在, |
| 470 | 471 | # 没法从 gateway 反查出来)。实时进度由后台 reconciler_loop 持续刷新进 last_known_json, |
| 471 | 472 | # 这里直接读,不在请求路径里再打一遍 gateway,避免列表接口被拖慢。 |
| 472 | 473 | # 软删除:deleted_at 非空的任务默认不出现在列表里,?trash=true 反过来只看这些—— |
| 473 | 474 | # 数据还在同一张表里,删除/恢复就是 UPDATE 一个时间戳,不用挪表、不用真删文件。 |
| 474 | - where = "deleted_at IS NOT NULL" if trash else "deleted_at IS NULL" | |
| 475 | - order = "deleted_at DESC" if trash else "created_at DESC" | |
| 475 | + where = ["deleted_at IS NOT NULL" if trash else "deleted_at IS NULL"] | |
| 476 | + params: list = [] | |
| 477 | + if q: | |
| 478 | + # prompt 只存在 req_json 这坨 JSON 文本里,没有单独的列——json.dumps 是 | |
| 479 | + # ensure_ascii=False 存的,中文原样落盘,直接 LIKE 子串匹配就够用,不用上 json_extract。 | |
| 480 | + where.append("(id LIKE ? OR req_json LIKE ?)") | |
| 481 | + params += [f"%{q}%", f"%{q}%"] | |
| 482 | + if status: | |
| 483 | + where.append("status = ?") | |
| 484 | + params.append(status) | |
| 485 | + if kind: | |
| 486 | + # 前端叫它"类型"(文生/首尾帧/参考图),数据库列叫 partition——是同一个值 | |
| 487 | + # (见 _partition_of),换个外部参数名单纯是避免跟 /v1/backends 里 GPU 分区的 | |
| 488 | + # "partition" 混着看。 | |
| 489 | + where.append("partition = ?") | |
| 490 | + params.append(kind) | |
| 491 | + order_col = "deleted_at" if trash else "created_at" | |
| 492 | + order = f"{order_col} {'ASC' if sort == 'asc' else 'DESC'}" | |
| 493 | + params.append(limit) | |
| 476 | 494 | with db() as con: |
| 477 | - rows = con.execute(f"SELECT * FROM jobs WHERE {where} ORDER BY {order} LIMIT ?", (limit,)).fetchall() | |
| 495 | + rows = con.execute( | |
| 496 | + f"SELECT * FROM jobs WHERE {' AND '.join(where)} ORDER BY {order} LIMIT ?", params | |
| 497 | + ).fetchall() | |
| 478 | 498 | return {"jobs": [_row_view(r) for r in rows]} |
| 479 | 499 | |
| 480 | 500 | ... | ... |
h3-console/static/index.html
| ... | ... | @@ -12,7 +12,15 @@ |
| 12 | 12 | #sidebar { width: 300px; flex-shrink: 0; border-right: 1px solid #ddd; display: flex; flex-direction: column; height: 100vh; } |
| 13 | 13 | #sidebar-header { padding: 16px; border-bottom: 1px solid #ddd; } |
| 14 | 14 | #sidebar-header h1 { font-size: 16px; margin: 0; } |
| 15 | + #sidebar-filters { padding: 8px 16px; border-bottom: 1px solid #ddd; display: flex; flex-direction: column; gap: 6px; } | |
| 16 | + #sidebar-filters input, #sidebar-filters select { font-size: 12px; padding: 4px 6px; width: auto; } | |
| 17 | + #sidebar-filters .sf-row { display: flex; gap: 6px; } | |
| 18 | + #sidebar-filters .sf-row select { flex: 1; min-width: 0; } | |
| 19 | + #job-sort-btn { font-size: 12px; padding: 4px 8px; border: 1px solid #888; background: transparent; | |
| 20 | + border-radius: 4px; cursor: pointer; white-space: nowrap; } | |
| 21 | + #job-sort-btn:hover { background: rgba(128,128,128,.15); } | |
| 15 | 22 | #job-list { flex: 1; overflow-y: auto; } |
| 23 | + #job-list-footer { padding: 10px 16px; text-align: center; font-size: 11px; opacity: .55; } | |
| 16 | 24 | .job-row { position: relative; padding: 10px 16px; border-bottom: 1px solid #eee; cursor: pointer; } |
| 17 | 25 | .job-row:hover { background: rgba(128,128,128,.08); } |
| 18 | 26 | .job-row.selected { background: rgba(74,170,119,.15); border-left: 3px solid #4a7; } |
| ... | ... | @@ -84,6 +92,26 @@ |
| 84 | 92 | <div id="sidebar-header"> |
| 85 | 93 | <h1>H3 Console</h1> |
| 86 | 94 | </div> |
| 95 | + <div id="sidebar-filters"> | |
| 96 | + <input id="job-search" type="text" placeholder="搜索 prompt / id…"> | |
| 97 | + <div class="sf-row"> | |
| 98 | + <select id="job-status-filter"> | |
| 99 | + <option value="">全部状态</option> | |
| 100 | + <option value="queued">排队中</option> | |
| 101 | + <option value="running">运行中</option> | |
| 102 | + <option value="done">已完成</option> | |
| 103 | + <option value="failed">失败</option> | |
| 104 | + <option value="cancelled">已取消</option> | |
| 105 | + </select> | |
| 106 | + <select id="job-kind-filter"> | |
| 107 | + <option value="">全部类型</option> | |
| 108 | + <option value="text">文生</option> | |
| 109 | + <option value="fl2va">首尾帧</option> | |
| 110 | + <option value="ref2va">参考图</option> | |
| 111 | + </select> | |
| 112 | + <button id="job-sort-btn" title="切换排序">↓最新</button> | |
| 113 | + </div> | |
| 114 | + </div> | |
| 87 | 115 | <div id="job-list"></div> |
| 88 | 116 | <div id="backends"></div> |
| 89 | 117 | <div id="sidebar-footer"> |
| ... | ... | @@ -232,6 +260,21 @@ let trashCache = []; |
| 232 | 260 | let viewingTrash = false; |
| 233 | 261 | let openMenuFor = null; |
| 234 | 262 | |
| 263 | +// 任务列表原来固定 limit=50,服务器上实测已经攒了 232 条未删除任务——超出的 182 条根本 | |
| 264 | +//没请求回来,#job-list 的 overflow-y:auto 只能滚动已加载的这 50 条,看起来像"下拉到底 | |
| 265 | +// 就没了"。改成滚到底部自动把 loadedLimit 加 50 再重新拉,直到某次返回条数小于请求的 | |
| 266 | +// limit(说明已经拉到头,noMoreJobs=true,不用再滚)。search/status/kind/sort 每次改动 | |
| 267 | +// 都把 loadedLimit 重置回 50 重新拉——过滤条件变了,"已经拉到第几页"这个状态就该归零。 | |
| 268 | +let loadedLimit = 50; | |
| 269 | +let noMoreJobs = false; | |
| 270 | +let loadingMore = false; | |
| 271 | +let filterQ = ''; | |
| 272 | +let filterStatus = ''; | |
| 273 | +let filterKind = ''; | |
| 274 | +let sortOrder = 'desc'; | |
| 275 | + | |
| 276 | +function resetPaging() { loadedLimit = 50; noMoreJobs = false; refreshJobs(); } | |
| 277 | + | |
| 235 | 278 | // URL 是 /task.html/<id> 或 /newtask.html(或裸 /,等同 newtask)——不是两个页面文件, |
| 236 | 279 | // server.py 里 /task.html/{tid} 和 /newtask.html 两条路由都返回这同一份 index.html, |
| 237 | 280 | // 这里只是从地址栏读出当前该显示哪个视图,方便复制链接分享、刷新页面不丢状态。 |
| ... | ... | @@ -249,6 +292,31 @@ document.querySelectorAll('.tabs button').forEach(btn => btn.onclick = () => { |
| 249 | 292 | ['text','fl2va','ref2va'].forEach(t => document.getElementById('tab-'+t).style.display = (t===btn.dataset.tab ? 'block' : 'none')); |
| 250 | 293 | }); |
| 251 | 294 | |
| 295 | +let searchDebounce = null; | |
| 296 | +document.getElementById('job-search').addEventListener('input', e => { | |
| 297 | + filterQ = e.target.value.trim(); | |
| 298 | + clearTimeout(searchDebounce); | |
| 299 | + searchDebounce = setTimeout(resetPaging, 300); // 防抖,不用每敲一个字就打一次请求 | |
| 300 | +}); | |
| 301 | +document.getElementById('job-status-filter').addEventListener('change', e => { filterStatus = e.target.value; resetPaging(); }); | |
| 302 | +document.getElementById('job-kind-filter').addEventListener('change', e => { filterKind = e.target.value; resetPaging(); }); | |
| 303 | +document.getElementById('job-sort-btn').addEventListener('click', e => { | |
| 304 | + sortOrder = sortOrder === 'desc' ? 'asc' : 'desc'; | |
| 305 | + e.target.textContent = sortOrder === 'desc' ? '↓最新' : '↑最早'; | |
| 306 | + resetPaging(); | |
| 307 | +}); | |
| 308 | + | |
| 309 | +// 滚到列表底部附近就自动加载下一页,不用额外放个"加载更多"按钮——viewingTrash/已经拉到头/ | |
| 310 | +// 上一页还没回来这三种情况都不重复触发。 | |
| 311 | +document.getElementById('job-list').addEventListener('scroll', e => { | |
| 312 | + if (viewingTrash || noMoreJobs || loadingMore) return; | |
| 313 | + const el = e.target; | |
| 314 | + if (el.scrollTop + el.clientHeight < el.scrollHeight - 80) return; | |
| 315 | + loadingMore = true; | |
| 316 | + loadedLimit += 50; | |
| 317 | + refreshJobs().finally(() => { loadingMore = false; }); | |
| 318 | +}); | |
| 319 | + | |
| 252 | 320 | function showCreate(pushUrl = true) { |
| 253 | 321 | selectedId = null; |
| 254 | 322 | if (pushUrl) history.pushState(null, '', H3_BASE + '/newtask.html'); |
| ... | ... | @@ -682,6 +750,7 @@ function fmtTime(ts) { |
| 682 | 750 | |
| 683 | 751 | function renderSidebar() { |
| 684 | 752 | const box = document.getElementById('job-list'); box.innerHTML = ''; |
| 753 | + document.getElementById('sidebar-filters').style.display = viewingTrash ? 'none' : 'flex'; | |
| 685 | 754 | |
| 686 | 755 | if (viewingTrash) { |
| 687 | 756 | const backRow = document.createElement('div'); |
| ... | ... | @@ -736,6 +805,18 @@ function renderSidebar() { |
| 736 | 805 | </div>` : ''}`; |
| 737 | 806 | box.appendChild(row); |
| 738 | 807 | }); |
| 808 | + | |
| 809 | + if (jobsCache.length) { | |
| 810 | + const footer = document.createElement('div'); | |
| 811 | + footer.id = 'job-list-footer'; | |
| 812 | + footer.textContent = noMoreJobs ? '— 没有更多了 —' : '下拉加载更多…'; | |
| 813 | + box.appendChild(footer); | |
| 814 | + } else if (filterQ || filterStatus || filterKind) { | |
| 815 | + const empty = document.createElement('div'); | |
| 816 | + empty.style.cssText = 'padding:16px;font-size:13px;opacity:.6'; | |
| 817 | + empty.textContent = '没有匹配的任务'; | |
| 818 | + box.appendChild(empty); | |
| 819 | + } | |
| 739 | 820 | } |
| 740 | 821 | |
| 741 | 822 | // 任意点击落在某条任务的「⋯」菜单外面,就把菜单收起来——不然点开一个菜单后点别处它会一直挂着 |
| ... | ... | @@ -873,8 +954,13 @@ async function refreshDetail() { |
| 873 | 954 | async function refreshJobs() { |
| 874 | 955 | if (viewingTrash) { await loadTrash(); renderSidebar(); return; } |
| 875 | 956 | try { |
| 876 | - const r = await fetch(H3_BASE + '/v1/videos?limit=50'); const d = await r.json(); | |
| 957 | + const params = new URLSearchParams({ limit: String(loadedLimit), sort: sortOrder }); | |
| 958 | + if (filterQ) params.set('q', filterQ); | |
| 959 | + if (filterStatus) params.set('status', filterStatus); | |
| 960 | + if (filterKind) params.set('kind', filterKind); | |
| 961 | + const r = await fetch(`${H3_BASE}/v1/videos?${params}`); const d = await r.json(); | |
| 877 | 962 | jobsCache = d.jobs || []; |
| 963 | + noMoreJobs = jobsCache.length < loadedLimit; | |
| 878 | 964 | renderSidebar(); |
| 879 | 965 | // 只有还在变化的任务(排队/运行中)才需要重新拉详情。`refreshDetail()` 是整段 |
| 880 | 966 | // innerHTML 重建,`done` 之后再重建会把正在播放的 <video> 元素连根拔掉—— | ... | ... |