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,16 +465,36 @@ async def download_video(jid: str, request: Request): | ||
| 465 | 465 | ||
| 466 | 466 | ||
| 467 | @router.get("/v1/videos") | 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 | # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在, | 470 | # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在, |
| 470 | # 没法从 gateway 反查出来)。实时进度由后台 reconciler_loop 持续刷新进 last_known_json, | 471 | # 没法从 gateway 反查出来)。实时进度由后台 reconciler_loop 持续刷新进 last_known_json, |
| 471 | # 这里直接读,不在请求路径里再打一遍 gateway,避免列表接口被拖慢。 | 472 | # 这里直接读,不在请求路径里再打一遍 gateway,避免列表接口被拖慢。 |
| 472 | # 软删除:deleted_at 非空的任务默认不出现在列表里,?trash=true 反过来只看这些—— | 473 | # 软删除:deleted_at 非空的任务默认不出现在列表里,?trash=true 反过来只看这些—— |
| 473 | # 数据还在同一张表里,删除/恢复就是 UPDATE 一个时间戳,不用挪表、不用真删文件。 | 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 | with db() as con: | 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 | return {"jobs": [_row_view(r) for r in rows]} | 498 | return {"jobs": [_row_view(r) for r in rows]} |
| 479 | 499 | ||
| 480 | 500 |
h3-console/static/index.html
| @@ -12,7 +12,15 @@ | @@ -12,7 +12,15 @@ | ||
| 12 | #sidebar { width: 300px; flex-shrink: 0; border-right: 1px solid #ddd; display: flex; flex-direction: column; height: 100vh; } | 12 | #sidebar { width: 300px; flex-shrink: 0; border-right: 1px solid #ddd; display: flex; flex-direction: column; height: 100vh; } |
| 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 | + #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 | #job-list { flex: 1; overflow-y: auto; } | 22 | #job-list { flex: 1; overflow-y: auto; } |
| 23 | + #job-list-footer { padding: 10px 16px; text-align: center; font-size: 11px; opacity: .55; } | ||
| 16 | .job-row { position: relative; padding: 10px 16px; border-bottom: 1px solid #eee; cursor: pointer; } | 24 | .job-row { position: relative; padding: 10px 16px; border-bottom: 1px solid #eee; cursor: pointer; } |
| 17 | .job-row:hover { background: rgba(128,128,128,.08); } | 25 | .job-row:hover { background: rgba(128,128,128,.08); } |
| 18 | .job-row.selected { background: rgba(74,170,119,.15); border-left: 3px solid #4a7; } | 26 | .job-row.selected { background: rgba(74,170,119,.15); border-left: 3px solid #4a7; } |
| @@ -84,6 +92,26 @@ | @@ -84,6 +92,26 @@ | ||
| 84 | <div id="sidebar-header"> | 92 | <div id="sidebar-header"> |
| 85 | <h1>H3 Console</h1> | 93 | <h1>H3 Console</h1> |
| 86 | </div> | 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 | <div id="job-list"></div> | 115 | <div id="job-list"></div> |
| 88 | <div id="backends"></div> | 116 | <div id="backends"></div> |
| 89 | <div id="sidebar-footer"> | 117 | <div id="sidebar-footer"> |
| @@ -232,6 +260,21 @@ let trashCache = []; | @@ -232,6 +260,21 @@ let trashCache = []; | ||
| 232 | let viewingTrash = false; | 260 | let viewingTrash = false; |
| 233 | let openMenuFor = null; | 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 | // URL 是 /task.html/<id> 或 /newtask.html(或裸 /,等同 newtask)——不是两个页面文件, | 278 | // URL 是 /task.html/<id> 或 /newtask.html(或裸 /,等同 newtask)——不是两个页面文件, |
| 236 | // server.py 里 /task.html/{tid} 和 /newtask.html 两条路由都返回这同一份 index.html, | 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,6 +292,31 @@ document.querySelectorAll('.tabs button').forEach(btn => btn.onclick = () => { | ||
| 249 | ['text','fl2va','ref2va'].forEach(t => document.getElementById('tab-'+t).style.display = (t===btn.dataset.tab ? 'block' : 'none')); | 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 | function showCreate(pushUrl = true) { | 320 | function showCreate(pushUrl = true) { |
| 253 | selectedId = null; | 321 | selectedId = null; |
| 254 | if (pushUrl) history.pushState(null, '', H3_BASE + '/newtask.html'); | 322 | if (pushUrl) history.pushState(null, '', H3_BASE + '/newtask.html'); |
| @@ -682,6 +750,7 @@ function fmtTime(ts) { | @@ -682,6 +750,7 @@ function fmtTime(ts) { | ||
| 682 | 750 | ||
| 683 | function renderSidebar() { | 751 | function renderSidebar() { |
| 684 | const box = document.getElementById('job-list'); box.innerHTML = ''; | 752 | const box = document.getElementById('job-list'); box.innerHTML = ''; |
| 753 | + document.getElementById('sidebar-filters').style.display = viewingTrash ? 'none' : 'flex'; | ||
| 685 | 754 | ||
| 686 | if (viewingTrash) { | 755 | if (viewingTrash) { |
| 687 | const backRow = document.createElement('div'); | 756 | const backRow = document.createElement('div'); |
| @@ -736,6 +805,18 @@ function renderSidebar() { | @@ -736,6 +805,18 @@ function renderSidebar() { | ||
| 736 | </div>` : ''}`; | 805 | </div>` : ''}`; |
| 737 | box.appendChild(row); | 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,8 +954,13 @@ async function refreshDetail() { | ||
| 873 | async function refreshJobs() { | 954 | async function refreshJobs() { |
| 874 | if (viewingTrash) { await loadTrash(); renderSidebar(); return; } | 955 | if (viewingTrash) { await loadTrash(); renderSidebar(); return; } |
| 875 | try { | 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 | jobsCache = d.jobs || []; | 962 | jobsCache = d.jobs || []; |
| 963 | + noMoreJobs = jobsCache.length < loadedLimit; | ||
| 878 | renderSidebar(); | 964 | renderSidebar(); |
| 879 | // 只有还在变化的任务(排队/运行中)才需要重新拉详情。`refreshDetail()` 是整段 | 965 | // 只有还在变化的任务(排队/运行中)才需要重新拉详情。`refreshDetail()` 是整段 |
| 880 | // innerHTML 重建,`done` 之后再重建会把正在播放的 <video> 元素连根拔掉—— | 966 | // innerHTML 重建,`done` 之后再重建会把正在播放的 <video> 元素连根拔掉—— |