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:
parent
06fcc552bd
commit
bcf5783b4c
|
|
@ -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">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
<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.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" 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>
|
||||
|
|
@ -1443,7 +1443,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
|||
<el-table-column type="expand" width="40">
|
||||
<template #default="s">
|
||||
<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">
|
||||
说明: {{ (s.row.progress.notes||[]).join(' | ') }}</div>
|
||||
<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">
|
||||
<template #default="s">
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
|
@ -1545,7 +1545,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
|||
<el-table-column label="安全垫" width="130">
|
||||
<template #default="s">
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
|
@ -1586,8 +1586,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
|||
<div style="padding:6px 16px">
|
||||
<div class="muted" v-if="(s.row.progress||{}).last_decision">
|
||||
最近判定 [{{ s.row.progress.last_decision.at }}]
|
||||
<b>{{ s.row.progress.last_decision.action }}</b> —
|
||||
{{ s.row.progress.last_decision.reason }}
|
||||
<b>{{ tx('verdict', s.row.progress.last_decision.action) }}</b> —
|
||||
{{ plainCheck(s.row.progress.last_decision.reason) }}
|
||||
</div>
|
||||
<div class="muted" v-if="(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 prop="price_at" label="评审价" width="90"></el-table-column>
|
||||
<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 prop="reason" label="理由" min-width="200"></el-table-column>
|
||||
</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>
|
||||
</template>
|
||||
<div class="panel">
|
||||
<h3>待确认队列 (自主档位 {{ ov.autonomy }})</h3>
|
||||
<h3>待确认队列(现在是{{ tx('autonomy', ov.autonomy) }})</h3>
|
||||
<div class="muted" style="margin-bottom:8px">
|
||||
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-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="action" label="动作" width="90"></el-table-column>
|
||||
<el-table-column prop="qty" label="数量" width="90"></el-table-column>
|
||||
<el-table-column label="硬数字" min-width="320">
|
||||
<template #default="s"><span class="mono muted">{{ JSON.stringify(s.row.hard_numbers) }}</span></template>
|
||||
<el-table-column label="动作" width="90">
|
||||
<template #default="s">{{ tx('action', s.row.action) }}</template>
|
||||
</el-table-column>
|
||||
<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 prop="judge_verdict" label="研判" width="100"></el-table-column>
|
||||
<el-table-column label="选股判决" min-width="220">
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
|
@ -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) }}
|
||||
<span v-if="(planCand.st_unknown||[]).length" style="color:#E6A23C">·
|
||||
{{ planCand.st_unknown.length }} 只无名称, ST 判不了已放行</span>
|
||||
<br><span class="mono">{{ (planCand.items||[]).map(x=>x.ts_code).join(' ') }}</span>
|
||||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue