pbls/wwwroot/pbl_overlay/pbl_overlay.js

226 lines
9.1 KiB
JavaScript
Raw Permalink 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.js — PBL 前端覆盖层M9
* 挂载方式:在 scense 渲染容器之上叠加 DOM 覆盖层 + 事件处理器,
* 不修改 scense/scense_game 渲染主循环render loop untouched
* 性能承诺:广播渲染 <= 500mspoll/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 =
'<div class="pbl-topbar" data-c="TopBar"></div>' +
'<div class="pbl-navrail" data-c="NavRail"></div>' +
'<div class="pbl-main" data-page="blueprint_list"></div>' +
'<div class="pbl-hud" data-c="HudOverlay"></div>';
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 '<div class="pbl-goal">' + escapeHtml(g.goal_code) + ' : ' + g.percent + '%</div>';
}).join('');
};
PblOverlay.prototype.showPage = function (code) {
if (PAGES.indexOf(code) < 0) { return; }
var main = this.root.querySelector('.pbl-main');
main.setAttribute('data-page', code);
this.api('overlay.save', {
play_session_id: this.playSessionId, participant_id: this.participantId,
page_code: code, overlay: { visited_at: Date.now() }
}).catch(function () { });
};
PblOverlay.prototype.degrade = function (key, on) {
var info = ERROR_STATES[key];
if (!info) return;
var box = this.root.querySelector('.pbl-error') || document.createElement('div');
if (on) {
box.className = 'pbl-error';
box.innerHTML = '<b>' + info[0] + '</b><span>' + info[1] + '</span>' +
'<button data-act="retry">重试</button>';
if (!box.parentNode) this.root.appendChild(box);
} else if (box.parentNode) {
box.parentNode.removeChild(box);
}
};
PblOverlay.prototype.toast = function (msg, isErr) {
var t = document.createElement('div');
t.className = 'pbl-toast' + (isErr ? ' pbl-toast-err' : '');
t.textContent = msg;
this.root.appendChild(t);
setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 3000);
};
PblOverlay.prototype.api = function (contract, body) {
var self = this;
return fetch(API + '/' + contract, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Tenant-Id': this.tenantId || '' },
credentials: 'include',
body: JSON.stringify(body || {})
}).then(function (r) {
if (r.status === 401) { self.degrade('AUTH_FAIL', true); throw { code: 'AUTH_FAIL' }; }
if (r.status === 429) { self.degrade('RATE_LIMIT', true); throw { code: 'RATE_LIMIT' }; }
return r.json();
});
};
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
global.PblOverlay = PblOverlay;
global.PBL_PAGES = PAGES;
global.PBL_COMPONENTS = COMPONENTS;
global.PBL_ERROR_STATES = ERROR_STATES;
})(window);