>()
+ .join(" ")
+ .trim_matches(|c: char| c.is_whitespace() || "()()·,、::".contains(c))
+ .trim()
+ .to_string()
+ };
+ let (x, y) = (strip(&a), strip(&b));
+ if x.is_empty() || y.is_empty() {
+ return None;
+ }
+ Some((x, y))
+}
+
+/// 这一条文案是否落在**可花族**:含可花标记,且**不含**永久标记(C2173 规则 2)。
+fn caption_names_spendable_half(
+ caption: &str,
+ spendable_marker: &str,
+ permanent_marker: &str,
+) -> bool {
+ let c = caption.to_lowercase();
+ c.contains(spendable_marker) && !c.contains(permanent_marker)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -4865,4 +4959,226 @@ mod tests {
" const cell = (v) => {{ const s = String(v == null ? \"\" : v); return /{class}/.test(s) ? '\"' + s.replace(/\"/g, '\"\"') + '\"' : s; }};\n"
)
}
+ /// 运营成员表的「余额」列说的必须是它列头那个词所指的那一半 —— **可花额**(C2173)。
+ ///
+ /// 产品定义:`available = balance + gift_balance`(`src/routes/wallet.rs`),而全站把**不带限定**的
+ /// 「点数余额 / Points balance」(`wallet.balance` / `common.balance` / `dash.balance`)绑给
+ /// `available`、把「永久点数 / Permanent points」(`wallet.forever` / `admin.emp.col.perm`)绑给
+ /// `balance`。`/api/ops/users` 两个半区**都**返回,前端却只印了 `u.balance` ⇒ 同一张屏幕上,
+ /// 用户自己看到 101、运营者在成员表里看到 100,差的就是**可花、会过期**的赠送那一半。
+ ///
+ /// 三条规则各有各的牙:
+ /// - 规则 1(取值):单元格的取值必须**同时**读到两个半区 —— 只读到永久额=本缺陷;
+ /// 只读到赠送=把轴修反。
+ /// - 规则 2(词):两包的列头文案必须仍落在**可花族**。把列头改名成「永久点数」也能让两边对上,
+ /// 但那是**改这一列声明的口径**(设计变更),不是修 —— 列头用的就是全站给可花额起的那个名字。
+ /// - 规则 3(反向):管理页的「永久点数」列必须**仍然只**读永久额 —— 挡「一键全改成 available」
+ /// 那种过度纠正(同屏那份兄弟表把两半各自起对了名,正确范例就是从它那里来的)。
+ #[test]
+ fn the_ops_members_balance_cell_is_the_half_its_caption_names() {
+ let ops = function_source(APP_JS, "renderOps").expect("找不到 renderOps");
+ let ops_lines = code_text_by_line(&ops);
+
+ // ── 前置:提取器停对了地方,且填充位点唯一(空集上的断言会假绿,坑 68)──────────────
+ assert!(
+ ops_lines.iter().any(|l| l.contains("ops-body"))
+ && !ops_lines.iter().any(|l| l.contains("function loadOps(")),
+ "renderOps 提取错了地方(规则 1 会在空集上假绿):{}",
+ ops_lines.join("\n")
+ );
+ assert_eq!(
+ ops_lines.iter().filter(|l| l.contains("function ")).count(),
+ 1,
+ "renderOps 提取长了(吞进了下一个函数):{}",
+ ops_lines.join("\n")
+ );
+ let fills: Vec<&String> = ops_lines
+ .iter()
+ .filter(|l| l.contains(OPS_BALANCE_CAPTION))
+ .collect();
+ assert_eq!(
+ fills.len(),
+ 1,
+ "`{OPS_BALANCE_CAPTION}` 在 renderOps 里出现了 {} 次(单元格要么没了要么重复)",
+ fills.len()
+ );
+ let value = cell_value_rhs(fills[0], OPS_BALANCE_CAPTION)
+ .unwrap_or_else(|| panic!("取不到余额单元格的取值表达式:{}", fills[0]));
+ assert!(
+ value.contains("D.fmt"),
+ "取值表达式取错了地方(规则 1 可能判在别的文本上):`{value}`"
+ );
+
+ // ── 规则 1:取值同时读到两个半区 ────────────────────────────────────────────────
+ assert!(
+ value_reaches_all_fields(APP_JS, &ops, &value, &HALF_FIELDS),
+ "运营成员表的余额列没有同时读到 `balance` 与 `gift_balance` —— 列头(`{OPS_BALANCE_CAPTION}`)\
+ 用的是全站给**可花额**起的那个名字,而这一列只印了永久额,比用户自己在侧栏/钱包里看到的\
+ 余额少掉全部赠送额:取值 = `{value}`"
+ );
+
+ // ── 规则 2:两包的列头文案仍须落在可花族 ────────────────────────────────────────
+ let zh = pack_region_strict(I18N_JS, ZH_PACK_START, EN_PACK_START);
+ let en = pack_region_strict(I18N_JS, EN_PACK_START, PACK_END);
+ for (pack, region) in [("zh", zh), ("en", en)] {
+ let spendable = pack_string(region, SPENDABLE_WORD_KEY)
+ .unwrap_or_else(|| panic!("{pack} 包里没有键 `{SPENDABLE_WORD_KEY}`"));
+ let permanent = pack_string(region, PERMANENT_WORD_KEY)
+ .unwrap_or_else(|| panic!("{pack} 包里没有键 `{PERMANENT_WORD_KEY}`"));
+ let (s_mark, p_mark) = half_markers(&spendable, &permanent).unwrap_or_else(|| {
+ panic!(
+ "{pack} 包的两个半区名推不出各自的标记(尺子坏了):\"{spendable}\" / \"{permanent}\""
+ )
+ });
+ let caption = pack_string(region, OPS_BALANCE_CAPTION)
+ .unwrap_or_else(|| panic!("{pack} 包里没有键 `{OPS_BALANCE_CAPTION}`"));
+ assert!(
+ caption_names_spendable_half(&caption, &s_mark, &p_mark),
+ "{pack} 包的 `{OPS_BALANCE_CAPTION}`(\"{caption}\")不再落在**可花族**\
+ (标记:可花 `{s_mark}` / 永久 `{p_mark}`)—— 把列头改名成永久族也能让两边对上,\
+ 但这一列的头用的就是全站给可花额起的那个名字,改它就是改这一列声明的口径"
+ );
+ }
+
+ // ── 规则 3(反向):管理页的「永久点数」列仍只读永久额 ──────────────────────────
+ let admin = function_source(APP_JS, "renderAdmin").expect("找不到 renderAdmin");
+ let admin_lines = code_text_by_line(&admin);
+ let perm: Vec<&String> = admin_lines
+ .iter()
+ .filter(|l| l.contains(ADMIN_PERM_CAPTION))
+ .collect();
+ assert_eq!(
+ perm.len(),
+ 1,
+ "`{ADMIN_PERM_CAPTION}` 在 renderAdmin 里出现了 {} 次(反向对照的位点不唯一)",
+ perm.len()
+ );
+ let perm_value = cell_value_rhs(perm[0], ADMIN_PERM_CAPTION)
+ .unwrap_or_else(|| panic!("取不到「永久点数」列的取值表达式:{}", perm[0]));
+ assert!(
+ mentions_identifier(&perm_value, "balance"),
+ "管理页的「永久点数」列不再读永久额(反向对照失去意义):取值 = `{perm_value}`"
+ );
+ assert!(
+ !mentions_identifier(&perm_value, "gift_balance"),
+ "管理页的「永久点数」列读到了赠送额 —— 那一列的名字说它只有永久额;\
+ 同一张表本来就另有两列(赠送 / 可用)。这条挡的是「一键全改 available」式过度纠正:\
+ 取值 = `{perm_value}`"
+ );
+ }
+
+ /// 规则 1/2/3 的三把尺子各有各的牙(合成输入,坑 68 与 #348)。
+ #[test]
+ fn the_ops_balance_half_checkers_have_teeth() {
+ // 单元格取值提取器:真形状取得到、且**停在取值段**;没有该单元格 ⇒ None
+ let line = " '| ' + D.fmt(u.balance || 0) + \" \" + T(\"common.points\") + \" | \" +";
+ assert_eq!(
+ cell_value_rhs(line, OPS_BALANCE_CAPTION).as_deref(),
+ Some("D.fmt(u.balance || 0) + \" \" + T(\"common.points\")"),
+ "取值提取器取错了(规则 1 会判在别的文本上)"
+ );
+ let admin_line = " '' + D.fmt(u.balance || 0) + \" | \" +";
+ assert_eq!(
+ cell_value_rhs(admin_line, ADMIN_PERM_CAPTION).as_deref(),
+ Some("D.fmt(u.balance || 0)"),
+ "同一个提取器在反向对照那一列上取错了"
+ );
+ assert!(
+ cell_value_rhs(" \" | \"", OPS_BALANCE_CAPTION).is_none(),
+ "没有该单元格时提取器凭空取到了值(规则 1 会在空集上假绿)"
+ );
+ // 少了 `data-label` 的收尾标记(`'">'`)⇒ 取不到,而不是把后面的东西当取值
+ assert!(
+ cell_value_rhs(
+ " '\"",
+ OPS_BALANCE_CAPTION
+ )
+ .is_none(),
+ "少了收尾标记时提取器仍取到值(形状变了却静默取值)"
+ );
+
+ // 尺子:在**真语言包**上推出的标记
+ let zh = pack_region_strict(I18N_JS, ZH_PACK_START, EN_PACK_START);
+ let en = pack_region_strict(I18N_JS, EN_PACK_START, PACK_END);
+ let (zs, zp) = half_markers(
+ &pack_string(zh, SPENDABLE_WORD_KEY).expect("zh 包缺 wallet.balance"),
+ &pack_string(zh, PERMANENT_WORD_KEY).expect("zh 包缺 wallet.forever"),
+ )
+ .expect("zh 半区标记取不到");
+ assert_eq!(
+ (zs.as_str(), zp.as_str()),
+ ("余额", "永久"),
+ "zh 半区标记取错了"
+ );
+ let (es, ep) = half_markers(
+ &pack_string(en, SPENDABLE_WORD_KEY).expect("en 包缺 wallet.balance"),
+ &pack_string(en, PERMANENT_WORD_KEY).expect("en 包缺 wallet.forever"),
+ )
+ .expect("en 半区标记取不到");
+ assert_eq!(
+ (es.as_str(), ep.as_str()),
+ ("balance", "permanent"),
+ "en 半区标记取错了"
+ );
+ // 两个名字完全相同 ⇒ 取不到标记(**不得**静默退化成空标记:空标记让 contains 恒真、规则 2 变哑)
+ assert!(
+ half_markers("points", "points").is_none(),
+ "两个名字相同时尺子给出了空标记(规则 2 会静默变哑)"
+ );
+
+ // 分类判别式:可花族、永久族、谁都不指(化妆)三态都要判对
+ assert!(caption_names_spendable_half("余额(点数)", &zs, &zp));
+ assert!(caption_names_spendable_half("Balance (pts)", &es, &ep));
+ assert!(
+ !caption_names_spendable_half("永久点数(点数)", &zs, &zp),
+ "改名成永久族没有被拒(那一列口径被换了却判绿)"
+ );
+ assert!(
+ !caption_names_spendable_half("Permanent points (pts)", &es, &ep),
+ "改名成永久族没有被拒(en 侧)"
+ );
+ assert!(
+ !caption_names_spendable_half("点数", &zs, &zp),
+ "谁都不指的化妆文案没有被拒(它既不是可花族也不是永久族)"
+ );
+ assert!(
+ !caption_names_spendable_half("Points", &es, &ep),
+ "谁都不指的化妆文案没有被拒(en 侧)"
+ );
+
+ // 规则 1 的牙:只读永久额 ⇒ 判红;只读赠送 ⇒ 判红;两项都读(含抽 helper 的等价写法)⇒ 判绿
+ let bare =
+ " function renderOps() { const row = \" | \" + D.fmt(u.balance || 0) + \" | \"; }";
+ assert!(
+ !value_reaches_all_fields(bare, bare, "D.fmt(u.balance || 0)", &HALF_FIELDS),
+ "规则 1 放行了只读永久额的取值(本缺陷的形状)"
+ );
+ let gift_only =
+ " function renderOps() { const row = \"\" + D.fmt(u.gift_balance || 0) + \" | \"; }";
+ assert!(
+ !value_reaches_all_fields(
+ gift_only,
+ gift_only,
+ "D.fmt(u.gift_balance || 0)",
+ &HALF_FIELDS
+ ),
+ "规则 1 放行了只读赠送的取值(轴修反)"
+ );
+ let both = " function renderOps() { const row = \"\" + D.fmt((u.balance || 0) + (u.gift_balance || 0)) + \" | \"; }";
+ assert!(
+ value_reaches_all_fields(
+ both,
+ both,
+ "D.fmt((u.balance || 0) + (u.gift_balance || 0))",
+ &HALF_FIELDS
+ ),
+ "规则 1 把两项都读到的取值判红了"
+ );
+ // 说明性注释里提到 `gift_balance` 不算证据(#296 的镜像:被自己的解释满足)
+ let commented = " function renderOps() { const row = \"\" + D.fmt(u.balance || 0) + \" | \"; // 另一半是 u.gift_balance\n }";
+ assert!(
+ !value_reaches_all_fields(commented, commented, "D.fmt(u.balance || 0)", &HALF_FIELDS),
+ "规则 1 被注释里的 `gift_balance` 满足了(假绿)"
+ );
+ }
}
diff --git a/ui/README.md b/ui/README.md
index d3ade8f..51c996c 100644
--- a/ui/README.md
+++ b/ui/README.md
@@ -643,6 +643,14 @@ try { const w = await api.get("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/api/wallet"); if (w) D.USER.balance = w.balance;
「屏幕上数字对」—— 竞争修法在探针下全绿(`tmp/c2145_probe.js` 实测:修复树 9/9、改前树恰 `A2`/`A3`
两腿红、竞争者 8/9 被 `A3` 拒绝)。CI 里没有 JS 运行器,`cargo test` 是唯一能长期守住的关口。
+## 运营成员表的「余额」列 = 可花额 = 用户自己所见(C2173)
+
+- **一个词一个数**:全站把**不带限定的**「点数余额 / Points balance」绑给 `available = balance + gift_balance`(`common.balance` / `dash.balance` / `wallet.balance` 三处),把「**永久**点数 / Permanent points」绑给 `balance`(`wallet.forever` / `admin.emp.col.perm`)。运营成员表的列头用的正是**前者**(`ops.users.col.balance` zh「余额(点数)」/ en「Balance (pts)」),而取值只印 `u.balance` ⇒ 用户自己看 101、运营者看 100,差的正是「赠送」那一半(可花、会过期、真划走,`gift.rs` 清扫时真扣)。
+- **修法**:取值折进 `gift_balance`,**与兄弟表逐字同形** —— `admin.emp.col.avail` 那一格早就是 `D.fmt((u.balance || 0) + (u.gift_balance || 0))`(同一个 PR 家族里写对了的范本)。**零新 i18n 键**。
+- **CI 覆盖**:`src/state_gate.rs::the_ops_members_balance_cell_is_the_half_its_caption_names`(三规则:① 取值表达式按**标识符 token** 同时读到 `balance` 与 `gift_balance` ② 两包列头须落在**可花族**(标记由 `wallet.balance` 的包值派生)③ 反向:`admin.emp.col.perm` 仍只读 `balance`)。
+- ⚠️ **射程**:门禁是**词法**的 —— 它证明取值**读到**两个字段、名字落在可花族,**不证明算术是 `+`**;行为由仪器 `c2173_probe.js` 钉(19 腿;甲/乙两棵合法树全绿,三条「半个答案 / 化妆 / 自相矛盾」被拒)。
+
+
## 载荷签名覆盖所有输入,控件变更只有一个重拉触发器(C2146)
交易列表/趋势的请求体由**两份**状态渲染:列筛选(`txTable.filters`)与时间段
diff --git a/ui/index.html b/ui/index.html
index a7f2fd6..82b92ab 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -855,6 +855,6 @@ 使用模型
-
+