diff --git a/ui/README.md b/ui/README.md index 793066d..2f109b3 100644 --- a/ui/README.md +++ b/ui/README.md @@ -349,3 +349,11 @@ ui/ - **运营视图**(2 tab 不变):运行概览新增四张卡片——**服务版本**(`/api/ops/runtime` 的 `version`,取自 `env!("CARGO_PKG_VERSION")`,与 `/healthz` 同源;前端**不得**写死版本号)与**运行时长**(`uptime_secs` / `uptime_days` / `uptime_hours` / `uptime_minutes` / `uptime_secs_rest`,进位在后端 `split_uptime` 完成,前端 `fmtUptime` 只挑「最高两个非零位」并取 `ops.uptime.{days,hours,minutes,seconds}` 单位)、**今日调用量(按小时)** `.bar-list`(`today_hours`,服务端 0-23 全量补零;GROUP BY 会省略无调用的小时,不补零会让柱子整体左移,与交易页 `txTrendDays` 同款坑)与**上游 key 健康** `.mini-list`(`key_health` 按厂商聚合 total/on/off,三态 pill:健康 / N 个异常 / 全部失败);成员充值的搜索框移到卡片标题行右侧(原型 `.spread`); - **交易量卡(第 9 张,回归修复)**:`total_txs`(`ops.stats.trades` + `cnt.trades` + `ops.stats.trades.sub`)自 `85982e8`(PR #80)起就在 `/api/ops/runtime` 返回(全库 `COUNT(*)`,累计**全部类型**),但 v1.22 零 mock 重构 `89963f3` 删掉 mock 分支那张卡时漏了重接。**这不是原型对齐**——原型没有这张卡(原型 4 张,实现 9 张,多出的卡是刻意的)。值一律取自响应,**不取** `D.TRANSACTIONS`; - **零 mock 不破**:以上数据全部来自真实端点,加载失败仍走空态 + 重试(`.mini-item` / `.bar-row` 只在有真实数据时才渲染)。 + +## 交易类型筛选:一份状态,两套控件(C2112) + +- **只有一份状态**:交易页的「类型」筛选器有两个控件 —— 顶部 **`#tx-tabs`**(全部 / 消费 / 收益)与「类型」列表头下的 **`select.th-filter[data-filter-key="type"]`**(6 个库内值)。**状态只存在 `txTable.filters.type`**(空串 = 不限;取值恒为库内值 ⇒ 语言无关,见 C2031:「选项 value 与文案分离」)。 +- **两者都读写它**:`txTypeFilter()` 是唯一取值定义(`loadTransactions` 的 `type` 参数、`renderTransactions` 的 tab 高亮都调它);`setTxTypeFilter(v)` 是唯一写入口,并**同步已渲染的 select**(#148 之后表头不重建 → 控件值不会自己跟上状态)。 +- **删掉的第二份状态**:`txTab`。此前顶部 tab 另存一份 `txTab`,而请求按 `filters.type || txTab` 取值 ⇒ 列筛选一旦出值,tab 的写入就被永久盖住:点 tab 只是挪高亮、列表不变(**死控件**),高亮却仍按 `txTab` 画(**说谎的指示器**)。jsdom 实测(改前 5/11):列筛选选「赠送」后,请求 `type=gift` 而「全部」仍高亮;点「消费」后请求仍是 `gift`、`select` 仍显示 `gift` 而高亮已跳到「消费」——三者各说各话。 +- **高亮口径**:生效值不属于 all/consume/earn 时(topup / withdraw / gift / expire)**没有任何 tab 自称生效**(都不高亮),因为把「全部」点亮而列表只有赠送行同样是说谎。改版后不变量:**请求参数 / tab 高亮 / 列筛选 select 三者恒为同一份状态的投影**。 +- **冒烟测试注意**:点 tab 后状态已变,重拉由 `renderTransactions` 的筛选签名比对触发 —— **不要再显式 `loadTransactions()`**(会与它并发两次请求,后到者可能把先到者的行覆盖回去)。断言请比对「最近一次 `/api/transactions` 请求的 `type`」与「高亮的 tab」与「`select` 的 value」,三者用**同一个派生**(`wantTabs(type)`),不要写死字面量 —— 字面量会在应用的高亮恰好拼对时误绿。 diff --git a/ui/index.html b/ui/index.html index 169a853..d293f65 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 0f60994..14f75fa 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -13,7 +13,7 @@ const T = window.t; // i18n(rant 2026-08-18T20:49:22) let activeView = "dashboard"; - let txTab = "all"; + // 交易类型筛选**只有一份状态**:`txTable.filters.type`(见 txTypeFilter(),原 `txTab` 已删) let txRange = "24h"; // 交易时间段快捷范围:24h / 7d / 30d / all / custom(默认最近 24 小时,rant 2026-08-22T10:50:00) let txCustomStart = ""; // 自定义开始(datetime-local 值,本地时区) let txCustomEnd = ""; // 自定义结束 @@ -1521,7 +1521,11 @@ } function renderTransactions() { - $$("#tx-tabs .tab").forEach((b) => b.classList.toggle("active", b.dataset.txTab === txTab)); + // tab 高亮 = **生效的类型筛选**(txTypeFilter()),不是另存的一份 tab 状态 —— 两处必须同源, + // 否则列筛选一出值,高亮就与列表说的不是同一件事。生效值不属于 all/consume/earn 时 + // (topup/withdraw/gift/expire),没有任何 tab 可以自称生效 ⇒ 都不高亮。 + const effTab = txTypeFilter() || "all"; + $$("#tx-tabs .tab").forEach((b) => b.classList.toggle("active", b.dataset.txTab === effTab)); // 零 mock(rant 2026-08-19T15:54:06):登录态绝不 fallback D.TRANSACTIONS; // 加载失败 → 空态 + 重试;游客不可达(导航拦截) if (loggedIn() && !Live.transactions) { @@ -1601,6 +1605,23 @@ if (f.status) add("status", f.status); return p.join("&"); } + // 交易类型筛选:**一份状态,两套控件**。顶部 tab(全部/消费/收益)与「类型」列筛选 select + // (6 个库内值)是同一个筛选器的两种控件,状态只存在 `txTable.filters.type`(空串 = 不限, + // 取值恒为库内值 ⇒ 语言无关,见 C2031)。请求参数、tab 高亮、列筛选控件三者都必须是**它**的 + // 投影;此前顶部 tab 另存一份 `txTab`,而请求按 `filters.type || txTab` 取值 ⇒ 列筛选一出值, + // tab 的写入就被永久盖住(点 tab 只是挪高亮、列表不变),高亮也仍按 `txTab` 画 + // ⇒ 指示器与列表说的不是同一件事。删掉第二份状态,该类不再存在。 + function txTypeFilter() { + return (txTable.filters && txTable.filters.type) || ""; + } + // 写类型筛选,并同步**已渲染**的列筛选 select:#148 之后表头不重建(保住筛选框焦点), + // 控件值不会自己跟上状态;表头若尚未渲染/已被清空则无需同步,下次重建会读 state 得到正确值。 + function setTxTypeFilter(v) { + txTable.filters = txTable.filters || {}; + txTable.filters.type = v || ""; + const sel = document.querySelector('#tx-table select[data-filter-key="type"]'); + if (sel) sel.value = txTable.filters.type; + } // 列筛选签名:筛选条件变化 → renderTransactions 触发重拉(rant 2026-08-25T10:33:26) function txFilterSig() { const f = txTable.filters || {}; @@ -1611,10 +1632,9 @@ // loadedPage/loadedPageSize 记录已加载页,renderTransactions 发现页码不一致时自动重拉) async function loadTransactions() { if (!loggedIn()) return; - // 类型:列筛选 type(select)优先于顶部 tab(tab=all 时即列筛选值);列筛选后端化后同走 type 参数。 + // 类型:单一状态(txTypeFilter()),tab 与列筛选都读写它;后端化后同走 type 参数。 // C2031:筛选状态即库内值(consume/earn/…),直接发送,无需反查。 - const colType = (txTable.filters && txTable.filters.type) ? txTable.filters.type : ""; - const type = colType || (txTab === "all" ? "" : txTab); + const type = txTypeFilter(); const range = txRangeParams(); const cols = txFilterParams(); // rant 2026-08-25T10:33:26:列筛选随请求发出,后端全量过滤 const page = Math.max(1, txTable.page || 1); @@ -3669,8 +3689,14 @@ // 容器级委托,避免为每个视图各绑一次;与原型 data-goto 语义一致 $$("[data-goto]").forEach((b) => b.addEventListener("click", () => switchView(b.dataset.goto))); - // 交易 Tab(P2-B:切 tab 重新拉后端过滤数据) - $$("#tx-tabs .tab").forEach((b) => b.addEventListener("click", () => { txTab = b.dataset.txTab; txTable.page = 1; renderTransactions(); if (loggedIn()) loadTransactions(); })); + // 交易 Tab(P2-B:切 tab 重新拉后端过滤数据):tab 写的就是类型筛选本身(setTxTypeFilter) + // —— 它也负责把「类型」列筛选控件同步成同一个值,否则两份控件又会各说各话;控件状态一变, + // renderTransactions 里的签名比对自会重拉(勿再显式调 loadTransactions,会与它并发两次请求)。 + $$("#tx-tabs .tab").forEach((b) => b.addEventListener("click", () => { + setTxTypeFilter(b.dataset.txTab === "all" ? "" : b.dataset.txTab); + txTable.page = 1; + renderTransactions(); + })); $("#tx-export-btn").addEventListener("click", exportTxCsv); // 导出 CSV(rant 20:46:57 E) // 交易时间段(rant 2026-08-22T10:50:00:快捷范围 + 自定义起止,切换后重载列表与汇总)