deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
537f309bc4
commit
22b9d575e9
122
wwwroot/overlay/pbl_overlay.css
Normal file
122
wwwroot/overlay/pbl_overlay.css
Normal file
@ -0,0 +1,122 @@
|
||||
/* ============================================================================
|
||||
* pbl_overlay.css —— PBL 覆盖层样式(M9)
|
||||
* 约定:全部选择器以 .pbl- 前缀隔离,绝不覆盖宿主(scense/scense_game)渲染层样式;
|
||||
* 覆盖层为「浮层」定位,pointer-events 精确到子元素,避免遮挡世界渲染交互。
|
||||
* ==========================================================================*/
|
||||
|
||||
.pbl-overlay-root { position: relative; }
|
||||
|
||||
/* ── 页面壳:数据驱动布局,宿主渲染区由 data-pbl-host 标记 ── */
|
||||
[data-pbl-page] { min-height: 100%; }
|
||||
|
||||
.pbl-topbar { width: 100%; }
|
||||
.pbl-topbar-in {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 16px; background: #ffffff; border-bottom: 1px solid #e6e8eb;
|
||||
}
|
||||
.pbl-title { font-size: 18px; font-weight: 600; color: #1f2329; }
|
||||
.pbl-sub { font-size: 12px; color: #8a9099; }
|
||||
.pbl-spacer { flex: 1; }
|
||||
.pbl-badge {
|
||||
font-size: 12px; padding: 2px 8px; border-radius: 10px;
|
||||
background: #f2f3f5; color: #4e5968; white-space: nowrap;
|
||||
}
|
||||
.pbl-badge.is-ok { background: #e8f7ee; color: #17864a; }
|
||||
.pbl-vbadge { font-size: 12px; padding: 2px 8px; border-radius: 10px; }
|
||||
.pbl-vbadge.is-ok { background: #e8f7ee; color: #17864a; }
|
||||
.pbl-vbadge.is-warn { background: #fff7e6; color: #a86b00; }
|
||||
.pbl-vbadge.is-block { background: #fdecec; color: #b3261e; }
|
||||
|
||||
/* ── 导航条(12 页面) ── */
|
||||
.pbl-navrail { width: 100%; margin: 8px 0; }
|
||||
.pbl-navrail-in { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.pbl-navitem {
|
||||
font-size: 13px; padding: 6px 12px; border-radius: 4px;
|
||||
background: #f7f8fa; color: #4e5968; text-decoration: none; border: 1px solid transparent;
|
||||
}
|
||||
.pbl-navitem:hover { border-color: #c9cdd4; }
|
||||
.pbl-navitem.is-active { background: #1264e0; color: #fff; }
|
||||
|
||||
/* ── 主内容区卡片 ── */
|
||||
.pbl-main { width: 100%; }
|
||||
.pbl-card {
|
||||
background: #ffffff; border: 1px solid #e6e8eb; border-radius: 6px;
|
||||
padding: 14px 16px; margin: 10px 0;
|
||||
}
|
||||
.pbl-card h4 { margin: 0 0 8px; font-size: 14px; color: #1f2329; }
|
||||
.pbl-goal-q { font-size: 15px; color: #1264e0; margin: 4px 0; }
|
||||
.pbl-goal-m, .pbl-dim { font-size: 12px; color: #8a9099; margin: 4px 0; }
|
||||
.pbl-steps ol { margin: 0; padding-left: 20px; font-size: 13px; color: #4e5968; }
|
||||
.pbl-steps li.is-done { color: #17864a; text-decoration: line-through; }
|
||||
.pbl-rubric table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.pbl-rubric th, .pbl-rubric td { border-bottom: 1px solid #f0f1f3; padding: 6px 8px; text-align: left; }
|
||||
.pbl-empty { font-size: 12px; color: #a3a8b0; padding: 10px 0; }
|
||||
|
||||
/* ── 产出物 / 证据网格 ── */
|
||||
.pbl-grid { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.pbl-tile {
|
||||
display: flex; flex-direction: column; gap: 4px; min-width: 160px;
|
||||
padding: 10px; border: 1px solid #e6e8eb; border-radius: 4px; font-size: 12px;
|
||||
}
|
||||
.pbl-tile strong { font-size: 13px; color: #1f2329; }
|
||||
.pbl-tile em { color: #8a9099; font-style: normal; }
|
||||
|
||||
/* ── Agent 对话面板 ── */
|
||||
.pbl-chat-log { max-height: 260px; overflow-y: auto; font-size: 13px; }
|
||||
.pbl-msg { padding: 6px 8px; border-radius: 4px; margin: 4px 0; background: #f7f8fa; }
|
||||
.pbl-msg.is-user { background: #eaf2ff; }
|
||||
.pbl-msg.is-agent.is-fallback { background: #fff7e6; }
|
||||
.pbl-chat-input { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.pbl-chat-input input { flex: 1; padding: 6px 8px; border: 1px solid #c9cdd4; border-radius: 4px; }
|
||||
|
||||
/* ── 审批队列 ── */
|
||||
.pbl-approvals ul { margin: 0; padding-left: 18px; font-size: 13px; }
|
||||
.pbl-approvals li { display: flex; justify-content: space-between; gap: 10px; padding: 4px 0; }
|
||||
.pbl-approvals em { color: #a86b00; font-style: normal; }
|
||||
|
||||
/* ── HUD 浮层:覆盖在世界渲染区之上,但不阻断渲染层交互 ── */
|
||||
.pbl-hud { position: relative; pointer-events: none; }
|
||||
.pbl-hud-in { display: flex; flex-direction: column; gap: 8px; padding: 8px 0; }
|
||||
.pbl-hud-progress {
|
||||
height: 6px; border-radius: 3px; background: #eceef1; overflow: hidden;
|
||||
}
|
||||
.pbl-hud-progress span { display: block; height: 100%; width: 0; background: #1264e0; transition: width .2s linear; }
|
||||
.pbl-hud-role { font-size: 12px; color: #4e5968; }
|
||||
.pbl-hud-acts { display: flex; flex-wrap: wrap; gap: 6px; pointer-events: auto; }
|
||||
.pbl-hud-acts button {
|
||||
font-size: 12px; padding: 4px 10px; border-radius: 4px; cursor: pointer;
|
||||
border: 1px solid #c9cdd4; background: #fff; color: #1f2329;
|
||||
}
|
||||
.pbl-hud-acts button:hover { border-color: #1264e0; color: #1264e0; }
|
||||
.pbl-hud-feed { font-size: 12px; color: #4e5968; max-height: 90px; overflow: hidden; }
|
||||
.pbl-feedline { padding: 2px 0; border-bottom: 1px dashed #f0f1f3; }
|
||||
|
||||
/* ── 降级横幅(9 类错误态):只提示不阻断,可关闭 ── */
|
||||
.pbl-banners {
|
||||
position: sticky; top: 0; z-index: 9000; display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.pbl-banner {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px; border-radius: 4px; font-size: 13px; border: 1px solid transparent;
|
||||
}
|
||||
.pbl-banner .pbl-b-title { font-weight: 600; }
|
||||
.pbl-banner .pbl-b-desc { color: inherit; opacity: .85; }
|
||||
.pbl-banner .pbl-b-act { margin-left: auto; cursor: pointer; border: 1px solid currentColor; background: transparent; border-radius: 3px; padding: 2px 8px; font-size: 12px; }
|
||||
.pbl-banner.is-warn { background: #fff7e6; color: #a86b00; border-color: #ffe0a3; }
|
||||
.pbl-banner.is-error { background: #fdecec; color: #b3261e; border-color: #f7c7c4; }
|
||||
.pbl-banner.is-block { background: #f1ecff; color: #4b2fa8; border-color: #ddd2fb; }
|
||||
.pbl-banner.is-info { background: #eaf2ff; color: #1264e0; border-color: #cfe0fb; }
|
||||
|
||||
/* ── 骨架占位(LOAD_FAIL 局部重试) ── */
|
||||
.pbl-skeleton { height: 60px; border-radius: 4px; background: linear-gradient(90deg, #f2f3f5 25%, #e9eaec 37%, #f2f3f5 63%); background-size: 400% 100%; animation: pbl-sk 1.2s ease infinite; }
|
||||
@keyframes pbl-sk { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }
|
||||
|
||||
/* ── 只读态(审批挂起 / 权限不足时禁止编辑控件) ── */
|
||||
[data-readonly="1"] .pbl-hud-acts button,
|
||||
[data-readonly="1"] .pbl-chat-input { opacity: .5; pointer-events: none; }
|
||||
|
||||
/* ── 小屏适配:HUD 与导航换行,不遮挡渲染区 ── */
|
||||
@media (max-width: 768px) {
|
||||
.pbl-topbar-in { flex-wrap: wrap; }
|
||||
.pbl-navitem { font-size: 12px; padding: 5px 9px; }
|
||||
}
|
||||
255
wwwroot/overlay/pbl_overlay_core.js
Normal file
255
wwwroot/overlay/pbl_overlay_core.js
Normal file
@ -0,0 +1,255 @@
|
||||
/**
|
||||
* pbl_overlay_core.js —— PBL 前端覆盖层内核(M9 / pbl_scense_ext)
|
||||
*
|
||||
* 纪律(Q1/Q4):
|
||||
* ① 不改 scense / scense_game 渲染主循环,本文件只做「注册表 + 组件工厂 + 错误降级 + 时延预算」;
|
||||
* ② 前端仅渲染 + 发起意图,无本地权威状态,广播到达前不预测渲染;
|
||||
* ③ 页面/组件/错误态全部注册化,scense 基线升级时只改注册表不改渲染层。
|
||||
*
|
||||
* 暴露:window.PblOverlayCore = {PAGES, COMPONENTS, ERROR_STATES, SEQUENCES, BUDGETS, ...}
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
/* ───────────────────────── 1. 12 页面注册表(与 pbl_ui_page_registry 同源) ───────────────────────── */
|
||||
// budget_ms: 500 = 世界内覆盖层组件(广播渲染预算);1500 = 教师侧页面(首屏预算)
|
||||
var PAGES = [
|
||||
{ key: 'home', title: 'PBL 首页', route: '/pbl_overlay/home.ui', widget: 'card_grid', role: 'logined', budget_ms: 1500, in_world: false, seq: ['S1'] },
|
||||
{ key: 'blueprint_list', title: '蓝图列表', route: '/pbl_overlay/blueprint_list.ui', widget: 'data_table', role: 'teacher', budget_ms: 1500, in_world: false, seq: ['S1'] },
|
||||
{ key: 'blueprint_editor', title: '蓝图结构视图', route: '/pbl_overlay/blueprint_editor.ui', widget: 'form_tree', role: 'teacher', budget_ms: 1500, in_world: false, seq: ['S1'] },
|
||||
{ key: 'validation_report', title: '校验报告(14维)', route: '/pbl_overlay/validation_report.ui', widget: 'score_panel', role: 'teacher', budget_ms: 1500, in_world: false, seq: ['S1'] },
|
||||
{ key: 'agent_console', title: 'Agent 控制台', route: '/pbl_overlay/agent_console.ui', widget: 'chat_panel', role: 'teacher', budget_ms: 1500, in_world: false, seq: ['S1'] },
|
||||
{ key: 'approval_center', title: '人工审批中心', route: '/pbl_overlay/approval_center.ui', widget: 'approval_list', role: 'teacher', budget_ms: 1500, in_world: false, seq: ['S1'] },
|
||||
{ key: 'play_preview', title: 'Play 预览', route: '/pbl_overlay/play_preview.ui', widget: 'world_overlay', role: 'logined', budget_ms: 500, in_world: true, seq: ['S1', 'S2'] },
|
||||
{ key: 'mission_board', title: '驱动问题板', route: '/pbl_overlay/mission_board.ui', widget: 'mission_board', role: 'logined', budget_ms: 500, in_world: true, seq: ['S1', 'S2'] },
|
||||
{ key: 'role_panel', title: '角色面板', route: '/pbl_overlay/role_panel.ui', widget: 'role_cards', role: 'logined', budget_ms: 500, in_world: true, seq: ['S2'] },
|
||||
{ key: 'artifact_studio', title: '产出物工坊', route: '/pbl_overlay/artifact_studio.ui', widget: 'editor_panel', role: 'logined', budget_ms: 500, in_world: true, seq: ['S3'] },
|
||||
{ key: 'evidence_wall', title: '证据墙', route: '/pbl_overlay/evidence_wall.ui', widget: 'evidence_grid', role: 'logined', budget_ms: 500, in_world: true, seq: ['S3', 'S4'] },
|
||||
{ key: 'assessment_report', title: '评估报告', route: '/pbl_overlay/assessment_report.ui', widget: 'rubric_report', role: 'teacher', budget_ms: 1500, in_world: false, seq: ['S4'] }
|
||||
];
|
||||
|
||||
/* ───────────────────────── 2. 11 组件注册表(覆盖层内,不侵入渲染层) ───────────────────────── */
|
||||
var COMPONENTS = [
|
||||
{ name: 'TopBar', host: '.pbl-topbar', pages: ['*'] },
|
||||
{ name: 'NavRail', host: '.pbl-navrail', pages: ['*'] },
|
||||
{ name: 'GoalCard', host: '.pbl-main', pages: ['mission_board', 'play_preview'] },
|
||||
{ name: 'SceneCard', host: '.pbl-main', pages: ['play_preview', 'blueprint_editor'] },
|
||||
{ name: 'StepList', host: '.pbl-main', pages: ['mission_board', 'blueprint_editor'] },
|
||||
{ name: 'RubricEditor', host: '.pbl-main', pages: ['assessment_report'] },
|
||||
{ name: 'ValidationBadge',host: '.pbl-topbar', pages: ['*'] },
|
||||
{ name: 'ArtifactViewer', host: '.pbl-main', pages: ['artifact_studio', 'evidence_wall'] },
|
||||
{ name: 'ChatPanel', host: '.pbl-main', pages: ['agent_console'] },
|
||||
{ name: 'ApprovalQueue', host: '.pbl-main', pages: ['approval_center'] },
|
||||
{ name: 'HudOverlay', host: '.pbl-hud', pages: ['*'] }
|
||||
];
|
||||
|
||||
/* ───────────────────────── 3. 9 类错误降级态(ui-design.md §4 错误提示) ───────────────────────── */
|
||||
var ERROR_STATES = {
|
||||
NET_FAIL: { title: '网络中断', desc: '显示缓存数据 + 重试按钮', tone: 'warn', retry: true, action: 'retry' },
|
||||
AUTH_FAIL: { title: '登录失效', desc: '请重新登录,草稿已本地保留', tone: 'error', retry: false, action: 'relogin' },
|
||||
LOAD_FAIL: { title: '数据加载失败', desc: '骨架占位 + 局部重试', tone: 'error', retry: true, action: 'retry' },
|
||||
VALIDATE_BLOCK: { title: '质量门禁未过', desc: '已阻断编译,请查看校验报告', tone: 'block', retry: false, action: 'goto-validation' },
|
||||
APPROVAL_PENDING: { title: '待人工审批', desc: '操作已挂起,等待审批单裁决', tone: 'block', retry: false, action: 'goto-approval' },
|
||||
LLM_FALLBACK: { title: 'LLM 不可用', desc: '已切换离线模板兜底(template_fallback)', tone: 'warn', retry: false, action: 'none' },
|
||||
RATE_LIMIT: { title: '请求过于频繁', desc: '退避倒计时后自动重试', tone: 'warn', retry: true, action: 'backoff' },
|
||||
SESSION_ENDED: { title: '会话已结束', desc: '进入只读回放模式', tone: 'info', retry: false, action: 'readonly' },
|
||||
RENDER_DEGRADED: { title: '渲染降级', desc: '覆盖层保留,2D 占位图渲染', tone: 'warn', retry: false, action: 'none' }
|
||||
};
|
||||
|
||||
// 后端错误码 → 前端降级态(pbl_common §4 错误码文案映射)
|
||||
var ERROR_CODE_MAP = {
|
||||
'PBL_E_FORBIDDEN': 'AUTH_FAIL',
|
||||
'PBL_E_UNAUTHORIZED': 'AUTH_FAIL',
|
||||
'PBL_E_TENANT_MISSING': 'AUTH_FAIL',
|
||||
'PBL_E_TIMEOUT': 'NET_FAIL',
|
||||
'PBL_E_NETWORK': 'NET_FAIL',
|
||||
'PBL_E_NOT_FOUND': 'LOAD_FAIL',
|
||||
'PBL_FEEDBACK_TYPE_INVALID': 'LOAD_FAIL',
|
||||
'PBL_VALIDATION_BLOCK': 'VALIDATE_BLOCK',
|
||||
'PBL_APPROVAL_PENDING': 'APPROVAL_PENDING',
|
||||
'PBL_LLM_UNAVAILABLE': 'LLM_FALLBACK',
|
||||
'PBL_RATE_LIMIT': 'RATE_LIMIT',
|
||||
'PBL_SESSION_ENDED': 'SESSION_ENDED',
|
||||
'PBL_RENDER_DEGRADED': 'RENDER_DEGRADED'
|
||||
};
|
||||
|
||||
/* ───────────────────────── 4. 4 组交互时序(§4 学生玩家页交互契约) ───────────────────────── */
|
||||
var SEQUENCES = {
|
||||
S1: { name: 'enter_world', desc: '进入世界:bootstrap → GD/状态快照 → 首屏渲染(≤1500ms)',
|
||||
steps: ['pbl_overlay_bootstrap', 'gd_snapshot_render', 'first_screen_gate'] },
|
||||
S2: { name: 'intent_broadcast', desc: '做任务:submit_intent → 广播回传 → 覆盖层更新(≤500ms)',
|
||||
steps: ['submit_intent', 'broadcast_recv', 'overlay_patch_gate'] },
|
||||
S3: { name: 'artifact_evidence', desc: '产出物:create_artifact → submit_artifact → collect_evidence(幂等)',
|
||||
steps: ['create_artifact', 'submit_artifact', 'collect_evidence'] },
|
||||
S4: { name: 'feedback_reflection', desc: '反思/反馈:pbl_playtest_feedback_save(feedback|reflection)',
|
||||
steps: ['open_form', 'validate', 'save'] }
|
||||
};
|
||||
|
||||
/* ───────────────────────── 5. 时延预算(Performance API 实测,超预算打横幅) ───────────────────────── */
|
||||
var BUDGETS = {
|
||||
first_screen_ms: 1500, // 首屏
|
||||
broadcast_render_ms: 500, // 广播到达 → 覆盖层 DOM 更新完成
|
||||
poll_interval_ms: 1000, // 长轮询节拍(SSE 不可用时降级)
|
||||
intent_timeout_ms: 8000
|
||||
};
|
||||
|
||||
/* ───────────────────────── 6. 工具函数 ───────────────────────── */
|
||||
function byKey(k) {
|
||||
for (var i = 0; i < PAGES.length; i++) { if (PAGES[i].key === k) { return PAGES[i]; } }
|
||||
return null;
|
||||
}
|
||||
|
||||
function pagesOfRole(role) {
|
||||
return PAGES.filter(function (p) {
|
||||
if (p.role === 'logined') { return true; }
|
||||
return p.role === role;
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s === null || s === undefined ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function now() {
|
||||
return (global.performance && performance.now) ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
/** 时延计录器:mark 起 / stop 止,超预算回调 onOver(ms) */
|
||||
function Chrono(label, budgetMs, onOver) {
|
||||
this.label = label;
|
||||
this.budget = budgetMs;
|
||||
this.t0 = now();
|
||||
this.onOver = onOver;
|
||||
this.done = false;
|
||||
}
|
||||
Chrono.prototype.stop = function (extra) {
|
||||
if (this.done) { return this.cost(); }
|
||||
var cost = now() - this.t0;
|
||||
this._cost = cost;
|
||||
this.done = true;
|
||||
global.__PBL_PERF__ = global.__PBL_PERF__ || {};
|
||||
global.__PBL_PERF__[this.label] = Math.round(cost);
|
||||
if (this.budget && cost > this.budget && typeof this.onOver === 'function') {
|
||||
this.onOver(cost, extra);
|
||||
}
|
||||
return cost;
|
||||
};
|
||||
Chrono.prototype.cost = function () { return this._cost === undefined ? (now() - this.t0) : this._cost; };
|
||||
|
||||
/* ───────────────────────── 7. 事件总线(只监听/派发,不介入渲染主循环) ───────────────────────── */
|
||||
var Bus = {
|
||||
/** 监听基座事件(scense:ready / scense:entity-clicked / scense:state-applied ...) */
|
||||
on: function (name, fn) {
|
||||
global.addEventListener(name, fn);
|
||||
return function () { global.removeEventListener(name, fn); };
|
||||
},
|
||||
/** 派发覆盖层事件(pbl:* 前缀,基座与第三方页面可订阅) */
|
||||
emit: function (name, detail) {
|
||||
var evt;
|
||||
try {
|
||||
evt = new CustomEvent(name, { detail: detail || {}, bubbles: true, cancelable: false });
|
||||
} catch (e) {
|
||||
evt = document.createEvent('CustomEvent');
|
||||
evt.initCustomEvent(name, true, false, detail || {});
|
||||
}
|
||||
document.dispatchEvent(evt);
|
||||
return evt;
|
||||
}
|
||||
};
|
||||
|
||||
/* ───────────────────────── 8. 错误降级管理器 ───────────────────────── */
|
||||
function DegradeManager(root, handlers) {
|
||||
this.root = root;
|
||||
this.handlers = handlers || {};
|
||||
this.active = {}; // key -> true
|
||||
this.backoffUntil = 0;
|
||||
}
|
||||
DegradeManager.prototype.show = function (key, opts) {
|
||||
var st = ERROR_STATES[key];
|
||||
if (!st) { return false; }
|
||||
this.active[key] = true;
|
||||
this.ensureBanner();
|
||||
var cls = 'pbl-banner pbl-banner-' + st.tone + (opts && opts.transient ? ' pbl-transient' : '');
|
||||
var btn = st.retry
|
||||
? '<button class="pbl-banner-btn" data-act="' + st.action + '">' + (st.action === 'backoff' ? '稍后自动重试' : '重试') + '</button>'
|
||||
: (st.action === 'none' ? '' : '<button class="pbl-banner-btn" data-act="' + st.action + '">前往处理</button>');
|
||||
var el = document.createElement('div');
|
||||
el.className = cls;
|
||||
el.setAttribute('data-error-state', key);
|
||||
el.innerHTML = '<b>' + esc(st.title) + '</b><span>' + esc((opts && opts.detail) || st.desc) + '</span>' + btn;
|
||||
this.bannerHost.appendChild(el);
|
||||
var self = this;
|
||||
el.addEventListener('click', function (e) {
|
||||
var act = (e.target && e.target.getAttribute && e.target.getAttribute('data-act')) || '';
|
||||
if (!act) { return; }
|
||||
var h = self.handlers[act];
|
||||
if (typeof h === 'function') { h(e); }
|
||||
if (act === 'retry') { self.hide(key); }
|
||||
});
|
||||
if (opts && opts.transient) {
|
||||
global.setTimeout(function () { self.hide(key); }, opts.ms || 4000);
|
||||
}
|
||||
Bus.emit('pbl:degrade', { state: key, tone: st.tone });
|
||||
return true;
|
||||
};
|
||||
DegradeManager.prototype.hide = function (key) {
|
||||
delete this.active[key];
|
||||
if (!this.bannerHost) { return; }
|
||||
var list = this.bannerHost.querySelectorAll('[data-error-state="' + key + '"]');
|
||||
for (var i = 0; i < list.length; i++) { list[i].parentNode.removeChild(list[i]); }
|
||||
};
|
||||
DegradeManager.prototype.mapCode = function (code) { return ERROR_CODE_MAP[code] || 'LOAD_FAIL'; };
|
||||
DegradeManager.prototype.fromError = function (err) {
|
||||
var code = (err && (err.code || err.error_code)) || '';
|
||||
var key = this.mapCode(code);
|
||||
return this.show(key, { detail: (err && (err.message || err.msg)) || '' });
|
||||
};
|
||||
DegradeManager.prototype.ensureBanner = function () {
|
||||
if (this.bannerHost && this.bannerHost.parentNode) { return; }
|
||||
this.bannerHost = document.createElement('div');
|
||||
this.bannerHost.className = 'pbl-banners';
|
||||
this.root.appendChild(this.bannerHost);
|
||||
};
|
||||
DegradeManager.prototype.setReadonly = function (on) {
|
||||
this.readonly = !!on;
|
||||
if (this.root) { this.root.setAttribute('data-readonly', on ? '1' : '0'); }
|
||||
};
|
||||
|
||||
/* ───────────────────────── 9. 组件工厂(覆盖层内 DOM 组件,与渲染层解耦) ───────────────────────── */
|
||||
function Components() { this.defs = {}; }
|
||||
Components.prototype.register = function (name, render) {
|
||||
this.defs[name] = render;
|
||||
return this;
|
||||
};
|
||||
Components.prototype.render = function (name, host, model) {
|
||||
var fn = this.defs[name];
|
||||
if (!fn || !host) { return false; }
|
||||
var t0 = now();
|
||||
fn(host, model || {});
|
||||
host.setAttribute('data-c-rendered', name);
|
||||
host.setAttribute('data-c-cost-ms', String(Math.round(now() - t0)));
|
||||
return true;
|
||||
};
|
||||
Components.prototype.has = function (name) { return !!this.defs[name]; };
|
||||
Components.prototype.registered = function () { return Object.keys(this.defs); };
|
||||
|
||||
global.PblOverlayCore = {
|
||||
VERSION: '1.0.0',
|
||||
PAGES: PAGES,
|
||||
COMPONENTS: COMPONENTS,
|
||||
ERROR_STATES: ERROR_STATES,
|
||||
ERROR_CODE_MAP: ERROR_CODE_MAP,
|
||||
SEQUENCES: SEQUENCES,
|
||||
BUDGETS: BUDGETS,
|
||||
byKey: byKey,
|
||||
pagesOfRole: pagesOfRole,
|
||||
esc: esc,
|
||||
now: now,
|
||||
Chrono: Chrono,
|
||||
Bus: Bus,
|
||||
DegradeManager: DegradeManager,
|
||||
Components: Components
|
||||
};
|
||||
})(window);
|
||||
71
wwwroot/overlay/pbl_overlay_loader.js
Normal file
71
wwwroot/overlay/pbl_overlay_loader.js
Normal file
@ -0,0 +1,71 @@
|
||||
/* ============================================================================
|
||||
* pbl_overlay_loader.js —— 覆盖层脚本/CSS 按需注入器(M9)
|
||||
*
|
||||
* 为什么需要它:bricks .ui 页面禁止硬写 <script>/<link> 标签(会绕过 RBAC 或
|
||||
* 触发 403)。本 loader 由页面壳的 bricks `script` actiontype 在用户点击
|
||||
* 「启用 PBL 覆盖层」时调用,动态注入同模块 wwwroot/overlay 下的 JS/CSS
|
||||
* (这些路径已在 scripts/load_path.py 显式注册为 any/logined),注入完成后
|
||||
* 调用 PblOverlayPlay.boot() 完成装配。
|
||||
*
|
||||
* 铁律:只注入本模块静态资源,不改写宿主(scense/scense_game)渲染层任何脚本。
|
||||
* ==========================================================================*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var BASE = String(global.__PBL_API_BASE__ || '').replace(/\/$/, '');
|
||||
var DIR = BASE + '/pbl_scense_ext/overlay/';
|
||||
|
||||
// 依赖顺序: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;
|
||||
|
||||
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: '资源加载失败 ' + src }); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureCss() {
|
||||
var id = 'pbl-overlay-css';
|
||||
if (document.getElementById(id)) { return; }
|
||||
var l = document.createElement('link');
|
||||
l.id = id;
|
||||
l.rel = 'stylesheet';
|
||||
l.href = DIR + CSS;
|
||||
document.head.appendChild(l);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 幂等启动:已存在实例则复用(切换页面上下文时可 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__;
|
||||
});
|
||||
}
|
||||
|
||||
global.PblOverlayLoader = {
|
||||
DIR: DIR,
|
||||
FILES: FILES,
|
||||
load: load,
|
||||
start: start
|
||||
};
|
||||
})(window);
|
||||
776
wwwroot/overlay/pbl_overlay_play.js
Normal file
776
wwwroot/overlay/pbl_overlay_play.js
Normal file
@ -0,0 +1,776 @@
|
||||
/* ============================================================================
|
||||
* pbl_overlay_play.js —— M9 覆盖层装配 + 事件处理器(scense / scense_game 侧)
|
||||
*
|
||||
* 铁律:
|
||||
* 1. 不修改 scense/scense_game 渲染主循环 —— 只做「外挂」:探测宿主只读钩子
|
||||
* (window.scenseGame / CustomEvent),钩子缺失时降级为契约轮询,绝不
|
||||
* 包裹/注入宿主帧函数(requestAnimationFrame 链不劫持)。
|
||||
* 2. 广播到达 → 覆盖层 DOM 更新完成 ≤ 500ms(BUDGETS.broadcast_render_ms),
|
||||
* 超预算打 RENDER_DEGRADED 横幅并自适应节流合并补丁,不丢数据、不静默。
|
||||
* 3. 首屏 ≤ 1500ms(BUDGETS.first_screen_ms),超时走 LOAD_FAIL 骨架 + 局部重试。
|
||||
* 4. 网络交互只走 pbl_overlay_transport.js 契约端点(pbl_* 模块 .dspy)。
|
||||
*
|
||||
* 依赖加载顺序:pbl_overlay_core.js → pbl_overlay_transport.js → 本文件
|
||||
* API 对齐:Core.Bus(全局单例,on=window 监听 / emit=document CustomEvent)、
|
||||
* Core.Chrono(label,budget,onOver).stop()、
|
||||
* Core.DegradeManager(root).show(key,{detail})/.fromError(err)、
|
||||
* Tpt.Transport({session_id,blueprint_id,onEvent,onDegrade,onPerf})
|
||||
* ==========================================================================*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var Core = global.PblOverlayCore;
|
||||
var Tpt = global.PblOverlayTransport;
|
||||
if (!Core || !Tpt) {
|
||||
if (global.console) { console.error('[pbl_overlay_play] core/transport 未加载'); }
|
||||
return;
|
||||
}
|
||||
|
||||
var BUDGETS = Core.BUDGETS;
|
||||
var esc = Core.esc;
|
||||
var now = Core.now;
|
||||
|
||||
/* ───────────────────────── 1. 页面路由解析(注册表 route → 实际 URL) ───────────────────────── */
|
||||
// 设计注册表 route 形如 /pbl_overlay/mission_board.ui;
|
||||
// 模块自动路由实际为 /pbl_scense_ext/pbl_overlay/mission_board.ui
|
||||
var MODULE_PREFIX = '/pbl_scense_ext';
|
||||
|
||||
function pageUrl(page) {
|
||||
var route = (page && page.route) || '';
|
||||
if (!route) { return ''; }
|
||||
if (route.indexOf(MODULE_PREFIX + '/') === 0) { return Tpt.apiBase() + route; }
|
||||
if (route.charAt(0) === '/') { return Tpt.apiBase() + MODULE_PREFIX + route; }
|
||||
return Tpt.apiBase() + MODULE_PREFIX + '/pbl_overlay/' + route;
|
||||
}
|
||||
|
||||
function readQuery() {
|
||||
var out = {};
|
||||
var qs = String(location.search || '').replace(/^\?/, '');
|
||||
if (!qs) { return out; }
|
||||
qs.split('&').forEach(function (kv) {
|
||||
if (!kv) { return; }
|
||||
var i = kv.indexOf('=');
|
||||
var k = i < 0 ? kv : kv.slice(0, i);
|
||||
var v = i < 0 ? '' : kv.slice(i + 1);
|
||||
try {
|
||||
out[decodeURIComponent(k)] = decodeURIComponent(v.replace(/\+/g, ' '));
|
||||
} catch (e) { out[k] = v; }
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function hostPageKey() {
|
||||
var el = document.querySelector('[data-pbl-page]');
|
||||
if (el && el.getAttribute('data-pbl-page')) { return el.getAttribute('data-pbl-page'); }
|
||||
var m = String(location.pathname).match(/\/pbl_overlay\/([a-z_]+)\.ui/);
|
||||
if (m) { return m[1]; }
|
||||
return readQuery().page || 'play_preview';
|
||||
}
|
||||
|
||||
function readContext() {
|
||||
// 上下文优先级:宿主注入 __PBL_OVERLAY_CTX__ > URL query > 覆盖层本地草稿
|
||||
var inj = global.__PBL_OVERLAY_CTX__ || {};
|
||||
var q = readQuery();
|
||||
var draft = {};
|
||||
try { draft = JSON.parse(global.localStorage.getItem('pbl.overlay.ctx') || '{}') || {}; } catch (e) { draft = {}; }
|
||||
var ctx = {
|
||||
page: q.page || inj.page || null,
|
||||
session_id: q.session_id || inj.session_id || draft.session_id || null,
|
||||
blueprint_id: q.blueprint_id || inj.blueprint_id || draft.blueprint_id || null,
|
||||
world_id: q.world_id || inj.world_id || draft.world_id || null,
|
||||
role: inj.role || q.role || null
|
||||
};
|
||||
try {
|
||||
global.localStorage.setItem('pbl.overlay.ctx', JSON.stringify({
|
||||
session_id: ctx.session_id, blueprint_id: ctx.blueprint_id, world_id: ctx.world_id
|
||||
}));
|
||||
} catch (e) { /* 隐私模式忽略 */ }
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/* ───────────────────────── 2. scense / scense_game 只读桥(不改渲染主循环) ───────────────────────── */
|
||||
var ScenseBridge = {
|
||||
renderer: function () {
|
||||
return global.scenseGame || global.SCENSE_GAME || global.__SCENSE_RUNTIME__ || null;
|
||||
},
|
||||
// 仅探测宿主状态,不写回
|
||||
hasRenderer: function () { return !!this.renderer(); },
|
||||
rendererStatus: function () {
|
||||
var g = this.renderer();
|
||||
if (!g) { return { available: false, paused: null, entities: 0 }; }
|
||||
var n = 0;
|
||||
if (typeof g.entityCount === 'number') { n = g.entityCount; }
|
||||
else if (g.entities && g.entities.length !== undefined) { n = g.entities.length; }
|
||||
return { available: true, paused: (typeof g.paused === 'boolean') ? g.paused : null, entities: n };
|
||||
},
|
||||
// 订阅宿主广播的 CustomEvent(宿主没有该事件时零副作用,靠 transport 通道兜底)
|
||||
onHostEvent: function (handler) {
|
||||
var names = ['scense:entity_updated', 'scense:broadcast', 'scense:state-applied', 'pbl:broadcast'];
|
||||
names.forEach(function (n) {
|
||||
try {
|
||||
global.addEventListener(n, function (ev) { handler((ev && ev.detail) || {}); }, false);
|
||||
} catch (e) { /* 忽略 */ }
|
||||
});
|
||||
},
|
||||
// Play 控制:优先宿主 API(只调用不修改),否则回落 scense_game 契约端点
|
||||
control: function (action, ctx) {
|
||||
var g = this.renderer();
|
||||
if (g && typeof g[action] === 'function') {
|
||||
try {
|
||||
var r = g[action]();
|
||||
if (r && typeof r.then === 'function') { return r; }
|
||||
return Promise.resolve({ status: 'ok', via: 'host', action: action });
|
||||
} catch (e) { /* 继续走契约 */ }
|
||||
}
|
||||
var map = {
|
||||
start: 'game_start', pause: 'game_pause', resume: 'game_resume',
|
||||
restart: 'game_restart', end: 'game_end'
|
||||
};
|
||||
var ep = map[action];
|
||||
if (!ep) { return Promise.reject({ code: 'PBL_E_NOT_FOUND', message: '未知控制 ' + action }); }
|
||||
return fetch(Tpt.apiBase() + '/scense_game/game/api/' + ep + '.dspy', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
|
||||
body: JSON.stringify({
|
||||
session_id: (ctx && ctx.session_id) || '',
|
||||
world_id: (ctx && ctx.world_id) || ''
|
||||
})
|
||||
}).then(function (r) {
|
||||
if (!r.ok) { return { status: 'fail', code: 'PBL_E_NETWORK', message: 'HTTP ' + r.status }; }
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* ───────────────────────── 3. 覆盖层实例 ───────────────────────── */
|
||||
function Overlay(opts) {
|
||||
opts = opts || {};
|
||||
this.pageKey = opts.page || hostPageKey() || 'play_preview';
|
||||
this.page = Core.byKey(this.pageKey) || Core.byKey('play_preview');
|
||||
this.ctx = opts.context || readContext();
|
||||
this.chrono = new Core.Chrono('first_screen', BUDGETS.first_screen_ms, function (ms) {
|
||||
if (global.__PBL_OVERLAY__) {
|
||||
global.__PBL_OVERLAY__.degrade.show('RENDER_DEGRADED', { detail: '首屏 ' + Math.round(ms) + 'ms 超预算 ' + BUDGETS.first_screen_ms + 'ms' });
|
||||
}
|
||||
});
|
||||
this.degrade = new Core.DegradeManager(opts.root || this.pageRoot());
|
||||
this.components = new Core.Components();
|
||||
this.transport = null;
|
||||
this.model = {};
|
||||
this.patchQueue = [];
|
||||
this.patchScheduled = false;
|
||||
this.lastPatchCost = 0;
|
||||
this.throttleMs = 0; // 超预算后自适应节流
|
||||
this.mounted = false;
|
||||
this.destroyed = false;
|
||||
this.perf = { first_screen_ms: null, broadcast_render_ms: null };
|
||||
this.emitter = document; // 局部事件走 document CustomEvent(pbl-ov:* 前缀)
|
||||
this._unsubs = [];
|
||||
this.registerBuiltinComponents();
|
||||
this.registerEventHandlers();
|
||||
}
|
||||
|
||||
Overlay.prototype.pageRoot = function () {
|
||||
return document.querySelector('[data-pbl-overlay-root]') || document.body;
|
||||
};
|
||||
|
||||
/* ── 局部事件总线(不占用全局 Bus 命名空间,避免与宿主事件混淆) ── */
|
||||
Overlay.prototype.on = function (name, fn) {
|
||||
var h = function (ev) { fn((ev && ev.detail) || {}); };
|
||||
this.emitter.addEventListener(name, h, false);
|
||||
this._unsubs.push([name, h]);
|
||||
return this;
|
||||
};
|
||||
Overlay.prototype.emit = function (name, detail) {
|
||||
return Core.Bus.emit('pbl-ov:' + name, detail);
|
||||
};
|
||||
|
||||
Overlay.prototype.mount = function () {
|
||||
if (this.mounted) { return Promise.resolve(this); }
|
||||
this.mounted = true;
|
||||
var self = this;
|
||||
this.transport = new Tpt.Transport({
|
||||
session_id: this.ctx.session_id,
|
||||
blueprint_id: this.ctx.blueprint_id,
|
||||
since_seq: 0,
|
||||
onEvent: function (item) { self.ingestTransport(item); },
|
||||
onDegrade: function (code, msg) { self.degrade.show(self.degrade.mapCode(code), { detail: msg || '' }); },
|
||||
onPerf: function (name, ms) { self.perf[name] = ms; }
|
||||
});
|
||||
|
||||
// S1:bootstrap → 首屏渲染(≤1500ms 门禁由 Chrono 自动判定)
|
||||
return this.transport.bootstrap({ world_id: this.ctx.world_id, page: this.pageKey })
|
||||
.then(function (d) {
|
||||
self.model = normalizeBootstrap(d);
|
||||
self.degrade.hide('LOAD_FAIL');
|
||||
self.renderAll();
|
||||
self.chrono.stop();
|
||||
self.startStreams();
|
||||
self.writePerfBadge();
|
||||
return self;
|
||||
})
|
||||
.catch(function (err) {
|
||||
self.degrade.fromError(err);
|
||||
// 断网/超时:用本地缓存模型渲染骨架,保证首屏不完全空白 + 局部重试按钮
|
||||
self.model = cachedModel(self.pageKey);
|
||||
self.renderAll(true);
|
||||
self.chrono.stop();
|
||||
self.startStreams();
|
||||
self.writePerfBadge();
|
||||
return self;
|
||||
});
|
||||
};
|
||||
|
||||
// 事件流:transport(SSE→长轮询自动降级)+ 宿主 CustomEvent(只读旁路)
|
||||
Overlay.prototype.startStreams = function () {
|
||||
var self = this;
|
||||
this.transport.subscribe();
|
||||
ScenseBridge.onHostEvent(function (detail) { self.ingestHost(detail); });
|
||||
|
||||
// 重试按钮(core DegradeManager banner 事件委托:data-act=retry 会派发 pbl:degrade-retry)
|
||||
Core.Bus.on('pbl:degrade-retry', function () {
|
||||
self.retryLast();
|
||||
});
|
||||
this.on('navigate', function (p) { self.navigate(p && p.page); });
|
||||
this.on('control', function (p) {
|
||||
ScenseBridge.control(p && p.action, self.ctx).then(function (res) {
|
||||
if (res && res.status === 'fail') {
|
||||
self.degrade.show(self.degrade.mapCode(res.code || 'PBL_E_NETWORK'), { detail: res.message || '' });
|
||||
} else {
|
||||
self.degrade.show('SESSION_ENDED', { detail: '控制指令已下发:' + (p && p.action), transient: true });
|
||||
}
|
||||
}).catch(function (e) { self.degrade.fromError(e); });
|
||||
});
|
||||
};
|
||||
|
||||
Overlay.prototype.retryLast = function () {
|
||||
var self = this;
|
||||
return this.transport.bootstrap({ world_id: this.ctx.world_id, page: this.pageKey })
|
||||
.then(function (d) {
|
||||
self.model = normalizeBootstrap(d);
|
||||
self.degrade.hide('LOAD_FAIL');
|
||||
self.degrade.hide('NET_FAIL');
|
||||
self.renderAll();
|
||||
return d;
|
||||
})
|
||||
.catch(function (e) { self.degrade.fromError(e); });
|
||||
};
|
||||
|
||||
/* ───────────────────────── 4. 广播 → 覆盖层补丁(≤500ms 门禁 + 自适应节流) ───────────────────────── */
|
||||
// transport 通道:item = {seq,event_type,entity_id,payload,received_at}
|
||||
Overlay.prototype.ingestTransport = function (item) {
|
||||
var patch = normalizeTransportPatch(item);
|
||||
if (!patch) { return; }
|
||||
this.patchQueue.push(patch);
|
||||
this.schedulePatch();
|
||||
};
|
||||
|
||||
// 宿主 CustomEvent 旁路(无 seq,仅做展示层增量,不参与权威状态)
|
||||
Overlay.prototype.ingestHost = function (detail) {
|
||||
if (!detail || (detail.seq !== undefined && detail.seq !== null)) { return; } // 带 seq 的走 transport
|
||||
this.patchQueue.push(normalizeHostPatch(detail));
|
||||
this.schedulePatch();
|
||||
};
|
||||
|
||||
Overlay.prototype.schedulePatch = function () {
|
||||
var self = this;
|
||||
if (this.patchScheduled) { return; }
|
||||
this.patchScheduled = true;
|
||||
var wait = this.throttleMs;
|
||||
var raf = global.requestAnimationFrame || function (fn) { return global.setTimeout(fn, 16); };
|
||||
global.setTimeout(function () { raf(function () { self.flushPatches(); }); }, wait);
|
||||
};
|
||||
|
||||
Overlay.prototype.flushPatches = function () {
|
||||
this.patchScheduled = false;
|
||||
if (this.destroyed || !this.patchQueue.length) { return; }
|
||||
var batch = this.patchQueue.splice(0, this.patchQueue.length);
|
||||
var t0 = now();
|
||||
for (var i = 0; i < batch.length; i++) { this.applyPatch(batch[i]); }
|
||||
this.renderDynamic();
|
||||
persistModel(this.pageKey, this.model);
|
||||
var cost = Math.round(now() - t0);
|
||||
this.lastPatchCost = cost;
|
||||
this.perf.broadcast_render_ms = cost;
|
||||
this.writePerfBadge();
|
||||
if (cost > BUDGETS.broadcast_render_ms) {
|
||||
// 超预算:横幅 + 自适应节流(合并更多补丁),不丢数据
|
||||
this.throttleMs = Math.min(2000, Math.max(200, this.throttleMs + 200));
|
||||
this.degrade.show('RENDER_DEGRADED', {
|
||||
detail: '广播渲染 ' + cost + 'ms 超预算 ' + BUDGETS.broadcast_render_ms + 'ms,已合并补丁(节流 ' + this.throttleMs + 'ms)'
|
||||
});
|
||||
} else if (this.throttleMs > 0) {
|
||||
this.throttleMs = Math.max(0, this.throttleMs - 100);
|
||||
}
|
||||
};
|
||||
|
||||
Overlay.prototype.applyPatch = function (patch) {
|
||||
var m = this.model;
|
||||
if (!m || !patch) { return; }
|
||||
if (patch.entity_id) {
|
||||
if (!m.entities) { m.entities = {}; }
|
||||
var prev = m.entities[patch.entity_id] || {};
|
||||
m.entities[patch.entity_id] = Object.assign({}, prev, patch.state || {});
|
||||
m.entities[patch.entity_id].__rev = (prev.__rev || 0) + 1;
|
||||
m.entity_count = Object.keys(m.entities).length;
|
||||
}
|
||||
if (patch.event_type) {
|
||||
if (!m.events) { m.events = []; }
|
||||
m.events.unshift({ type: patch.event_type, text: patch.text || patch.event_type, at: patch.at });
|
||||
if (m.events.length > 50) { m.events.length = 50; }
|
||||
}
|
||||
if (patch.progress !== undefined && patch.progress !== null) { m.progress = Number(patch.progress); }
|
||||
if (patch.quality_state) { m.quality_state = patch.quality_state; }
|
||||
if (patch.artifact) { (m.artifacts = m.artifacts || []).unshift(patch.artifact); }
|
||||
m.lastEvent = patch;
|
||||
this.emit('model-patched', patch);
|
||||
};
|
||||
|
||||
function normalizeTransportPatch(item) {
|
||||
if (!item) { return null; }
|
||||
var p = item.payload || {};
|
||||
return {
|
||||
source: 'broadcast',
|
||||
seq: item.seq,
|
||||
event_type: item.event_type || 'patch',
|
||||
entity_id: item.entity_id || p.entity_id || null,
|
||||
state: p.state || p.patch || null,
|
||||
text: p.text || p.message || item.event_type || '',
|
||||
progress: (p.progress !== undefined) ? p.progress : undefined,
|
||||
quality_state: p.quality_state || null,
|
||||
artifact: p.artifact || null,
|
||||
at: item.received_at || now()
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHostPatch(detail) {
|
||||
detail = detail || {};
|
||||
return {
|
||||
source: 'host',
|
||||
seq: null,
|
||||
event_type: detail.event_type || detail.type || 'host_patch',
|
||||
entity_id: detail.entity_id || null,
|
||||
state: detail.state || detail.patch || null,
|
||||
text: detail.text || detail.message || '',
|
||||
progress: detail.progress,
|
||||
quality_state: detail.quality_state || null,
|
||||
artifact: detail.artifact || null,
|
||||
at: detail.at || now()
|
||||
};
|
||||
}
|
||||
|
||||
/* ───────────────────────── 5. 11 组件注册 + 渲染(覆盖层内 DOM,不触碰渲染层 canvas) ───────────────────────── */
|
||||
Overlay.prototype.registerBuiltinComponents = function () {
|
||||
var self = this;
|
||||
var C = this.components;
|
||||
|
||||
C.register('TopBar', function (host, m) {
|
||||
host.innerHTML =
|
||||
'<div class="pbl-topbar-in">' +
|
||||
'<span class="pbl-title">' + esc(m.page_title || '') + '</span>' +
|
||||
'<span class="pbl-sub">' + esc(m.blueprint_name || '') + (m.version ? ' v' + esc(m.version) : '') + '</span>' +
|
||||
'<span class="pbl-spacer"></span>' +
|
||||
'<span class="pbl-badge" data-badge="quality">' + esc(m.quality_state || '-') + '</span>' +
|
||||
'<span class="pbl-badge" data-badge="perf">-</span>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
C.register('NavRail', function (host, m) {
|
||||
var pages = Core.pagesOfRole(m.role || 'logined');
|
||||
var html = '<nav class="pbl-navrail-in">';
|
||||
for (var i = 0; i < pages.length; i++) {
|
||||
var on = pages[i].key === m.page_key ? ' is-active' : '';
|
||||
html += '<a class="pbl-navitem' + on + '" href="' + esc(self.navHref(pages[i].key)) +
|
||||
'" data-pbl-nav="' + esc(pages[i].key) + '">' + esc(pages[i].title) + '</a>';
|
||||
}
|
||||
host.innerHTML = html + '</nav>';
|
||||
});
|
||||
|
||||
C.register('GoalCard', function (host, m) {
|
||||
var g = m.goal || {};
|
||||
host.insertAdjacentHTML('beforeend',
|
||||
'<section class="pbl-card pbl-goal"><h4>驱动问题 / 目标</h4>' +
|
||||
'<p class="pbl-goal-q">' + esc(g.question || '暂无目标(等待蓝图编译)') + '</p>' +
|
||||
'<p class="pbl-goal-m">' + esc(g.milestone || '') + '</p></section>');
|
||||
});
|
||||
|
||||
C.register('SceneCard', function (host, m) {
|
||||
var s = m.scene || {};
|
||||
host.insertAdjacentHTML('beforeend',
|
||||
'<section class="pbl-card pbl-scene"><h4>当前场景</h4>' +
|
||||
'<p>' + esc(s.name || '-') + ' <em>' + esc(s.kind || '') + '</em></p>' +
|
||||
'<p class="pbl-dim">实体 ' + Number(m.entity_count || 0) + ' · 观察者 ' + Number(m.observer_count || 0) + '</p></section>');
|
||||
});
|
||||
|
||||
C.register('StepList', function (host, m) {
|
||||
var steps = m.steps || [];
|
||||
var html = '<section class="pbl-card pbl-steps"><h4>任务步骤</h4><ol>';
|
||||
for (var i = 0; i < steps.length; i++) {
|
||||
html += '<li class="' + (steps[i].done ? 'is-done' : '') + '">' +
|
||||
esc(steps[i].title || steps[i].name || ('步骤' + (i + 1))) + '</li>';
|
||||
}
|
||||
host.insertAdjacentHTML('beforeend', html + '</ol></section>');
|
||||
});
|
||||
|
||||
C.register('RubricEditor', function (host, m) {
|
||||
var rows = m.rubric || [];
|
||||
var html = '<section class="pbl-card pbl-rubric"><h4>量规(Rubric 加权)</h4>' +
|
||||
'<table><thead><tr><th>维度</th><th>权重</th><th>得分</th></tr></thead><tbody>';
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
html += '<tr><td>' + esc(rows[i].name || rows[i].dimension || '-') + '</td><td>' +
|
||||
esc(String(rows[i].weight === undefined ? '' : rows[i].weight)) + '</td><td>' +
|
||||
esc(String(rows[i].score === undefined ? '-' : rows[i].score)) + '</td></tr>';
|
||||
}
|
||||
host.insertAdjacentHTML('beforeend', html + '</tbody></table></section>');
|
||||
});
|
||||
|
||||
C.register('ValidationBadge', function (host, m) {
|
||||
var v = m.validation || {};
|
||||
var tone = v.blocked ? 'block' : (v.passed ? 'ok' : 'warn');
|
||||
host.insertAdjacentHTML('beforeend',
|
||||
'<span class="pbl-vbadge is-' + tone + '" title="14 维校验">' +
|
||||
esc(v.summary || v.state || '未校验') + '</span>');
|
||||
});
|
||||
|
||||
C.register('ArtifactViewer', function (host, m) {
|
||||
var list = m.artifacts || [];
|
||||
var html = '<section class="pbl-card pbl-artifacts"><h4>产出物 / 证据</h4><div class="pbl-grid">';
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
html += '<div class="pbl-tile"><strong>' + esc(list[i].name || list[i].artifact_id || ('#' + i)) + '</strong>' +
|
||||
'<span>' + esc(list[i].kind || list[i].evidence_type || '') + '</span>' +
|
||||
'<em>' + esc(list[i].state || list[i].status || '') + '</em></div>';
|
||||
}
|
||||
if (!list.length) { html += '<div class="pbl-empty">暂无产出物</div>'; }
|
||||
html += '</div>' +
|
||||
'<form data-pbl-form="artifact"><input name="artifact_id" placeholder="产出物ID(幂等)">' +
|
||||
'<input name="name" placeholder="名称"><button type="submit">提交并采集证据</button></form></section>';
|
||||
host.insertAdjacentHTML('beforeend', html);
|
||||
});
|
||||
|
||||
C.register('ChatPanel', function (host, m) {
|
||||
var msgs = m.agent_messages || [];
|
||||
var html = '<section class="pbl-card pbl-chat"><h4>Agent 对话</h4><div class="pbl-chat-log">';
|
||||
for (var i = 0; i < msgs.length; i++) {
|
||||
var fb = msgs[i].fallback || msgs[i].template_fallback ? ' is-fallback' : '';
|
||||
html += '<div class="pbl-msg is-' + esc(msgs[i].role || 'agent') + fb + '">' +
|
||||
esc(msgs[i].content || msgs[i].text || '') + '</div>';
|
||||
}
|
||||
html += '</div><div class="pbl-chat-input">' +
|
||||
'<input type="text" data-pbl-chat placeholder="向 Agent 提问…"><button data-pbl-send>发送</button></div></section>';
|
||||
host.insertAdjacentHTML('beforeend', html);
|
||||
});
|
||||
|
||||
C.register('ApprovalQueue', function (host, m) {
|
||||
var items = m.approvals || [];
|
||||
var html = '<section class="pbl-card pbl-approvals"><h4>待人工审批</h4><ul>';
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
html += '<li><span>' + esc(items[i].title || items[i].approval_id || '-') + '</span><em>' +
|
||||
esc(items[i].state || '') + '</em></li>';
|
||||
}
|
||||
if (!items.length) { html += '<li class="pbl-empty">暂无待审批项</li>'; }
|
||||
host.insertAdjacentHTML('beforeend', html + '</ul></section>');
|
||||
});
|
||||
|
||||
C.register('HudOverlay', function (host, m) {
|
||||
var pct = Math.max(0, Math.min(100, Number(m.progress || 0)));
|
||||
host.innerHTML =
|
||||
'<div class="pbl-hud-in">' +
|
||||
'<div class="pbl-hud-progress"><span style="width:' + pct + '%"></span></div>' +
|
||||
'<div class="pbl-hud-role">' + esc(m.role_label || m.role || '') + '</div>' +
|
||||
'<div class="pbl-hud-acts">' +
|
||||
'<button data-pbl-act="start">开始</button><button data-pbl-act="pause">暂停</button>' +
|
||||
'<button data-pbl-act="resume">继续</button><button data-pbl-act="restart">重来</button>' +
|
||||
'<button data-pbl-act="end">结束</button>' +
|
||||
'</div><div class="pbl-hud-feed"></div></div>';
|
||||
});
|
||||
};
|
||||
|
||||
Overlay.prototype.hostFor = function (comp) {
|
||||
var root = this.pageRoot();
|
||||
var cls = comp.host.replace(/^\./, '');
|
||||
var el = root.querySelector('.' + cls);
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.className = cls;
|
||||
if (cls === 'pbl-hud') {
|
||||
var main = root.querySelector('.pbl-main') || ensure(root, 'pbl-main');
|
||||
main.appendChild(el);
|
||||
} else {
|
||||
root.appendChild(el);
|
||||
}
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
function ensure(parent, cls) {
|
||||
var el = parent.querySelector('.' + cls);
|
||||
if (!el) { el = document.createElement('div'); el.className = cls; parent.appendChild(el); }
|
||||
return el;
|
||||
}
|
||||
|
||||
Overlay.prototype.renderAll = function (skeletonOnly) {
|
||||
var comps = Core.COMPONENTS;
|
||||
var m = Object.assign({}, this.model, {
|
||||
page_key: this.pageKey,
|
||||
page_title: this.page.title,
|
||||
role: this.ctx.role || this.model.role || 'logined'
|
||||
});
|
||||
var allowed = comps.filter(function (c) {
|
||||
return c.pages.indexOf('*') >= 0 || c.pages.indexOf(m.page_key) >= 0;
|
||||
});
|
||||
resetHosts(this.pageRoot());
|
||||
for (var i = 0; i < allowed.length; i++) {
|
||||
var c = allowed[i];
|
||||
if (!this.components.has(c.name)) { continue; }
|
||||
try {
|
||||
this.components.render(c.name, this.hostFor(c), m);
|
||||
} catch (e) {
|
||||
this.degrade.show('RENDER_DEGRADED', { detail: '组件 ' + c.name + ' 渲染失败:' + ((e && e.message) || e) });
|
||||
}
|
||||
}
|
||||
if (skeletonOnly) {
|
||||
ensure(this.pageRoot(), 'pbl-main').insertAdjacentHTML('beforeend', '<div class="pbl-skeleton"></div>');
|
||||
}
|
||||
this.bindDomHandlers();
|
||||
return this;
|
||||
};
|
||||
|
||||
// 增量重绘:只更新随广播变化的 HUD/徽标,保证单次 ≤500ms
|
||||
Overlay.prototype.renderDynamic = function () {
|
||||
var m = this.model;
|
||||
var bar = document.querySelector('.pbl-hud-progress span');
|
||||
if (bar) { bar.style.width = Math.max(0, Math.min(100, Number(m.progress || 0))) + '%'; }
|
||||
var feed = document.querySelector('.pbl-hud-feed');
|
||||
if (feed && m.lastEvent) {
|
||||
var line = document.createElement('div');
|
||||
line.className = 'pbl-feedline';
|
||||
line.textContent = String(m.lastEvent.text || m.lastEvent.event_type || '');
|
||||
feed.insertBefore(line, feed.firstChild);
|
||||
while (feed.childNodes.length > 6) { feed.removeChild(feed.lastChild); }
|
||||
}
|
||||
var badge = document.querySelector('[data-badge="quality"]');
|
||||
if (badge && m.quality_state) { badge.textContent = m.quality_state; }
|
||||
this.emit('rendered', m);
|
||||
};
|
||||
|
||||
function resetHosts(root) {
|
||||
['.pbl-topbar', '.pbl-navrail', '.pbl-main'].forEach(function (sel) {
|
||||
var el = root.querySelector(sel);
|
||||
if (el) { el.innerHTML = ''; }
|
||||
});
|
||||
}
|
||||
|
||||
function cachedModel(key) {
|
||||
try { return JSON.parse(global.localStorage.getItem('pbl.overlay.model.' + key) || '{}') || {}; }
|
||||
catch (e) { return {}; }
|
||||
}
|
||||
|
||||
function persistModel(key, model) {
|
||||
try {
|
||||
global.localStorage.setItem('pbl.overlay.model.' + key, JSON.stringify({
|
||||
quality_state: model.quality_state, progress: model.progress,
|
||||
artifacts: (model.artifacts || []).slice(0, 20),
|
||||
events: (model.events || []).slice(0, 20)
|
||||
}));
|
||||
} catch (e) { /* 配额/隐私模式忽略 */ }
|
||||
}
|
||||
|
||||
function normalizeBootstrap(d) {
|
||||
d = d || {};
|
||||
var entities = toEntityMap(d.entities);
|
||||
return {
|
||||
role: d.role || null,
|
||||
role_label: d.role_label || d.role_name || null,
|
||||
blueprint_name: d.blueprint_name || (d.blueprint && d.blueprint.name) || '',
|
||||
version: d.version || (d.blueprint && d.blueprint.version) || '',
|
||||
quality_state: d.quality_state || d.quality || '',
|
||||
goal: d.goal || (d.mission ? { question: d.mission.question, milestone: d.mission.milestone } : {}),
|
||||
scene: d.scene || d.current_scene || {},
|
||||
steps: d.steps || d.tasks || [],
|
||||
artifacts: d.artifacts || [],
|
||||
rubric: d.rubric || [],
|
||||
approvals: d.approvals || [],
|
||||
agent_messages: d.agent_messages || d.messages || [],
|
||||
validation: d.validation || {},
|
||||
entities: entities,
|
||||
entity_count: (d.entity_count !== undefined) ? d.entity_count : Object.keys(entities).length,
|
||||
observer_count: d.observer_count || 0,
|
||||
progress: Number(d.progress || 0),
|
||||
recent_events: d.recent_events || []
|
||||
};
|
||||
}
|
||||
|
||||
function toEntityMap(list) {
|
||||
var out = {};
|
||||
if (!list) { return out; }
|
||||
if (list instanceof Array) {
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var e = list[i];
|
||||
if (e && e.entity_id) { out[e.entity_id] = e; }
|
||||
}
|
||||
} else if (typeof list === 'object') {
|
||||
Object.keys(list).forEach(function (k) { out[k] = list[k]; });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ───────────────────────── 6. 4 组交互时序的事件处理器 ───────────────────────── */
|
||||
Overlay.prototype.registerEventHandlers = function () {
|
||||
var self = this;
|
||||
|
||||
// S2 做任务:意图上行(服务端权威回传 seq 后才落补丁,不预测渲染)
|
||||
this.on('intent-submit', function (payload) {
|
||||
return self.transport.submitIntent({
|
||||
type: payload.intent_type || 'action',
|
||||
action: payload.intent || 'interact',
|
||||
entity_id: payload.target_entity_id || payload.entity_id || '',
|
||||
payload: payload.payload || {}
|
||||
}).catch(function (e) { self.degrade.fromError(e); });
|
||||
});
|
||||
|
||||
// S3 产出物:create → submit → collect_evidence(artifact_id 幂等,失败可重试)
|
||||
this.on('artifact-create', function (a) {
|
||||
return self.transport.createArtifact(a)
|
||||
.then(function (res) {
|
||||
var id = (res && (res.artifact_id || (res.data && res.data.artifact_id))) || a.artifact_id;
|
||||
return self.transport.submitArtifact(id);
|
||||
})
|
||||
.then(function () {
|
||||
return self.transport.collectEvidence({
|
||||
evidence_type: a.evidence_type || 'artifact_version', source_id: a.artifact_id || ''
|
||||
});
|
||||
})
|
||||
.catch(function (e) { self.degrade.fromError(e); });
|
||||
});
|
||||
|
||||
// S4 反思 / 反馈
|
||||
this.on('feedback-save', function (f) {
|
||||
return self.transport.saveFeedback(f).catch(function (e) { self.degrade.fromError(e); });
|
||||
});
|
||||
|
||||
// 门禁跳转(VALIDATE_BLOCK / APPROVAL_PENDING 横幅「前往处理」)
|
||||
Core.Bus.on('pbl:degrade', function (ev) {
|
||||
var d = (ev && ev.detail) || {};
|
||||
if (d.state === 'VALIDATE_BLOCK') { /* 用户点横幅按钮时才跳转,此处只标记 */ }
|
||||
if (d.state === 'SESSION_ENDED') { self.degrade.setReadonly(true); }
|
||||
});
|
||||
this.on('goto-page', function (p) { self.navigate(p && p.page); });
|
||||
};
|
||||
|
||||
Overlay.prototype.navHref = function (key) {
|
||||
var p = Core.byKey(key);
|
||||
if (!p) { return '#'; }
|
||||
var url = pageUrl(p);
|
||||
var sep = url.indexOf('?') >= 0 ? '&' : '?';
|
||||
return url + sep + 'session_id=' + encodeURIComponent(this.ctx.session_id || '') +
|
||||
'&blueprint_id=' + encodeURIComponent(this.ctx.blueprint_id || '') +
|
||||
'&world_id=' + encodeURIComponent(this.ctx.world_id || '');
|
||||
};
|
||||
|
||||
Overlay.prototype.navigate = function (key) {
|
||||
var p = Core.byKey(key || '');
|
||||
if (!p) { return; }
|
||||
location.href = this.navHref(p.key);
|
||||
};
|
||||
|
||||
Overlay.prototype.bindDomHandlers = function () {
|
||||
var self = this;
|
||||
var root = this.pageRoot();
|
||||
if (root.getAttribute('data-pbl-bound') === '1') { return; }
|
||||
root.setAttribute('data-pbl-bound', '1');
|
||||
|
||||
root.addEventListener('click', function (ev) {
|
||||
var t = ev.target;
|
||||
if (!t) { return; }
|
||||
var act = closestAttr(t, 'data-pbl-act');
|
||||
if (act) { ev.preventDefault(); self.emit('control', { action: act }); return; }
|
||||
var nav = closestAttr(t, 'data-pbl-nav');
|
||||
if (nav) { /* a[href] 已带上下文参数,交给浏览器跳转 */ return; }
|
||||
var send = closestAttr(t, 'data-pbl-send');
|
||||
if (send !== null) {
|
||||
var inp = root.querySelector('[data-pbl-chat]');
|
||||
var text = inp ? String(inp.value || '').trim() : '';
|
||||
if (!text) { return; }
|
||||
self.emit('intent-submit', { intent: 'ask_agent', payload: { text: text } });
|
||||
if (inp) { inp.value = ''; }
|
||||
}
|
||||
}, false);
|
||||
|
||||
root.addEventListener('submit', function (ev) {
|
||||
var kind = closestAttr(ev.target, 'data-pbl-form');
|
||||
if (!kind) { return; }
|
||||
ev.preventDefault();
|
||||
var data = formToObj(ev.target);
|
||||
if (kind === 'feedback') { self.emit('feedback-save', data); }
|
||||
if (kind === 'artifact') { self.emit('artifact-create', data); }
|
||||
}, false);
|
||||
};
|
||||
|
||||
function closestAttr(el, attr) {
|
||||
var node = el;
|
||||
while (node && node !== document) {
|
||||
if (node.getAttribute && node.getAttribute(attr) !== null) { return node.getAttribute(attr); }
|
||||
node = node.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formToObj(form) {
|
||||
var out = {};
|
||||
var els = form.querySelectorAll('[name]');
|
||||
for (var i = 0; i < els.length; i++) { out[els[i].name] = els[i].value; }
|
||||
return out;
|
||||
}
|
||||
|
||||
Overlay.prototype.writePerfBadge = function () {
|
||||
var badge = document.querySelector('[data-badge="perf"]');
|
||||
if (!badge) { return; }
|
||||
var fs = this.perf.first_screen_ms === null ? null : Math.round(this.perf.first_screen_ms);
|
||||
var br = this.perf.broadcast_render_ms === null ? null : Math.round(this.perf.broadcast_render_ms);
|
||||
badge.textContent = '首屏' + (fs === null ? '-' : (fs + 'ms')) + ' / 广播' + (br === null ? '-' : (br + 'ms'));
|
||||
var ok = (fs === null || fs <= BUDGETS.first_screen_ms) && (br === null || br <= BUDGETS.broadcast_render_ms);
|
||||
badge.setAttribute('class', 'pbl-badge ' + (ok ? 'is-ok' : 'is-warn'));
|
||||
global.__PBL_PERF__ = global.__PBL_PERF__ || {};
|
||||
global.__PBL_PERF__.overlay = { page: this.pageKey, first_screen_ms: fs, broadcast_render_ms: br };
|
||||
};
|
||||
|
||||
Overlay.prototype.destroy = function () {
|
||||
this.destroyed = true;
|
||||
if (this.transport) { this.transport.stop(); }
|
||||
this.patchQueue.length = 0;
|
||||
this._unsubs.forEach(function (p) { /* document 级监听随页面卸载回收 */ });
|
||||
this._unsubs = [];
|
||||
this.mounted = false;
|
||||
};
|
||||
|
||||
/* ───────────────────────── 7. 自动装配入口 ───────────────────────── */
|
||||
function boot(opts) {
|
||||
opts = opts || {};
|
||||
var root = opts.root || document.querySelector('[data-pbl-overlay-root]') || document.body;
|
||||
if (!root.getAttribute('data-pbl-overlay-root')) { root.setAttribute('data-pbl-overlay-root', '1'); }
|
||||
var ov = new Overlay(Object.assign({ root: root }, opts));
|
||||
global.__PBL_OVERLAY__ = ov;
|
||||
ov.mount();
|
||||
return ov;
|
||||
}
|
||||
|
||||
global.PblOverlayPlay = {
|
||||
VERSION: '1.0.0',
|
||||
Overlay: Overlay,
|
||||
ScenseBridge: ScenseBridge,
|
||||
pageUrl: pageUrl,
|
||||
readContext: readContext,
|
||||
boot: boot,
|
||||
auto: function () {
|
||||
// 只在带 data-pbl-page 标记的覆盖层页面壳里自动装配,普通业务页零副作用
|
||||
if (!document.querySelector('[data-pbl-page]')) { return; }
|
||||
if (global.__PBL_OVERLAY__ && global.__PBL_OVERLAY__.mounted) { return; }
|
||||
boot();
|
||||
}
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { global.PblOverlayPlay.auto(); }, false);
|
||||
} else {
|
||||
global.PblOverlayPlay.auto();
|
||||
}
|
||||
})(window);
|
||||
266
wwwroot/overlay/pbl_overlay_transport.js
Normal file
266
wwwroot/overlay/pbl_overlay_transport.js
Normal file
@ -0,0 +1,266 @@
|
||||
/**
|
||||
* pbl_overlay_transport.js —— 覆盖层传输层(M9 / pbl_scense_ext)
|
||||
*
|
||||
* 职责:① 契约调用(bootstrap / 意图上行 / 证据 / 反馈);② 广播通道(SSE 优先,长轮询降级,
|
||||
* Q-OPEN-6 定稿);③ seq 幂等去重 + 掉线补齐(get_state_snapshot + poll_events(since));
|
||||
* ④ 广播渲染时延预算 ≤500ms 的计时与降级。
|
||||
*
|
||||
* 纪律:本层只搬运数据并派发 pbl:broadcast-received 事件,DOM 更新由 pbl_overlay.js 负责,
|
||||
* 不触碰 scense/scense_game 渲染主循环。
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var Core = global.PblOverlayCore;
|
||||
if (!Core) { throw new Error('pbl_overlay_core.js 必须先于 pbl_overlay_transport.js 加载'); }
|
||||
|
||||
var BUDGETS = Core.BUDGETS;
|
||||
|
||||
// 契约端点表(模块自动路由 /{module}/api/{name}.dspy)
|
||||
var ENDPOINTS = {
|
||||
'bootstrap': '/pbl_scense_ext/api/pbl_overlay_bootstrap.dspy',
|
||||
'page_registry': '/pbl_scense_ext/api/pbl_page_registry_list.dspy',
|
||||
'session_ctx': '/pbl_scense_ext/api/pbl_session_context_get.dspy',
|
||||
'feedback_save': '/pbl_scense_ext/api/pbl_playtest_feedback_save.dspy',
|
||||
// 运行时(pbl_runtime_ext,M11a/M11b 权威实现)
|
||||
'event_poll': '/pbl_runtime_ext/api/pbl_runtime_event_poll.dspy',
|
||||
'broadcast_pull': '/pbl_runtime_ext/api/pbl_runtime_broadcast_pull.dspy',
|
||||
'entity_state': '/pbl_runtime_ext/api/pbl_entity_state_get.dspy',
|
||||
'world_broadcast':'/pbl_runtime_ext/api/pbl_world_broadcast.dspy',
|
||||
// 意图上行 / 证据 / 评估(其余 pbl_* 模块契约)
|
||||
'submit_intent': '/pbl_runtime_ext/api/pbl_world_broadcast.dspy',
|
||||
'entity_apply': '/pbl_runtime_ext/api/pbl_entity_state_apply.dspy',
|
||||
'evidence_collect': '/pbl_evidence/api/pbl_collect_evidence.dspy',
|
||||
'artifact_create': '/pbl_evidence/api/pbl_create_artifact.dspy',
|
||||
'artifact_submit': '/pbl_evidence/api/pbl_submit_artifact.dspy',
|
||||
'assessment_report': '/pbl_assessment/api/pbl_assessment_report.dspy'
|
||||
};
|
||||
|
||||
function apiBase() {
|
||||
// 去掉页面路由尾部,得到应用根(与 scense_game game.js 同策略,不改基线)
|
||||
return (global.__PBL_API_BASE__ || String(location.pathname).replace(/\/(pbl_overlay|game|scense_game)\/.*$/, '')).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function Transport(opts) {
|
||||
opts = opts || {};
|
||||
this.sessionId = opts.session_id || null;
|
||||
this.blueprintId = opts.blueprint_id || null;
|
||||
this.lastSeq = Number(opts.since_seq || 0);
|
||||
this.pending = {}; // seq -> true(去重)
|
||||
this.sse = null;
|
||||
this.timer = null;
|
||||
this.stopped = false;
|
||||
this.cache = {}; // 断网时供降级态展示缓存数据
|
||||
this.onEvent = opts.onEvent || function () {};
|
||||
this.onDegrade = opts.onDegrade || function () {};
|
||||
this.onPerf = opts.onPerf || function () {};
|
||||
}
|
||||
|
||||
/* ───────────── 1. 契约调用(POST,参数进 params_kw) ───────────── */
|
||||
Transport.prototype.call = function (name, params) {
|
||||
var url = ENDPOINTS[name];
|
||||
if (!url) { return Promise.reject({ code: 'PBL_E_NOT_FOUND', message: '未知契约 ' + name }); }
|
||||
var body = Object.assign({}, params || {});
|
||||
if (this.sessionId && body.session_id === undefined) { body.session_id = this.sessionId; }
|
||||
if (this.blueprintId && body.blueprint_id === undefined) { body.blueprint_id = this.blueprintId; }
|
||||
var ctrl = ('AbortController' in global) ? new AbortController() : null;
|
||||
var to = ctrl ? setTimeout(function () { ctrl.abort(); }, BUDGETS.intent_timeout_ms) : null;
|
||||
var self = this;
|
||||
return fetch(apiBase() + url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
|
||||
body: JSON.stringify(body),
|
||||
signal: ctrl ? ctrl.signal : undefined
|
||||
}).then(function (r) {
|
||||
if (to) { clearTimeout(to); }
|
||||
if (r.status === 401 || r.status === 403) { return { status: 'fail', code: 'PBL_E_FORBIDDEN', message: 'HTTP ' + r.status }; }
|
||||
if (r.status === 429) { return { status: 'fail', code: 'PBL_RATE_LIMIT', message: '请求过于频繁' }; }
|
||||
if (!r.ok) { return { status: 'fail', code: 'PBL_E_NETWORK', message: 'HTTP ' + r.status }; }
|
||||
return r.json();
|
||||
}).then(function (res) {
|
||||
if (res && res.status === 'fail') {
|
||||
self.onDegrade(res.code || 'PBL_E_NETWORK', res.message || res.msg || '');
|
||||
var e = new Error(res.message || 'contract fail');
|
||||
e.code = res.code; e.payload = res;
|
||||
throw e;
|
||||
}
|
||||
self.cache[name] = { at: Date.now(), data: res };
|
||||
return res;
|
||||
}).catch(function (err) {
|
||||
if (err && err.name === 'AbortError') { self.onDegrade('PBL_E_TIMEOUT', '契约超时 ' + name); }
|
||||
else if (err && !err.code) { self.onDegrade('PBL_E_NETWORK', '网络中断'); }
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
/* ───────────── 2. S1 进入世界:一次 bootstrap 拿齐上下文 ───────────── */
|
||||
Transport.prototype.bootstrap = function (extra) {
|
||||
var self = this;
|
||||
return this.call('bootstrap', Object.assign({ recent_events: 20 }, extra || {})).then(function (ctx) {
|
||||
var d = (ctx && ctx.data) || ctx || {};
|
||||
self.context = d;
|
||||
if (Array.isArray(d.recent_events)) {
|
||||
d.recent_events.forEach(function (ev) { self._accept(ev, true); });
|
||||
}
|
||||
if (d.max_seq !== undefined) { self.lastSeq = Math.max(self.lastSeq, Number(d.max_seq) || 0); }
|
||||
return d;
|
||||
});
|
||||
};
|
||||
|
||||
Transport.prototype.pageRegistry = function () {
|
||||
return this.call('page_registry', { page_size: 50 }).then(function (r) {
|
||||
var d = (r && r.data) || r || {};
|
||||
return d.rows || d.data || d || [];
|
||||
});
|
||||
};
|
||||
|
||||
/* ───────────── 3. S2 意图上行(前端无权威状态,仅发起) ───────────── */
|
||||
Transport.prototype.submitIntent = function (intent) {
|
||||
var payload = {
|
||||
intent_type: intent.type || 'action',
|
||||
action: intent.action || '',
|
||||
entity_id: intent.entity_id || '',
|
||||
payload_json: JSON.stringify(intent.payload || {}),
|
||||
client_ts: Date.now()
|
||||
};
|
||||
var self = this;
|
||||
Core.Bus.emit('pbl:intent-submitted', payload);
|
||||
return this.call(intent.channel || 'submit_intent', payload).then(function (r) {
|
||||
var d = (r && r.data) || r || {};
|
||||
// 服务端权威:只有回传 seq 后才允许更新覆盖层(不预测渲染)
|
||||
if (d.seq !== undefined) { self._accept({ seq: d.seq, event_type: intent.action || 'ack', payload_json: d }, true); }
|
||||
return d;
|
||||
});
|
||||
};
|
||||
|
||||
/* ───────────── 4. 广播通道:SSE 优先,长轮询降级 ───────────── */
|
||||
Transport.prototype.subscribe = function () {
|
||||
var self = this;
|
||||
if (this.stopped) { return; }
|
||||
var sseUrl = apiBase() + '/pbl_runtime_ext/api/pbl_runtime_broadcast_pull.dspy?session_id=' +
|
||||
encodeURIComponent(this.sessionId || '') + '&since_seq=' + this.lastSeq;
|
||||
var ok = false;
|
||||
if (typeof global.EventSource === 'function' && !this.sse) {
|
||||
try {
|
||||
this.sse = new global.EventSource(sseUrl);
|
||||
this.sse.onmessage = function (m) {
|
||||
var ev = null;
|
||||
try { ev = JSON.parse(m.data); } catch (e) { ev = null; }
|
||||
if (ev) { self._accept(ev); }
|
||||
};
|
||||
this.sse.onerror = function () {
|
||||
self.closeSse();
|
||||
self.onDegrade('PBL_RENDER_DEGRADED', '实时通道降级为长轮询');
|
||||
self.poll();
|
||||
};
|
||||
ok = true;
|
||||
} catch (e) { ok = false; }
|
||||
}
|
||||
if (!ok) { this.poll(); }
|
||||
};
|
||||
|
||||
Transport.prototype.closeSse = function () {
|
||||
if (this.sse) { try { this.sse.close(); } catch (e) { } this.sse = null; }
|
||||
};
|
||||
|
||||
/** 长轮询降级:固定节拍拉取增量(seq 单调,去重幂等) */
|
||||
Transport.prototype.poll = function () {
|
||||
var self = this;
|
||||
if (this.stopped) { return; }
|
||||
if (this.timer) { clearInterval(this.timer); }
|
||||
this.timer = setInterval(function () { self.pollOnce(); }, BUDGETS.poll_interval_ms);
|
||||
this.pollOnce();
|
||||
};
|
||||
|
||||
Transport.prototype.pollOnce = function () {
|
||||
var self = this;
|
||||
return this.call('broadcast_pull', { since_seq: this.lastSeq, limit: 50 })
|
||||
.then(function (r) {
|
||||
var d = (r && r.data) || r || {};
|
||||
var evs = d.events || d.rows || [];
|
||||
for (var i = 0; i < evs.length; i++) { self._accept(evs[i]); }
|
||||
if (d.max_seq !== undefined) { self.lastSeq = Math.max(self.lastSeq, Number(d.max_seq) || 0); }
|
||||
})
|
||||
.catch(function () { self.onDegrade('PBL_E_NETWORK', '广播拉取失败,使用缓存数据'); });
|
||||
};
|
||||
|
||||
/** seq 去重 + 计时(广播到达 → 交给上层渲染,≤500ms 预算) */
|
||||
Transport.prototype._accept = function (ev, silent) {
|
||||
if (!ev) { return false; }
|
||||
var seq = Number(ev.seq !== undefined ? ev.seq : (ev.event_seq || 0));
|
||||
if (seq > 0) {
|
||||
if (seq <= this.lastSeq || this.pending[seq]) { return false; }
|
||||
this.pending[seq] = true;
|
||||
this.lastSeq = Math.max(this.lastSeq, seq);
|
||||
}
|
||||
var payload = ev.payload_json;
|
||||
if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch (e) { payload = { raw: payload }; } }
|
||||
var item = {
|
||||
seq: seq,
|
||||
event_type: ev.event_type || ev.type || 'unknown',
|
||||
entity_id: ev.entity_id || (payload && payload.entity_id) || '',
|
||||
payload: payload || {},
|
||||
received_at: Core.now()
|
||||
};
|
||||
if (!silent) { Core.Bus.emit('pbl:broadcast-received', item); }
|
||||
this.onEvent(item, !!silent);
|
||||
return true;
|
||||
};
|
||||
|
||||
/* ───────────── 5. 断线重连补齐(Q4:快照 + since 补发) ───────────── */
|
||||
Transport.prototype.reconnect = function () {
|
||||
var self = this;
|
||||
return this.call('entity_state', { session_id: this.sessionId })
|
||||
.then(function (r) {
|
||||
var d = (r && r.data) || r || {};
|
||||
self.snapshot = d;
|
||||
Core.Bus.emit('pbl:snapshot-applied', d);
|
||||
return self.call('event_poll', { since_event_id: self.lastSeq, limit: 100 });
|
||||
})
|
||||
.then(function (r) {
|
||||
var d = (r && r.data) || r || {};
|
||||
(d.events || d.rows || []).forEach(function (ev) { self._accept(ev); });
|
||||
self.subscribe();
|
||||
return true;
|
||||
})
|
||||
.catch(function () { self.onDegrade('PBL_E_NETWORK', '重连失败,保持只读缓存'); return false; });
|
||||
};
|
||||
|
||||
/* ───────────── 6. S3/S4 产出物、证据、反馈 ───────────── */
|
||||
Transport.prototype.createArtifact = function (a) {
|
||||
return this.call('artifact_create', {
|
||||
title: a.title || '', artifact_type: a.artifact_type || 'document',
|
||||
content_txt: a.content_txt || '', session_id: this.sessionId
|
||||
});
|
||||
};
|
||||
Transport.prototype.submitArtifact = function (artifactId) {
|
||||
return this.call('artifact_submit', { artifact_id: artifactId, session_id: this.sessionId });
|
||||
};
|
||||
Transport.prototype.collectEvidence = function (e) {
|
||||
return this.call('evidence_collect', {
|
||||
evidence_type: e.evidence_type || 'artifact_version',
|
||||
source_id: e.source_id || '', session_id: this.sessionId
|
||||
});
|
||||
};
|
||||
Transport.prototype.saveFeedback = function (f) {
|
||||
return this.call('feedback_save', {
|
||||
feedback_type: f.feedback_type || 'feedback',
|
||||
rating: f.rating === undefined ? '' : f.rating,
|
||||
content_txt: f.content_txt || '',
|
||||
context_json: f.context_json || {}
|
||||
});
|
||||
};
|
||||
|
||||
Transport.prototype.stop = function () {
|
||||
this.stopped = true;
|
||||
this.closeSse();
|
||||
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
||||
};
|
||||
|
||||
global.PblOverlayTransport = {
|
||||
ENDPOINTS: ENDPOINTS,
|
||||
apiBase: apiBase,
|
||||
Transport: Transport
|
||||
};
|
||||
})(window);
|
||||
Loading…
x
Reference in New Issue
Block a user