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

This commit is contained in:
agent.develop 2026-09-21 13:17:15 +08:00
parent 4ee1c5df02
commit dc1c52b827
2 changed files with 262 additions and 11 deletions

View File

@ -30,7 +30,7 @@ PBL 覆盖层前端扩展模块(scense / scense_game 之上的 pbl_scense_ext
grep -rnE '第 ?9 ?步' . --exclude-dir=.git ; echo "rc=$? (期望 1)"
```
说明两点:
1. 第四条自检**刻意**用 ERE 形式(`第` + 可选空格 + `9` + 可选空格 + `步`),一次性覆盖
1. 第四条自检**刻意**用 ERE 形式(`第`+可选空格+`9`+可选空格+`步`),一次性覆盖
「数字紧贴汉字」与「数字两侧带空格」两种写法,且该写法本身不会被自己命中——若把某种
字面量抄进本文档,README 自身就会成为 grep 的命中项,使「零命中」声明再次不可复现
(QC 复审 #1 指出的正是这类自验声明与实测不符的问题)。

View File

@ -1,14 +1,28 @@
/* ============================================================================
* pbl_overlay_loader.js —— 覆盖层脚本/CSS 按需注入器(M9)
* pbl_overlay_loader.js —— 覆盖层脚本/CSS 按需注入器 + 运行时 i18n 链路(M9)
*
* 为什么需要它:bricks .ui 页面禁止硬写 <script>/<link> 标签(会绕过 RBAC 或
* 触发 403)。本 loader 由页面壳(wwwroot/pbl_overlay/*.ui)的 bricks
* `script` actiontype 在用户点击「启用 PBL 覆盖层」时调用,动态注入同模块
* wwwroot/overlay 下的 JS/CSS,注入完成后调用 PblOverlayPlay.boot() 完成装配。
*
* RBAC 事实(QC #1 已补齐,与 scripts/load_path.py PATHS 表一一对应,禁止只改一边):
* i18n 运行时链路(QC 退回意见 #7):文案单一事实源是
* wwwroot/i18n/<lang>/msg.txt(key: value 一行一条,# 开头为注释)
* 页面壳里内嵌的中文只是「首屏零请求可读」的快照,真实多语言由本文件
* loadLang(lang) 在运行时 fetch msg.txt → 解析 → 覆盖
* document.querySelectorAll('[data-pbl-i18n]') 节点文案(占位符
* {title}/{key}/{budget}/{src} 由节点 data-pbl-i18n-args 的 JSON 提供)。
* 解析规则与 scripts/gen_overlay_pages.py 的 MSG_RE 逐字一致,保证
* 「生成期」与「运行期」对同一份 msg.txt 得到同一组 key/value。
* fetch/解析失败 → 内置中文降级(FALLBACK_ZH)+ degrade 横幅
* (key = pbl.overlay.err.i18n),不白屏、不打断宿主。
*
* RBAC 事实(QC #1 补齐 / QC #6 追加 i18n,与 scripts/load_path.py PATHS 表
* 一一对应,禁止只改一边):
* - /pbl_scense_ext/overlay/pbl_overlay_loader.js → any (预鉴权页壳引导)
* - /pbl_scense_ext/overlay/pbl_overlay.css → any (页壳首屏样式)
* - /pbl_scense_ext/i18n/zh/msg.txt → any (页壳首屏文案表)
* - /pbl_scense_ext/i18n/en/msg.txt → any (页壳首屏文案表)
* - /pbl_scense_ext/overlay/pbl_overlay_core.js → logined (业务内核)
* - /pbl_scense_ext/overlay/pbl_overlay_transport.js→ logined (广播/意图通道)
* - /pbl_scense_ext/overlay/pbl_overlay_play.js → logined (覆盖层装配)
@ -26,11 +40,12 @@
var inj = String(global.__PBL_API_BASE__ || '');
if (inj) { return inj.replace(/\/$/, ''); }
return String(global.location && global.location.pathname || '')
.replace(/\/(pbl_overlay|overlay|game|scense_game|scense)\/.*$/, '')
.replace(/\/(pbl_overlay|overlay|i18n|game|scense_game|scense)\/.*$/, '')
.replace(/\/$/, '');
}
var DIR = apiBase() + '/' + MODULE + '/overlay/';
var I18N_DIR = apiBase() + '/' + MODULE + '/i18n/';
// 依赖顺序:core → transport → play(play 内部硬依赖前两者)
var FILES = ['pbl_overlay_core.js', 'pbl_overlay_transport.js', 'pbl_overlay_play.js'];
@ -72,11 +87,11 @@
function ensureCss() {
var id = 'pbl-overlay-css';
if (document.getElementById(id)) { return; }
var l = document.createElement('link');
l.id = id;
l.rel = 'stylesheet';
l.href = DIR + CSS;
document.head.appendChild(l);
var link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.href = DIR + CSS;
document.head.appendChild(link);
}
function load() {
@ -88,27 +103,263 @@
return loading;
}
/* ───────────────────────── i18n 运行时(QC #7) ─────────────────────────
* msg.txt 语法(与 gen_overlay_pages.py load_msg() 同规则):
* - 一行一条 `key: value`;
* - 以 # 开头的行是注释,空行忽略;
* - key 不含冒号与空白起始字符,value 取冒号后到行尾(原样,不去内部空格)。
* MSG_RE 与 gen_overlay_pages.py:MSG_RE 逐字一致,改一边必须改另一边。
*/
var MSG_RE = /^\s*([^#:\s][^:]*?)\s*:\s*(.+?)\s*$/;
var LANG_STORAGE_KEY = '***';
var SUPPORTED_LANGS = { en: 'en' }; // 其余一律归一到 zh
/* 内置中文降级表:与 wwwroot/i18n/zh/msg.txt 同源快照。
* 仅在 msg.txt fetch/解析失败时使用,保证「文案表挂了界面也不挂」。 */
var FALLBACK_ZH = {
'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': '文案表加载失败(降级为内置中文)'
};
var langCache = {}; // lang -> Promise<dict>
var currentMsgs = {}; // 当前生效文案字典
var currentLangCode = '';
var langHooked = false;
var observer = null;
function normalizeLang(lang) {
var l = String(lang || '').toLowerCase();
return SUPPORTED_LANGS[l.split('-')[0]] === 'en' ? 'en' : 'zh';
}
// 宿主显式切换 > localStorage 记忆 > 浏览器语言,逐级兜底
function detectLang() {
var l = global.__PBL_LANG__;
if (!l) {
try { l = global.localStorage && global.localStorage.getItem(LANG_STORAGE_KEY); } catch (e) { l = ''; }
}
if (!l) { l = (global.navigator && (global.navigator.language || global.navigator.userLanguage)) || 'zh'; }
return normalizeLang(l);
}
function parseMsg(text) {
var dict = {};
var lines = String(text == null ? '' : text).split(/\r\n|\r|\n/);
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) { continue; }
var trimmed = line.replace(/^\s+|\s+$/g, '');
if (!trimmed || trimmed.charAt(0) === '#') { continue; } // 注释 / 空行
var m = MSG_RE.exec(trimmed);
if (!m) { continue; } // 非法行忽略(不让一行坏数据打断整表)
dict[m[1]] = m[2];
}
return dict;
}
function fetchMsg(lang) {
var url = I18N_DIR + lang + '/msg.txt';
if (typeof global.fetch !== 'function') {
return Promise.reject({ code: 'PBL_E_NETWORK', message: 'fetch unavailable: ' + url });
}
return global.fetch(url, { credentials: 'same-origin', cache: 'no-cache' }).then(function (resp) {
if (!resp || !resp.ok) {
throw { code: 'PBL_E_NETWORK', message: 'i18n http ' + (resp && resp.status) + ' ' + url };
}
return resp.text();
}).then(function (body) {
var dict = parseMsg(body);
if (!Object.keys(dict).length) {
throw { code: 'PBL_E_I18N', message: 'i18n empty/unparsable ' + url };
}
return dict;
});
}
// 模板占位替换:{title}/{key}/{budget}/{src}…,args 来自 data-pbl-i18n-args(JSON)
function fmt(tpl, args) {
var out = String(tpl);
if (!args || typeof args !== 'object') { return out; }
for (var k in args) {
if (!Object.prototype.hasOwnProperty.call(args, k)) { continue; }
var val = args[k] == null ? '' : String(args[k]);
out = out.split('{' + k + '}').join(val);
}
return out;
}
function readArgs(node) {
var raw = node.getAttribute('data-pbl-i18n-args');
if (!raw) { return null; }
try { return JSON.parse(raw) || null; } catch (e) { return null; }
}
// 把字典套到 [data-pbl-i18n] 节点上;返回覆盖节点数。只写 textContent,不改结构。
function applyMsgs(dict) {
var applied = 0;
if (!dict || typeof document === 'undefined' || !document.querySelectorAll) { return applied; }
var nodes = document.querySelectorAll('[data-pbl-i18n]');
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];
var key = node.getAttribute('data-pbl-i18n');
if (!key || !Object.prototype.hasOwnProperty.call(dict, key)) { continue; }
var text = fmt(dict[key], readArgs(node));
if (node.textContent !== text) {
node.textContent = text;
}
node.setAttribute('data-pbl-i18n-lang', currentLangCode || detectLang());
applied++;
}
return applied;
}
/* 语言切换钩子:
* 1) 宿主/bricks 可派发事件 pbl-lang-change / bricks-lang-change / languagechange,
* detail 支持 'en' | 'zh' | {lang:'en'} | {language:'en-US'};
* 2) 也可直接调 window.__pblSetLang('en') / PblOverlayLoader.setLang('en');
* 3) 覆盖层是异步渲染的(boot 之后节点才出现),故加 MutationObserver,
* 新挂载的 [data-pbl-i18n] 节点立即按当前语言覆盖。 */
function bindLangHooks() {
if (langHooked || typeof document === 'undefined') { return; }
langHooked = true;
var EVENTS = ['pbl-lang-change', 'pbl:langchange', 'bricks-lang-change', 'bricks:langchange', 'languagechange'];
for (var i = 0; i < EVENTS.length; i++) {
(function (name) {
try {
global.addEventListener(name, function (ev) {
var d = ev && (ev.detail !== undefined ? ev.detail : ev.lang);
if (d && typeof d === 'object') { d = d.lang || d.language || d.value || ''; }
loadLang(d ? normalizeLang(d) : detectLang());
});
} catch (e) { /* 宿主不支持该事件名则忽略 */ }
})(EVENTS[i]);
}
try {
if (typeof global.MutationObserver === 'function' && document.body) {
observer = new global.MutationObserver(function (muts) {
if (!currentMsgs || !Object.keys(currentMsgs).length) { return; }
for (var i = 0; i < muts.length; i++) {
var added = muts[i].addedNodes || [];
for (var j = 0; j < added.length; j++) {
var n = added[j];
if (!n || n.nodeType !== 1) { continue; }
if (n.getAttribute && n.getAttribute('data-pbl-i18n')) { applyMsgs(currentMsgs); return; }
if (n.querySelector && n.querySelector('[data-pbl-i18n]')) { applyMsgs(currentMsgs); return; }
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
} catch (e) { /* 观察失败不影响主链路 */ }
}
/* 加载并应用某语言文案表。幂等:同一 lang 只 fetch 一次(后续复用缓存)。
* 失败不抛出——降级为内置中文并打 degrade 横幅,返回 degraded:true。 */
function loadLang(lang) {
var want = normalizeLang(lang || detectLang());
bindLangHooks();
if (!langCache[want]) {
langCache[want] = fetchMsg(want).then(function (dict) {
return { dict: dict, degraded: false };
}).catch(function (err) {
degrade('PBL_E_I18N', FALLBACK_ZH['pbl.overlay.err.i18n']);
try {
if (global.console && global.console.warn) {
global.console.warn('[pbl_overlay] i18n fallback to built-in zh:', err && (err.message || err.code || err));
}
} catch (e) { /* 无 console 环境忽略 */ }
var copy = {};
for (var k in FALLBACK_ZH) {
if (Object.prototype.hasOwnProperty.call(FALLBACK_ZH, k)) { copy[k] = FALLBACK_ZH[k]; }
}
return { dict: copy, degraded: true };
});
}
return langCache[want].then(function (res) {
currentMsgs = res.dict;
currentLangCode = want;
global.__PBL_MSGS__ = currentMsgs;
global.__PBL_LANG__ = want;
var applied = applyMsgs(currentMsgs);
return { lang: want, applied: applied, degraded: !!res.degraded, keys: Object.keys(currentMsgs).length };
});
}
function setLang(lang) {
var want = normalizeLang(lang);
global.__PBL_LANG__ = want;
try {
if (global.localStorage) { global.localStorage.setItem(LANG_STORAGE_KEY, want); }
} catch (e) { /* 隐私模式等场景忽略 */ }
return loadLang(want);
}
// 幂等启动:已存在实例则复用(切换页面上下文时可 destroy 后重建)
function start(opts) {
return load().then(function () {
if (!global.PblOverlayPlay) { throw { code: 'PBL_E_NOT_FOUND', message: 'PblOverlayPlay 未注册' }; }
if (global.__PBL_OVERLAY__ && global.__PBL_OVERLAY__.mounted) { return global.__PBL_OVERLAY__; }
return global.PblOverlayPlay.boot(opts || {}) || global.__PBL_OVERLAY__;
}).then(function (booted) {
// boot 完成后覆盖层节点已入 DOM —— 此时套一次文案(QC #7:boot 后调一次)
return loadLang((opts && opts.lang) || currentLangCode || detectLang()).then(function () {
return booted;
});
}).catch(function (err) {
degrade((err && err.code) || 'PBL_E_LOAD_FAIL', (err && err.message) || String(err));
degrade((err && err.code) || 'PBL_E_LOAD_FAIL',
(err && err.message) || currentMsgs['pbl.overlay.err.loadfail'] || String(err));
throw err;
});
}
function t(key, args) {
var tpl = (key && currentMsgs[key]) || FALLBACK_ZH[key] || '';
return tpl ? fmt(tpl, args) : '';
}
global.PblOverlayLoader = {
MODULE: MODULE,
DIR: DIR,
FILES: FILES,
CSS: CSS,
I18N_DIR: I18N_DIR,
FALLBACK_ZH: FALLBACK_ZH,
apiBase: apiBase,
load: load,
start: start,
degrade: degrade
degrade: degrade,
// ── i18n 运行时 API(QC #7) ──
parseMsg: parseMsg,
detectLang: detectLang,
normalizeLang: normalizeLang,
loadLang: loadLang,
setLang: setLang,
applyMsgs: applyMsgs,
getLang: function () { return currentLangCode || detectLang(); },
t: t
};
global.__pblOverlayFail = degrade;
global.__pblSetLang = setLang;
})(window);