285 lines
13 KiB
Python
285 lines
13 KiB
Python
#!/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())
|