Commit ce4f6b204fa81e1958dff99e0eafa098b38debd0

Authored by yibo
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
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(&#39;.tabs button&#39;).forEach(btn =&gt; btn.onclick = () =&gt; {
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> 元素连根拔掉——
... ...