From 3126a5ab9f6c8fbedfb9735e1f4b1b4cc07f9549 Mon Sep 17 00:00:00 2001 From: argszero Date: Mon, 14 Sep 2026 04:02:15 +0800 Subject: [PATCH] fix(ui): sort the four token columns by their real value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transactions table's four Token columns (input / cached / output / total) rendered a sortable header button, but clicking it never reordered a row — while the Points and Time columns beside them sorted correctly. Two faces, one blind spot: 1. The comparator falls back to `row[key]`, and the view rows built by `txsToView` do not carry those keys: the exact numbers live under `inputRaw` / `cachedRaw` / `outputRaw` / `tokensRaw`, while `tokens` and the three `*Tokens` fields hold the K/M *display* strings ("5K"). So `Number(undefined)` and `Number("5K")` are NaN, `cmp` is NaN, `cmp !== 0` is false and the comparator degenerates to "equal" — the rows never move and nothing is reported. 2. The `▲`/`▼` direction marker is emitted only by `tableTheadHtml`, which since #148 runs only when the container has no `` (that is what keeps the filter inputs alive). At that moment `state.sort` is still empty, so the sort state was never shown for any column — including the Points column, which does sort. The fix applies the rule C2054 already established for the Points column (sort on the value the cell shows) to the class: each token column declares a `sortVal` that reads its exact numeric field, and the direction marker is painted in place on every rebuild instead of relying on the one-shot thead build. Rebuilding the thead was deliberately avoided — it would destroy the filter inputs and break #148's focus invariant. `sortArrow()` and `colTitle()` are now the single definition shared by the thead builder, the in-place painter and `tableBodyHtml`'s `data-label`. One file (ui/js/app.js), zero Rust, zero new i18n keys, no Config or schema change. --- ui/index.html | 2 +- ui/js/app.js | 49 +++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/ui/index.html b/ui/index.html index e917731..3460d0f 100644 --- a/ui/index.html +++ b/ui/index.html @@ -845,6 +845,6 @@

使用模型

- + diff --git a/ui/js/app.js b/ui/js/app.js index e181f60..de3ea03 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -1329,14 +1329,22 @@ { key: "model", title: () => T("tx.col.model"), sort: "string", filter: "text" }, // rant 2026-08-22T17:21:39 需求 2:Key 列改为显示分发 key 的 name(api_keys.name),而非上游 keys 的 provider/plan { key: "key", title: () => T("tx.col.apiKeyName"), sort: "string", filter: "text" }, + // C2102:四列 Token 的排序键不能在行对象上找 —— 行里存的是 K/M **显示串**(`tokens`/`inputTokens`…) + // 与精确值两套字段(`tokensRaw`/`inputRaw`…),列 key 与行字段名不同源 ⇒ 默认 `row[key]` 取到 + // undefined(input/cached/output)或显示串(tokens),`Number()` 得 NaN ⇒ 比较器恒「相等」, + // 点列头永不动行。与 C2054(pts 的 `sortVal`)同一条规则:**排序必须与单元格里的数字同口径**。 { key: "input", title: () => T("tx.col.input"), sort: "number", align: "num", - render: (t) => '' + t.inputTokens + "" }, + render: (t) => '' + t.inputTokens + "", + sortVal: (t) => t.inputRaw }, { key: "cached", title: () => T("tx.col.cached"), sort: "number", align: "num", - render: (t) => '' + t.cachedTokens + "" }, + render: (t) => '' + t.cachedTokens + "", + sortVal: (t) => t.cachedRaw }, { key: "output", title: () => T("tx.col.output"), sort: "number", align: "num", - render: (t) => '' + t.outputTokens + "" }, + render: (t) => '' + t.outputTokens + "", + sortVal: (t) => t.outputRaw }, { key: "tokens", title: () => T("tx.col.tokens"), sort: "number", align: "num", - render: (t) => '' + t.tokens + "" }, + render: (t) => '' + t.tokens + "", + sortVal: (t) => t.tokensRaw }, { key: "pts", title: () => T("tx.col.pts"), sort: "number", filter: "number-range", align: "num", // C2047:方向取自 type(signedPts),不能按 pts 的符号判 —— 所有 writer 都存正数 ⇒ 消费会显示成绿色的 +N render: (t) => { @@ -1754,13 +1762,36 @@ } // 表头(排序按钮)+ 筛选行 HTML(rant 2026-08-25T11:15:16:拆出独立渲染,整表重建不销毁筛选输入框) + // 排序方向标记(" ▲" / " ▼" / "")—— 表头构建与就地刷新共用的**唯一真源**。 + function sortArrow(state, key) { + const sk = state.sort.find((s) => s.key === key); + return sk ? (sk.dir === "asc" ? " ▲" : " ▼") : ""; + } + + // 列标题文案(列可声明 title 为函数:随语言变)。 + function colTitle(col) { + return typeof col.title === "function" ? col.title() : col.title; + } + + // 排序列头 ▲/▼ 就地刷新。为什么不能靠 tableTheadHtml:thead 只在容器无
时构建一次 + // (#148 为保住筛选输入框焦点),而排序状态是**此后**点击才产生的 ⇒ 表头不会自己重画, + // 排序方向对用户永远不可见(仅在切语言触发的整表重建后才偶然出现一次)。 + // 就地改按钮文本而**不**重建 thead:重建会销毁筛选输入框,破坏 #148 的焦点不变量。 + function paintSortIndicators(container, columns, state) { + const btns = container.querySelectorAll("thead [data-sort-key]"); + if (!btns.length) return; + btns.forEach((b) => { + const col = columns.find((c) => c.key === b.dataset.sortKey); + if (!col) return; + b.textContent = colTitle(col) + sortArrow(state, col.key); + }); + } + function tableTheadHtml(columns, state) { let html = ""; columns.forEach((col) => { - const sk = state.sort.find((s) => s.key === col.key); - const arrow = sk ? (sk.dir === "asc" ? " ▲" : " ▼") : ""; html += '"; + esc(colTitle(col)) + sortArrow(state, col.key) + ""; }); html += ""; columns.forEach((col) => { @@ -1795,7 +1826,7 @@ pageRows.forEach((row) => { html += ""; columns.forEach((col) => { - html += "' + + html += "' + (col.render ? col.render(row) : esc(row[col.key] == null ? "" : row[col.key])) + ""; }); html += ""; @@ -1928,6 +1959,8 @@ // 5) 数据行(每次重建 tbody 内容) const tbody = table.querySelector("tbody"); tbody.innerHTML = tableBodyHtml(pageRows, columns); + // 5b) 排序方向标记(thead 不重建,见 paintSortIndicators 注释) + paintSortIndicators(container, columns, state); // 6) 分页器 + 每页行数(每次重建) const oldPager = container.querySelector(".pager");