pbl_scense_ext/scripts/gen_overlay_pages.py
agent.develop a451b1d413 fix(pbl_scense_ext): 生成产物入库声明与仓库事实一致(QC #9)
wwwroot/pbl_overlay/*.ui 是构建产物,此前未列入 .gitignore 却已被 12 个
.ui 跟踪入库,与 gen_overlay_pages.py docstring「不入库、已在 .gitignore
排除」的自述矛盾。采用方案(a)消除矛盾:
- .gitignore 追加 wwwroot/pbl_overlay/
- git rm --cached wwwroot/pbl_overlay/*.ui(仅解除跟踪,磁盘文件保留,
  由 apps/pbls/build.sh 第9步重跑生成)
- docstring 写明具体排除条目并附 git check-ignore / git ls-files 自检命令
- README 新增「生成产物与入库边界」章节,与仓库事实一致
验证:git check-ignore -v 命中 .gitignore:15;git ls-files wwwroot/pbl_overlay 为空;
python3 scripts/gen_overlay_pages.py --check rc=0。
2026-09-21 12:50:40 +08:00

253 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成 M9 覆盖层 12 个页面壳 wwwroot/pbl_overlay/<key>.ui(bricks 纯 JSON)。
【产物定位:构建产物,不入库】
wwwroot/pbl_overlay/*.ui 属生成目录,仓库根 .gitignore 以条目
`wwwroot/pbl_overlay/` 将其排除,由 apps/pbls/build.sh 第 9 步调用本脚本重跑生成
(module-development-spec「CRUD-Generated wwwroot Directories Must Not Be Git-Tracked」/
QC 退回意见 #3 方案 (b))。可核验:
git check-ignore -v wwwroot/pbl_overlay/home.ui # 命中 .gitignore 中的该条目
git ls-files wwwroot/pbl_overlay # 输出为空(无跟踪文件)
本脚本(生成器)与 wwwroot/i18n/*/msg.txt(文案事实源)才是入库的交付物。
为什么需要页面壳:wwwroot/overlay/pbl_overlay_core.js 的 PAGES 注册表 route 指向
`/pbl_overlay/<key>.ui`,play.js 的 pageUrl() 会拼成
`/pbl_scense_ext/pbl_overlay/<key>.ui`;页面壳文件不存在则导航即 404。
页面壳职责(只做壳,不做业务渲染):
1. 打 `data-pbl-page="<key>"` + `data-pbl-overlay-root="1"` 标记 —— play.js
hostPageKey() 据此识别当前页、boot() 据此挂载覆盖层根节点;
2. 每个文案节点带 `data-pbl-i18n="<key>"`,正文内嵌中文(首屏零请求可读);
loader.js 在切换语言时按 msg.txt 覆盖同名节点(i18n 单一事实源);
3. 提供 `script` actiontype 按钮调用 `PblOverlayLoader.start({page:...})`,
由 loader 按需注入 core/transport/play JS 与 CSS(不在 .ui 里硬写 <script>/<link>,
遵守 module-development-spec「ahserver auto-serves .css/.js」);
4. 提供返回首页的 `url` actiontype 导航。
文案:全部取自 wwwroot/i18n/zh/msg.txt(en 用于校验 key 集合一致),
禁止在本文件里硬编码界面文案(QC 退回意见 #3:新增文案必须提取到 i18n)。
route 与 RBAC:新增/删除页面壳必须同步 scripts/load_path.py 的 OVERLAY_PAGES。
用法:
python3 scripts/gen_overlay_pages.py # 生成 12 个页面壳
python3 scripts/gen_overlay_pages.py --check # 只校验(zh/en key 一致 + 壳与注册表一致),不写文件
python3 scripts/gen_overlay_pages.py --clean # 删除生成目录内容
"""
import argparse
import io
import json
import os
import re
import sys
MODULE = 'pbl_scense_ext'
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LOADER_PATH = '/%s/overlay/pbl_overlay_loader.js' % MODULE
OUT_DIR = os.path.join(ROOT, 'wwwroot', 'pbl_overlay')
I18N_DIR = os.path.join(ROOT, 'wwwroot', 'i18n')
# (key, in_world) —— 与 wwwroot/overlay/pbl_overlay_core.js PAGES 注册表同源;
# 页面标题从 i18n key `pbl.overlay.page.<key>` 取,不在这里硬编码文案。
PAGES = [
('home', False),
('blueprint_list', False),
('blueprint_editor', False),
('validation_report', False),
('agent_console', False),
('approval_center', False),
('play_preview', True),
('mission_board', True),
('role_panel', True),
('artifact_studio', True),
('evidence_wall', True),
('assessment_report', False),
]
MSG_RE = re.compile(r'^\s*([^#:\s][^:]*?)\s*:\s*(.+?)\s*$')
def load_msg(lang):
"""读 wwwroot/i18n/<lang>/msg.txt(key: value 一行一条,# 注释)。"""
path = os.path.join(I18N_DIR, lang, 'msg.txt')
msgs = {}
if not os.path.isfile(path):
raise SystemExit('[gen_overlay_pages] 缺少文案表 %s' % path)
with io.open(path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
m = MSG_RE.match(line)
if not m:
raise SystemExit('[gen_overlay_pages] %s 非法行: %r' % (path, line))
msgs[m.group(1)] = m.group(2)
return msgs
def fmt(tpl, **kw):
"""msg.txt 模板用 {name} 占位,避免 str.format 与 JSON 花括号冲突。"""
out = tpl
for k, v in kw.items():
out = out.replace('{' + k + '}', v)
return out
def enable_script(key):
"""点击「启用 PBL 覆盖层」:优先用已注入的 loader;未注入则先注入 loader.js(RBAC=any)再启动。"""
return (
"window.PblOverlayLoader"
"?PblOverlayLoader.start({page:'%s'})"
":(function(){"
"var b=String(window.__PBL_API_BASE__||'').replace(/\\/$/,'');"
"var s=document.createElement('script');s.src=b+'%s';s.async=false;"
"s.onload=function(){window.PblOverlayLoader.start({page:'%s'})};"
"s.onerror=function(){window.__pblOverlayFail&&window.__pblOverlayFail('PBL_E_NETWORK','')}"
";document.head.appendChild(s);})()"
) % (key, LOADER_PATH, key)
def build_page(key, in_world, zh):
"""页面壳:文案内嵌中文 + data-pbl-i18n 标记(loader 按语言覆盖)。"""
title = zh['pbl.overlay.page.' + key]
budget = zh['pbl.overlay.shell.budget.world'] if in_world \
else zh['pbl.overlay.shell.budget.firstscreen']
shell_html = (
'<div data-pbl-page="{key}" data-pbl-overlay-root="1" class="pbl-shell">'
'<div class="pbl-shell-hint" data-pbl-i18n="pbl.overlay.shell.hint">{hint}</div>'
'<div class="pbl-shell-budget" data-pbl-i18n="pbl.overlay.shell.budget'
'.{bkey}">{budget}</div>'
'</div>'
).format(
key=key,
bkey='world' if in_world else 'firstscreen',
hint=fmt(zh['pbl.overlay.shell.hint'], title=title, key=key, budget=budget),
budget=budget,
)
return {
'widgettype': 'VBox',
'options': {
'width': '100%', 'height': '100%', 'padding': '12px',
'backgroundColor': '#0B1020',
},
'subwidgets': [
{
'widgettype': 'Text',
'options': {'label': fmt(zh['pbl.overlay.shell.title'], title=title),
'fontSize': '18px',
'fontWeight': 'bold', 'color': '#E5E7EB'},
'attrs': {'data-pbl-i18n': 'pbl.overlay.shell.title',
'data-pbl-i18n-args': json.dumps({'title': title}, ensure_ascii=False)},
},
{
'widgettype': 'Html',
'id': 'pbl_overlay_shell_' + key,
'options': {'html': shell_html},
},
{
'widgettype': 'HBox',
'options': {'width': '100%', 'gap': '8px', 'marginTop': '10px'},
'subwidgets': [
{
'widgettype': 'Button',
'options': {'label': zh['pbl.overlay.btn.enable'], 'type': 'primary'},
'attrs': {'data-pbl-i18n': 'pbl.overlay.btn.enable'},
'binds': [{'wid': 'self', 'event': 'click',
'actiontype': 'script',
'options': {'script': enable_script(key)}}],
},
{
'widgettype': 'Button',
'options': {'label': zh['pbl.overlay.btn.home']},
'attrs': {'data-pbl-i18n': 'pbl.overlay.btn.home'},
'binds': [{'wid': 'self', 'event': 'click',
'actiontype': 'url',
'options': {'url': "{{entire_url('/%s/pbl_overlay/home.ui')}}" % MODULE}}],
},
],
},
{
'widgettype': 'Text',
'options': {'label': zh['pbl.overlay.shell.context'],
'fontSize': '12px', 'color': '#6B7280', 'marginTop': '10px'},
'attrs': {'data-pbl-i18n': 'pbl.overlay.shell.context'},
},
],
}
def check(zh, en):
"""校验:① zh/en key 集合一致;② 每个页面的 i18n key 齐备;③ 生成目录与注册表一致。"""
problems = []
only_zh = sorted(set(zh) - set(en))
only_en = sorted(set(en) - set(zh))
if only_zh:
problems.append('en/msg.txt 缺 key: %s' % ', '.join(only_zh))
if only_en:
problems.append('zh/msg.txt 缺 key: %s' % ', '.join(only_en))
need = ['pbl.overlay.shell.title', 'pbl.overlay.shell.hint',
'pbl.overlay.shell.budget.world', 'pbl.overlay.shell.budget.firstscreen',
'pbl.overlay.shell.context', 'pbl.overlay.btn.enable', 'pbl.overlay.btn.home']
for k in need:
if k not in zh:
problems.append('zh/msg.txt 缺必需 key: %s' % k)
for key, _in_world in PAGES:
pk = 'pbl.overlay.page.' + key
if pk not in zh:
problems.append('zh/msg.txt 缺页面标题 key: %s' % pk)
if pk not in en:
problems.append('en/msg.txt 缺页面标题 key: %s' % pk)
if os.path.isdir(OUT_DIR):
on_disk = sorted(f for f in os.listdir(OUT_DIR) if f.endswith('.ui'))
expect = sorted(k + '.ui' for k, _ in PAGES)
if on_disk and on_disk != expect:
problems.append('生成目录与 PAGES 注册表不一致: 磁盘 %s / 注册 %s'
% (on_disk, expect))
return problems
def main(argv=None):
ap = argparse.ArgumentParser(description='生成 pbl_scense_ext 覆盖层页面壳(构建产物,不入库)')
ap.add_argument('--check', action='store_true', help='只校验不写文件')
ap.add_argument('--clean', action='store_true', help='清空生成目录')
args = ap.parse_args(argv)
zh = load_msg('zh')
en = load_msg('en')
problems = check(zh, en)
if problems:
for p in problems:
print('[gen_overlay_pages][FAIL] %s' % p, file=sys.stderr)
return 1
if args.check:
print('[gen_overlay_pages] CHECK PASS: %d 页面壳 / zh+en 各 %d 条文案 key 一致'
% (len(PAGES), len(zh)))
return 0
if args.clean:
if os.path.isdir(OUT_DIR):
for f in os.listdir(OUT_DIR):
if f.endswith('.ui'):
os.remove(os.path.join(OUT_DIR, f))
print('[gen_overlay_pages] cleaned %s' % os.path.relpath(OUT_DIR, ROOT))
return 0
if not os.path.isdir(OUT_DIR):
os.makedirs(OUT_DIR)
written = []
for key, in_world in PAGES:
obj = build_page(key, in_world, zh)
path = os.path.join(OUT_DIR, key + '.ui')
with io.open(path, 'w', encoding='utf-8') as f:
json.dump(obj, f, ensure_ascii=False, indent=2)
f.write('\n')
written.append(path)
print('generated %d overlay page shells (build artifacts, git-ignored):' % len(written))
for p in written:
print(' ' + os.path.relpath(p, ROOT))
return 0
if __name__ == '__main__':
sys.exit(main())