# -*- coding: utf-8 -*- """页面接线守卫:模板里用到的东西,必须真的交给模板(静态扫描,不连库、不起浏览器)。 ## 为什么要有它 2026-09-10 上午,持仓管理系统的页面整页白屏,浏览器控制台只有一句 `propVerdictLine is not a function`。 根因是:这个函数在 setup() 里定义好了、模板里也在用,但**没有写进 setup() 末尾那份 return 清单**。Vue 的组合式写法里,只有 return 出去的东西模板才拿得到, 没 return 的在模板里就是 undefined,当成函数调用就抛异常,整棵组件树渲染失败。 它坏得很安静。那几行都挂着 v-if,条件是「这条提议带选股判决」。平时提议队列是空的、 条件不成立,函数一次都不会被调用,页面看上去完全正常。这个洞从 2026-09-04 那批 文案改造就埋下了,一直到 09-10 早上队列里出现八条新建仓提议,第一次真的调到它, 页面当场白屏。也就是说:**它从埋下到爆炸隔了六天,而这六天里页面每天都在用。** 同一批一共漏了十一个(propVerdictLine、propBasis、propLogic、propLogicMore、 propText、propHeads、hardCn、opCn、paramsCn、dropCn、plainCheck), 说明这不是一次手误,是这种错误没有任何东西拦得住。 index.html 是二十七万字节的单文件页面,没有任何单测,也没有构建期检查。 scripts/test_page_enum_guard.py 守的是「文案有没有翻译」,守不到接线。所以要有这一道。 ## 它检查什么 一、**漏 return**:模板里作为标识符引用、且在 setup() 里定义过、却不在 return 清单里的名字。这一条就是白屏那个 bug。 二、**清单里有、setup 里没有**:return 清单里写了一个 setup 里根本没定义的名字, JS 会直接抛 ReferenceError,整个 setup 挂掉,页面同样白屏,而且是打开就白。 改名或删函数时忘了改清单就会这样。 两条都只看静态文本,不执行任何 JS。 ## 它检查不到什么 模板里引用了一个**任何地方都没定义**的名字(既不在 setup 里也不在清单里), 这道扫描看不出来 —— 因为它无从判断那是笔误还是 Vue 的内置项、还是 v-for 的循环变量。 那类错误要靠真在浏览器里打开页面才看得见。 ## 怎么维护 不用维护名单,它是从文件本身推出来的。setup 里新加一个给模板用的函数或状态, 照常写进 return 清单就行,忘了写这道检查会喊。 跑法:python3 scripts/test_page_wiring_guard.py """ import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) PAGE = os.path.join(os.path.dirname(HERE), "app", "web", "static", "index.html") # Vue 模板里合法但不由 setup 提供的东西,出现在清单差集里也不算问题。 # (目前用不上,留着是因为将来引入 defineExpose 或全局属性时这里是唯一的落点。) ALLOW_MISSING = set() def _strip_line_comments(text): """剥掉 // 行注释。return 清单里现在有中文注释,注释里的英文词不能被当成导出名。""" out = [] for line in text.split("\n"): i = line.find("//") out.append(line[:i] if i >= 0 else line) return "\n".join(out) def _strip_strings(text): """剥掉字符串字面量。模板表达式里的 'trader'、"ACCEPTED" 这些不是标识符引用。""" text = re.sub(r"'[^'\n]*'", "''", text) text = re.sub(r'"[^"\n]*"', '""', text) return text def _idents(expr): """从一段表达式里取出被当作标识符引用的名字。 排除两类:紧跟在点号后面的(那是属性名,不是标识符),以及后面紧跟冒号的 (那是对象字面量的键)。不排除的话 p.verdict 里的 verdict 会被误当成引用。 """ expr = _strip_strings(expr) names = set() for m in re.finditer(r"(? 或 createApp({," "这道检查的前提没了,先修这里") sys.exit(1) tpl = src[m_app.start():i_setup] body = src[i_setup:] # setup() 里的顶层定义。缩进恰好四格的那一层才是 setup 的直接作用域, # 更深的缩进是别的函数内部的局部变量,模板本来就够不到。 defined = set() for m in re.finditer(r"^ (?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=", body, re.M): defined.add(m.group(1)) for m in re.finditer(r"^ function\s+([A-Za-z_$][\w$]*)", body, re.M): defined.add(m.group(1)) if not defined: print("在 setup() 里一个顶层定义都没找到,缩进约定或文件结构变了,先修这里") sys.exit(1) # setup() 末尾那份 return 清单。 m_ret = re.search(r"\n return \{(.*?)\};\n \}\n\}\)\.use", body, re.S) if not m_ret: print("找不到 setup() 末尾的 return 清单,这道检查的前提没了,先修这里") sys.exit(1) ret_src = _strip_line_comments(m_ret.group(1)) exported = set(re.findall(r"[A-Za-z_$][\w$]*", ret_src)) # 模板里的引用:插值 {{ }}、指令 v-xxx="…"、属性绑定 :x="…"、事件 @x="…"。 used = set() for chunk in re.findall(r"\{\{(.*?)\}\}", tpl, re.S): used |= _idents(chunk) for chunk in re.findall(r'(?:v-[\w:.\-]+|[:@][\w:.\-]+)="([^"]*)"', tpl): used |= _idents(chunk) bad = [] # 一、模板在用、setup 里有、却没 return for n in sorted((used & defined) - exported - ALLOW_MISSING): dl = next((i for i, l in enumerate(src.split("\n"), 1) if re.match(r"^ (?:const|let|var|function)\s+" + re.escape(n) + r"\b", l)), "?") ul = next((i for i, l in enumerate(tpl.split("\n"), 1) if re.search(r"\b" + re.escape(n) + r"\b", l)), "?") bad.append(f"{n} 在 setup() 第 {dl} 行定义、模板第 {ul} 行左右在用," f"但没写进 return 清单。模板拿到的是 undefined," f"当函数调用就整页白屏。") # 二、清单里写了、setup 里却没定义 for n in sorted(exported - defined): # 清单里的名字未必都是顶层 const/function,也可能来自解构(如 Vue 的 ref)。 # 只有在 setup 体内完全找不到这个词时才算真的没有。 if re.search(r"\b" + re.escape(n) + r"\b", body[:m_ret.start()]): continue bad.append(f"return 清单里有 {n},但 setup() 里找不到它。" f"JS 会抛 ReferenceError,setup 整个挂掉、页面打开就白。" f"多半是改名或删函数时忘了改清单。") if bad: print(f"页面接线守卫:发现 {len(bad)} 处") for b in bad: print(f" - {b}") sys.exit(1) print(f"ALL OK — 页面接线守卫:模板引用 {len(used & defined)} 个 setup 名字," f"return 清单 {len(exported)} 项,两侧对得上") if __name__ == "__main__": main()