From 400620c3079eea30334485b77bfa54c66248a96f Mon Sep 17 00:00:00 2001 From: argszero Date: Sun, 13 Sep 2026 17:20:31 +0800 Subject: [PATCH] fix(ui): derive the live containers from the DOM, not from rosters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hand-written rosters enumerated the "live" containers by id, and both omitted the admin models table `#model-body`: 1. `bindLiveRetry("", fn)` bound the load-error 重试 button of 13 containers. `#model-body` was never registered, so `renderAdminModels()` rendered a 重试 button that did nothing when clicked. The smoking gun is `loadErrorHtml(emptyLabel, retryFn, retryLabel)`: it declared `retryFn` and never used it — the renderer was always meant to hand its loader over. 2. `KBD_TABLE_IDS` listed the tables reachable by ↑/↓/Enter. `#model-body` was absent, so its rows could not be activated (no `.row-active`). Both rosters are removed rather than extended — a roster that must be maintained by hand is the defect, not the missing entry: - `setLiveError(container, html, loader)` replaces `bindLiveRetry` and the unused `retryFn` parameter. Whoever renders the degraded state hands over its loader; a one-time, container-level click delegation (WeakMap/WeakSet) dispatches `[data-live-retry]` clicks, so a rebuilt container keeps working. All 12 `loadErrorHtml`/`loadErrorRow` call sites were converted. - The keyboard-nav container is derived from the event target (`kbdTbodyOf` = `closest("tbody")`) instead of matched against a list of ids, via a single document-level click delegation. Any data table is now navigable without being registered, and dynamically rebuilt tables keep their handler. Declared side effect: the JS-built raise-requests table gains keyboard row navigation (it was outside the old roster and its degraded state passed `null` for the retry callback, so it had no retry button either; it now reloads via `loadAdmin()`). Docs: `ui/README.md` described both deleted rosters by name; the two sections are updated to the DOM-derived contract. Verified with a jsdom probe booting the real `index.html` + the four scripts (fetch stubbed, admin endpoints 500): before the change `#model-body` renders but re-requests 0 times on retry and never activates on click (5/7 checks red); after, all 7 pass, control `#dept-body` passes in both legs, and a run-time-injected table's rows activate (no roster can cover it). `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` and `cargo test` (190 passed) all clean; cache-bust bumped to `app.js?v=20260913-3`. --- ui/README.md | 12 +++--- ui/index.html | 2 +- ui/js/app.js | 105 ++++++++++++++++++++++++-------------------------- 3 files changed, 58 insertions(+), 61 deletions(-) diff --git a/ui/README.md b/ui/README.md index 64508a9..8ec207b 100644 --- a/ui/README.md +++ b/ui/README.md @@ -272,13 +272,13 @@ ui/ - 下载:`Blob(type="text/csv;charset=utf-8")` → `URL.createObjectURL` → 临时 `` click → `remove()` → `setTimeout 1s` revoke;文件名 **`aitokenpool-transactions-YYYYMMDD.csv`**(`new Date()` 本地日期); - 冒烟测试注意:stub 需给 `document.createElement("a")` 返回带 `click()`/`remove()` 的元素并捕获 `href`/`download`,`URL.createObjectURL` 捕获 Blob(`arrayBuffer()` 首 3 字节 EF BB BF 验证 BOM——`blob.text()` 会按规范剥掉 BOM);列筛选联动可注入 `#tx-table` 的 `querySelectorAll(".th-filter")`/`querySelector('[data-filter-key=…]')` 假输入并 fire `input`。 -## 数据表格键盘导航约定(v1.20,rant 2026-08-17T20:46:57 F) +## 数据表格键盘导航约定(v1.20,rant 2026-08-17T20:46:57 F;2026-09-13 改为 DOM 派生,去名册) -- **作用表**(`KBD_TABLE_IDS`):`mk-body` / `share-body` / `api-keys` / `emp-body` / `dept-body` / `ops-body`(tbody)+ `tx-table`(div 包 table); -- **激活**:① 点击表格行(容器 click 委托,`e.target.closest("tr")`,跳过 `.mk-detail`);② 直接按 ↑/↓——`kbdContainerFrom(t)` 沿 `closest("tbody")`(id 匹配)或 `closest("table").parentNode`(#tx-table)解析当前表格,找不到沿用 `kbd.c`; +- **作用表 = 从 DOM 派生,不维护名册**:任何 `` 里的数据行都可导航(`mk-body` / `share-body` / `api-keys` / `emp-body` / `dept-body` / `model-body` / `ops-body` / JS 建的 `raise-requests` 表,以及 `tx-table` 内动态建出的 tbody)。新增数据表无需登记,重绘也无需重新绑定——此前是一份手写 id 名册,`model-body` 漏登记即导致重试死键 + 无键盘导航; +- **激活**:① 点击表格行(**document 级**一次性 click 委托,容器 `kbdTbodyOf(e.target)` = `closest("tbody")`,行取 `closest("tr")`,跳过 `.mk-detail`);② 直接按 ↑/↓——`kbdContainerFrom(t)` 同样按 `closest("tbody")` 解析(键盘事件目标通常是 body ⇒ 沿用 `kbd.c`;`kbd.c` 若已被重建(`isConnected === false`)则视为未激活); - **键位**:`ArrowDown/Up` → `kbdMove(dir, c)` 行高亮 `.row-active`(accent 左侧竖条 `inset 3px 0 0` + `--accent-soft` 底),未激活时 ↓ 首行 / ↑ 末行,`scrollIntoView({block:"nearest"})`;`Enter` → `kbdEnter()` 点击行内首个可用 `button.btn:not(.row-expand)`(disabled 不触发);`Esc` → `kbdClear()`(无高亮时落到原逻辑:关帮助/行内表单/引导); - **守卫**:typing(INPUT/TEXTAREA/SELECT/contentEditable)与 meta/ctrl/alt 组合键不拦截;`?`、数字键视图切换、Esc 原有优先级(引导 > 帮助 > 行内新建 Key > 表格高亮)均不受影响; -- 冒烟测试注意:`qs(sel)` 的 stub id 会带 `#` 前缀——`kbdContainerFrom` 依赖真实无 `#` 的 id(如 `tx-table`),须手动修正;Esc 分支链依赖 `#ak-new-inline`、`#help-panel` hidden 预置 + `atp-tour-done=1`(防 tour 拦截)。 +- 冒烟测试注意:导航容器由真实 DOM 的 `closest("tbody")` 解析(不再比对 id)⇒ 旧 stub(`qs(sel)` 返回带 `#` 前缀的假 id)不影响它,但事件目标须是真实 DOM 节点;Esc 分支链依赖 `#ak-new-inline`、`#help-panel` hidden 预置 + `atp-tour-done=1`(防 tour 拦截)。 ## 品牌与登录页氛围约定(v1.20,rant 2026-08-17T20:46:57 G) @@ -312,7 +312,7 @@ ui/ ## 登录态零 mock 约定(v1.22,rant 2026-08-19T15:54:06 系统性清理) - **铁律**:登录态(`loggedIn()`)任何视图**不得渲染 `data.js` 的 `D.*` mock 数据**;mock 仅限游客模式(`GUEST_VIEWS = ["marketplace"]`,且市场模型列表登录态用 `GET /api/models` 真实数据); -- **统一降级模式**:登录态 `Live.x ? view(Live.x) : loadError(空态+重试)`——绝不 fallback 到 `D.*`;失败渲染 `loadErrorHtml`(div 容器)或 `loadErrorRow`(tbody 容器,`` 包裹,避免 div 直接进 tbody 被浏览器提升到表外破坏布局与重试委托);重试按钮 `data-live-retry` 由 `bindLiveRetry` 委托(DOMContentLoaded 一次性绑定,容器级); +- **统一降级模式**:登录态 `Live.x ? view(Live.x) : loadError(空态+重试)`——绝不 fallback 到 `D.*`;失败渲染 `loadErrorHtml`(div 容器)或 `loadErrorRow`(tbody 容器,`` 包裹,避免 div 直接进 tbody 被浏览器提升到表外破坏布局与重试委托);重试按钮 `data-live-retry` 由 `setLiveError(容器, html, loader)` 委托——**渲染方写下降级态时把 loader 一并交出**,容器上一次性的 click 委托(`WeakMap` 存 loader、`WeakSet` 记已绑定,重建不重绑)负责分发。谁渲染降级态谁负责传 loader ⇒ 新增表格无需在名册里登记(漏登记 = 重试按钮点了没反应); - **已清零路径**(原 10 处 mock 泄漏):仪表盘统计(`D.TRANSACTIONS` 聚合)→ 登录用 `/api/wallet` + `/api/dashboard`,未就绪显示 0;月度聚合/sparkline → 登录用 `Live.dashboard.series`,未就绪净 0 空行;交易页/CSV 导出 → 登录失败空态;API Key 设置 → 登录失败空态(生成/改名/删除已走真实 API);管理视图成员 → 登录失败空态;用量报表 → 登录失败空态;部门管理(CRUD 已真实 API,移除本地 push/splice);运营者(runtime/users)→ 登录失败空态;加额申请(提交/审批已真实 API);上架表单(登录已 `POST /api/sharings`,游客无上架权限移除 mock 分支); - **`data.js` 保留对象**:`MODELS`(上架表单定价兜底)、`PLANS`(上架表单兜底,登录用 `/api/plans` 单一真源)、`PROVIDERS` / `PROVIDER_LABELS`(厂商枚举/显示名)、`MARKET`(游客市场浏览;`multi`/`success` 虚构字段已移除——登录态 `multi = available_keys >= 2` 真实计算,成功率后端暂无字段不再展示)、`USER`(会话存储:登录后由 `/api/me` + `/api/wallet` 覆盖,初始值不渲染); - **已删除对象**:`TRANSACTIONS` / `SHARINGS` / `API_KEYS` / `EMPLOYEES` / `DEPARTMENTS` / `USAGE_MODEL` / `USAGE_EMP` / `OPERATOR_USERS` / `RAISE_REQUESTS`; @@ -336,7 +336,7 @@ ui/ - **数据源**:登录后 `loadDashboard()` 拉取 `/api/wallet` + `/api/dashboard` + **`/api/sharings`**(此前漏拉 sharings → 仪表盘「我的共享」恒显示 `D.SHARINGS` mock,如 GLM/deepseek 假行);`Live.sharings` 拉取成功后 `renderDashboard()` 用真实数据渲染「厂商 · Plan / 已用 / 额度 / 累计收益」; - **降级原则**:**mock 只用于游客模式**——`Live.x ? view(Live.x) : D.x` 的 fallback 在登录态一律不得暴露 mock: - 登录态 `Live.sharings` 为空数组(`[]`,上架 0 条)→ 空态「还没有上架的 key」; - - 登录态拉取失败(`null`)→ `loadErrorHtml(空态文案, 重试函数, err.loadFail)`:空态 + 「重试」按钮(`data-live-retry` 委托,`bindLiveRetry` 在 DOMContentLoaded 一次性绑定 `#dash-sharings` / `#share-body`),不白屏、不显示 mock; + - 登录态拉取失败(`null`)→ `setLiveError($("#dash-sharings"), loadErrorHtml(空态文案, T("err.loadFail")), () => loadDashboard())`:空态 + 「重试」按钮(`data-live-retry` 容器级委托),不白屏、不显示 mock; - 游客模式(`loggedIn()` 为假)→ 继续用 `D.SHARINGS` mock 浏览(不变); - **覆盖范围**:仪表盘 `renderDashboard()` 与共享管理 `renderSharing()` 两处 `Live.sharings` 消费点均按此约定(共享页为登录态专属视图,原 mock fallback 只在拉取失败时暴露,同属本 bug 类); - **冒烟测试注意**:登录态断言「我的共享」不得含 `glm-5.2` / `deepseek-v4-flash` 行;干净库(上架 0 条)应见空态文案;stub `api.get("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/api/sharings")` 抛错时断言出现 `[data-live-retry]` 按钮且点击后重新调用 `loadDashboard()`。 diff --git a/ui/index.html b/ui/index.html index 67c332b..72de0be 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 a83234c..fcdd938 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -610,7 +610,7 @@ sparkline(cumSeries, { labels: days, fmt: (v) => "+" + D.fmt(v), stroke: "var(--ok)" })); } } else { - $("#dash-sharings").innerHTML = loadErrorHtml(T("dash.loadFail"), () => loadDashboard(), T("err.loadFail")); + setLiveError($("#dash-sharings"), loadErrorHtml(T("dash.loadFail"), T("err.loadFail")), () => loadDashboard()); } renderMonthChanges(); } @@ -775,7 +775,7 @@ let list = Live.models ? modelsToView(Live.models) : (loggedIn() ? null : D.MARKET); if (!list) { $("#mk-count").textContent = T("cnt.on", { n: 0 }); - $("#mk-body").innerHTML = loadErrorRow(7, T("mk.loadFail"), T("err.loadFail")); + setLiveError($("#mk-body"), loadErrorRow(7, T("mk.loadFail"), T("err.loadFail")), () => loadMarketplace()); pulseTbody($("#mk-body")); renderRecent(); return; @@ -894,7 +894,7 @@ const list = Live.sharings ? sharingsToView(Live.sharings) : null; if (!list) { $("#share-stats").innerHTML = ""; - $("#share-body").innerHTML = loadErrorRow(8, T("share.loadFail"), T("err.loadFail")); + setLiveError($("#share-body"), loadErrorRow(8, T("share.loadFail"), T("err.loadFail")), () => loadSharing()); return; } const on = list.filter((s) => s.status === "on"); @@ -1262,7 +1262,7 @@ // 零 mock(rant 15:54:06):登录态绝不 fallback D.RAISE_REQUESTS;失败 → 空态 + 重试 const list = Live.raiseRequests; if (!list) { - el.innerHTML = loadErrorHtml(T("admin.raise.loadFail"), null, T("err.loadFail")); + setLiveError(el, loadErrorHtml(T("admin.raise.loadFail"), T("err.loadFail")), () => loadAdmin()); return; } // 表头在渲染期取 i18n(本表由 JS 在 boot 之后注入,无 data-i18n 钩子可走): @@ -1513,7 +1513,7 @@ renderTxTrend(); const c = $("#tx-count"); if (c) c.textContent = ""; - $("#tx-table").innerHTML = loadErrorHtml(T("tx.loadFail"), null, T("err.loadFail")); + setLiveError($("#tx-table"), loadErrorHtml(T("tx.loadFail"), T("err.loadFail")), () => loadTransactions()); return; } // 真后端分页 + 列筛选(rant 2026-08-24T10:51:57 + 2026-08-25T10:33:26):页码/每页行数/筛选条件 @@ -1654,12 +1654,13 @@ toast(T("tx.export.ok", { n: list.length }), "success"); } - /* --- 数据表格键盘导航(rant 20:46:57 F:↑/↓ 行高亮 .row-active,Enter 触发主操作,Esc 清除) --- */ - const KBD_TABLE_IDS = ["mk-body", "share-body", "api-keys", "emp-body", "dept-body", "ops-body", "tx-table"]; + /* --- 数据表格键盘导航(rant 20:46:57 F:↑/↓ 行高亮 .row-active,Enter 触发主操作,Esc 清除) --- + 容器由 DOM 派生(事件目标最近的 ),不维护「哪些表格可导航」的名册: + 数据表一律可导航,新增表格无需登记,重绘也无需重新绑定。 */ let kbd = { c: null, i: -1 }; // 当前激活的表格容器 + 高亮行下标 function kbdRows(c) { - if (!c) return []; + if (!c || c.isConnected === false) return []; // 表格被整体重建 ⇒ 记住的容器已失效,视为未激活 const trs = c.tagName === "TBODY" ? c.querySelectorAll("tr") : c.querySelectorAll("tbody tr"); return [].filter.call(trs, (tr) => tr && !(tr.classList && tr.classList.contains("mk-detail"))); } @@ -1694,15 +1695,13 @@ const btn = tr.querySelector ? tr.querySelector("button.btn:not(.row-expand)") : null; if (btn && !btn.disabled) btn.click(); } + // 数据表容器 = 事件目标最近的 (判断依据是元素本身,不是它的 id 是否被某个名册登记过) + function kbdTbodyOf(t) { + return (t && t.closest) ? t.closest("tbody") : null; + } function kbdContainerFrom(t) { - // 从事件目标向上找表格容器(tbody 本身或包 table 的 #tx-table),找不到沿用上次激活的表格 - if (t && t.closest) { - const tb = t.closest("tbody"); - if (tb && KBD_TABLE_IDS.indexOf(tb.id) >= 0) return tb; - const tbl = t.closest("table"); - if (tbl && tbl.parentNode && tbl.parentNode.id === "tx-table") return tbl.parentNode; - } - return kbd.c; + // 键盘事件的目标通常是 body(无 tbody 可寻)⇒ 沿用上次激活的表格 + return kbdTbodyOf(t) || (kbd.c && kbd.c.isConnected !== false ? kbd.c : null); } /* --- 通用 MRT 风格数据表格渲染器 --- @@ -1983,7 +1982,7 @@ // 零 mock(rant 2026-08-19T15:54:06):登录态绝不 fallback D.API_KEYS; // 加载失败 → 空态 + 重试;设置页仅登录可达 if (loggedIn() && !Live.apiKeys) { - $("#api-keys").innerHTML = loadErrorRow(6, T("settings.ak.loadFail"), T("err.loadFail")); + setLiveError($("#api-keys"), loadErrorRow(6, T("settings.ak.loadFail"), T("err.loadFail")), () => loadApiKeys()); pulseTbody($("#api-keys")); return; } @@ -2224,7 +2223,7 @@ // 加载失败 → 空态 + 重试 if (!Live.adminUsers) { $("#emp-stats").innerHTML = ""; - $("#emp-body").innerHTML = loadErrorRow(7, T("admin.emp.loadFail"), T("err.loadFail")); + setLiveError($("#emp-body"), loadErrorRow(7, T("admin.emp.loadFail"), T("err.loadFail")), () => loadAdmin()); pulseTbody($("#emp-body")); return; } @@ -2262,7 +2261,7 @@ } else if (tab === "usage") { // 零 mock(rant 15:54:06):用量报表仅登录可达,绝不 fallback D.USAGE_MODEL/D.USAGE_EMP if (!Live.adminUsage) { - $("#usage-model").innerHTML = loadErrorHtml(T("admin.usage.loadFail"), null, T("err.loadFail")); + setLiveError($("#usage-model"), loadErrorHtml(T("admin.usage.loadFail"), T("err.loadFail")), () => loadAdmin()); $("#usage-emp").innerHTML = ""; $("#usage-dept").innerHTML = ""; return; @@ -2301,7 +2300,7 @@ // 模型搜索过滤 + 表格渲染(数据来自 /api/admin/models;零 mock:加载失败 → 空态 + 重试) function renderAdminModels() { if (!Live.adminModels) { - $("#model-body").innerHTML = loadErrorRow(8, T("admin.models.loadFail"), T("err.loadFail")); + setLiveError($("#model-body"), loadErrorRow(8, T("admin.models.loadFail"), T("err.loadFail")), () => loadAdmin()); pulseTbody($("#model-body")); return; } @@ -2484,7 +2483,7 @@ const src = Live.departments; if (!src) { $("#dept-stats").innerHTML = ""; - $("#dept-body").innerHTML = loadErrorRow(7, T("admin.org.loadFail"), T("err.loadFail")); + setLiveError($("#dept-body"), loadErrorRow(7, T("admin.org.loadFail"), T("err.loadFail")), () => loadAdmin()); pulseTbody($("#dept-body")); return; } @@ -2599,7 +2598,7 @@ const note = $("#ops-demo-note"); if (note) note.innerHTML = ""; if (!Live.opsRuntime) { - $("#ops-stats").innerHTML = loadErrorHtml(T("ops.loadFail"), null, T("err.loadFail")); + setLiveError($("#ops-stats"), loadErrorHtml(T("ops.loadFail"), T("err.loadFail")), () => loadOps()); $("#ops-hours").innerHTML = ""; $("#ops-keys").innerHTML = ""; return; @@ -2651,7 +2650,7 @@ // tab === "users":成员充值(零 mock,rant 15:54:06) if (!Live.opsUsers) { - $("#ops-body").innerHTML = loadErrorRow(4, T("ops.loadFail"), T("err.loadFail")); + setLiveError($("#ops-body"), loadErrorRow(4, T("ops.loadFail"), T("err.loadFail")), () => loadOps()); pulseTbody($("#ops-body")); return; } @@ -3017,24 +3016,34 @@ } // 通用降级渲染:加载失败 → 空态 + 重试按钮(不白屏) - function loadErrorHtml(emptyLabel, retryFn, retryLabel) { + function loadErrorHtml(emptyLabel, retryLabel) { return '
' + EMPTY_ICON + "

" + esc(emptyLabel) + "

" + '

' + esc(retryLabel || T("err.loadFail")) + '

' + '
"; } - // 重试按钮委托(容器级) - function bindLiveRetry(containerId, fn) { - const c = document.getElementById(containerId); - if (!c) return; - c.addEventListener("click", (e) => { - if (e.target.closest("[data-live-retry]")) fn(); - }); + // 「加载失败 → 重试」的容器级委托:渲染方调用 setLiveError(容器, html, loader) 时把 loader 一并交出来, + // 容器上的**一次性** click 委托(重建不重绑)再把 `[data-live-retry]` 的点击分发给它。 + // 谁渲染降级态谁就负责传 loader ⇒ 新增表格不必在任何名册里登记(漏登记 = 重试按钮点了没反应)。 + const liveLoaders = new WeakMap(); // 容器 → 该容器的重试回调 + const liveRetryBound = new WeakSet(); // 已挂上委托的容器(只挂一次) + function setLiveError(container, html, loader) { + if (!container) return; + liveLoaders.set(container, loader); + if (!liveRetryBound.has(container)) { + liveRetryBound.add(container); + container.addEventListener("click", (e) => { + if (!e.target.closest || !e.target.closest("[data-live-retry]")) return; + const fn = liveLoaders.get(container); + if (fn) fn(); + }); + } + container.innerHTML = html; } // tbody 容器专用降级行: 内嵌 loadErrorHtml(div 直接进 tbody 会被浏览器提升到表外,破坏布局与重试委托) function loadErrorRow(colspan, emptyLabel, retryLabel) { - return '' + loadErrorHtml(emptyLabel, null, retryLabel) + ""; + return '' + loadErrorHtml(emptyLabel, retryLabel) + ""; } // 后端 models → 视图行(点数按 points_per_unit=1、锚定 CNY 折算;USD 价 ×7.2;ctx 来自 models.context_window; @@ -3643,15 +3652,15 @@ }); } - // 表格键盘导航(rant 20:46:57 F):点击行 → 激活高亮,之后 ↑/↓/Enter/Esc 可用 - KBD_TABLE_IDS.forEach((id) => { - const c = document.getElementById(id); + // 表格键盘导航(rant 20:46:57 F):点击行 → 激活高亮,之后 ↑/↓/Enter/Esc 可用。 + // document 级一次性委托 + 容器从 DOM 派生 ⇒ 所有数据表自动可导航(新增表格不必登记, + // 动态重建的表格也不会丢绑定)。 + document.addEventListener("click", (e) => { + const c = kbdTbodyOf(e.target); if (!c) return; - c.addEventListener("click", (e) => { - const tr = e.target.closest ? e.target.closest("tr") : null; - if (!tr || (tr.classList && tr.classList.contains("mk-detail"))) return; - kbdSet(c, kbdRows(c).indexOf(tr)); - }); + const tr = e.target.closest ? e.target.closest("tr") : null; + if (!tr || (tr.classList && tr.classList.contains("mk-detail"))) return; + kbdSet(c, kbdRows(c).indexOf(tr)); }); // API Key 生成(行内编辑;列表展示脱敏、复制给完整 id) @@ -3785,20 +3794,8 @@ }); renderNav(); bindEvents(); - // 登录态加载失败的空态重试按钮(rant 2026-08-19T15:48:17 / 15:54:06:各视图 loadError* 的 data-live-retry 委托) - bindLiveRetry("dash-sharings", () => loadDashboard()); - bindLiveRetry("share-body", () => loadSharing()); - bindLiveRetry("mk-body", () => loadMarketplace()); - bindLiveRetry("tx-table", () => loadTransactions()); - bindLiveRetry("api-keys", () => loadApiKeys()); - bindLiveRetry("emp-body", () => loadAdmin()); - bindLiveRetry("dept-body", () => loadAdmin()); - bindLiveRetry("usage-model", () => loadAdmin()); - bindLiveRetry("usage-emp", () => loadAdmin()); - bindLiveRetry("usage-dept", () => loadAdmin()); - bindLiveRetry("raise-requests", () => loadAdmin()); - bindLiveRetry("ops-stats", () => loadOps()); - bindLiveRetry("ops-body", () => loadOps()); + // 登录态加载失败的空态重试按钮(rant 2026-08-19T15:48:17 / 15:54:06):由渲染方经 setLiveError + // 把 loader 交给容器,这里不再逐个 id 登记。 renderView("dashboard"); $("#side-balance").textContent = D.fmt(D.USER.balance);