diff --git a/wwwroot/overlay/pbl_overlay.css b/wwwroot/overlay/pbl_overlay.css
new file mode 100644
index 0000000..1eaaf97
--- /dev/null
+++ b/wwwroot/overlay/pbl_overlay.css
@@ -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; }
+}
diff --git a/wwwroot/overlay/pbl_overlay_core.js b/wwwroot/overlay/pbl_overlay_core.js
new file mode 100644
index 0000000..5efcd1a
--- /dev/null
+++ b/wwwroot/overlay/pbl_overlay_core.js
@@ -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, ''');
+ }
+
+ 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
+ ? ''
+ : (st.action === 'none' ? '' : '');
+ var el = document.createElement('div');
+ el.className = cls;
+ el.setAttribute('data-error-state', key);
+ el.innerHTML = '' + esc(st.title) + '' + esc((opts && opts.detail) || st.desc) + '' + 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);
diff --git a/wwwroot/overlay/pbl_overlay_loader.js b/wwwroot/overlay/pbl_overlay_loader.js
new file mode 100644
index 0000000..b6b4066
--- /dev/null
+++ b/wwwroot/overlay/pbl_overlay_loader.js
@@ -0,0 +1,71 @@
+/* ============================================================================
+ * pbl_overlay_loader.js —— 覆盖层脚本/CSS 按需注入器(M9)
+ *
+ * 为什么需要它:bricks .ui 页面禁止硬写