deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-21 12:39:00 +08:00
parent eb9cd56b6b
commit 3742c65c32
3 changed files with 209 additions and 41 deletions

View File

@ -2,44 +2,93 @@
# -*- coding: utf-8 -*-
"""生成 M9 覆盖层 12 个页面壳 wwwroot/pbl_overlay/<key>.ui(bricks 纯 JSON)。
为什么需要:wwwroot/overlay/pbl_overlay_core.js 的 PAGES 注册表 route 指向
【产物定位:构建产物,不入库】
wwwroot/pbl_overlay/*.ui 属生成目录,已在 .gitignore 中排除,由
apps/pbls/build.sh 第 9 步调用本脚本重跑生成(module-development-spec
「CRUD-Generated wwwroot Directories Must Not Be Git-Tracked」/ QC 退回意见 #3 方案 (b))。
本脚本(生成器)与 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(QC #3)。
`/pbl_scense_ext/pbl_overlay/<key>.ui`;页面壳文件不存在则导航即 404。
页面壳职责(只做壳,不做业务渲染):
1. 打 `data-pbl-page="<key>"` + `data-pbl-overlay-root="1"` 标记 —— play.js
hostPageKey() 据此识别当前页、boot() 据此挂载覆盖层根节点;
2. 提供 `script` actiontype 按钮调用 `PblOverlayLoader.start({page:...})`,
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」);
3. 提供返回首页的 `url` actiontype 导航。
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, title, in_world) —— 与 wwwroot/overlay/pbl_overlay_core.js PAGES 注册表同源
# (key, in_world) —— 与 wwwroot/overlay/pbl_overlay_core.js PAGES 注册表同源;
# 页面标题从 i18n key `pbl.overlay.page.<key>` 取,不在这里硬编码文案。
PAGES = [
('home', 'PBL 首页', False),
('blueprint_list', '蓝图列表', False),
('blueprint_editor', '蓝图结构视图', False),
('validation_report', '校验报告(14维)', False),
('agent_console', 'Agent 控制台', False),
('approval_center', '人工审批中心', False),
('play_preview', 'Play 预览', True),
('mission_board', '驱动问题板', True),
('role_panel', '角色面板', True),
('artifact_studio', '产出物工坊', True),
('evidence_wall', '证据墙', True),
('assessment_report', '评估报告', False),
('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),
]
OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'wwwroot', 'pbl_overlay')
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):
@ -51,22 +100,27 @@ def enable_script(key):
"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);})()"
"s.onerror=function(){window.__pblOverlayFail&&window.__pblOverlayFail('PBL_E_NETWORK','')}"
";document.head.appendChild(s);})()"
) % (key, LOADER_PATH, key)
def build_page(key, title, in_world):
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">'
'{title}({key})· 覆盖层未启用:点击下方「启用 PBL 覆盖层」装配 PBL 组件;'
'{hint}'
'</div></div>'
'<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, title=title,
hint='本页为世界内覆盖层(广播渲染预算 ≤500ms)。' if in_world
else '本页为教师/学生侧页面(首屏预算 ≤1.5s)。'
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',
@ -77,8 +131,11 @@ def build_page(key, title, in_world):
'subwidgets': [
{
'widgettype': 'Text',
'options': {'label': 'PBL 覆盖层 · ' + title, 'fontSize': '18px',
'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',
@ -91,14 +148,16 @@ def build_page(key, title, in_world):
'subwidgets': [
{
'widgettype': 'Button',
'options': {'label': '启用 PBL 覆盖层', 'type': 'primary'},
'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': 'PBL 首页'},
'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}}],
@ -107,29 +166,84 @@ def build_page(key, title, in_world):
},
{
'widgettype': 'Text',
'options': {'label': '上下文:URL 携带 session_id / blueprint_id / world_id;'
'覆盖层仅渲染服务端权威状态,不本地预测(Q4)。',
'options': {'label': zh['pbl.overlay.shell.context'],
'fontSize': '12px', 'color': '#6B7280', 'marginTop': '10px'},
'attrs': {'data-pbl-i18n': 'pbl.overlay.shell.context'},
},
],
}
def main():
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, title, in_world in PAGES:
obj = build_page(key, title, in_world)
for key, in_world in PAGES:
obj = build_page(key, in_world, zh)
path = os.path.join(OUT_DIR, key + '.ui')
with open(path, 'w', encoding='utf-8') as f:
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:' % len(written))
print('generated %d overlay page shells (build artifacts, git-ignored):' % len(written))
for p in written:
print(' ' + os.path.relpath(p, os.path.dirname(OUT_DIR)))
print(' ' + os.path.relpath(p, ROOT))
return 0
if __name__ == '__main__':
main()
sys.exit(main())

26
wwwroot/i18n/en/msg.txt Normal file
View File

@ -0,0 +1,26 @@
# pbl_scense_ext overlay English texts (M9, key: value per line)
# Same key set as wwwroot/i18n/zh/msg.txt (enforced by scripts/gen_overlay_pages.py --check).
pbl.overlay.page.home: PBL Home
pbl.overlay.page.blueprint_list: Blueprint List
pbl.overlay.page.blueprint_editor: Blueprint Structure View
pbl.overlay.page.validation_report: Validation Report (14 dims)
pbl.overlay.page.agent_console: Agent Console
pbl.overlay.page.approval_center: Approval Center
pbl.overlay.page.play_preview: Play Preview
pbl.overlay.page.mission_board: Mission Board
pbl.overlay.page.role_panel: Role Panel
pbl.overlay.page.artifact_studio: Artifact Studio
pbl.overlay.page.evidence_wall: Evidence Wall
pbl.overlay.page.assessment_report: Assessment Report
pbl.overlay.shell.title: PBL Overlay · {title}
pbl.overlay.shell.hint: {title} ({key}) · overlay not mounted: click "Enable PBL Overlay" below; {budget}
pbl.overlay.shell.budget.world: In-world overlay page (broadcast render budget ≤500ms).
pbl.overlay.shell.budget.firstscreen: Teacher/student page (first-screen budget ≤1.5s).
pbl.overlay.shell.context: Context: URL carries session_id / blueprint_id / world_id; the overlay renders server-authoritative state only, no local prediction (Q4).
pbl.overlay.btn.enable: Enable PBL Overlay
pbl.overlay.btn.home: PBL Home
pbl.overlay.err.network: Overlay bootstrap script failed to load
pbl.overlay.err.resload: Resource load failed (403/404?) {src}
pbl.overlay.err.playmissing: PblOverlayPlay is not registered
pbl.overlay.err.loadfail: Overlay load failed
pbl.overlay.err.i18n: i18n bundle load failed (fallback to built-in zh)

28
wwwroot/i18n/zh/msg.txt Normal file
View File

@ -0,0 +1,28 @@
# pbl_scense_ext 覆盖层中文文案(M9,key: value 一行一条,# 开头为注释)
# 单一事实源:wwwroot/pbl_overlay/*.ui 页面壳由 scripts/gen_overlay_pages.py 读本表生成;
# 运行时由 wwwroot/overlay/pbl_overlay_loader.js fetch 本表并替换 [data-pbl-i18n] 节点文案。
# 新增/删除 key 必须同步 wwwroot/i18n/en/msg.txt(两语言 key 集合一致,build.sh 有 --check 门禁)。
pbl.overlay.page.home: PBL 首页
pbl.overlay.page.blueprint_list: 蓝图列表
pbl.overlay.page.blueprint_editor: 蓝图结构视图
pbl.overlay.page.validation_report: 校验报告(14维)
pbl.overlay.page.agent_console: Agent 控制台
pbl.overlay.page.approval_center: 人工审批中心
pbl.overlay.page.play_preview: Play 预览
pbl.overlay.page.mission_board: 驱动问题板
pbl.overlay.page.role_panel: 角色面板
pbl.overlay.page.artifact_studio: 产出物工坊
pbl.overlay.page.evidence_wall: 证据墙
pbl.overlay.page.assessment_report: 评估报告
pbl.overlay.shell.title: PBL 覆盖层 · {title}
pbl.overlay.shell.hint: {title}({key})· 覆盖层未启用:点击下方「启用 PBL 覆盖层」装配 PBL 组件;{budget}
pbl.overlay.shell.budget.world: 本页为世界内覆盖层(广播渲染预算 ≤500ms)。
pbl.overlay.shell.budget.firstscreen: 本页为教师/学生侧页面(首屏预算 ≤1.5s)。
pbl.overlay.shell.context: 上下文:URL 携带 session_id / blueprint_id / world_id;覆盖层仅渲染服务端权威状态,不本地预测(Q4)。
pbl.overlay.btn.enable: 启用 PBL 覆盖层
pbl.overlay.btn.home: PBL 首页
pbl.overlay.err.network: 覆盖层引导脚本加载失败
pbl.overlay.err.resload: 资源加载失败(403/404?) {src}
pbl.overlay.err.playmissing: PblOverlayPlay 未注册
pbl.overlay.err.loadfail: 覆盖层加载失败
pbl.overlay.err.i18n: 文案表加载失败(降级为内置中文)