pbl_scense_ext/scripts/gen_overlay_pages.py

285 lines
13 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/` 将其排除(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(文案事实源)才是入库的交付物。
【生成时机:如实声明,不声称不存在的步骤(QC #9 复审改法 b)】
截至本次提交,apps/pbls/build.sh 中**不存在**调用本脚本的步骤——实测
`grep -n gen_overlay_pages apps/pbls/build.sh` 零命中。因此新克隆/新部署环境
必须显式执行 `python3 scripts/gen_overlay_pages.py` 生成页面壳,否则
wwwroot/pbl_overlay/ 为空目录、`/pbl_overlay/<key>.ui` 导航全量 404。
把该生成步骤接入应用构建脚本(模块安装之后、wwwroot 软链之前)由 PM 另行派单
跟踪(本模块不拥有 apps/pbls/build.sh,不在本任务改动范围内);`--check` 会
调用 warn_build_integration() 实测并打印 WARN,使本声明可机械核验。
为什么需要页面壳: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 warn_build_integration():
"""机械核验:页面壳生成步骤是否已接入应用构建脚本(QC #9 复审意见 #2)。
只告警、不改返回码——apps/pbls/build.sh 归应用侧所有,接入与否由 PM 派单跟踪;
但文档不得依赖未经核验的口头承诺,故每次校验都实测一次并如实打印结论。
"""
build_sh = os.path.normpath(os.path.join(ROOT, os.pardir, os.pardir,
'apps', 'pbls', 'build.sh'))
if not os.path.isfile(build_sh):
print('[gen_overlay_pages][WARN] 未找到 %s:无法核验生成步骤是否已接入构建脚本,'
'新环境请手动执行本脚本生成页面壳' % build_sh)
return
with io.open(build_sh, 'r', encoding='utf-8', errors='replace') as f:
body = f.read()
if 'gen_overlay_pages' in body:
print('[gen_overlay_pages] OK: 生成步骤已接入 %s' % build_sh)
else:
print('[gen_overlay_pages][WARN] 生成步骤未接入 apps/pbls/build.sh:'
'新克隆/新部署环境需手动执行 `python3 scripts/gen_overlay_pages.py`,'
'否则 wwwroot/pbl_overlay/ 为空、/pbl_overlay/<key>.ui 全量 404'
'(接入构建脚本由 PM 派单跟踪,见 README「生成产物与入库边界」)')
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)))
warn_build_integration()
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())