From 9295682587d7de5bc683b2b9e8bf926efa05dea3 Mon Sep 17 00:00:00 2001 From: argszero Date: Fri, 25 Sep 2026 01:45:39 +0800 Subject: [PATCH] fix(ui): let the keyboard row action follow the highlight, not the index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyboard navigation kept the "current row" as an index (`kbd.i`) into the ``'s row list, and `kbdEnter()` clicked whatever row that index landed on — including a row the user had never highlighted, and one that belongs to a view they had already left. Two faces: 1. `switchView()` changed views without clearing the armed row, so a stale `kbd.i` survived the switch and Enter still acted on the old table's row. 2. `kbdEnter()` trusted the index alone: after any repaint replaced the tbody's `innerHTML`, the index pointed at a different row than the one carrying the highlight. Make the highlight itself the identity: `kbdEnter()` acts only on a row that carries `.row-active` itself (a repaint drops the old element and with it the highlight, so any repaint invalidates the arming), and `switchView()` calls `kbdClear()` after the role/guest guard accepts the destination (a rejected switch must not clear). Gate `state_gate::the_keyboard_row_action_follows_the_highlight_not_the_index` verifies the two halves independently; `ui/README.md` states the contract and the gate's scope. Cache-bust bumped for `ui/js/app.js`. --- src/state_gate.rs | 509 ++++++++++++++++++++++++++++++++++++++++++++++ ui/README.md | 1 + ui/index.html | 2 +- ui/js/app.js | 2 + 4 files changed, 513 insertions(+), 1 deletion(-) diff --git a/src/state_gate.rs b/src/state_gate.rs index 993d4af..46d796a 100644 --- a/src/state_gate.rs +++ b/src/state_gate.rs @@ -15272,3 +15272,512 @@ fn r171_variant_drop_slots(app: &str) -> String { ); out } + +// --------------------------------------------------------------------------------------- +// R173 · 键盘的「当前行」以**行自己的高亮**为身份,不以记住的**下标**为身份 +// (`kbdEnter` 的目标解析 + `switchView` 的清除调用) +// --------------------------------------------------------------------------------------- +// +// `ui/js/app.js` 把表格键盘导航放在**模块级**状态里:`let kbd = { c: null, i: -1 };` +// —— 「当前激活的 ``」+「高亮行在**它当前那些行**里的下标」。点任意数据表行武装它 +// (document 级委托),↑/↓ 移动它(`kbdMove`),Enter 触发该行的主操作(`kbdEnter` → +// `tr.querySelector("button.btn:not(.row-expand)").click()`),Esc 清除它。 +// +// 而这份武装**什么也不使它失效**: +// * 表格重绘(`innerHTML = …`)把旧元素连同 `.row-active` 一起换掉 —— 屏幕上**没有**任何 +// 高亮,而 `kbd.i` 仍指着那个位置,那个位置现在坐着**另一行**; +// * `switchView()` 把整个视图 `display:none`、渲染目的地,却从不碰 `kbd` —— 方向键与 +// Enter 继续作用于**不在屏幕上的**那张表。 +// +// `kbdRows` 自己带一条失效守卫 —— `if (!c || c.isConnected === false) return [];`(#216 加的, +// 注释写着「表格被整体重建 ⇒ 记住的容器已失效」)—— 所以「记住的容器失效后不得被作用」这条 +// **规则本来就是作者立的**;那条守卫只覆盖了三种失效形状里的「元素被换掉」一种 +// (`innerHTML` 替换保持 `` 连接;切换视图什么都不重建)。 +// +// 三个消费者全是**改状态**的动作,全由那一颗按钮到达:`#mk-body` 的首个 +// `button.btn:not(.row-expand)` 是 `data-use-model` → `POST /v1/chat/completions`(花钱); +// `#share-body` 是 `data-share-toggle` → `PATCH /api/sharings/`(改上架);`#raise-requests` +// 是 `data-raise-approve` → `POST /api/admin/raise-requests//approve`(发点数)。 +// +// 规则(**每条都从代码派生**,不写死函数名/标识符,#469): +// R1 目标解析:`kbdEnter` 点击的那一行必须**自己**通过 `.row-active` 检验,且在点击**之前** —— +// 目标行标识符从 `X.click()` → `const X = .querySelector(…)` 这条链**推导**, +// 不硬编码 `tr`;「裸 `rows[kbd.i]` 查表」就是原缺陷。 +// R2 边界清除:`switchView` 必须调用**键盘清除器**,且位置在它**每一个** `return` 守卫 +// **之后**(被拒绝的切换不得清除)。清除器从代码推导:唯一那个「把状态变量复位成空字面量、 +// 移除 `row-active`、且**不**添加 `row-active`」的函数。 +// R3 反向:唯一的高亮**装填者**(`add("row-active")` 的归属函数)必须仍在、必须与清除器不同、 +// 必须仍被调用 —— 否则「干脆什么都不武装」的树也能过 R1/R2(控制腿 `C1` 的静态对应物)。 +// +// 射程(如实):本门禁是**静态词法**的 —— 它证「Enter 的目标必须由高亮决定」+「切换视图会收起 +// 武装」这两件**形状**;**不证**屏幕上那一刻高亮是否真的消失了(`innerHTML` 替换会销毁 +// `.row-active` 是**浏览器事实**,只有 DOM 仪器能证)—— 那一半归 jsdom 探针 `r173_probe.js` +// 的 A1/A2/A3/A5/A4 腿。两台仪器各自能看见对方看不见的东西(C2148/R168 前例)。 + +// ── 扫描器 ────────────────────────────────────────────────────────────────────────────── + +/// 去掉 `//` 与 `/* */` 注释,**保留字符串内容** —— 本门禁要读 `.contains("row-active")` +/// 的那个**实参**(坑 #296/#309:注释里正会写下被检验的表达式)。 +/// +/// 与 `r171_bare` 的差别是这里不吞字符串体。`app.js` 的这几个函数里没有正则字面量, +/// 未做正则识别的代价因此在射程之外 —— 如实记录,不假装是词法分析器。 +fn r173_bare(src: &str) -> String { + let b: Vec = src.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < b.len() { + let c = b[i]; + if c == '/' && i + 1 < b.len() && b[i + 1] == '/' { + while i < b.len() && b[i] != '\n' { + i += 1; + } + continue; + } + if c == '/' && i + 1 < b.len() && b[i + 1] == '*' { + i += 2; + while i + 1 < b.len() && !(b[i] == '*' && b[i + 1] == '/') { + i += 1; + } + i += 2; + continue; + } + if c == '"' || c == '\'' || c == '`' { + out.push(c); + i += 1; + while i < b.len() { + if b[i] == '\\' { + out.push(b[i]); + i += 1; + if i < b.len() { + out.push(b[i]); + i += 1; + } + continue; + } + if b[i] == c { + break; + } + out.push(b[i]); + i += 1; + } + if i < b.len() { + out.push(b[i]); + i += 1; + } + continue; + } + out.push(c); + i += 1; + } + out +} + +/// `text` 里 `kw` 作为**标识符 token**出现的位置(前后不是标识符字符;`dump` 不该被绊到)。 +fn r173_keyword_positions(text: &str, kw: &str) -> Vec { + let b = text.as_bytes(); + let is_word = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$'; + let mut out = Vec::new(); + let mut from = 0usize; + while let Some(rel) = text[from..].find(kw) { + let at = from + rel; + let end = at + kw.len(); + let before_ok = at == 0 || !is_word(b[at - 1]); + let after_ok = end >= b.len() || !is_word(b[end]); + if before_ok && after_ok { + out.push(at); + } + from = at + 1; + if from >= text.len() { + break; + } + } + out +} + +/// 从 `.` 的位置(`dot`)向后走完整条属性链,返回**链底标识符**。 +/// +/// `tr.classList.contains(` 传进 `.contains(` 前那个 `.` ⇒ 返回 `tr`(不是 `classList`); +/// `btn.click()` 传进 `.click()` 前那个 `.` ⇒ 返回 `btn`。 +fn r173_base_before_dot(text: &str, dot: usize) -> Option { + let b = text.as_bytes(); + let is_word = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$'; + if dot >= b.len() || b[dot] != b'.' { + return None; + } + let mut i = dot; + let mut last: Option = None; + loop { + let mut k = i; + let mut name: Vec = Vec::new(); + while k > 0 && is_word(b[k - 1]) { + name.push(b[k - 1]); + k -= 1; + } + if name.is_empty() { + break; + } + name.reverse(); + last = Some(String::from_utf8(name).ok()?); + // 这个标识符是不是某个东西的属性(`.seg`)?是则继续向左走。 + let mut m = k; + while m > 0 && b[m - 1].is_ascii_whitespace() { + m -= 1; + } + if m > 0 && b[m - 1] == b'.' { + i = m - 1; + } else { + break; + } + } + last +} + +/// ` = ;` 的 `rhs`(`const X = …` / `let X = …`)。 +fn r173_binding_rhs(body: &str, name: &str) -> Option { + let eq = format!("{name} = "); + for line in body.lines() { + let t = line.trim(); + let t = t + .strip_prefix("const ") + .or_else(|| t.strip_prefix("let ")) + .unwrap_or(t); + if let Some(rest) = t.strip_prefix(&eq) { + return Some(rest.trim_end_matches(';').trim().to_string()); + } + } + None +} + +/// `kbdEnter` 点击的那一行的**标识符**:从 `X.click()` → `const X = .querySelector(…)` +/// 推导(不硬编码 `tr`)。 +fn r173_row_ident(body: &str) -> Option { + let click_dot = body.find(".click()")?; + let clicked = r173_base_before_dot(body, click_dot)?; + let rhs = r173_binding_rhs(body, &clicked)?; + let qs_dot = rhs.find(".querySelector(")?; + r173_base_before_dot(&rhs, qs_dot) +} + +/// `text` 里有没有 `method("row-active")` 形式的调用(`add` / `remove` 共用)。 +fn r173_active_arg(text: &str, method: &str) -> bool { + let needle = format!("{method}("); + let mut from = 0usize; + while let Some(rel) = text[from..].find(&needle) { + let at = from + rel + needle.len(); + let arg = text[at..].trim_start(); + if arg.starts_with("\"row-active\"") || arg.starts_with("'row-active'") { + return true; + } + from = at; + if from >= text.len() { + break; + } + } + false +} + +/// 该函数体里是否**添加**行高亮(唯一装填点的判据)。 +fn r173_add_active(text: &str) -> bool { + r173_active_arg(text, "add") +} + +/// 该函数体里是否**移除**行高亮。 +fn r173_remove_active(text: &str) -> bool { + r173_active_arg(text, "remove") +} + +/// 逐行归属:命中行的**归属函数**(其之前最近声明的 `function NAME(`;顶层为 ``)。 +fn r173_owner_sites(bare: &str, hit: impl Fn(&str) -> bool) -> Vec { + let mut out = Vec::new(); + let mut owner = String::from(""); + for line in bare.lines() { + if let Some(n) = function_name(line) { + owner = n.to_string(); + } + if hit(line) { + out.push(owner.clone()); + } + } + out +} + +/// 模块级的键盘状态变量名 —— 从它自己的**空字面量**推导(`let kbd = { c: null, i: -1 };`)。 +fn r173_state_var(bare: &str) -> Option { + let at = bare.find("= { c: null, i: -1 }")?; + let b = bare.as_bytes(); + let is_word = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$'; + let mut k = at; + while k > 0 && b[k - 1].is_ascii_whitespace() { + k -= 1; + } + let end = k; + while k > 0 && is_word(b[k - 1]) { + k -= 1; + } + if k == end { + return None; + } + String::from_utf8(b[k..end].to_vec()).ok() +} + +/// 键盘**清除器**:唯一那个「把状态变量复位成空字面量 + 移除 `row-active` + **不**添加 +/// `row-active`」的函数(`kbdSet` 也复位、也移除,但它添加 ⇒ 被排除)。 +fn r173_clearer(bare: &str, state_var: Option<&str>) -> Option { + let var = state_var?; + let reset = format!("{var} = {{ c: null, i: -1 }}"); + for line in bare.lines() { + let Some(name) = function_name(line) else { + continue; + }; + let Some(body) = js_function_body(bare, name) else { + continue; + }; + if body.contains(&reset) && r173_remove_active(body) && !r173_add_active(body) { + return Some(name.to_string()); + } + } + None +} + +/// `kbdEnter` 是否在点击**之前**用行自己的 `.row-active` 检验了目标行。 +fn r173_enter_guards_highlight(body: &str, row: &str, click_at: usize) -> bool { + let mut from = 0usize; + while let Some(rel) = body[from..].find(".contains(") { + let dot = from + rel; + let arg_at = dot + ".contains(".len(); + let arg = body[arg_at..].trim_start(); + let is_active = arg.starts_with("\"row-active\"") || arg.starts_with("'row-active'"); + if is_active && dot < click_at && r173_base_before_dot(body, dot).as_deref() == Some(row) { + return true; + } + from = dot + 1; + if from >= body.len() { + break; + } + } + false +} + +/// `switchView` 里对清除器的调用数,以及「清除是否在**每一个** `return` 守卫之后」。 +fn r173_clear_after_guards(sv: &str, clearer: &str) -> (usize, bool) { + let calls = r173_keyword_positions(sv, clearer); + let returns = r173_keyword_positions(sv, "return"); + let after_guards = calls.len() == 1 && returns.iter().all(|&r| r < calls[0]); + (calls.len(), after_guards) +} + +// ── 判词 ──────────────────────────────────────────────────────────────────────────────── + +/// 一次读取的全部证据与三条判词。 +struct R173Reading { + state_var: Option, + armer: Option, + clearer: Option, + row_ident: Option, + arm_sites: usize, + clear_calls: usize, + r1: bool, + r2: bool, + r3: bool, +} + +impl R173Reading { + fn verdicts(&self) -> (bool, bool, bool) { + (self.r1, self.r2, self.r3) + } + + fn report(&self) -> String { + format!( + "r1={} r2={} r3={} | armer={:?} clearer={:?} row_ident={:?} arm_sites={} clear_calls={} state_var={:?}", + self.r1, + self.r2, + self.r3, + self.armer, + self.clearer, + self.row_ident, + self.arm_sites, + self.clear_calls, + self.state_var + ) + } +} + +/// 读 `ui/js/app.js`,导出三条判词。所有期望值都从被测算的代码**派生**。 +fn r173_read(app: &str) -> R173Reading { + let bare = r173_bare(app); + let state_var = r173_state_var(&bare); + + let sites = r173_owner_sites(&bare, r173_add_active); + let arm_sites = sites.len(); + let armer = match (arm_sites, sites.first()) { + (1, Some(owner)) if owner != "" => Some(owner.clone()), + _ => None, + }; + let clearer = r173_clearer(&bare, state_var.as_deref()); + + let enter_body = function_source(&bare, "kbdEnter").unwrap_or_default(); + let row_ident = r173_row_ident(&enter_body); + let r1 = match (&row_ident, enter_body.find(".click()")) { + (Some(row), Some(click_at)) => r173_enter_guards_highlight(&enter_body, row, click_at), + _ => false, + }; + + let (clear_calls, after_guards) = match (&clearer, function_source(&bare, "switchView")) { + (Some(c), Some(sv)) => r173_clear_after_guards(&sv, c), + _ => (0, false), + }; + let r2 = clear_calls == 1 && after_guards; + + let r3 = arm_sites == 1 + && armer.is_some() + && armer != clearer + && matches!(&armer, Some(a) if r173_keyword_positions(&bare, a).len() >= 2); + + R173Reading { + state_var, + armer, + clearer, + row_ident, + arm_sites, + clear_calls, + r1, + r2, + r3, + } +} + +// ── 测试 ──────────────────────────────────────────────────────────────────────────────── + +/// 轴:键盘的行操作跟随**高亮**,不跟随**记住的下标**。 +/// +/// 三条规则的含义见上方文件头。每条规则的**牙**由 [`the_r173_rules_have_teeth`] 用合成变异体 +/// 逐条测量(基线=已知为绿的活树)。 +#[test] +fn the_keyboard_row_action_follows_the_highlight_not_the_index() { + let rd = r173_read(APP_JS); + assert!( + rd.verdicts() == (true, true, true), + "R173 未修:键盘的行操作没有以行自己的高亮为身份\ + (R1 目标解析/R2 视图边界清除/R3 仍有唯一装填者):{}", + rd.report() + ); +} + +/// 提取器自证 + 反面对照:把两半都退回缺陷形状 ⇒ R1/R2 同时红、R3 仍绿(装填者没动)。 +#[test] +fn the_r173_roster_is_real() { + let live = r173_read(APP_JS); + assert!( + live.state_var.is_some(), + "键盘状态变量没被解析出来:{}", + live.report() + ); + assert!( + live.armer.is_some(), + "高亮装填者没被解析出来:{}", + live.report() + ); + assert!( + live.clearer.is_some(), + "键盘清除器没被解析出来:{}", + live.report() + ); + assert!( + live.row_ident.is_some(), + "Enter 的目标行标识符没被推导出来:{}", + live.report() + ); + assert_eq!(live.arm_sites, 1, "装填点不是唯一一个:{}", live.report()); + assert_eq!( + live.clear_calls, + 1, + "switchView 里对清除器的调用不是恰好一次:{}", + live.report() + ); + assert_ne!( + live.armer, + live.clearer, + "装填者与清除器成了同一个函数:{}", + live.report() + ); + + let broken = r173_variant_defect(APP_JS); + let rd = r173_read(&broken); + assert_eq!( + rd.verdicts(), + (false, false, true), + "退回缺陷形状没有同时打翻 R1/R2:{}", + rd.report() + ); +} + +/// 三条规则**各有独立的牙**:每个合成变异体只打翻它针对的那一条(基线=已知为绿的活树)。 +/// +/// ⚠️ 变异体一律从**活树**上构造,且每个锚点都断言唯一(#612 家族):变体若没生效, +/// 构造器里的断言先响,而不是让某条腿因错误的原因变绿(#605)。 +#[test] +fn the_r173_rules_have_teeth() { + let live = r173_read(APP_JS); + assert_eq!( + live.verdicts(), + (true, true, true), + "基线不是绿的 —— 变异体的读数无从解释:{}", + live.report() + ); + + // ① 原缺陷的同一半:Enter 退回**纯下标**查表 ⇒ 只翻 R1。 + let v = r173_variant_drop_highlight_test(APP_JS); + let rd = r173_read(&v); + assert_eq!( + rd.verdicts(), + (false, true, true), + "删掉高亮检验只应翻掉 R1:{}", + rd.report() + ); + + // ② 另一半:`switchView` 不再清除武装 ⇒ 只翻 R2。 + let v = r173_variant_drop_clear(APP_JS); + let rd = r173_read(&v); + assert_eq!( + rd.verdicts(), + (true, false, true), + "删掉 switchView 的清除只应翻掉 R2:{}", + rd.report() + ); + + // ③ 反向:删掉唯一的装填点 ⇒ 只翻 R3(「干脆不武装」的树过不了)。 + let v = r173_variant_drop_armer(APP_JS); + let rd = r173_read(&v); + assert_eq!( + rd.verdicts(), + (true, true, false), + "删掉唯一装填点只应翻掉 R3:{}", + rd.report() + ); +} + +/// 变异体①:删掉 `kbdEnter` 里的高亮检验(原缺陷的那一半)。 +fn r173_variant_drop_highlight_test(app: &str) -> String { + let line = " if (!(tr.classList && tr.classList.contains(\"row-active\"))) return; // R173: only the highlighted row\n"; + assert_eq!(app.matches(line).count(), 1, "高亮检验锚点不唯一"); + app.replace(line, "") +} + +/// 变异体②:删掉 `switchView` 里对键盘清除器的调用。 +fn r173_variant_drop_clear(app: &str) -> String { + let line = " kbdClear(); // R173: the armed row belongs to the view being left\n"; + assert_eq!(app.matches(line).count(), 1, "清除调用锚点不唯一"); + app.replace(line, "") +} + +/// 变异体③:删掉唯一的高亮装填点(`kbdSet` 里的 `add("row-active")`)。 +fn r173_variant_drop_armer(app: &str) -> String { + let line = " rows[idx].classList.add(\"row-active\");\n"; + assert_eq!(app.matches(line).count(), 1, "装填点锚点不唯一"); + app.replace(line, "") +} + +/// 两半一起退回缺陷形状(基线对照用)。 +fn r173_variant_defect(app: &str) -> String { + r173_variant_drop_clear(&r173_variant_drop_highlight_test(app)) +} diff --git a/ui/README.md b/ui/README.md index 86dbae5..c1d55ce 100644 --- a/ui/README.md +++ b/ui/README.md @@ -306,6 +306,7 @@ ui/ - **作用表 = 从 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()`(无高亮时落到原逻辑:关帮助/行内表单/引导); +- **高亮即身份(R173)**:键盘的「当前行」以**行自己的高亮类** `.row-active` 为身份,不以记住的**下标**为身份 —— `kbdEnter()` 只在目标行**自己**带 `.row-active` 时才执行其主操作(重绘换掉 `innerHTML` ⇒ 高亮随旧元素消失 ⇒ 任何重绘自动失效),`switchView()` 在被接受的角色/访客守卫**之后**调用 `kbdClear()`(被拒绝的切换不得清除)。门禁 `state_gate::the_keyboard_row_action_follows_the_highlight_not_the_index` 按此校验(射程:`kbdEnter` 的目标解析 + `switchView` 的清除调用 + `kbdSet` 仍是唯一装填者); - **守卫**:typing(INPUT/TEXTAREA/SELECT/contentEditable)与 meta/ctrl/alt 组合键不拦截;`?`、数字键视图切换、Esc 原有优先级(引导 > 帮助 > 行内新建 Key > 消费对话框 > 表格高亮)均不受影响; - **每个浮层守卫读该元素**自己**的隐藏机制**(R168,由各自的关闭函数推导):`#help-panel` / `#chat-modal` 用 **class**(`classList.contains("hidden")`),`#ak-new-inline` / `#dept-form-card` / `#model-form-card` / 充值·加额卡用 **属性**(`.hidden`) ⇒ 判「开没开」必须读对那一个,写反会让守卫恒真、把 Esc 变成无条件捕获。 门禁 `state_gate::the_escape_contract_reaches_every_dismissible_overlay` 按此**推导**校验。 - 冒烟测试注意:导航容器由真实 DOM 的 `closest("tbody")` 解析(不再比对 id)⇒ 旧 stub(`qs(sel)` 返回带 `#` 前缀的假 id)不影响它,但事件目标须是真实 DOM 节点;Esc 分支链依赖 `#ak-new-inline`、`#help-panel` hidden 预置 + `atp-tour-done=1`(防 tour 拦截)。 diff --git a/ui/index.html b/ui/index.html index 2b3d8f2..3647aa6 100644 --- a/ui/index.html +++ b/ui/index.html @@ -862,6 +862,6 @@

使用模型

- + diff --git a/ui/js/app.js b/ui/js/app.js index 7a1d8f3..185298a 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -612,6 +612,7 @@ // 挂这里而不是 renderView()/renderTxTrend() —— 那两个还会被 atp:langchange 调用, // 切一次语言就会静默重置用户的档位(#660)。 if (id === "transactions") txTrendMetric = "net"; + kbdClear(); // R173: the armed row belongs to the view being left activeView = id; $$(".view").forEach((v) => v.classList.add("hidden")); $("#view-" + id).classList.remove("hidden"); @@ -2022,6 +2023,7 @@ if (!rows.length || kbd.i < 0) return; const tr = rows[kbd.i]; if (!tr) return; + if (!(tr.classList && tr.classList.contains("row-active"))) return; // R173: only the highlighted row // 主操作 = 行内第一个可用的操作按钮(排除行展开 +/-,含 .btn 但非 row-expand) const btn = tr.querySelector ? tr.querySelector("button.btn:not(.row-expand)") : null; if (btn && !btn.disabled) btn.click();