执行窗口截止: {{ s.row.progress.deadline }}
@@ -1649,7 +1649,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
@@ -1663,7 +1663,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
-
待确认队列 (自主档位 {{ ov.autonomy }})
+
待确认队列(现在是{{ tx('autonomy', ov.autonomy) }})
propose_only 档位下, 增持类自主动作全部在此等待裁决; 减持方向不设确认门槛。
采纳后先落指令表 (先记账后动作), 由择时执行器分日出手。
@@ -1674,15 +1674,19 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
-
-
-
- {{ JSON.stringify(s.row.hard_numbers) }}
+
+ {{ tx('action', s.row.action) }}
+
+
+
+ {{ hardCn(s.row.hard_numbers) }}
+
+
+ {{ tx('verdict', s.row.judge_verdict) }}
-
- 「{{ s.row.hard_numbers.verdict }}」 {{ propReasons(s.row).join(';') }}
+ {{ propVerdictLine(s.row) }}
—
@@ -1858,7 +1862,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
按当前参数筛选: 排序池 {{ planCand.considered }} → 合格 {{ planCand.eligible }} →
取 {{ (planCand.items||[]).length }} 只 ·
白名单档位取全 {{ planCand.tier_complete===null ? '不适用' : (planCand.tier_complete ? '是':'否') }} ·
- 丢弃 {{ JSON.stringify(planCand.dropped||{}) }}
+ 落选 {{ dropCn(planCand.dropped) }}
·
{{ planCand.st_unknown.length }} 只无名称, ST 判不了已放行
{{ (planCand.items||[]).map(x=>x.ts_code).join(' ') }}
@@ -2225,7 +2229,138 @@ createApp({
const m = T[g];
if (m && Object.prototype.hasOwnProperty.call(m, c)) return m[c];
if (c == null || c === '') return '—';
- return String(c); // 未登记的码:显示原样, 不臆造
+ // 未登记的码:不臆造它的意思, 但也不能让一个英文词单独立在页面上当句子。
+ // 退到括号里, 前面加一句实话 —— 读的人至少知道「这是系统没翻译, 不是我该懂的词」。
+ return '其他(' + String(c) + ')';
+ };
+
+ // 命令的参数原先整个字典打出来。人要读的就是那几个数, 键名换成中文, 空值不显示。
+ const PARAM_CN = {
+ target_pct: '目标仓位', pct: '比例', qty: '数量', price: '价格',
+ ts_code: '股票', cmd_type: '命令', reason: '理由', note: '备注',
+ side: '方向', amount: '金额', level: '档位', status: '状态', type: '类型',
+ };
+ const paramsCn = (d) => {
+ const o = d || {};
+ const bits = [];
+ for (const [k, v] of Object.entries(o)) {
+ if (v === null || v === undefined || v === '') continue;
+ if (typeof v === 'object') continue; // 嵌套的不展开, 留给悬浮提示
+ let val = v;
+ if (k === 'side') val = tx('side', v);
+ if (k === 'status') val = tx('stratStatus', v);
+ if (k === 'type') val = tx('stype', v);
+ bits.push((PARAM_CN[k] || k) + ' ' + val);
+ }
+ return bits.join(',');
+ };
+
+ // 账本里那一列原先叫「硬数字」, 把整个字典打出来给人看。它其实只有几项是人要读的:
+ // 当时的判决依据、价格、以及决策系统的置信度。完整原文退到悬浮提示里,
+ // 运维要查随时能看到, 交易员不用在一堆键值对里找。
+ const hardCn = (h) => {
+ const o = h || {};
+ const bits = [];
+ if (o.basis) bits.push(String(o.basis));
+ if (o.price != null) bits.push('当时价格 ' + o.price);
+ if (o.judge_conf != null) bits.push('决策系统把握 ' + o.judge_conf);
+ if (o.plan_rank != null) bits.push('候选榜第 ' + o.plan_rank + ' 名');
+ if (!bits.length) {
+ const n = Object.keys(o).length;
+ return n ? ('这一条记了 ' + n + ' 项,鼠标停上去看原文') : '—';
+ }
+ return bits.join(';');
+ };
+
+ // 候选池落选原因的中文说法。与后端 app/services/proposal_service.py 的 _DROP_CN
+ // 同源, 顺序也一样 —— 那是筛选实际发生的顺序, 照着念下来正好是一只票走过的路。
+ // 改一处必须两处同改。
+ const DROP_CN = [
+ ['held', '已经持有'], ['black', '在黑名单里'], ['display', '选股系统判定今天不买'],
+ ['st', '是 ST 类'], ['tier', '档位不够'], ['score', '分数不够'],
+ ['sources', '传导源数不够'], ['upside', '券商预期空间不够'],
+ ['theme', '同一主题已经选够'], ['dup', '和已选的重复'], ['capped', '超出今天的名额'],
+ ];
+ // 只念不为零的那几项。原先把整个计数字典打出来, 键全是英文、值为零的也全列出来,
+ // 读的人要在十一个数字里自己找哪个不是零。
+ const dropCn = (d) => {
+ const o = d || {};
+ const parts = DROP_CN.filter(([k]) => Number(o[k]) > 0)
+ .map(([k, cn]) => cn + ' ' + o[k] + ' 只');
+ return parts.length ? parts.join(',') : '没有落选的';
+ };
+
+ // 选股系统的判决怎么说给人听。三个判决词(候选/关注/仅展示)是跨系统的**数据**,
+ // 被六个文件和十几条断言按字面比对, 所以只改显示、绝不改值。
+ // 原先页面上写「判为「仅展示」,只差覆盖」—— 判决词本身不告诉人能不能买,
+ // 所以句子主干换成原因, 判决词不再出现。
+ const propVerdictLine = (row) => {
+ const h = (row && row.hard_numbers) || {};
+ const v = String(h.verdict || '').trim();
+ if (!v) return '';
+ const basis = String(h.basis || '').trim();
+ const missing = (h.missing || []).filter(Boolean);
+ if (v === '候选') return basis || '选股系统认为今天可以买';
+ if (v === '关注') {
+ return missing.length
+ ? ('它自己也拿不准,还缺:' + missing.join(';') + ',要你来定')
+ : '它自己也拿不准,要你来定';
+ }
+ if (v === '仅展示') return basis || '选股系统今天不建议买入';
+ return '选股系统给的结论是「' + v + '」,本系统没见过这个说法,按「要你来定」处理';
+ };
+
+ // 操作日志里那一列, 后端落库的是函数名式的操作码, 例如 issue_command:EXIT_STOCK、
+ // set_strategy_status:PAUSED、decide_proposal。原先直接印出来, 页面上就是一串英文。
+ // 冒号前是做了什么、冒号后是对象, 后半段复用页面已有的翻译组。
+ // 这些码同时是账本里的字面值, **只改显示不改值**。
+ const OP_CN = {
+ decide_proposal: '处理提议', cancel_command: '撤销这条命令',
+ cancel_instruction: '撤销一笔还没执行的下单', replan_command: '重新算这条命令的执行方案',
+ resume_strategy_buy: '恢复这个方案的买入',
+ macro_scan: '重算大盘信号(只算,不下单)', macro_adopt: '采纳今天的大盘建议',
+ strategy_attach_scan: '重算该给哪些持仓挂交易方案(只算,不挂)',
+ archive_command: '从列表移除这条命令', unarchive_command: '重新显示这条命令',
+ archive_instruction: '从列表移除这笔下单', unarchive_instruction: '重新显示这笔下单',
+ archive_proposal: '从列表移除这条提议', unarchive_proposal: '重新显示这条提议',
+ archive_strategy: '从列表移除这个交易方案', unarchive_strategy: '重新显示这个交易方案',
+ };
+ const opCn = (o) => {
+ const raw = String((o && o.op) || '').trim();
+ if (!raw) return '其他操作';
+ const i = raw.indexOf(':');
+ if (i < 0) {
+ if (raw === 'decide_proposal') {
+ const d = ((o.params || {}).decision) || '';
+ if (d === 'ACCEPTED') return '采纳提议';
+ if (d === 'DECLINED') return '否决提议';
+ }
+ return OP_CN[raw] || ('其他操作(' + raw + ')');
+ }
+ const head = raw.slice(0, i), tail = raw.slice(i + 1);
+ if (head === 'issue_command') {
+ const spec = (catalog.value || []).find(c => c.cmd_type === tail);
+ return '下达命令 · ' + ((spec && spec.label) || tail);
+ }
+ if (head === 'attach_strategy') return '挂交易方案 · ' + tx('stype', tail);
+ if (head === 'set_strategy_status') {
+ const m = { PAUSED: '暂停这个交易方案', ACTIVE: '恢复这个交易方案',
+ CANCELLED: '撤下这个交易方案', DONE: '标记这个交易方案已完成' };
+ return m[tail] || ('改交易方案状态 · ' + tx('stratStatus', tail));
+ }
+ return (OP_CN[head] || head) + ' · ' + tail;
+ };
+
+ // 剥掉给运维看的大写判据码, 只留中文说明。与后端 app/core/copy.py 的 strip_code
+ // 同一口径, 改一处必须两处同改 —— 判据码留在句首是承重的(账本靠它去重、命令进度
+ // 靠它聚类), 所以只能在显示层剥。
+ const plainCheck = (t) => {
+ const s = String(t == null ? '' : t).trim();
+ const i = s.indexOf(':');
+ if (i <= 0) return s;
+ const head = s.slice(0, i).trim();
+ if (head && /^[A-Z0-9_]+$/.test(head)) return s.slice(i + 1).trim() || s;
+ return s;
};
const nameMap = ref({});
@@ -3276,7 +3411,7 @@ createApp({
for (const o of (opLog.value || [])) {
if (o.result === 'OK') continue; // 顺利做完的不占消息栏, 在「你做过的操作」里查
out.push({ kind:'strat', time:_hm(o.at), sort:_hms(o.at), ts_code:o.ts_code || '',
- cat:o.op + ' ' + (o.result === 'BLOCKED' ? '被合规检查挡下' : ('结果 ' + o.result)),
+ cat:opCn(o) + ' ' + (o.result === 'BLOCKED' ? '被合规检查挡下' : ('结果 ' + o.result)),
strong:true, why:o.reason || '', goLabel:'看记录', go:'ins' });
}
out.sort((a, b) => String(b.sort).localeCompare(String(a.sort)));
diff --git a/scripts/run_tests.py b/scripts/run_tests.py
index c715f3e..93b0c55 100644
--- a/scripts/run_tests.py
+++ b/scripts/run_tests.py
@@ -53,12 +53,15 @@
test_batch21_units.py 减持的自动执行边界 (2026-09-03 安全修复): 保垫减仓照旧自动/
强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱
来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流 (15 例)
+ test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许
+ 直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象
+ 打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 例)
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) +
目标价到价必定入队 (档位 full 也不自动卖) +
用户设的止损价与目标价单独成列显示 (70 例)
- 共 654 例
+ 共 655 例
(总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条
- 减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 现为 654)
+ 减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 现为 655)
任一子集失败即整体失败 (退出码 1)。
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):
@@ -80,6 +83,9 @@
死参数名单 scripts/test_batch10_units.py 的 KNOWN_DEAD ([P] 组) 与 param_store.py 顶部
那段清点 —— 「页面能改而全库没人读」的参数只能同时改这两处。接通了、摘掉了、
或者又添了一个摆设, 都要在这里过一遍手
+ 页面枚举名单 scripts/test_page_enum_guard.py 顶部 ENUM_FIELDS —— 后端新增会送到页面的
+ 枚举字段就登记一行, 并在 index.html 的 T 表里加对应的组。不登记等于不检查。
+ index.html 没有别的测试, 这道扫描是它唯一的护栏。
动作求值器 scripts/test_batch12_units.py 用例「回归·既有四类动作的判据与研判范围没被
动过」—— action_engine.EVALUATORS 的名单与次序钉在那里 (次序有意义: 减持排
在买入前面, 同轮买卖互斥才让得了路)
@@ -98,6 +104,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch16_units.py", "test_batch17_units.py",
"test_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py",
"test_batch21_units.py",
+ "test_page_enum_guard.py",
"test_wiring.py"]
diff --git a/scripts/test_page_enum_guard.py b/scripts/test_page_enum_guard.py
new file mode 100644
index 0000000..3720ad0
--- /dev/null
+++ b/scripts/test_page_enum_guard.py
@@ -0,0 +1,150 @@
+# -*- coding: utf-8 -*-
+"""页面文案守卫:枚举字段不许直接印到页面上(静态扫描,不连库、不起浏览器)。
+
+## 为什么要有它
+
+app/web/static/index.html 是三千行的单文件页面,**没有任何单测**。改错了不会有
+东西报警。而它上面最容易出的错,是把后端送来的枚举值直接塞进模板——
+页面上就出现 COMMAND_HALT、REJECT、issue_command:EXIT_STOCK 这种东西。
+
+页面里其实早就有翻译层(T 表加 tx 函数),问题是它被系统性地绕开了:
+2026-09-04 实测,同一份文件里 :899 走了 tx('frozen', …)、:1533 没走,印的是
+同一个字段。这不是没想到,是执行漏网。所以需要一道机器检查。
+
+## 它检查什么
+
+一、**枚举字段直出**:模板里形如 {{ x.frozen_reason }} 的插值,字段名落在
+ ENUM_FIELDS 里,而外面没有包 tx( 或别的翻译函数,就报。
+二、**判据码直出**:failed_checks、reason 这类会带 `CODE: ` 前缀的字段,
+ 必须过 plainCheck 才能显示。
+三、**整个对象打给人看**:JSON.stringify 出现在给交易员看的区域就报。
+ 运维面板那几处是有意的,登记在 JSON_OK 里放行。
+四、**翻译表兜底不许裸奔**:tx 找不到码时必须把码退到括号里,
+ 不能让一个英文词单独立在页面上当句子。
+
+## 怎么维护
+
+加了新的枚举字段就往 ENUM_FIELDS 里加一行。这份名单是这道检查的全部依据,
+不加就等于不检查——所以它和 T 表要一起改。
+
+跑法:python3 scripts/test_page_enum_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")
+
+# 后端会送到页面的枚举字段。取值全集在 T 表里,两者要一起改。
+# 只列**枚举**字段:后端已经写成中文句子的自由文本(basis、why、note 这类)不在此列,
+# 那些本来就该原样显示。
+ENUM_FIELDS = [
+ "frozen_reason", # 冻结来路:COMMAND_HALT / BRAKE / MANUAL / NONE
+ "cushion_state", # 安全垫:SOLID / THIN / NONE
+ "lot_kind", # 批次:BASE / FILL / ADD / DCA / T0 / RECON
+ "arbiter", # 谁裁的:rule / judge / user
+ "stype", # 交易方案类型:T0 / GRID / TRAIL
+ "autonomy", # 自主档:full / propose_only / off
+ "ref_source", # 参考位来源:bionic / self_calc / user / none
+]
+
+# 这些字段带 `CODE: ` 前缀,显示前必须剥掉。
+CODED_FIELDS = ["failed_checks"]
+
+# 把整个对象打给人看,只有落在**交易员会看的区域**才算问题。两类不算:
+# 一是存本地状态(sessionStorage / localStorage),那不是给人看的;
+# 二是运维面板里那几个"原样看接口返回"的框,读者是运维不是交易员,
+# 它们本来就该显示原始数据。
+# 判定靠变量名:写进 opsResult / planResult 这类运维面板的框,或者进浏览器存储,
+# 都放行。别的地方出现 JSON.stringify 就报。
+JSON_SKIP_MARKS = (
+ "sessionStorage", "localStorage", # 存本地状态,不是给人看的
+ "opsResult", "planResult", # 运维面板:有意显示接口原文
+ "console.", # 控制台
+ # 下面几处都在运维面板里,读者是运维不是交易员,本来就该看原始数据。
+ # 每加一条都要写清它在哪个面板、为什么放行。
+ "planStatus.theme_sync", # 上游计划诊断行:主题映射,排查用
+ "planStatus.query", # 上游计划诊断行:本次请求参数,排查用
+ "chgLog", # 运维面板「快照台账」:整行原文
+ "report.report", # 运维面板「快照台账」:日报原文
+ "wsc.queue", # 运维面板「ws 通道」:出口队列计数
+ "s.row.payload", # 运维面板「ws 通道」:报文原文
+ "s.row.hard_numbers", # 只在悬浮提示里作原文备查,正文已是中文
+ "s.row)", # 运维面板「快照台账」:整行原文
+)
+
+# 允许包住枚举字段的翻译函数。
+WRAPPERS = ("tx(", "opCn(", "plainCheck(", "propVerdictLine(")
+
+
+def fail(msgs):
+ for m in msgs:
+ print(" FAIL", m)
+
+
+def main():
+ src = open(PAGE, encoding="utf-8").read()
+ lines = src.split("\n")
+ bad = []
+
+ # 一、枚举字段直出
+ for i, line in enumerate(lines, 1):
+ for f in ENUM_FIELDS:
+ # 只看插值与属性绑定里出现的字段引用
+ for m in re.finditer(r"[\w.\[\]]*\.%s\b" % re.escape(f), line):
+ seg = line[max(0, m.start() - 60):m.end() + 20]
+ if any(w in seg for w in WRAPPERS):
+ continue
+ # v-if 这类判断用途不算显示
+ if re.search(r'v-(if|else-if|show)="[^"]*\.%s\b' % re.escape(f), line):
+ continue
+ # JS 里做比较也不算显示
+ if re.search(r"\.%s\s*(===|!==|==|!=|&&|\|\||\?)" % re.escape(f), seg):
+ continue
+ if "{{" not in line and ":title=" not in line and ":content=" not in line:
+ continue
+ bad.append(f"第 {i} 行:枚举字段 {f} 直接印到页面上,没走翻译。\n"
+ f" {line.strip()[:120]}")
+
+ # 二、判据码直出
+ for i, line in enumerate(lines, 1):
+ for f in CODED_FIELDS:
+ if f"{f}" in line and "{{" in line and "plainCheck" not in line:
+ bad.append(f"第 {i} 行:{f} 带大写判据码前缀,显示前必须过 plainCheck。\n"
+ f" {line.strip()[:120]}")
+
+ # 三、整个对象打给人看
+ for i, line in enumerate(lines, 1):
+ if "JSON.stringify" in line:
+ if not any(k in line for k in JSON_SKIP_MARKS):
+ bad.append(f"第 {i} 行:把整个对象打给交易员看。要么改成中文说明,"
+ f"要么说明它落在运维面板并登记进 JSON_SKIP_MARKS。\n"
+ f" {line.strip()[:120]}")
+
+ # 四、兜底不许裸奔
+ m = re.search(r"const tx = \(g, c\) => \{(.*?)\n \};", src, re.S)
+ if not m:
+ bad.append("找不到 tx 函数,这道检查的前提没了")
+ elif "return String(c);" in m.group(1):
+ bad.append("tx 的兜底是原样返回,会让一个英文词单独立在页面上当句子。"
+ "改成把码退到括号里。")
+
+ # 五、翻译表与本文件的字段名单要对得上
+ for f in ENUM_FIELDS:
+ group = {"frozen_reason": "frozen", "cushion_state": "cushion",
+ "lot_kind": "lot", "arbiter": "arbiter", "stype": "stype",
+ "autonomy": "autonomy", "ref_source": "refSource"}.get(f)
+ if group and f" {group}: {{" not in src and f" {group}:{{" not in src:
+ bad.append(f"字段 {f} 在名单里,但 T 表里没有对应的 {group} 组")
+
+ if bad:
+ print(f"页面文案守卫:发现 {len(bad)} 处")
+ fail(bad)
+ sys.exit(1)
+ print(f"ALL OK — 页面文案守卫:{len(ENUM_FIELDS)} 个枚举字段、"
+ f"{len(CODED_FIELDS)} 个带判据码的字段、整对象直出、翻译兜底 全部通过")
+
+
+if __name__ == "__main__":
+ main()