pbl_scense_ext/scripts/gen_overlay_pages.py
agent.develop 5403f8ef1b docs+fix(pbl_scense_ext): M9 收口——入库边界与挂载链路表述对齐应用侧事实(QC #1/#2/#9/#10)
- .gitignore:注释补全「生成 + 模块 wwwroot 软链挂载」两步链路,指向 README 可机械核验的前提检查
- README.md:一致性自检由四条扩为五条(新增第五条=应用侧可达性),补「应用侧可达性(模块 wwwroot 挂载,已接入)」段,说明 pageUrl() 请求的应用侧 URL 与 website.root 的边界、软链非拷贝的理由、应用仓以 wwwroot/pbl_* 排除不入库
- scripts/gen_overlay_pages.py:warn_build_integration() 由「只验生成步骤」加强为「同时实测生成调用与模块 wwwroot 挂载步骤」,两项齐备才打印 OK,任一项被回退打印 WARN(只告警不改返回码);docstring 同步「生成 + 自动挂载」口径
- 选择性 git add:仅上述 3 个文件,未 add wwwroot/(生成产物目录 wwwroot/pbl_overlay/ 已由 .gitignore:19 排除,git ls-files 为 0)
- 自检:gen_overlay_pages.py --check rc=0(12 页壳 / zh+en 各 24 key 一致 / 生成+挂载均已接入)、load_path.py --audit rc=0(零输出)
2026-09-22 17:12:18 +08:00

309 lines
14 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(文案事实源)才是入库的交付物。
【生成时机:已接入应用构建脚本,构建期自动生成 + 自动挂载】
apps/pbls/build.sh 已调用本脚本生成页面壳,并紧随其后以 `--check` 作为门禁
(rc≠0 终止部署并打印原因)——实测 `grep -n gen_overlay_pages apps/pbls/build.sh`
命中。调用位置在模块安装(pip install -e modules/pbl_*)之后、模块 wwwroot 软链
挂载之前,故新克隆/新部署环境执行 build.sh 即自动产出页面壳;若该目录为空,
`/pbl_overlay/<key>.ui` 导航会全量 404,构建期门禁即为此前置拦截。
生成物落在**本模块仓** wwwroot/pbl_overlay/ 下,而 play.js 的 pageUrl() 请求的是
应用侧 URL `/pbl_scense_ext/pbl_overlay/<key>.ui`,应用 conf/config.json 的
website.root=wwwroot 只指向应用自身目录——因此 build.sh 在生成与 --check 之后还有
一步「模块 wwwroot 挂载」:对所有含 wwwroot/ 的 pbl_* 模块执行
`ln -sfn "${WS_DIR}/modules/<m>/wwwroot" "${APP_DIR}/wwwroot/<m>"`,并以应用侧路径
实测页面壳数=12 作为门禁(不符即终止部署)。只有「生成 + 挂载」两步齐备,
页面壳在应用侧才真正可达;缺挂载 = 生成物在磁盘上但 404(QC 退回意见 #1 根因)。
人工执行 `python3 scripts/gen_overlay_pages.py` 仅用于本地开发调试。
warn_build_integration() 作为常驻前提检查保留:`--check` 每次实测 apps/pbls/build.sh
是否同时含生成调用与 wwwroot 挂载步骤,打印 OK(两项齐备)或 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():
"""机械核验:构建脚本是否齐备「生成」与「模块 wwwroot 挂载」两个前提。
只告警、不改返回码——apps/pbls/build.sh 归应用侧所有,接入与否由 PM 派单跟踪;
但文档不得依赖未经核验的口头承诺,故每次校验都实测一次并如实打印结论。
两项检查的依据(QC 退回意见 #1/#2):
① 生成:脚本内必须调用本脚本(否则新克隆环境页面壳目录为空);
② 挂载:脚本内必须把模块 wwwroot 软链进应用 wwwroot(否则生成物虽在模块仓
磁盘上、应用侧 URL /pbl_scense_ext/pbl_overlay/<key>.ui 仍取不到)。
"""
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:无法核验生成/挂载步骤是否已接入构建脚本,'
'新环境请手动执行本脚本生成页面壳,并自行把模块 wwwroot 挂载到应用 wwwroot'
% build_sh)
return
with io.open(build_sh, 'r', encoding='utf-8', errors='replace') as f:
body = f.read()
has_gen = 'gen_overlay_pages' in body
# 挂载步骤的机械特征:对模块 wwwroot 建软链(ln -sfn ... modules/<m>/wwwroot -> 应用 wwwroot)
has_mount = ('ln -s' in body) and ('wwwroot' in body)
if has_gen and has_mount:
print('[gen_overlay_pages] OK: 生成步骤与模块 wwwroot 挂载步骤均已接入 %s' % build_sh)
return
missing = []
if not has_gen:
missing.append('生成步骤(本脚本调用)——缺失则新克隆/新部署环境 wwwroot/pbl_overlay/ '
'为空,/pbl_overlay/<key>.ui 全量 404')
if not has_mount:
missing.append('模块 wwwroot 挂载步骤(ln -s 模块 wwwroot 到应用 wwwroot)——缺失则'
'生成物只落在模块仓,应用侧 /pbl_scense_ext/pbl_overlay/<key>.ui 仍取不到')
print('[gen_overlay_pages][WARN] 前提检查失败:%s 内缺少 %s,请恢复构建脚本中的对应步骤'
'(见 README「生成产物与入库边界」)' % (build_sh, ';'.join(missing)))
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())