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 页面禁止硬写