366 lines
16 KiB
JavaScript
366 lines
16 KiB
JavaScript
/* ============================================================================
|
||
* 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);
|