PMS 文案第三批:页面枚举直出,并加一道静态守卫

页面是三千行的单文件,没有任何测试,改错了不会有东西报警。所以这一批先写守卫,
再靠守卫把问题扫出来——它比人工照清单改可靠,实际也确实多抓出了四处清单里没有的。

改掉的直出点:
- 操作日志印的是函数名式的操作码(issue_command:EXIT_STOCK 这类)。冒号前是做了
  什么、冒号后是对象,后半段复用页面已有的翻译组,英文码退到悬浮提示。
- 持仓表的冻结原因没走翻译,而同一份文件里另一处走了,是执行漏网。
- 提议卡两处印判决词。判决词本身不告诉人能不能买,句子主干换成原因。这里顺带
  接上了 missing 字段——它一路从后端带到前端却从没被渲染过,"关注"态终于说得出
  到底缺什么。
- 规则闸未通过项与裁决理由带着大写判据码。前端加一份剥前缀的实现,与后端
  app/core/copy.py 同口径。
- 账本那一列原先叫「硬数字」,把整个字典打出来。改成只念人要读的几项,完整原文
  退到悬浮提示。同一行的动作与研判两列也是英文原值,一起走翻译。
- 候选池落选明细、命令参数、安全垫状态、自主档位,都是直接印原值。
- 翻译兜底原先是原样返回,会让一个英文词单独立在页面上当句子。改成退到括号里。

守卫检查四件事:枚举字段直出、判据码没剥前缀、把整个对象打给交易员看、翻译兜底
裸奔。运维面板里那些有意显示原始数据的地方逐条登记放行,每条写明在哪个面板、
为什么放行。

例数 654 到 655,测试入口的例数表与哨兵位置清单同改——新增会送到页面的枚举字段
要往守卫的名单里登记一行,不登记等于不检查。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-04 14:43:22 +08:00
parent 06fcc552bd
commit bcf5783b4c
3 changed files with 312 additions and 20 deletions

View File

@ -989,7 +989,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div v-for="(o,i) in opLog" :key="i" class="feed-row"> <div v-for="(o,i) in opLog" :key="i" class="feed-row">
<span class="muted mono">{{ (o.at||'').slice(11,16) }}</span> <span class="muted mono">{{ (o.at||'').slice(11,16) }}</span>
<el-tag size="small" :type="o.result==='OK'?'primary':(o.result==='BLOCKED'?'warning':'info')" style="margin:0 6px">{{ o.result==='OK'?'已执行':(o.result==='BLOCKED'?'被挡下':o.result) }}</el-tag> <el-tag size="small" :type="o.result==='OK'?'primary':(o.result==='BLOCKED'?'warning':'info')" style="margin:0 6px">{{ o.result==='OK'?'已执行':(o.result==='BLOCKED'?'被挡下':o.result) }}</el-tag>
<b>{{ o.op }}</b><span v-if="o.ts_code" class="muted"> · {{ nm(o.ts_code) }}</span> <b :title="o.op">{{ opCn(o) }}</b><span v-if="o.ts_code" class="muted"> · {{ nm(o.ts_code) }}</span>
<span v-if="o.reason" style="color:var(--warn)"> — {{ o.reason }}</span> <span v-if="o.reason" style="color:var(--warn)"> — {{ o.reason }}</span>
</div> </div>
</template> </template>
@ -1026,7 +1026,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-tag size="small" effect="dark" type="primary">{{ tx('action', p.action) }}</el-tag> <el-tag size="small" effect="dark" type="primary">{{ tx('action', p.action) }}</el-tag>
<span class="spacer"></span><span class="muted" style="font-size:11px">{{ p.expire_at }} 前有效</span></div> <span class="spacer"></span><span class="muted" style="font-size:11px">{{ p.expire_at }} 前有效</span></div>
<div class="st" v-if="p.judge_reason"><span class="muted">决策系统:</span>{{ p.judge_reason }}<span class="muted" v-if="(p.hard_numbers||{}).judge_conf!=null">(置信度 {{ (p.hard_numbers||{}).judge_conf }}</span></div> <div class="st" v-if="p.judge_reason"><span class="muted">决策系统:</span>{{ p.judge_reason }}<span class="muted" v-if="(p.hard_numbers||{}).judge_conf!=null">(置信度 {{ (p.hard_numbers||{}).judge_conf }}</span></div>
<div class="st" v-if="(p.hard_numbers||{}).verdict"><span class="muted">选股系统:</span>判为「{{ (p.hard_numbers||{}).verdict }}」<span v-if="propReasons(p).length">{{ propReasons(p).join('') }}</span></div> <div class="st" v-if="(p.hard_numbers||{}).verdict"><span class="muted">选股系统:</span>{{ propVerdictLine(p) }}</div>
<div class="st muted" v-if="propBasis(p)">{{ propBasis(p) }}</div> <div class="st muted" v-if="propBasis(p)">{{ propBasis(p) }}</div>
<div class="st" v-if="propLogic(p)"><span class="muted">研报说:</span>{{ propLogic(p) }}<span class="muted" v-if="propLogicMore(p)">(另有 {{ propLogicMore(p) }} 条)</span></div> <div class="st" v-if="propLogic(p)"><span class="muted">研报说:</span>{{ propLogic(p) }}<span class="muted" v-if="propLogicMore(p)">(另有 {{ propLogicMore(p) }} 条)</span></div>
<div class="st muted">{{ propWhy(p) }}</div> <div class="st muted">{{ propWhy(p) }}</div>
@ -1443,7 +1443,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-table-column type="expand" width="40"> <el-table-column type="expand" width="40">
<template #default="s"> <template #default="s">
<div style="padding:8px 16px"> <div style="padding:8px 16px">
<div class="muted">参数: <span class="mono">{{ JSON.stringify(s.row.params) }}</span></div> <div class="muted" v-if="paramsCn(s.row.params)">{{ paramsCn(s.row.params) }}</div>
<div class="muted" v-if="s.row.progress && s.row.progress.notes"> <div class="muted" v-if="s.row.progress && s.row.progress.notes">
说明: {{ (s.row.progress.notes||[]).join(' | ') }}</div> 说明: {{ (s.row.progress.notes||[]).join(' | ') }}</div>
<el-button size="small" style="margin:8px 0" @click="loadPlans(s.row.command_id)"> <el-button size="small" style="margin:8px 0" @click="loadPlans(s.row.command_id)">
@ -1531,7 +1531,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-table-column label="冻结" width="110"> <el-table-column label="冻结" width="110">
<template #default="s"> <template #default="s">
<el-tag v-if="s.row.frozen_reason && s.row.frozen_reason!=='NONE'" size="small" <el-tag v-if="s.row.frozen_reason && s.row.frozen_reason!=='NONE'" size="small"
type="danger" effect="dark">{{ s.row.frozen_reason }}</el-tag> type="danger" effect="dark">{{ tx('frozen', s.row.frozen_reason) }}</el-tag>
<span v-else class="muted"></span> <span v-else class="muted"></span>
</template> </template>
</el-table-column> </el-table-column>
@ -1545,7 +1545,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-table-column label="安全垫" width="130"> <el-table-column label="安全垫" width="130">
<template #default="s"> <template #default="s">
<el-tag size="small" effect="dark" :type="cuTag(s.row.cushion_state)"> <el-tag size="small" effect="dark" :type="cuTag(s.row.cushion_state)">
{{ pct(s.row.cushion_pct) }} {{ s.row.cushion_state }}</el-tag> {{ pct(s.row.cushion_pct) }} {{ tx('cushion', s.row.cushion_state) }}</el-tag>
<span v-if="s.row.neg_cushion_days>0" class="muted"> 连负{{ s.row.neg_cushion_days }}日</span> <span v-if="s.row.neg_cushion_days>0" class="muted"> 连负{{ s.row.neg_cushion_days }}日</span>
</template> </template>
</el-table-column> </el-table-column>
@ -1586,8 +1586,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div style="padding:6px 16px"> <div style="padding:6px 16px">
<div class="muted" v-if="(s.row.progress||{}).last_decision"> <div class="muted" v-if="(s.row.progress||{}).last_decision">
最近判定 [{{ s.row.progress.last_decision.at }}] 最近判定 [{{ s.row.progress.last_decision.at }}]
<b>{{ s.row.progress.last_decision.action }}</b> <b>{{ tx('verdict', s.row.progress.last_decision.action) }}</b>
{{ s.row.progress.last_decision.reason }} {{ plainCheck(s.row.progress.last_decision.reason) }}
</div> </div>
<div class="muted" v-if="(s.row.progress||{}).deadline"> <div class="muted" v-if="(s.row.progress||{}).deadline">
执行窗口截止: {{ s.row.progress.deadline }} 执行窗口截止: {{ s.row.progress.deadline }}
@ -1649,7 +1649,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</el-table-column> </el-table-column>
<el-table-column prop="price_at" label="评审价" width="90"></el-table-column> <el-table-column prop="price_at" label="评审价" width="90"></el-table-column>
<el-table-column label="未通过项" min-width="240"> <el-table-column label="未通过项" min-width="240">
<template #default="s"><span class="muted">{{ (s.row.failed_checks||[]).join(' | ') }}</span></template> <template #default="s"><span class="muted">{{ (s.row.failed_checks||[]).map(plainCheck).join('') }}</span></template>
</el-table-column> </el-table-column>
<el-table-column prop="reason" label="理由" min-width="200"></el-table-column> <el-table-column prop="reason" label="理由" min-width="200"></el-table-column>
</el-table> </el-table>
@ -1663,7 +1663,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-badge v-if="proposals.length" :value="proposals.length" class="ml"></el-badge> <el-badge v-if="proposals.length" :value="proposals.length" class="ml"></el-badge>
</template> </template>
<div class="panel"> <div class="panel">
<h3>待确认队列 (自主档位 {{ ov.autonomy }})</h3> <h3>待确认队列(现在是{{ tx('autonomy', ov.autonomy) }}</h3>
<div class="muted" style="margin-bottom:8px"> <div class="muted" style="margin-bottom:8px">
propose_only 档位下, 增持类自主动作全部在此等待裁决; 减持方向不设确认门槛。 propose_only 档位下, 增持类自主动作全部在此等待裁决; 减持方向不设确认门槛。
采纳后先落指令表 (先记账后动作), 由择时执行器分日出手。 采纳后先落指令表 (先记账后动作), 由择时执行器分日出手。
@ -1674,15 +1674,19 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-table :data="proposals" size="small" border max-height="420"> <el-table :data="proposals" size="small" border max-height="420">
<el-table-column prop="proposal_id" label="提议号" width="230" class-name="mono"></el-table-column> <el-table-column prop="proposal_id" label="提议号" width="230" class-name="mono"></el-table-column>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column> <el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
<el-table-column prop="action" label="动作" width="90"></el-table-column> <el-table-column label="动作" width="90">
<el-table-column prop="qty" label="数量" width="90"></el-table-column> <template #default="s">{{ tx('action', s.row.action) }}</template>
<el-table-column label="硬数字" min-width="320"> </el-table-column>
<template #default="s"><span class="mono muted">{{ JSON.stringify(s.row.hard_numbers) }}</span></template> <el-table-column prop="qty" label="数量" width="90"></el-table-column>
<el-table-column label="当时的依据" min-width="320">
<template #default="s"><span class="muted" :title="JSON.stringify(s.row.hard_numbers)">{{ hardCn(s.row.hard_numbers) }}</span></template>
</el-table-column>
<el-table-column label="研判" width="100">
<template #default="s">{{ tx('verdict', s.row.judge_verdict) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="judge_verdict" label="研判" width="100"></el-table-column>
<el-table-column label="选股判决" min-width="220"> <el-table-column label="选股判决" min-width="220">
<template #default="s"> <template #default="s">
<span v-if="(s.row.hard_numbers||{}).verdict">「{{ s.row.hard_numbers.verdict }}」 {{ propReasons(s.row).join('') }}</span> <span v-if="(s.row.hard_numbers||{}).verdict">{{ propVerdictLine(s.row) }}</span>
<span v-else class="muted"></span> <span v-else class="muted"></span>
</template> </template>
</el-table-column> </el-table-column>
@ -1858,7 +1862,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
按当前参数筛选: 排序池 {{ planCand.considered }} → 合格 {{ planCand.eligible }} → 按当前参数筛选: 排序池 {{ planCand.considered }} → 合格 {{ planCand.eligible }} →
取 {{ (planCand.items||[]).length }} 只 · 取 {{ (planCand.items||[]).length }} 只 ·
白名单档位取全 {{ planCand.tier_complete===null ? '不适用' : (planCand.tier_complete ? '是':'否') }} · 白名单档位取全 {{ planCand.tier_complete===null ? '不适用' : (planCand.tier_complete ? '是':'否') }} ·
丢弃 {{ JSON.stringify(planCand.dropped||{}) }} 落选 {{ dropCn(planCand.dropped) }}
<span v-if="(planCand.st_unknown||[]).length" style="color:#E6A23C">· <span v-if="(planCand.st_unknown||[]).length" style="color:#E6A23C">·
{{ planCand.st_unknown.length }} 只无名称, ST 判不了已放行</span> {{ planCand.st_unknown.length }} 只无名称, ST 判不了已放行</span>
<br><span class="mono">{{ (planCand.items||[]).map(x=>x.ts_code).join(' ') }}</span> <br><span class="mono">{{ (planCand.items||[]).map(x=>x.ts_code).join(' ') }}</span>
@ -2225,7 +2229,138 @@ createApp({
const m = T[g]; const m = T[g];
if (m && Object.prototype.hasOwnProperty.call(m, c)) return m[c]; if (m && Object.prototype.hasOwnProperty.call(m, c)) return m[c];
if (c == null || c === '') return '—'; 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({}); const nameMap = ref({});
@ -3276,7 +3411,7 @@ createApp({
for (const o of (opLog.value || [])) { for (const o of (opLog.value || [])) {
if (o.result === 'OK') continue; // 顺利做完的不占消息栏, 在「你做过的操作」里查 if (o.result === 'OK') continue; // 顺利做完的不占消息栏, 在「你做过的操作」里查
out.push({ kind:'strat', time:_hm(o.at), sort:_hms(o.at), ts_code:o.ts_code || '', 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' }); strong:true, why:o.reason || '', goLabel:'看记录', go:'ins' });
} }
out.sort((a, b) => String(b.sort).localeCompare(String(a.sort))); out.sort((a, b) => String(b.sort).localeCompare(String(a.sort)));

View File

@ -53,12 +53,15 @@
test_batch21_units.py 减持的自动执行边界 (2026-09-03 安全修复): 保垫减仓照旧自动/ test_batch21_units.py 减持的自动执行边界 (2026-09-03 安全修复): 保垫减仓照旧自动/
强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱 强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱
来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流 (15 ) 来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流 (15 )
test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许
直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象
打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) + test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) +
目标价到价必定入队 (档位 full 也不自动卖) + 目标价到价必定入队 (档位 full 也不自动卖) +
用户设的止损价与目标价单独成列显示 (70 ) 用户设的止损价与目标价单独成列显示 (70 )
654 655
(总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条 (总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条
减持与研究理由两键各一例, 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 现为 654) 减持与研究理由两键各一例, 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 654; 又加了页面文案守卫一例, 现为 655)
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): 哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):
@ -80,6 +83,9 @@
死参数名单 scripts/test_batch10_units.py KNOWN_DEAD ([P] ) param_store.py 顶部 死参数名单 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 用例回归·既有四类动作的判据与研判范围没被 动作求值器 scripts/test_batch12_units.py 用例回归·既有四类动作的判据与研判范围没被
动过 action_engine.EVALUATORS 的名单与次序钉在那里 (次序有意义: 减持排 动过 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_batch16_units.py", "test_batch17_units.py",
"test_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py", "test_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py",
"test_batch21_units.py", "test_batch21_units.py",
"test_page_enum_guard.py",
"test_wiring.py"] "test_wiring.py"]

View File

@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
"""页面文案守卫:枚举字段不许直接印到页面上(静态扫描,不连库、不起浏览器)。
## 为什么要有它
app/web/static/index.html 是三千行的单文件页面**没有任何单测**改错了不会有
东西报警而它上面最容易出的错是把后端送来的枚举值直接塞进模板
页面上就出现 COMMAND_HALTREJECTissue_command:EXIT_STOCK 这种东西
页面里其实早就有翻译层T 表加 tx 函数问题是它被系统性地绕开了
2026-09-04 实测同一份文件里 :899 走了 tx('frozen', ):1533 没走印的是
同一个字段这不是没想到是执行漏网所以需要一道机器检查
## 它检查什么
**枚举字段直出**模板里形如 {{ x.frozen_reason }} 的插值字段名落在
ENUM_FIELDS 而外面没有包 tx( 或别的翻译函数就报
**判据码直出**failed_checksreason 这类会带 `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()