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 231 # 两行判断中间没有 await,天然原子,不会被其它协程插进来)。
232 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 247 async def _try_submit(cid: str):
236 248 """把一条本地排队的任务尝试提交给 gateway。给 create_video 首次调用和 reconciler_loop 复用。"""
... ... @@ -242,31 +254,32 @@ async def _try_submit(cid: str):
242 254 return # 任务不存在,或已经提交过
243 255 _SUBMITTING.add(cid)
244 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 283 finally:
271 284 _SUBMITTING.discard(cid)
272 285  
... ... @@ -313,42 +326,46 @@ async def _discover_from_gateway():
313 326 捞回来。两边后端都有 GET /v1/videos 全量列表(raylight 直接返回 list,老 gateway 包一层
314 327 {"jobs":[...]}),本来就是给这个用的。2026-08-24 真出过一次:有人直连后端提交,console
315 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 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 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 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 371 @api.post("/v1/videos", status_code=202)
... ... @@ -527,7 +544,7 @@ async def download_video(jid: str, request: Request):
527 544  
528 545  
529 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 548 kind: str = "", sort: str = "desc"):
532 549 # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在,
533 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 569 params.append(kind)
553 570 order_col = "deleted_at" if trash else "created_at"
554 571 order = f"{order_col} {'ASC' if sort == 'asc' else 'DESC'}"
555   - params.append(limit)
556 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 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 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 582 @api.delete("/v1/videos/{jid}")
... ...
h3-console/static/index.html
... ... @@ -9,7 +9,9 @@
9 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 15 #sidebar-header { padding: 16px; border-bottom: 1px solid #ddd; }
14 16 #sidebar-header h1 { font-size: 16px; margin: 0; }
15 17 #sidebar-filters { padding: 8px 16px; border-bottom: 1px solid #ddd; display: flex; flex-direction: column; gap: 6px; }
... ... @@ -21,6 +23,9 @@
21 23 #job-sort-btn:hover { background: rgba(128,128,128,.15); }
22 24 #job-list { flex: 1; overflow-y: auto; }
23 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 29 .job-row { position: relative; padding: 10px 16px; border-bottom: 1px solid #eee; cursor: pointer; }
25 30 .job-row:hover { background: rgba(128,128,128,.08); }
26 31 .job-row.selected { background: rgba(74,170,119,.15); border-left: 3px solid #4a7; }
... ... @@ -29,6 +34,16 @@
29 34 .job-row .jr-actions { display: flex; align-items: center; gap: 6px; }
30 35 .job-row .jr-prompt { font-size: 13px; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
31 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 47 .jr-menu-btn { cursor: pointer; padding: 0 4px; opacity: .55; font-size: 14px; line-height: 1; }
33 48 .jr-menu-btn:hover { opacity: 1; }
34 49 .jr-menu { position: absolute; top: 22px; right: 12px; background: Canvas; color: CanvasText;
... ... @@ -310,20 +325,26 @@ let trashCache = [];
310 325 let viewingTrash = false;
311 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 335 let filterQ = '';
322 336 let filterStatus = '';
323 337 let filterKind = '';
324 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 349 // URL 是 /task.html/<id> 或 /newtask.html(或裸 /,等同 newtask)——不是两个页面文件,
329 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 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 380 function showCreate(pushUrl = true) {
371 381 selectedId = null;
372 382 if (pushUrl) history.pushState(null, '', H3_BASE + '/newtask.html');
... ... @@ -825,6 +835,28 @@ async function resetGpu() {
825 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 860 function statusLabel(s) {
829 861 const map = { queued: '排队中', running: '运行中', done: '已完成', failed: '失败', cancelled: '已取消' };
830 862 return `<span class="status-${s}">${map[s] || s}</span>`;
... ... @@ -843,8 +875,24 @@ function fmtTime(ts) {
843 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 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 896 document.getElementById('sidebar-filters').style.display = viewingTrash ? 'none' : 'flex';
849 897 // "+新建任务" 固定在筛选框上面、列表外面,不随 #job-list 滚动——之前是塞进 job-list 第一
850 898 // 行的,列表一滚就跟着跑没了,2026-08-28 用户反馈要求固定住。
... ... @@ -853,6 +901,7 @@ function renderSidebar() {
853 901 newTaskRow.classList.toggle('selected', !viewingTrash && selectedId === null);
854 902  
855 903 if (viewingTrash) {
  904 + box.innerHTML = '';
856 905 const backRow = document.createElement('div');
857 906 backRow.className = 'job-row new-row';
858 907 backRow.onclick = () => backToList();
... ... @@ -879,13 +928,27 @@ function renderSidebar() {
879 928 return;
880 929 }
881 930  
  931 + const seen = new Set();
882 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 940 row.className = 'job-row' + (j.id === selectedId ? ' selected' : '');
885 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 949 const prompt = j.req ? (j.req.prompt || '(无 prompt)') : '';
887 950 const menuOpen = openMenuFor === j.id;
888   - row.innerHTML = `
  951 + row.querySelector('.jr-info').innerHTML = `
889 952 <div class="jr-top"><span>${j.id}</span>
890 953 <div class="jr-actions">
891 954 ${statusLabel(j.status)}
... ... @@ -893,25 +956,42 @@ function renderSidebar() {
893 956 </div>
894 957 </div>
895 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 965 ${j.status === 'queued' ? `<div class="jr-menu-item" onclick="prioritizeJob('${j.id}')">优先插队</div>` : ''}
899 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 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 1168 async function refreshJobs() {
1089 1169 if (viewingTrash) { await loadTrash(); renderSidebar(); return; }
1090 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 1172 if (filterQ) params.set('q', filterQ);
1093 1173 if (filterStatus) params.set('status', filterStatus);
1094 1174 if (filterKind) params.set('kind', filterKind);
1095 1175 const r = await fetch(`${H3_BASE}/v1/videos?${params}`); const d = await r.json();
1096 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 1184 renderSidebar();
1099 1185 // 只有还在变化的任务(排队/运行中)才需要重新拉详情。`refreshDetail()` 是整段
1100 1186 // innerHTML 重建,`done` 之后再重建会把正在播放的 <video> 元素连根拔掉——
... ...