pbls/wwwroot/pbl_overlay/pbl_overlay.js

226 lines
9.1 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.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 =
'<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);