pbl_scense_ext/wwwroot/overlay/pbl_overlay_loader.js
2026-09-21 13:17:15 +08:00

366 lines
16 KiB
JavaScript
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.

/* ============================================================================
* 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() 完成装配。
*
* 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 (覆盖层装配)
* - /pbl_scense_ext/pbl_overlay/<page>.ui × 12 → logined (页面壳)
*
* 铁律:只注入本模块静态资源,不改写宿主(scense/scense_game)渲染层任何脚本。
* ==========================================================================*/
(function (global) {
'use strict';
var MODULE = 'pbl_scense_ext';
function apiBase() {
// 宿主显式注入优先;否则从当前 pathname 去掉模块路由尾部推应用根
var inj = String(global.__PBL_API_BASE__ || '');
if (inj) { return inj.replace(/\/$/, ''); }
return String(global.location && global.location.pathname || '')
.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'];
var CSS = 'pbl_overlay.css';
var injected = {};
var loading = null;
/* 注入失败时的最小可见降级(不依赖 core,避免「白屏无提示」)。
* 页面壳的 script 里也引用本函数:window.__pblOverlayFail(code, message) */
function degrade(code, message) {
try {
var host = document.querySelector('[data-pbl-overlay-root]') || document.body;
var box = document.getElementById('pbl-loader-degrade');
if (!box) {
box = document.createElement('div');
box.id = 'pbl-loader-degrade';
box.setAttribute('class', 'pbl-banner pbl-banner--error');
host.appendChild(box);
}
box.textContent = '[' + (code || 'PBL_E_NETWORK') + '] ' + (message || '覆盖层加载失败');
box.setAttribute('data-pbl-degrade', '1');
} catch (e) { /* DOM 不可用时静默,不抛出打断宿主 */ }
return { code: code || 'PBL_E_NETWORK', message: message || 'overlay load failed' };
}
function loadOne(src) {
return new Promise(function (resolve, reject) {
if (injected[src]) { return resolve(true); }
var s = document.createElement('script');
s.src = src;
s.async = false;
s.onload = function () { injected[src] = true; resolve(true); };
s.onerror = function () { reject({ code: 'PBL_E_NETWORK', message: '资源加载失败(403/404?) ' + src }); };
document.head.appendChild(s);
});
}
function ensureCss() {
var id = 'pbl-overlay-css';
if (document.getElementById(id)) { return; }
var link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.href = DIR + CSS;
document.head.appendChild(link);
}
function load() {
ensureCss();
if (loading) { return loading; }
loading = FILES.reduce(function (p, f) {
return p.then(function () { return loadOne(DIR + f); });
}, Promise.resolve());
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) || 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,
// ── 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);