226 lines
9.1 KiB
JavaScript
226 lines
9.1 KiB
JavaScript
/*
|
||
* 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||
});
|
||
}
|
||
|
||
global.PblOverlay = PblOverlay;
|
||
global.PBL_PAGES = PAGES;
|
||
global.PBL_COMPONENTS = COMPONENTS;
|
||
global.PBL_ERROR_STATES = ERROR_STATES;
|
||
})(window);
|