diff --git a/PROGRESS.md b/PROGRESS.md index f19501e..ba5b06c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,6 +21,12 @@ ## 已完成关键能力 +### 2026-06-26 / 消息目录圆点错位再修(点击竞态 + 触底兜底)(bump 0.31.2) + +- 现象(0.20.4 后仍残留):① 点圆点,被点的圆点不变红、活跃态跑到途经轮次(尤其点 #1 跳到 #2);② 点最后一个 / 滚到底,倒数第二个变红。 +- 根因:① `jumpToMessage` 的 `scrollIntoView({behavior:"smooth"})` 在动画途中连发 scroll 事件,`updateActiveOutlineDot` 按动画途中位置反复改写,抢走刚 `setActiveOutlineIdx` 的显式点选;② 「顶线以上最后一卡」判活跃,最后几轮永远顶不到顶线(容器先到底)→ 永远停在倒数第二个,这是 scroll-spy 经典「不可达末项」bug,普通滚动也复现。 +- 修复(`web/static/js/chat.js`):① 加 `_outlineJumpLock`,点选后锁定活跃态,平滑滚动期间 `updateActiveOutlineDot` 直接返回,700ms 兜底解锁并按落点重算一次;② `updateActiveOutlineDot` 加触底分支——滚到容器底且无更新内容可加载(`!msgHasMoreNewer`)时,直接判最后一个已加载轮为当前。 + ### 2026-06-26 / admin 近7天用量表加合计行(bump 0.31.1) - 纯前端展示:`renderByDay`(`web/static/js/admin.js`)在 `by_day_7d` 表底加 `` 合计行,对 7 天 cost_cny/tokens_in/tokens_out 求和;`tfoot .total-row` 样式(粗体 + 上分隔线)在 `admin.html`。无数据时不渲染合计行。后端数据已有(`_usage_section`),无改动。 diff --git a/core/__init__.py b/core/__init__.py index 110e847..98d588a 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.31.1" +__version__ = "0.31.2" diff --git a/web/static/js/chat.js b/web/static/js/chat.js index d28c158..f4d0c15 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -637,10 +637,11 @@ async function jumpToMessage(idx) { // 顶部对齐(非居中):第一轮上方无内容无法居中、会被钉到顶端,而 updateActiveOutlineDot // 按「顶线」判活跃轮 —— 两套锚点必须一致,否则贴顶时活跃圆点会越界到下一轮。 // .msg 的 scroll-margin-top 给卡片留一点上方呼吸空间。 + setActiveOutlineIdx(idx); + lockOutlineDuringJump(); // 锁住活跃圆点:平滑滚动途中的 scroll 事件不得把活跃态抢到途经轮次 card.scrollIntoView({ behavior: "smooth", block: "start" }); card.classList.add("msg-jump-flash"); setTimeout(() => card.classList.remove("msg-jump-flash"), 1200); - setActiveOutlineIdx(idx); } // 顶/底 sentinel 进视口即自动补更早 / 更新 —— 复用 task list 的同款范式。 @@ -699,14 +700,44 @@ function setActiveOutlineIdx(idx) { }); } +// 点圆点跳转期间锁定活跃态:平滑滚动会连发 scroll 事件,若不锁,updateActiveOutlineDot +// 会按动画途中的位置反复改写,把刚点中的圆点抢成途经轮次(表现:点 #1 不变红 / 跳到 #2)。 +let _outlineJumpLock = false; +let _outlineJumpTimer = 0; +function lockOutlineDuringJump() { + _outlineJumpLock = true; + clearTimeout(_outlineJumpTimer); + // 平滑滚动一般 <500ms;700ms 兜底解锁后按落点重算一次(触底轮由下面 atBottom 分支兜) + _outlineJumpTimer = setTimeout(() => { + _outlineJumpLock = false; + updateActiveOutlineDot(); + }, 700); +} + // 视口顶线以上的最后一个已加载 user 卡 = 当前轮,高亮对应圆点 function updateActiveOutlineDot() { const rail = $("msg-outline-rail"); if (!rail || rail.style.display === "none") return; + if (_outlineJumpLock) return; // 显式跳转动画期间不抢 const wrap = $("chat-stream"); + const items = state.outline || []; + + // 触底兜底:最后几轮永远顶不到顶线(容器先到底),按原逻辑会一直停在倒数第二个 + // (表现:点最后一个 / 滚到底时倒数第二个变红)。滚到容器底且无更新内容可加载时, + // 直接判最后一个已加载轮为当前。 + if (!state.msgHasMoreNewer + && wrap.scrollTop + wrap.clientHeight >= wrap.scrollHeight - 2) { + for (let i = items.length - 1; i >= 0; i--) { + if (wrap.querySelector(`.msg[data-idx="${items[i].idx}"]`)) { + setActiveOutlineIdx(items[i].idx); + return; + } + } + } + const top = wrap.getBoundingClientRect().top; let activeIdx = null; - for (const it of (state.outline || [])) { + for (const it of items) { const card = wrap.querySelector(`.msg[data-idx="${it.idx}"]`); if (!card) continue; // 容差与 .msg 的 scroll-margin-top(16px)对齐:贴顶的短第一轮判到自己,不越界