Commit 96cc2bc2511318c3d6b569aacff799830ad46603
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
Showing
2 changed files
with
206 additions
and
101 deletions
h3-console/server.py
| ... | ... | @@ -231,6 +231,18 @@ def _row_view(row) -> 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 = "", 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('job-sort-btn').addEventListener('click', e => { |
| 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> 元素连根拔掉—— | ... | ... |