/* * pbl_overlay.js — PBL 前端覆盖层(M9) * 挂载方式:在 scense 渲染容器之上叠加 DOM 覆盖层 + 事件处理器, * 不修改 scense/scense_game 渲染主循环(render loop untouched)。 * 性能承诺:广播渲染 <= 500ms(poll/subscribe 增量应用);首屏 <= 1.5s(骨架先行 + 懒加载数据)。 * 页面:12 页面(page_code 路由);组件:11 个;错误降级态:9 类。 */ (function (global) { 'use strict'; var API = '/api/pbls'; // 应用唯一入口 :9300 var POLL_MS = 3000; // 3s 轮询兜底(与后端 POLL_FALLBACK_MS 一致) var BROADCAST_BUDGET_MS = 500; // 广播渲染预算 var FIRST_SCREEN_BUDGET_MS = 1500; // 首屏预算 // 12 页面(ui-design.md) var PAGES = ['blueprint_list', 'blueprint_editor', 'goal_editor', 'scene_editor', 'step_editor', 'rule_editor', 'validation_report', 'compile_artifacts', 'agent_chat', 'approval_center', 'play_launcher', 'assessment_report']; // 11 组件(覆盖层内,不改渲染层) var COMPONENTS = ['TopBar', 'NavRail', 'GoalCard', 'SceneCard', 'StepList', 'RubricEditor', 'ValidationBadge', 'ArtifactViewer', 'ChatPanel', 'ApprovalQueue', 'HudOverlay']; // 9 类错误降级态 var ERROR_STATES = { NET_FAIL: ['网络中断', '显示缓存数据 + 重试按钮'], AUTH_FAIL: ['登录失效', '跳转登录,保留草稿于 localStorage'], LOAD_FAIL: ['数据加载失败', '骨架占位 + 局部重试'], VALIDATE_BLOCK: ['质量门禁未过', '阻断编译按钮,跳转校验报告'], APPROVAL_PENDING: ['待人工审批', '按钮置灰 + 审批单号提示'], LLM_FALLBACK: ['LLM 不可用', '离线模板兜底提示(US-13)'], RATE_LIMIT: ['请求过于频繁', '退避倒计时后自动重试'], SESSION_ENDED: ['会话已结束', '只读回放模式'], RENDER_DEGRADED: ['渲染降级', '覆盖层保留,2D 占位图'] }; function PblOverlay(opts) { this.root = opts.root; // 覆盖层容器(scense 容器之上) this.playSessionId = opts.playSessionId || null; this.participantId = opts.participantId || null; this.tenantId = opts.tenantId; this.afterSeq = 0; this.timer = null; this.state = {}; this.mount(); } PblOverlay.prototype.mount = function () { var t0 = performance.now(); this.root.className = 'pbl-overlay'; this.root.innerHTML = '
' + '' + '' + ''; this.bindEvents(); this.loadFirstScreen().then(function () { var cost = performance.now() - t0; if (cost > FIRST_SCREEN_BUDGET_MS) { global.console.warn('[pbl_overlay] first screen %.0fms > %.0fms budget', cost, FIRST_SCREEN_BUDGET_MS); } }); }; /* 事件处理器:只监听/派发,不介入渲染主循环 */ PblOverlay.prototype.bindEvents = function () { var self = this; this.root.addEventListener('click', function (e) { var act = e.target.getAttribute('data-act'); if (!act) return; var handlers = { 'send-command': function (el) { self.sendCommand(el); }, 'retry': function () { self.pollOnce(); }, 'goto-approval': function () { self.showPage('approval_center'); }, 'goto-validation': function () { self.showPage('validation_report'); } }; if (handlers[act]) handlers[act](e.target); }); // scense 侧事件桥(scense 通过 CustomEvent 通知,覆盖层被动响应) if (global.addEventListener) { global.addEventListener('scense:entity-clicked', function (e) { self.onEntityClicked(e.detail); }); global.addEventListener('scense:ready', function () { self.degrade('RENDER_DEGRADED', false); }); } }; PblOverlay.prototype.loadFirstScreen = function () { var self = this; return this.api('play.list', { blueprint_id: self.blueprintId }).then(function (r) { self.state.plays = (r.data && r.data.items) || []; self.renderHud(); }).catch(function () { self.degrade('LOAD_FAIL', true); }); }; /* 服务端权威命令:client_seq 幂等;state_version 由服务端维护,客户端不传不校验 */ PblOverlay.prototype.sendCommand = function (el) { var self = this; this._seq = (this._seq || 0) + 1; var body = { play_session_id: this.playSessionId, actor_id: this.participantId, client_seq: 'c-' + (this.participantId || 'anon') + '-' + this._seq, entity_id: el.getAttribute('data-entity'), capability: el.getAttribute('data-cap'), command: el.getAttribute('data-cmd'), state_delta: JSON.parse(el.getAttribute('data-delta') || '{}') }; return this.api('runtime.command', body).then(function (r) { var d = r.data || {}; if (d.ack_result === 'DENIED') { self.toast('操作被拒绝: ' + (d.reason || ''), true); } else if (d.ack_result === 'ACCEPTED') { self.afterSeq = Math.max(self.afterSeq, d.event_seq || 0); } return d; }).catch(function (e) { self.degrade(String(e.code || 'NET_FAIL'), true); }); }; PblOverlay.prototype.onEntityClicked = function (detail) { if (!detail) return; this.api('runtime.state_read', { play_session_id: this.playSessionId, entity_id: detail.entityId }).then(function (r) { global.dispatchEvent(new CustomEvent('pbl:entity-state', { detail: r.data })); }).catch(function () { }); }; /* 增量拉取并渲染:预算 500ms,超时降级为下一次轮询 */ PblOverlay.prototype.pollOnce = function () { if (!this.playSessionId) return; var self = this, t0 = performance.now(); this.api('runtime.poll', { play_session_id: this.playSessionId, after_seq: this.afterSeq }) .then(function (r) { var d = r.data || {}; (d.events || []).forEach(function (ev) { self.afterSeq = Math.max(self.afterSeq, ev.event_seq); if (performance.now() - t0 > BROADCAST_BUDGET_MS) { return; } global.dispatchEvent(new CustomEvent('pbl:broadcast', { detail: ev })); }); self.state.states = d.states || self.state.states; self.renderHud(); }) .catch(function () { self.degrade('NET_FAIL', true); }); }; PblOverlay.prototype.startPolling = function () { var self = this; this.stopPolling(); this.timer = setInterval(function () { self.pollOnce(); }, POLL_MS); }; PblOverlay.prototype.stopPolling = function () { if (this.timer) { clearInterval(this.timer); this.timer = null; } }; PblOverlay.prototype.renderHud = function () { var hud = this.root.querySelector('.pbl-hud'); if (!hud) return; var goals = (this.state.goalProgress || []); hud.innerHTML = goals.map(function (g) { return '