diff --git a/src/state_gate.rs b/src/state_gate.rs index 6d31b16..f8d7471 100644 --- a/src/state_gate.rs +++ b/src/state_gate.rs @@ -46,7 +46,38 @@ //! 这两条与第一条不同:它们**不能用 DOM 探针钉方向**(改前/改后都是「屏幕上对不对」), //! 必须由静态断言钉住形状(C2128 坑 #287)。 -use std::collections::BTreeSet; +//! # C2135:**渲染谁就装载谁** —— 视图的 loader 必须装载它的 renderer 读到的每个槽 +//! +//! 前两条说的是「槽自身的纪律」。这一条说的是**槽与视图的对应关系**:`renderView` 的每个分支 +//! 都要 `render` 一个视图、并(登录时)`load` 它自己的数据 —— 但**「它自己的」不是由分支名 +//! 决定的,而是由渲染闭包读了哪些槽决定的**。一个视图会读**别的**视图的槽(共享槽), +//! 那时只拉自己那份数据就会让那一格永远空着。 +//! +//! C2135 实测的形状:`#month-changes`(钱包视图)与 `#dash-month-changes`(仪表盘)由**同一个** +//! `renderMonthChanges()` 绘制,两者都读 `Live.dashboard`;而该槽的写者只有仪表盘的 `loadDashboard`。 +//! 钱包分支只调 `loadWallet()`(它只刷 `Live.wallet`)⇒ **会话在钱包视图上建立时**(hash `#/wallet` +//! 后登录;以及在钱包页登出再登录)没有任何人装载那个槽:净变化印 `0` + 「本月暂无变动」, +//! 而同一份载荷在仪表盘上渲染正确,且**永不自愈**(钱包的 loader 不碰该槽)。 +//! ⚠️ 带 token **刷新**看不到它 —— boot 在 `DOMContentLoaded` 里**无条件** `renderView("dashboard")` +//! 顺手把槽装好了(这正是它长期潜伏的原因)。 +//! +//! 修法=**共享槽只能有一个写者**(C2131),装载它的事收进一个函数(`refreshDashboard()`), +//! 由**每个渲染它的视图**各调一次。本门禁钉的就是这条对应关系: +//! +//! > 对每个槽 `S`、每个 `renderView` 分支 `B`:若 `B` 的**渲染闭包**(`render…` 的传递调用集) +//! > 里有人读 `Live.S`,则 `B` 的 **loader 闭包**(`load…` 的传递调用集 ∪ 会话级 `loadSession` +//! > 的闭包)里必须有人写 `Live.S`。 +//! +//! 为什么要把 `loadSession` 算进来:`models` / `publicUrl` 是**会话级**数据(`loadSession` 装载, +//! 所有视图共用),它们的「装载者」本来就不是某个视图的 loader。把会话级写者计入后, +//! 全仓**没有任何一处**需要豁免清单(豁免清单=会腐烂的花名册)。 +//! +//! 已知边界(与上一条同型,如实的射程):槽宇宙 = `Live` 字面量声明 ∪ 代码里出现过的 +//! `Live.<名>`。**`Live` 字面量本身漏登记的槽**(C2135 记账:`Live.dashboardTrend` 只被读写、 +//! 未在字面量里声明 ⇒ `resetSessionCaches` 的派生名册清不到它)本门禁**看不见** —— +//! 那属于「身份边界」那条不变量,已记账待单独处理,不在本条射程内。 + +use std::collections::{BTreeMap, BTreeSet}; /// 前端源码在**编译期**读入:测试不依赖工作目录与文件系统布局。 const APP_JS: &str = include_str!("../ui/js/app.js"); @@ -219,6 +250,205 @@ fn view_router_branches(body: &str) -> Vec { .collect() } +// ── C2135:传递闭包(渲染闭包读哪些槽 / loader 闭包写哪些槽)────────────────────────── + +/// 函数体里出现的调用名(`ident(` 形状)。用于算**传递闭包**:视图的渲染函数会调用别的渲染 +/// 函数(`renderDashboard → renderMonthChanges`),loader 亦然(`loadWallet → refreshDashboard`)。 +/// 注释行不参与(否则一段解释性的散文就能造出幻影调用点,坑 #296)。 +fn callee_names(body: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + for line in body.lines() { + let t = line.trim_start(); + if t.starts_with("//") || t.starts_with("/*") || t.starts_with('*') { + continue; + } + let bytes = line.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + let c = bytes[i]; + if c.is_ascii_alphabetic() || c == b'_' || c == b'$' { + let start = i; + while i < bytes.len() + && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' || bytes[i] == b'$') + { + i += 1; + } + if i < bytes.len() && bytes[i] == b'(' { + out.insert(line[start..i].to_string()); + } + } else { + i += 1; + } + } + } + out +} + +/// 全仓函数名 → 它的调用名集合(BFS 闭包用)。 +fn call_graph(src: &str) -> BTreeMap> { + let mut graph = BTreeMap::new(); + for line in src.lines() { + let Some(name) = function_name(line) else { + continue; + }; + if graph.contains_key(name) { + continue; + } + if let Some(body) = js_function_body(src, name) { + graph.insert(name.to_string(), callee_names(body)); + } + } + graph +} + +/// 从 `roots` 出发能到达的函数集合(含 `roots` 自身)。名字不在图里也保留 —— 外部/未解析的调用 +/// 不该让闭包缩水。 +fn reachable(graph: &BTreeMap>, roots: &[String]) -> BTreeSet { + let mut seen = BTreeSet::new(); + let mut queue: Vec = roots.to_vec(); + while let Some(f) = queue.pop() { + if !seen.insert(f.clone()) { + continue; + } + if let Some(callees) = graph.get(&f) { + for c in callees { + if !seen.contains(c) { + queue.push(c.clone()); + } + } + } + } + seen +} + +/// 行里是否出现 `Live.`(**标识符边界严格**:`Live.dashboardTrend` 不算提到 `dashboard`)。 +fn mentions_slot(line: &str, slot: &str) -> bool { + let needle = format!("Live.{slot}"); + let bytes = line.as_bytes(); + let mut from = 0usize; + while let Some(rel) = line[from..].find(&needle) { + let end = from + rel + needle.len(); + let boundary_ok = end >= bytes.len() + || !(bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_' || bytes[end] == b'$'); + if boundary_ok { + return true; + } + from += rel + 1; + if from >= line.len() { + break; + } + } + false +} + +/// 这一行是否**写到** `Live.`。比 [`writes_slot`] 严格:标识符边界必须闭合, +/// 所以 `Live.dashboardTrend = …` **不是**写 `dashboard`(`writes_slot` 会误判为是)。 +fn writes_slot_exact(line: &str, slot: &str) -> bool { + let needle = format!("Live.{slot}"); + let bytes = line.as_bytes(); + let mut from = 0usize; + while let Some(rel) = line[from..].find(&needle) { + let at = from + rel; + let end = at + needle.len(); + let boundary_ok = end >= bytes.len() + || !(bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_' || bytes[end] == b'$'); + if boundary_ok { + let trimmed = line[end..].trim_start(); + if trimmed.starts_with('=') && !trimmed.starts_with("==") { + return true; + } + } + from = at + 1; + if from >= line.len() { + break; + } + } + // 通用缓存写入:`liveLoad("", …)` + line.contains(&format!("liveLoad(\"{slot}\"")) +} + +/// 只看代码行(剔除 `//` 行、`/* … */` 块注释行与 `*` 续行)。C2135 的几个闭包判别式用它, +/// 理由与 [`code_only`] 相同,只是块注释也要挡住 —— 否则一段 `/* Live.d */` 就能造出幻影读点。 +fn code_lines(src: &str) -> String { + src.lines() + .filter(|l| { + let t = l.trim_start(); + !(t.starts_with("//") || t.starts_with("/*") || t.starts_with('*')) + }) + .collect::>() + .join("\n") +} + +/// 某个函数的**代码行**(剔除注释行)。 +fn function_code(src: &str, name: &str) -> String { + js_function_body(src, name) + .map(code_lines) + .unwrap_or_default() +} + +/// 闭包里是否有人**读** `Live.`。 +fn closure_reads( + src: &str, + graph: &BTreeMap>, + roots: &[String], + slot: &str, +) -> bool { + reachable(graph, roots).iter().any(|f| { + function_code(src, f) + .lines() + .any(|l| mentions_slot(l, slot)) + }) +} + +/// 闭包里是否有人**写** `Live.`。 +fn closure_writes( + src: &str, + graph: &BTreeMap>, + roots: &[String], + slot: &str, +) -> bool { + reachable(graph, roots).iter().any(|f| { + function_code(src, f) + .lines() + .any(|l| writes_slot_exact(l, slot)) + }) +} + +/// 一行里以 `prefix` 开头的调用名(`renderView` 分支里的 `render…` / `load…`)。 +fn callee_with_prefix(line: &str, prefix: &str) -> Option { + callee_names(line) + .into_iter() + .find(|c| c.starts_with(prefix)) +} + +/// 槽宇宙:`Live` 字面量声明的字段 ∪ 代码里出现过的 `Live.<名>`。 +/// +/// 只用字面量会让**漏登记**的槽静默逃逸(C2135 记账:`dashboardTrend` 只被读写、不在字面量里)。 +fn all_live_slots(src: &str) -> Vec { + let mut out: BTreeSet = live_slots(src).into_iter().collect(); + for line in code_lines(src).lines() { + let mut from = 0usize; + while let Some(rel) = line[from..].find("Live.") { + let start = from + rel + "Live.".len(); + let bytes = line.as_bytes(); + let mut end = start; + while end < bytes.len() + && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_' || bytes[end] == b'$') + { + end += 1; + } + if end > start { + out.insert(line[start..end].to_string()); + } + from = start; + if from >= line.len() { + break; + } + } + } + out.into_iter().collect() +} + #[cfg(test)] mod tests { use super::*; @@ -541,4 +771,194 @@ mod tests { "合成输入上 `live_slots` 的派生结果不对" ); } + + /// C2135:**渲染谁就装载谁** —— 每个视图分支的 loader 闭包必须装载该分支渲染闭包读到的每个槽。 + /// + /// 反例(实测):钱包视图渲染 `#month-changes`(读 `Live.dashboard`),而它的 loader 只刷 + /// `Live.wallet` ⇒ 会话在钱包视图上建立时那一格永远是空的(详见本文件头部)。 + /// + /// `loadSession` 的闭包算**所有**分支的写者:`models` / `publicUrl` 是会话级数据, + /// 由它装载、各视图共用。有了这一条,全仓**零豁免清单**。 + #[test] + fn every_view_branch_loads_each_slot_its_renderer_reads() { + let src = code_only(APP_JS); + let slots = all_live_slots(&src); + let graph = call_graph(&src); + let rv = js_function_body(&src, "renderView").expect("找不到 renderView()"); + let branches = view_router_branches(rv); + + // ── 前置:提取器必须真的看见东西(空集上的断言会假绿,坑 68)─────────────────── + assert!( + slots.len() >= 8 && graph.len() >= 50, + "槽宇宙/调用图太小(slots={} funcs={})—— 提取器坏了", + slots.len(), + graph.len() + ); + assert!( + branches.len() >= 5, + "只扫到 {} 个视图分支:{branches:?}", + branches.len() + ); + let session = reachable(&graph, &["loadSession".to_string()]); + assert!( + session.len() >= 2, + "`loadSession` 的闭包只算出 {} 个函数 —— 会话级写者认不出来", + session.len() + ); + + let mut checked = 0usize; + for b in &branches { + let renderer = callee_with_prefix(b, "render") + .unwrap_or_else(|| panic!("分支行里找不到 render… 调用:{b}")); + let loader = callee_with_prefix(b, "load") + .unwrap_or_else(|| panic!("分支行里找不到 load… 调用:{b}")); + let mut loader_roots = reachable(&graph, std::slice::from_ref(&loader)); + loader_roots.extend(session.iter().cloned()); + let loader_roots: Vec = loader_roots.into_iter().collect(); + + for slot in &slots { + if !closure_reads(&src, &graph, std::slice::from_ref(&renderer), slot) { + continue; + } + checked += 1; + assert!( + closure_writes(&src, &graph, &loader_roots, slot), + "`{renderer}()` 读了 `Live.{slot}`,但分支的 loader 闭包 \ + (`{loader}()` ∪ `loadSession()`)里没有任何人写它 ⇒ 会话在该视图上建立时\ + 这一格永远空着(C2135 的钱包缺陷形状)。共享槽的正解是「一个写者 \ + (如 `refreshDashboard()`)+ 每个渲染它的视图各调一次」。分支:{b}" + ); + } + } + // 前置:闭合检查的次数必须够多,否则判别式可能什么都没比 + assert!( + checked >= 10, + "只做了 {checked} 次「读了 ⇒ 被装载」检查 —— 判别式太弱" + ); + } + + /// 提取器/判别式自证:闭包、注释剥离、标识符边界,都要在**合成输入**上有牙齿。 + #[test] + fn the_slot_closure_scanners_have_teeth() { + // (a) 传递闭包必须跨函数:renderer 自己只调用,真正读槽的是它调用的那个函数 + let src = concat!( + " const Live = {\n shared: null,\n own: null,\n };\n", + " function renderA() {\n", + " paintA();\n", + " }\n\n", + " function paintA() {\n", + " if (Live.shared) body();\n", + " }\n\n", + " function loadA() {\n", + " Live.own = 1;\n", + " }\n\n", + " function refreshShared() {\n", + " Live.shared = api.get(\"/x\");\n", + " }\n\n", + " function loadB() {\n", + " refreshShared();\n", + " }\n" + ); + let graph = call_graph(src); + assert_eq!( + callee_with_prefix( + "if (id === \"a\") { renderA(); if (loggedIn()) loadA(); }", + "render" + ), + Some("renderA".to_string()), + "分支行里的 render… 调用没被取出" + ); + assert!( + closure_reads(src, &graph, &["renderA".to_string()], "shared"), + "闭包没跨函数:renderA → paintA 读到 Live.shared 应被认出" + ); + assert!( + !closure_reads(src, &graph, &["renderA".to_string()], "own"), + "阴性对照失败:renderA 的闭包不该「读」Live.own" + ); + // 竞争修法形状:loader 只写自己的槽 ⇒ 必须红 + assert!( + !closure_writes( + src, + &graph, + &reachable(&graph, &["loadA".to_string()]) + .into_iter() + .collect::>(), + "shared" + ), + "判别式没有牙齿:只写 `Live.own` 的 loader 竟被判成装载了 `Live.shared`" + ); + // 正解形状:loader 调的那个写者写了共享槽 ⇒ 绿 + assert!( + closure_writes( + src, + &graph, + &reachable(&graph, &["loadB".to_string()]) + .into_iter() + .collect::>(), + "shared" + ), + "闭包没跨函数:loadB → refreshShared 写 Live.shared 应被认出" + ); + + // (b) 标识符边界:`Live.dashboardTrend` 不等于 `Live.dashboard` + assert!( + mentions_slot( + " const tr = Live.dashboardTrend || null;", + "dashboardTrend" + ), + "阳性对照失败:Live.dashboardTrend 本身没被认出" + ); + assert!( + !mentions_slot(" const tr = Live.dashboardTrend || null;", "dashboard"), + "阴性对照失败:`Live.dashboardTrend` 被当成了 `Live.dashboard`" + ); + assert!( + !writes_slot_exact( + " Live.dashboardTrend = await api.get(\"/t\");", + "dashboard" + ), + "阴性对照失败:写 `Live.dashboardTrend` 被当成了写 `Live.dashboard`" + ); + assert!( + writes_slot_exact(" Live.dashboard = await api.get(\"/d\");", "dashboard") + && writes_slot_exact(" } catch (e) { Live.dashboard = null; }", "dashboard"), + "阳性对照失败:`Live.dashboard = …` 的两种形态(赋值 / catch 兜底)没被认出" + ); + assert!( + writes_slot_exact(" await liveLoad(\"models\", \"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/api/models\");", "models"), + "阳性对照失败:通用缓存写入 `liveLoad(\"models\", …)` 没被认出" + ); + + // (c) 注释不参与:解释性的散文里出现 `Live.dashboard` 不得造出「读」 + let commented = concat!( + " const Live = {\n d: null,\n };\n", + " function renderC() {\n", + " // 这里必须能提到 Live.d 而不触发门禁(本文件的题眼就是这种注释)\n", + " return 1;\n", + " }\n" + ); + let g2 = call_graph(commented); + assert!( + !closure_reads(commented, &g2, &["renderC".to_string()], "d"), + "阴性对照失败:`//` 注释里的 `Live.d` 被当成了读(坑 #296)" + ); + let block_commented = " function renderD() {\n /* Live.d */\n return 1;\n }\n"; + let g3 = call_graph(block_commented); + assert!( + !closure_reads(block_commented, &g3, &["renderD".to_string()], "d"), + "阴性对照失败:`/* Live.d */` 块注释被当成了读" + ); + + // (d) 槽宇宙包含「未在字面量里登记但被读写过」的槽 + let undeclared = concat!( + " const Live = {\n a: null,\n };\n", + " function f() {\n Live.hidden = 1;\n }\n" + ); + assert_eq!( + all_live_slots(undeclared), + vec!["a".to_string(), "hidden".to_string()], + "槽宇宙没纳入「代码里出现过但字面量漏登记」的槽" + ); + } } diff --git a/ui/README.md b/ui/README.md index 1abad6e..a1c1e59 100644 --- a/ui/README.md +++ b/ui/README.md @@ -433,6 +433,14 @@ ui/ - **CI 覆盖**:`src/state_gate.rs` 再加两条 —— `the_identity_boundaries_drop_every_session_cache`(清空必须含 `Object.keys(Live)`;体内**不得**出现逐个槽的赋值,否则就是第二份名册;`loadSession` 与 `exitGuest` 都必须调用它)与 `the_view_router_renders_and_loads_in_every_branch`(`renderView` 每个分支行都既含 `render` 又含 `load`)。两条都带**提取器自证**与**合成输入**(手抄名册 / 缺 loader 的分支必须变红)。 - **冒烟测试注意**:① 夹具必须让两个账号的钱包**可区分**,且 **`available ≠ balance`**(有当日赠送)—— 否则「拿到了自己的永久余额」与「回落到 available」不可区分;② 冻结点要**精确**:`loadSession` 自己的 `/api/wallet` 必须放行(否则会话建立就卡住),只扣住**仪表盘刷新**那一次(按序放行第 1 个、冻结第 2 个);③ 隔离瞬态脸时,乙的**落点**必须是仪表盘 —— 若乙落在钱包,钱包的新 loader 会在登录过程中就把共享的 `Live.wallet` 刷新掉,瞬态脸**看不见**(这正是「只加 loader 不清缓存」这条竞争修法能让探针全绿的原因);④ 断言按**读到的 DOM 文本**,不要读 `Live` 内部(探针可临时注入 `window.__Live = Live` 仅用于**诊断**)。 +## 渲染谁就装载谁:一个视图可能渲染**别的视图的槽**(C2135) + +- **分支名不等于数据来源**。`renderView` 的每个分支 `load` 的必须是**它的渲染闭包真正读到的那些槽**——而渲染闭包会读到别人的槽。C2135 实测:`#month-changes`(钱包视图)与 `#dash-month-changes`(仪表盘)由**同一个** `renderMonthChanges()` 绘制,两者都读 `Live.dashboard`,而该槽此前只有仪表盘的 `loadDashboard()` 会写 ⇒ 钱包分支只调 `loadWallet()`(它只刷 `Live.wallet`)时,**会话在钱包视图上建立**(hash `#/wallet` 后登录;在钱包页登出再登录)那一格就永远是空的。 +- **不变量**:对每个槽 `S`、每个 `renderView` 分支 `B` —— 若 `B` 的**渲染闭包**(`render…` 的传递调用集)里有人读 `Live.S`,则 `B` 的 **loader 闭包**(`load…` 的传递调用集 ∪ 会话级 `loadSession` 的闭包)里必须有人写 `Live.S`。`models` / `publicUrl` 是会话级数据(`loadSession` 装载、各视图共用)⇒ 把 `loadSession` 的闭包计入写者之后,**无需任何豁免清单**。 +- **修法=共享槽一个写者,装载事由每个渲染它的视图各做一次**:槽的写入收进 `refreshDashboard()`(`Live.dashboard` 的唯一写者,C2131 的纪律),`loadDashboard()` 与 `loadWallet()` 各 `await refreshDashboard();`。**不要让渲染函数自己去拉数据**(渲染保持纯同步;「先同步渲染缓存、再异步拉取」只允许发生在 `renderView` 的分支里)。 +- **修前的脸**(jsdom 启真 `index.html` + 四脚本、只 stub `fetch`、驱动**真登录表单**):`#/wallet` 页面上登录 ⇒ `#month-changes` 印「本月暂无变动」+ 净变化 `0`,而同一份 `/api/dashboard` 载荷在仪表盘上渲染正确(`-7.5` / 各类型行齐全),且**永不自愈**。**带 token 刷新看不到** —— boot 在 `DOMContentLoaded` 里**无条件** `renderView("dashboard")`,顺手就把槽装好了(这正是它长期潜伏的原因)。 +- **CI 覆盖**:`src/state_gate.rs::every_view_branch_loads_each_slot_its_renderer_reads`(传递闭包 + 会话级写者;改前树**恰好**红在钱包那一支)。判别式自带牙齿对照:`Live.dashboardTrend` 不得被当成 `Live.dashboard`(标识符边界)、`//` 与 `/* */` 注释里的 `Live.x` 不得造出幻影读点、只写自己槽的 loader 必须判红、经 `refreshShared()` 间接写入必须判绿。**射程**:槽宇宙 = `Live` 字面量 ∪ 代码里出现过的 `Live.<名>`;字面量本身**漏登记**的槽(记账:`Live.dashboardTrend` 未在字面量里声明 ⇒ `resetSessionCaches` 的派生名册清不到它)不在本条射程内。 + ## 后端自造的显示文案:分界线在「数据字段 / 文案字段」(C2133) diff --git a/ui/index.html b/ui/index.html index e7bee3d..b3d145b 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 99f75bc..3cf0689 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -719,9 +719,7 @@ async function loadDashboard() { if (!loggedIn()) return; try { await refreshWallet(); } catch (e) { /* 降级 */ } - try { - Live.dashboard = await api.get("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/api/dashboard"); - } catch (e) { Live.dashboard = null; } + await refreshDashboard(); // 近 14 天双色趋势(rant 2026-09-11T16:23:43 第 3 节):复用交易页趋势接口, // 按日聚合 income/expense,与列表同口径;失败 → null(renderDashTrend 显示空态,不 mock)。 // start 取「今天 UTC 零点 - 13 天」而非 now-13d:与后端 strftime('%Y-%m-%d', time)(UTC 日桶) @@ -3393,6 +3391,18 @@ }); } + // 本月点数变化(`#dash-month-changes` / `#month-changes`)那一格的**唯一**写者。 + // + // 这一格被**两个**视图渲染:仪表盘 `renderDashboard` 与钱包 `renderWallet` 都经 `renderMonthChanges` + // 读 `Live.dashboard` ⇒ 它是**共享**槽,写者只能有一个(C2131:一个槽两个写者会让「缓存」与 + // 「它的有效性证据」脱钩)。装载它的每个视图各调一次即可 —— 这正是 C2135 的修法: + // 此前 `renderWallet` 渲染这一格,而它的 loader 只刷 `Live.wallet`,于是会话若在钱包视图上 + // 建立(hash `#/wallet` 后登录 / 在钱包页登出再登录),这一格永远印「本月暂无变动」。 + async function refreshDashboard() { + try { Live.dashboard = await api.get("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/api/dashboard"); } + catch (e) { Live.dashboard = null; } + } + // 刷新钱包缓存(登录后);返回最新 available async function refreshWallet() { try { @@ -3404,9 +3414,11 @@ // 钱包视图自己的 loader(C2132):`renderView("wallet")` 此前只渲染不拉取,是八个视图里 // **唯一**没有 loader 的分支 —— 缓存一旦有值(哪怕是上一位用户的)就永远不会刷新。 - // 镜像 loadDashboard 的尾段:先拉真实钱包,再重渲染。 + // C2135:渲染谁就装载谁 —— 钱包页同样渲染「本月点数变化」,它的数据在 `Live.dashboard` 里, + // 只拉钱包的话那一格在「会话建立于钱包视图」时永远是空的(详见 refreshDashboard 的注释)。 async function loadWallet() { await refreshWallet(); + await refreshDashboard(); renderWallet(); }