Commit 96cc2bc2511318c3d6b569aacff799830ad46603

Authored by yibo
1 parent f1f62301

h3-console: 任务列表加缩略图+分页,修复重复记录的竞态

左侧任务列表宽度 300px→50%,每条任务加缩略图(已完成显示结果视频首帧,
未完成显示输入的首帧图/参考图);行节点按 job id 复用,避免每 5 秒轮询
重建 DOM 把还没加载完的 <video>/<img> 连根拔掉。

无限滚动改成真分页(默认 20 条/页),/v1/videos 加 offset 参数和 total
字段——列表变慢是因为无限滚动堆积的缩略图媒体请求越滚越多,分页把并发
请求数固定住。

修复 _try_submit(提交任务给 gateway)和 _discover_from_gateway(捞取
绕过 console 直连的任务)之间的竞态:提交请求已发出但 gateway_id 还没
写回数据库的窗口内,discover 会把这个任务误判成孤儿、用 gateway_id 自己
当 console id 再插一行,导致同一个真实任务在列表里出现两条记录(同一个
comfy_prompt_id)。两段操作现在共享同一把锁,不再重叠。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFw9ZBh3eVjssk6quYQ4Ki
h3-console/server.py
@@ -231,6 +231,18 @@ def _row_view(row) -&gt; dict: @@ -231,6 +231,18 @@ def _row_view(row) -&gt; dict:
231 # 两行判断中间没有 await,天然原子,不会被其它协程插进来)。 231 # 两行判断中间没有 await,天然原子,不会被其它协程插进来)。
232 _SUBMITTING: set[str] = set() 232 _SUBMITTING: set[str] = set()
233 233
  234 +# 2026-09-04:上面这把 _SUBMITTING 只挡得住"同一个 cid 被提交两次",挡不住另一种真实踩到过的
  235 +# 重复——_try_submit 从发 POST 到把 gateway_id 写回数据库之间有一段 await 窗口,这段时间里
  236 +# gateway(raylight) 那边这条任务已经创建、能在它自己的 /v1/videos 列表里查到了,但 console 的
  237 +# jobs 表还没记上这个 gateway_id。如果 reconciler_loop 的 _discover_from_gateway() 正好在这个
  238 +# 窗口执行,会把这条"看起来没人认领"的 gateway 任务当成孤儿任务,拿 gateway_id 本身当 console id
  239 +# 再插一行——于是同一个真实生成任务在任务列表里出现两条记录,状态/进度看起来一样,其实是同一个
  240 +# comfy_prompt_id(案例:4c99f52a3a7a / ff818215fd3e,2026-09-04)。
  241 +# 修法:把"提交"(发请求→写回 gateway_id)和"发现孤儿"(读已知 gateway_id 集合→插入孤儿行)
  242 +# 这两段簿记操作用同一把锁互斥,全程不重叠——反正 raylight 本来就一次只处理一个请求,串行化
  243 +# 这两段轻量 HTTP 调用不影响真正的生成吞吐量。
  244 +_SUBMIT_LOCK = asyncio.Lock()
  245 +
234 246
235 async def _try_submit(cid: str): 247 async def _try_submit(cid: str):
236 """把一条本地排队的任务尝试提交给 gateway。给 create_video 首次调用和 reconciler_loop 复用。""" 248 """把一条本地排队的任务尝试提交给 gateway。给 create_video 首次调用和 reconciler_loop 复用。"""
@@ -242,31 +254,32 @@ async def _try_submit(cid: str): @@ -242,31 +254,32 @@ async def _try_submit(cid: str):
242 return # 任务不存在,或已经提交过 254 return # 任务不存在,或已经提交过
243 _SUBMITTING.add(cid) 255 _SUBMITTING.add(cid)
244 try: 256 try:
245 - req = json.loads(row["req_json"])  
246 - try:  
247 - resp = await client.post(f"{GATEWAY_URL}/v1/videos", json=req)  
248 - except httpx.HTTPError:  
249 - return # gateway 暂时连不上,下一轮再试  
250 - now = time.time()  
251 - if resp.status_code < 400:  
252 - job = resp.json()  
253 - gateway_id = job["id"]  
254 - job["id"] = cid # 对外只暴露 console 自己的 id  
255 - with db() as con:  
256 - con.execute(  
257 - "UPDATE jobs SET gateway_id=?, backend=?, status=?, updated_at=?, last_known_json=? WHERE id=?",  
258 - (gateway_id, job.get("backend"), job.get("status", "queued"),  
259 - now, json.dumps(job, ensure_ascii=False), cid),  
260 - )  
261 - elif resp.status_code in (503, 429):  
262 - return # 没有就绪后端 / 队列满,留在本地队列,等 flip 或别的任务跑完再试  
263 - else:  
264 - # 真正的客户端错误(比如 422 参数越界、400 turbo+steps>8),重试也不会成功,直接判失败  
265 - err_view = {"id": cid, "status": "failed", "error": resp.text,  
266 - "req": req, "partition": row["partition"], "created_at": row["created_at"]}  
267 - with db() as con:  
268 - con.execute("UPDATE jobs SET status='failed', updated_at=?, last_known_json=? WHERE id=?",  
269 - (now, json.dumps(err_view, ensure_ascii=False), cid)) 257 + async with _SUBMIT_LOCK:
  258 + req = json.loads(row["req_json"])
  259 + try:
  260 + resp = await client.post(f"{GATEWAY_URL}/v1/videos", json=req)
  261 + except httpx.HTTPError:
  262 + return # gateway 暂时连不上,下一轮再试
  263 + now = time.time()
  264 + if resp.status_code < 400:
  265 + job = resp.json()
  266 + gateway_id = job["id"]
  267 + job["id"] = cid # 对外只暴露 console 自己的 id
  268 + with db() as con:
  269 + con.execute(
  270 + "UPDATE jobs SET gateway_id=?, backend=?, status=?, updated_at=?, last_known_json=? WHERE id=?",
  271 + (gateway_id, job.get("backend"), job.get("status", "queued"),
  272 + now, json.dumps(job, ensure_ascii=False), cid),
  273 + )
  274 + elif resp.status_code in (503, 429):
  275 + return # 没有就绪后端 / 队列满,留在本地队列,等 flip 或别的任务跑完再试
  276 + else:
  277 + # 真正的客户端错误(比如 422 参数越界、400 turbo+steps>8),重试也不会成功,直接判失败
  278 + err_view = {"id": cid, "status": "failed", "error": resp.text,
  279 + "req": req, "partition": row["partition"], "created_at": row["created_at"]}
  280 + with db() as con:
  281 + con.execute("UPDATE jobs SET status='failed', updated_at=?, last_known_json=? WHERE id=?",
  282 + (now, json.dumps(err_view, ensure_ascii=False), cid))
270 finally: 283 finally:
271 _SUBMITTING.discard(cid) 284 _SUBMITTING.discard(cid)
272 285
@@ -313,42 +326,46 @@ async def _discover_from_gateway(): @@ -313,42 +326,46 @@ async def _discover_from_gateway():
313 捞回来。两边后端都有 GET /v1/videos 全量列表(raylight 直接返回 list,老 gateway 包一层 326 捞回来。两边后端都有 GET /v1/videos 全量列表(raylight 直接返回 list,老 gateway 包一层
314 {"jobs":[...]}),本来就是给这个用的。2026-08-24 真出过一次:有人直连后端提交,console 327 {"jobs":[...]}),本来就是给这个用的。2026-08-24 真出过一次:有人直连后端提交,console
315 界面完全看不到,两边还因为都不知道对方在用哪张卡而抢 GPU。 328 界面完全看不到,两边还因为都不知道对方在用哪张卡而抢 GPU。
316 - 没有反过来发现「新任务」的必要去重锁——本函数只在 reconciler_loop 里单协程顺序调用,  
317 - 不会跟自己并发。"""  
318 - try:  
319 - resp = await client.get(f"{GATEWAY_URL}/v1/videos", params={"limit": 200})  
320 - except httpx.HTTPError:  
321 - return  
322 - if resp.status_code != 200:  
323 - return  
324 - body = resp.json()  
325 - remote = body.get("jobs", body) if isinstance(body, dict) else body  
326 - with db() as con:  
327 - known = {r["gateway_id"] for r in con.execute(  
328 - "SELECT gateway_id FROM jobs WHERE gateway_id IS NOT NULL")}  
329 - for summary in remote:  
330 - gid = summary.get("id")  
331 - if not gid or gid in known:  
332 - continue 329 + 2026-09-04 更正:本函数确实只在 reconciler_loop 里单协程顺序调用、不会跟自己并发,但会跟
  330 + 别的协程(create_video/get_video 触发的 _try_submit)并发——如果正好在某个 _try_submit
  331 + "已经把请求 POST 给 gateway、gateway_id 还没写回数据库"的窗口执行,会把这条其实已经被
  332 + console 自己认领、只是还没记上账的任务当成孤儿,插出一条重复记录(同一个 comfy_prompt_id
  333 + 在任务列表里出现两条)。用跟 _try_submit 共享的 _SUBMIT_LOCK 互斥,两段簿记操作不再重叠。"""
  334 + async with _SUBMIT_LOCK:
333 try: 335 try:
334 - detail = await client.get(f"{GATEWAY_URL}/v1/videos/{gid}") 336 + resp = await client.get(f"{GATEWAY_URL}/v1/videos", params={"limit": 200})
335 except httpx.HTTPError: 337 except httpx.HTTPError:
336 - continue  
337 - if detail.status_code != 200:  
338 - continue  
339 - job = detail.json()  
340 - req = job.get("req", {})  
341 - now = time.time()  
342 - job["id"] = gid # 发现的任务没有独立的 console id,直接用 gateway id 本身 338 + return
  339 + if resp.status_code != 200:
  340 + return
  341 + body = resp.json()
  342 + remote = body.get("jobs", body) if isinstance(body, dict) else body
343 with db() as con: 343 with db() as con:
344 - con.execute(  
345 - "INSERT OR IGNORE INTO jobs "  
346 - "(id, gateway_id, partition, backend, status, req_json, created_at, updated_at, last_known_json) "  
347 - "VALUES (?,?,?,?,?,?,?,?,?)",  
348 - (gid, gid, job.get("partition") or _partition_of(req), job.get("backend"),  
349 - job.get("status", "queued"), json.dumps(req, ensure_ascii=False),  
350 - job.get("created_at", now), now, json.dumps(job, ensure_ascii=False)),  
351 - ) 344 + known = {r["gateway_id"] for r in con.execute(
  345 + "SELECT gateway_id FROM jobs WHERE gateway_id IS NOT NULL")}
  346 + for summary in remote:
  347 + gid = summary.get("id")
  348 + if not gid or gid in known:
  349 + continue
  350 + try:
  351 + detail = await client.get(f"{GATEWAY_URL}/v1/videos/{gid}")
  352 + except httpx.HTTPError:
  353 + continue
  354 + if detail.status_code != 200:
  355 + continue
  356 + job = detail.json()
  357 + req = job.get("req", {})
  358 + now = time.time()
  359 + job["id"] = gid # 发现的任务没有独立的 console id,直接用 gateway id 本身
  360 + with db() as con:
  361 + con.execute(
  362 + "INSERT OR IGNORE INTO jobs "
  363 + "(id, gateway_id, partition, backend, status, req_json, created_at, updated_at, last_known_json) "
  364 + "VALUES (?,?,?,?,?,?,?,?,?)",
  365 + (gid, gid, job.get("partition") or _partition_of(req), job.get("backend"),
  366 + job.get("status", "queued"), json.dumps(req, ensure_ascii=False),
  367 + job.get("created_at", now), now, json.dumps(job, ensure_ascii=False)),
  368 + )
352 369
353 370
354 @api.post("/v1/videos", status_code=202) 371 @api.post("/v1/videos", status_code=202)
@@ -527,7 +544,7 @@ async def download_video(jid: str, request: Request): @@ -527,7 +544,7 @@ async def download_video(jid: str, request: Request):
527 544
528 545
529 @api.get("/v1/videos") 546 @api.get("/v1/videos")
530 -async def list_videos(limit: int = 50, trash: bool = False, q: str = "", status: str = "", 547 +async def list_videos(limit: int = 20, offset: int = 0, trash: bool = False, q: str = "", status: str = "",
531 kind: str = "", sort: str = "desc"): 548 kind: str = "", sort: str = "desc"):
532 # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在, 549 # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在,
533 # 没法从 gateway 反查出来)。实时进度由后台 reconciler_loop 持续刷新进 last_known_json, 550 # 没法从 gateway 反查出来)。实时进度由后台 reconciler_loop 持续刷新进 last_known_json,
@@ -552,12 +569,14 @@ async def list_videos(limit: int = 50, trash: bool = False, q: str = &quot;&quot;, status: @@ -552,12 +569,14 @@ async def list_videos(limit: int = 50, trash: bool = False, q: str = &quot;&quot;, status:
552 params.append(kind) 569 params.append(kind)
553 order_col = "deleted_at" if trash else "created_at" 570 order_col = "deleted_at" if trash else "created_at"
554 order = f"{order_col} {'ASC' if sort == 'asc' else 'DESC'}" 571 order = f"{order_col} {'ASC' if sort == 'asc' else 'DESC'}"
555 - params.append(limit)  
556 with db() as con: 572 with db() as con:
  573 + # 前端要做真分页(第X/共Y页),得知道筛选条件下总共有多少条,不能只看这一页拿回了几条。
  574 + total = con.execute(f"SELECT COUNT(*) FROM jobs WHERE {' AND '.join(where)}", params).fetchone()[0]
557 rows = con.execute( 575 rows = con.execute(
558 - f"SELECT * FROM jobs WHERE {' AND '.join(where)} ORDER BY {order} LIMIT ?", params 576 + f"SELECT * FROM jobs WHERE {' AND '.join(where)} ORDER BY {order} LIMIT ? OFFSET ?",
  577 + params + [limit, offset],
559 ).fetchall() 578 ).fetchall()
560 - return {"jobs": [_row_view(r) for r in rows]} 579 + return {"jobs": [_row_view(r) for r in rows], "total": total}
561 580
562 581
563 @api.delete("/v1/videos/{jid}") 582 @api.delete("/v1/videos/{jid}")
h3-console/static/index.html
@@ -9,7 +9,9 @@ @@ -9,7 +9,9 @@
9 body { font-family: -apple-system, "PingFang SC", sans-serif; margin: 0; padding: 0; display: flex; height: 100vh; overflow: hidden; } 9 body { font-family: -apple-system, "PingFang SC", sans-serif; margin: 0; padding: 0; display: flex; height: 100vh; overflow: hidden; }
10 10
11 /* ---- 左侧任务记录 ---- */ 11 /* ---- 左侧任务记录 ---- */
12 - #sidebar { width: 300px; flex-shrink: 0; border-right: 1px solid #ddd; display: flex; flex-direction: column; height: 100vh; } 12 + /* 2026-09-04:原来 300px 定宽太窄,列表里看不出每条任务在生成什么,只能点进详情才知道——
  13 + 改成视口 50%,腾出空间放缩略图(见下面 .jr-thumb)。 */
  14 + #sidebar { width: 50%; 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; } 15 #sidebar-header { padding: 16px; border-bottom: 1px solid #ddd; }
14 #sidebar-header h1 { font-size: 16px; margin: 0; } 16 #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; } 17 #sidebar-filters { padding: 8px 16px; border-bottom: 1px solid #ddd; display: flex; flex-direction: column; gap: 6px; }
@@ -21,6 +23,9 @@ @@ -21,6 +23,9 @@
21 #job-sort-btn:hover { background: rgba(128,128,128,.15); } 23 #job-sort-btn:hover { background: rgba(128,128,128,.15); }
22 #job-list { flex: 1; overflow-y: auto; } 24 #job-list { flex: 1; overflow-y: auto; }
23 #job-list-footer { padding: 10px 16px; text-align: center; font-size: 11px; opacity: .55; } 25 #job-list-footer { padding: 10px 16px; text-align: center; font-size: 11px; opacity: .55; }
  26 + .page-btn { font-size: 12px; padding: 3px 10px; border: 1px solid #888; background: transparent;
  27 + border-radius: 4px; cursor: pointer; }
  28 + .page-btn:disabled { opacity: .4; cursor: default; }
24 .job-row { position: relative; padding: 10px 16px; border-bottom: 1px solid #eee; cursor: pointer; } 29 .job-row { position: relative; padding: 10px 16px; border-bottom: 1px solid #eee; cursor: pointer; }
25 .job-row:hover { background: rgba(128,128,128,.08); } 30 .job-row:hover { background: rgba(128,128,128,.08); }
26 .job-row.selected { background: rgba(74,170,119,.15); border-left: 3px solid #4a7; } 31 .job-row.selected { background: rgba(74,170,119,.15); border-left: 3px solid #4a7; }
@@ -29,6 +34,16 @@ @@ -29,6 +34,16 @@
29 .job-row .jr-actions { display: flex; align-items: center; gap: 6px; } 34 .job-row .jr-actions { display: flex; align-items: center; gap: 6px; }
30 .job-row .jr-prompt { font-size: 13px; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 35 .job-row .jr-prompt { font-size: 13px; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
31 .job-row .jr-time { font-size: 11px; opacity: .55; margin-top: 2px; } 36 .job-row .jr-time { font-size: 11px; opacity: .55; margin-top: 2px; }
  37 + /* 2026-09-04:任务列表加缩略图——已完成的显示结果视频(取第一帧当封面),还没完成的
  38 + 显示输入的首帧图/参考图,两者都没有就显示任务类型文字兜底。 */
  39 + .jr-body { display: flex; gap: 10px; align-items: flex-start; }
  40 + .jr-thumb { width: 88px; height: 118px; flex-shrink: 0; border-radius: 6px; overflow: hidden;
  41 + background: rgba(128,128,128,.15); display: flex; align-items: center; justify-content: center; }
  42 + .jr-thumb img, .jr-thumb video { width: 100%; height: 100%; object-fit: cover; display: block; }
  43 + .jr-thumb-empty { font-size: 12px; opacity: .5; text-align: center; padding: 0 4px; }
  44 + .jr-info { flex: 1; min-width: 0; }
  45 + .jr-info .jr-prompt { white-space: normal; display: -webkit-box; -webkit-box-orient: vertical;
  46 + -webkit-line-clamp: 4; }
32 .jr-menu-btn { cursor: pointer; padding: 0 4px; opacity: .55; font-size: 14px; line-height: 1; } 47 .jr-menu-btn { cursor: pointer; padding: 0 4px; opacity: .55; font-size: 14px; line-height: 1; }
33 .jr-menu-btn:hover { opacity: 1; } 48 .jr-menu-btn:hover { opacity: 1; }
34 .jr-menu { position: absolute; top: 22px; right: 12px; background: Canvas; color: CanvasText; 49 .jr-menu { position: absolute; top: 22px; right: 12px; background: Canvas; color: CanvasText;
@@ -310,20 +325,26 @@ let trashCache = []; @@ -310,20 +325,26 @@ let trashCache = [];
310 let viewingTrash = false; 325 let viewingTrash = false;
311 let openMenuFor = null; 326 let openMenuFor = null;
312 327
313 -// 任务列表原来固定 limit=50,服务器上实测已经攒了 232 条未删除任务——超出的 182 条根本  
314 -//没请求回来,#job-list 的 overflow-y:auto 只能滚动已加载的这 50 条,看起来像"下拉到底  
315 -// 就没了"。改成滚到底部自动把 loadedLimit 加 50 再重新拉,直到某次返回条数小于请求的  
316 -// limit(说明已经拉到头,noMoreJobs=true,不用再滚)。search/status/kind/sort 每次改动  
317 -// 都把 loadedLimit 重置回 50 重新拉——过滤条件变了,"已经拉到第几页"这个状态就该归零。  
318 -let loadedLimit = 50;  
319 -let noMoreJobs = false;  
320 -let loadingMore = false; 328 +// 2026-09-04:原来是"滚到底部自动多拉50条"的无限滚动,列表越滚越长,每条任务的缩略图
  329 +// (视频/图片)也跟着越攒越多,同时几十个 <video preload=metadata> 一起抢带宽,这才是
  330 +// "任务列表加载变慢"的真正原因(不是接口慢,是 DOM 和媒体请求堆积)。改成真分页——每页
  331 +// 固定 20 条,翻页时旧页的行/缩略图节点整个丢弃,同一时间最多只有一页的媒体请求在飞。
  332 +const PAGE_SIZE = 20;
  333 +let currentPage = 1;
  334 +let totalJobs = 0;
321 let filterQ = ''; 335 let filterQ = '';
322 let filterStatus = ''; 336 let filterStatus = '';
323 let filterKind = ''; 337 let filterKind = '';
324 let sortOrder = 'desc'; 338 let sortOrder = 'desc';
325 339
326 -function resetPaging() { loadedLimit = 50; noMoreJobs = false; refreshJobs(); } 340 +function resetPaging() { currentPage = 1; refreshJobs(); }
  341 +
  342 +function gotoPage(p) {
  343 + const totalPages = Math.max(1, Math.ceil(totalJobs / PAGE_SIZE));
  344 + currentPage = Math.min(Math.max(1, p), totalPages);
  345 + refreshJobs();
  346 + document.getElementById('job-list').scrollTop = 0;
  347 +}
327 348
328 // URL 是 /task.html/<id> 或 /newtask.html(或裸 /,等同 newtask)——不是两个页面文件, 349 // URL 是 /task.html/<id> 或 /newtask.html(或裸 /,等同 newtask)——不是两个页面文件,
329 // server.py 里 /task.html/{tid} 和 /newtask.html 两条路由都返回这同一份 index.html, 350 // server.py 里 /task.html/{tid} 和 /newtask.html 两条路由都返回这同一份 index.html,
@@ -356,17 +377,6 @@ document.getElementById(&#39;job-sort-btn&#39;).addEventListener(&#39;click&#39;, e =&gt; { @@ -356,17 +377,6 @@ document.getElementById(&#39;job-sort-btn&#39;).addEventListener(&#39;click&#39;, e =&gt; {
356 resetPaging(); 377 resetPaging();
357 }); 378 });
358 379
359 -// 滚到列表底部附近就自动加载下一页,不用额外放个"加载更多"按钮——viewingTrash/已经拉到头/  
360 -// 上一页还没回来这三种情况都不重复触发。  
361 -document.getElementById('job-list').addEventListener('scroll', e => {  
362 - if (viewingTrash || noMoreJobs || loadingMore) return;  
363 - const el = e.target;  
364 - if (el.scrollTop + el.clientHeight < el.scrollHeight - 80) return;  
365 - loadingMore = true;  
366 - loadedLimit += 50;  
367 - refreshJobs().finally(() => { loadingMore = false; });  
368 -});  
369 -  
370 function showCreate(pushUrl = true) { 380 function showCreate(pushUrl = true) {
371 selectedId = null; 381 selectedId = null;
372 if (pushUrl) history.pushState(null, '', H3_BASE + '/newtask.html'); 382 if (pushUrl) history.pushState(null, '', H3_BASE + '/newtask.html');
@@ -825,6 +835,28 @@ async function resetGpu() { @@ -825,6 +835,28 @@ async function resetGpu() {
825 await refreshJobs(); 835 await refreshJobs();
826 } 836 }
827 837
  838 +function jobKindLabel(req) {
  839 + if (req.first_frame_url || req.last_frame_url) return '首尾帧';
  840 + if ((req.reference_image_urls || []).length || (req.reference_video_urls || []).length || (req.reference_audio_urls || []).length) return '参考图';
  841 + return '文生';
  842 +}
  843 +
  844 +// 已完成的任务直接拿结果视频当封面(preload=metadata 只取第一帧,不下载整段);
  845 +// 还没完成的用输入的首帧图/参考图占位,好歹能看出这条任务在做什么;两者都没有
  846 +// (纯文生视频还没跑完)就退化成显示任务类型文字。
  847 +// ponytail: 列表里同时出现很多条"已完成"任务时,浏览器会并发发起同等数量的
  848 +// metadata 探测请求,量大可能变卡——真遇到再改成后端生成静态封面图。
  849 +function jobThumbHtml(j) {
  850 + const req = j.req || {};
  851 + if (j.status === 'done') {
  852 + const src = h3FileUrl(H3_BASE + '/v1/videos/' + j.id + '/file');
  853 + return `<video src="${src}" muted preload="metadata"></video>`;
  854 + }
  855 + const imgUrl = req.first_frame_url || (req.reference_image_urls || [])[0];
  856 + if (imgUrl) return `<img src="${imgUrl}" loading="lazy">`;
  857 + return `<div class="jr-thumb-empty">${jobKindLabel(req)}</div>`;
  858 +}
  859 +
828 function statusLabel(s) { 860 function statusLabel(s) {
829 const map = { queued: '排队中', running: '运行中', done: '已完成', failed: '失败', cancelled: '已取消' }; 861 const map = { queued: '排队中', running: '运行中', done: '已完成', failed: '失败', cancelled: '已取消' };
830 return `<span class="status-${s}">${map[s] || s}</span>`; 862 return `<span class="status-${s}">${map[s] || s}</span>`;
@@ -843,8 +875,24 @@ function fmtTime(ts) { @@ -843,8 +875,24 @@ function fmtTime(ts) {
843 return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false }); 875 return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false });
844 } 876 }
845 877
  878 +// id -> { el, thumbSig }。renderSidebar() 每 5 秒被 refreshJobs() 轮询触发一次,加了缩略图
  879 +// 之后如果还是老办法"box.innerHTML=''整表重建",<img loading=lazy>/<video preload=metadata>
  880 +// 就会在真正加载出内容之前被连根拔掉重来一遍——缩略图永远来不及显示,长期空白。
  881 +// 这两个变量按 job id 缓存已经建好的行节点,跨轮询复用,缩略图只在来源真的变了(比如
  882 +// 排队中变成已完成,thumbSig 从 img:xxx 变成 video)才重建;只是状态/时间文字变化则原地更新。
  883 +let jobRowNodes = new Map();
  884 +let lastSidebarMode = null;
  885 +
  886 +function jobThumbSig(j) {
  887 + if (j.status === 'done') return 'video';
  888 + const req = j.req || {};
  889 + return 'img:' + (req.first_frame_url || (req.reference_image_urls || [])[0] || '');
  890 +}
  891 +
846 function renderSidebar() { 892 function renderSidebar() {
847 - const box = document.getElementById('job-list'); box.innerHTML = ''; 893 + const box = document.getElementById('job-list');
  894 + const mode = viewingTrash ? 'trash' : 'normal';
  895 + if (mode !== lastSidebarMode) { box.innerHTML = ''; jobRowNodes.clear(); lastSidebarMode = mode; }
848 document.getElementById('sidebar-filters').style.display = viewingTrash ? 'none' : 'flex'; 896 document.getElementById('sidebar-filters').style.display = viewingTrash ? 'none' : 'flex';
849 // "+新建任务" 固定在筛选框上面、列表外面,不随 #job-list 滚动——之前是塞进 job-list 第一 897 // "+新建任务" 固定在筛选框上面、列表外面,不随 #job-list 滚动——之前是塞进 job-list 第一
850 // 行的,列表一滚就跟着跑没了,2026-08-28 用户反馈要求固定住。 898 // 行的,列表一滚就跟着跑没了,2026-08-28 用户反馈要求固定住。
@@ -853,6 +901,7 @@ function renderSidebar() { @@ -853,6 +901,7 @@ function renderSidebar() {
853 newTaskRow.classList.toggle('selected', !viewingTrash && selectedId === null); 901 newTaskRow.classList.toggle('selected', !viewingTrash && selectedId === null);
854 902
855 if (viewingTrash) { 903 if (viewingTrash) {
  904 + box.innerHTML = '';
856 const backRow = document.createElement('div'); 905 const backRow = document.createElement('div');
857 backRow.className = 'job-row new-row'; 906 backRow.className = 'job-row new-row';
858 backRow.onclick = () => backToList(); 907 backRow.onclick = () => backToList();
@@ -879,13 +928,27 @@ function renderSidebar() { @@ -879,13 +928,27 @@ function renderSidebar() {
879 return; 928 return;
880 } 929 }
881 930
  931 + const seen = new Set();
882 jobsCache.forEach(j => { 932 jobsCache.forEach(j => {
883 - const row = document.createElement('div'); 933 + seen.add(j.id);
  934 + let cached = jobRowNodes.get(j.id);
  935 + if (!cached) {
  936 + cached = { el: document.createElement('div'), thumbSig: null };
  937 + jobRowNodes.set(j.id, cached);
  938 + }
  939 + const row = cached.el;
884 row.className = 'job-row' + (j.id === selectedId ? ' selected' : ''); 940 row.className = 'job-row' + (j.id === selectedId ? ' selected' : '');
885 row.onclick = () => selectJob(j.id); 941 row.onclick = () => selectJob(j.id);
  942 +
  943 + const thumbSig = jobThumbSig(j);
  944 + if (cached.thumbSig !== thumbSig) {
  945 + row.innerHTML = `<div class="jr-body"><div class="jr-thumb">${jobThumbHtml(j)}</div><div class="jr-info"></div></div>`;
  946 + cached.thumbSig = thumbSig;
  947 + }
  948 +
886 const prompt = j.req ? (j.req.prompt || '(无 prompt)') : ''; 949 const prompt = j.req ? (j.req.prompt || '(无 prompt)') : '';
887 const menuOpen = openMenuFor === j.id; 950 const menuOpen = openMenuFor === j.id;
888 - row.innerHTML = ` 951 + row.querySelector('.jr-info').innerHTML = `
889 <div class="jr-top"><span>${j.id}</span> 952 <div class="jr-top"><span>${j.id}</span>
890 <div class="jr-actions"> 953 <div class="jr-actions">
891 ${statusLabel(j.status)} 954 ${statusLabel(j.status)}
@@ -893,25 +956,42 @@ function renderSidebar() { @@ -893,25 +956,42 @@ function renderSidebar() {
893 </div> 956 </div>
894 </div> 957 </div>
895 <div class="jr-prompt" title="${prompt}">${prompt}</div> 958 <div class="jr-prompt" title="${prompt}">${prompt}</div>
896 - <div class="jr-time">${timeAgo(j.created_at)}</div>  
897 - ${menuOpen ? `<div class="jr-menu" onclick="event.stopPropagation()"> 959 + <div class="jr-time">${timeAgo(j.created_at)}</div>`;
  960 +
  961 + const oldMenu = row.querySelector(':scope > .jr-menu');
  962 + if (oldMenu) oldMenu.remove();
  963 + if (menuOpen) {
  964 + row.insertAdjacentHTML('beforeend', `<div class="jr-menu" onclick="event.stopPropagation()">
898 ${j.status === 'queued' ? `<div class="jr-menu-item" onclick="prioritizeJob('${j.id}')">优先插队</div>` : ''} 965 ${j.status === 'queued' ? `<div class="jr-menu-item" onclick="prioritizeJob('${j.id}')">优先插队</div>` : ''}
899 <div class="jr-menu-item" onclick="deleteJob('${j.id}')">删除</div> 966 <div class="jr-menu-item" onclick="deleteJob('${j.id}')">删除</div>
900 - </div>` : ''}`;  
901 - box.appendChild(row); 967 + </div>`);
  968 + }
  969 + box.appendChild(row); // 已经在 DOM 里的话是"挪位置",不会销毁子节点(缩略图不受影响)
902 }); 970 });
903 971
904 - if (jobsCache.length) {  
905 - const footer = document.createElement('div'); 972 + // 丢掉不在当前列表里的行缓存(被删除/被筛选掉了),顺带把对应 DOM 节点也摘掉
  973 + for (const [id, cached] of jobRowNodes) {
  974 + if (!seen.has(id)) { cached.el.remove(); jobRowNodes.delete(id); }
  975 + }
  976 +
  977 + let footer = document.getElementById('job-list-footer');
  978 + if (!footer) {
  979 + footer = document.createElement('div');
906 footer.id = 'job-list-footer'; 980 footer.id = 'job-list-footer';
907 - footer.textContent = noMoreJobs ? '— 没有更多了 —' : '下拉加载更多…';  
908 - box.appendChild(footer);  
909 - } else if (filterQ || filterStatus || filterKind) {  
910 - const empty = document.createElement('div');  
911 - empty.style.cssText = 'padding:16px;font-size:13px;opacity:.6';  
912 - empty.textContent = '没有匹配的任务';  
913 - box.appendChild(empty);  
914 } 981 }
  982 + box.appendChild(footer); // 挪到最后一行任务后面(新建的话就是插入)
  983 +
  984 + if (totalJobs === 0) {
  985 + footer.innerHTML = `<div style="padding:6px 0">${(filterQ || filterStatus || filterKind) ? '没有匹配的任务' : '还没有任务'}</div>`;
  986 + return;
  987 + }
  988 + const totalPages = Math.max(1, Math.ceil(totalJobs / PAGE_SIZE));
  989 + footer.innerHTML = `
  990 + <div style="display:flex;align-items:center;justify-content:center;gap:10px">
  991 + <button class="page-btn" ${currentPage <= 1 ? 'disabled' : ''} onclick="gotoPage(${currentPage - 1})">‹ 上一页</button>
  992 + <span>第 ${currentPage}/${totalPages} 页 · 共 ${totalJobs} 条</span>
  993 + <button class="page-btn" ${currentPage >= totalPages ? 'disabled' : ''} onclick="gotoPage(${currentPage + 1})">下一页 ›</button>
  994 + </div>`;
915 } 995 }
916 996
917 // 任意点击落在某条任务的「⋯」菜单外面,就把菜单收起来——不然点开一个菜单后点别处它会一直挂着 997 // 任意点击落在某条任务的「⋯」菜单外面,就把菜单收起来——不然点开一个菜单后点别处它会一直挂着
@@ -1088,13 +1168,19 @@ async function loadComfyHistory(id) { @@ -1088,13 +1168,19 @@ async function loadComfyHistory(id) {
1088 async function refreshJobs() { 1168 async function refreshJobs() {
1089 if (viewingTrash) { await loadTrash(); renderSidebar(); return; } 1169 if (viewingTrash) { await loadTrash(); renderSidebar(); return; }
1090 try { 1170 try {
1091 - const params = new URLSearchParams({ limit: String(loadedLimit), sort: sortOrder }); 1171 + const params = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String((currentPage - 1) * PAGE_SIZE), sort: sortOrder });
1092 if (filterQ) params.set('q', filterQ); 1172 if (filterQ) params.set('q', filterQ);
1093 if (filterStatus) params.set('status', filterStatus); 1173 if (filterStatus) params.set('status', filterStatus);
1094 if (filterKind) params.set('kind', filterKind); 1174 if (filterKind) params.set('kind', filterKind);
1095 const r = await fetch(`${H3_BASE}/v1/videos?${params}`); const d = await r.json(); 1175 const r = await fetch(`${H3_BASE}/v1/videos?${params}`); const d = await r.json();
1096 jobsCache = d.jobs || []; 1176 jobsCache = d.jobs || [];
1097 - noMoreJobs = jobsCache.length < loadedLimit; 1177 + totalJobs = d.total ?? jobsCache.length;
  1178 + // 轮询轮到的这一页如果因为任务被删/移出回收站而变空了(比如正好停在最后一页),
  1179 + // 退回上一页重新拉一次,不要晾着一页空列表。
  1180 + if (jobsCache.length === 0 && currentPage > 1 && totalJobs > 0) {
  1181 + currentPage = Math.max(1, Math.ceil(totalJobs / PAGE_SIZE));
  1182 + return refreshJobs();
  1183 + }
1098 renderSidebar(); 1184 renderSidebar();
1099 // 只有还在变化的任务(排队/运行中)才需要重新拉详情。`refreshDetail()` 是整段 1185 // 只有还在变化的任务(排队/运行中)才需要重新拉详情。`refreshDetail()` 是整段
1100 // innerHTML 重建,`done` 之后再重建会把正在播放的 <video> 元素连根拔掉—— 1186 // innerHTML 重建,`done` 之后再重建会把正在播放的 <video> 元素连根拔掉——