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,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(&#39;.tabs button&#39;).forEach(btn =&gt; btn.onclick = () =&gt; { @@ -249,6 +292,31 @@ document.querySelectorAll(&#39;.tabs button&#39;).forEach(btn =&gt; btn.onclick = () =&gt; {
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> 元素连根拔掉——