/* iAOP 配置化驾驶舱渲染客户端(issue #131 / PRD 5.5「⑤ 配置化驾驶舱」)。 * * 纯原生 JS(无构建链)。只消费 web/cockpit/plans/*.json 渲染计划: * - 布局 plan:core/cockpit/renderer.py 的 RenderPlan(themeTokens + widget specs) * - 告警 plan:core/cockpit/alarm_config.py 的 alarm_panel props(severityColors 等) * 组件注册表 + 通用 WidgetHost 逐个挂载;切换行业模板 = 换一份 plan JSON, * 前端代码零改动(PRD 5.5 验收口径)。 * * 实时数据:一期推理/时序总线尚未提供点位订阅接口,趋势/KPI/告警用 * 「按 bind 点 ID 确定性播种」的模拟数据源演示绑定链路;接入真实总线时 * 只需替换 DataFeed 三处 subscribe 实现,组件代码不动。 */ "use strict"; /* ---- 配置 -------------------------------------------------------------- */ // 已部署推理服务(issue #131 指定),NL 查询与健康检查直连 var INFER_BASE = "http://39.101.182.167:30800"; // 行业模板注册表:新增模板 = 在这里加一行 + 跑 scripts/build_plans.py var TEMPLATES = [ { id: "ti-cl4", name: "海绵钛(Ti)", plan: "plans/ti-cl4.json", alarmPlan: "plans/alarm_panel.ti.json" }, { id: "resin", name: "吸附树脂", plan: "plans/resin.json", alarmPlan: null } ]; // 树脂模板暂无告警面板配置资产时的兜底三级配色(语义同 alarm_panel.ti.yaml, // 2026-08 对齐 Ant Design 5 色板) var FALLBACK_SEVERITY_STYLES = { P0: { fg: "#ff4d4f", bg: "rgba(255,77,79,0.10)", border: "#ff4d4f" }, P1: { fg: "#faad14", bg: "rgba(250,173,20,0.10)", border: "#faad14" }, P2: { fg: "#1677ff", bg: "rgba(22,119,255,0.08)" } }; var state = { plan: null, // 当前布局 RenderPlan alarmProps: null, // 当前告警面板 props timers: [], // 组件定时器(切模板时统一清理) model: null // 推理服务可用模型名(/v1/models 第一项) }; /* ---- 工具 -------------------------------------------------------------- */ function el(tag, cls, text) { var n = document.createElement(tag); if (cls) n.className = cls; if (text !== undefined) n.textContent = text; return n; } // 按字符串确定性播种的 PRNG(mulberry32):同一 bind 点 ID 各组件曲线可复现 function seededRandom(seedStr) { var h = 1779033703 ^ seedStr.length; for (var i = 0; i < seedStr.length; i++) { h = Math.imul(h ^ seedStr.charCodeAt(i), 3432918353); h = (h << 13) | (h >>> 19); } return function () { h = Math.imul(h ^ (h >>> 16), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); h ^= h >>> 16; return (h >>> 0) / 4294967296; }; } function addTimer(id) { state.timers.push(id); } function clearTimers() { state.timers.forEach(clearInterval); state.timers = []; } /* ---- 模拟数据馈送(接真实总线时替换这三处) ----------------------------- */ var DataFeed = { // 趋势序列:以 bind 为种子生成正弦 + 噪声的滚动窗口 nextTrendPoint: function (bind, t) { var rnd = seededRandom(bind); var phase = rnd() * Math.PI * 2; var base = 50 + rnd() * 40; return base + Math.sin(t / 6 + phase) * 12 + (rnd() - 0.5) * 6; }, // KPI 瞬时值 nextKpiValue: function (metric) { var rnd = seededRandom(metric + "|" + Math.floor(Date.now() / 5000)); return 60 + rnd() * 40; }, // 告警流:按模板生成一批演示告警(severity 覆盖 P0/P1/P2 三级配色验收) demoAlarms: function (plan) { var binds = plan.widgets .filter(function (w) { return w.props && (w.props.series || w.props.metric); }) .map(function (w) { return w.props.series || w.props.metric; }); var msgs = ["越上限告警", "波动异常", "偏离设定值", "通信抖动", "质量预测异常"]; var sevs = ["P0", "P1", "P2", "P1", "P2"]; var now = new Date(); return sevs.map(function (sev, i) { return { id: "ALM-" + (1000 + i), severity: sev, point: binds[i % Math.max(binds.length, 1)] || "—", message: (binds[i % Math.max(binds.length, 1)] || "点位") + " " + msgs[i], time: new Date(now.getTime() - i * 7 * 60000).toLocaleTimeString("zh-CN"), sop: "处置 SOP:1) 现场确认仪表;2) 切手动控制;3) 通知值班长复核。" }; }); } }; /* ---- 推理服务对接(/health、/v1/models、/v1/chat/completions) ------------ */ var InferClient = { checkHealth: function () { var dot = document.getElementById("infer-status"); var txt = document.getElementById("infer-status-text"); fetch(INFER_BASE + "/health", { method: "GET" }) .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); dot.className = "status-dot ok"; txt.textContent = "推理服务在线"; }) .catch(function () { dot.className = "status-dot down"; txt.textContent = "推理服务不可达"; }); }, loadModel: function () { return fetch(INFER_BASE + "/v1/models") .then(function (r) { return r.json(); }) .then(function (d) { if (d && d.data && d.data.length) state.model = d.data[0].id; }) .catch(function () { /* 模型清单不可达时 chat 用默认模型名 */ }); }, chat: function (question) { return fetch(INFER_BASE + "/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: state.model || "default", messages: [ { role: "system", content: "你是 iAOP 工业驾驶舱助手,用简体中文简洁回答工艺/质量/能耗问题。" }, { role: "user", content: question } ], max_tokens: 512 }) }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }).then(function (d) { var c = d && d.choices && d.choices[0]; return (c && c.message && c.message.content) || "(空响应)"; }); } }; /* ---- 组件注册表(键名 = #51 WIDGET_COMPONENT) ---------------------------- */ var ComponentRegistry = { /* 四状态工艺流程视图:stages 来自布局资产,状态色走 .state-* 四类 */ ProcessView: function (host, spec) { var props = spec.props || {}; host.appendChild(el("div", "w-title", spec.description || props.src || "工艺流程")); var body = el("div", "w-body"); var flow = el("div", "process-flow"); var stages = (props.stages || []).slice().sort(function (a, b) { return (a.order || 0) - (b.order || 0); }); var states = props.states || ["running", "warning", "alarm", "offline"]; if (!stages.length) { // 布局未声明 stages(如树脂模板):按 src 渲染占位 + 四状态图例 flow.appendChild(el("div", "stage state-running", (props.src || "流程图资源") + "(流程节点由模板 stages 声明)")); } stages.forEach(function (s, i) { var st = states[i % states.length]; // 一期演示:轮流着色,接总线后按实时状态 var node = el("div", "stage state-" + st); node.appendChild(el("div", "stage-name", s.name || s.id)); if (s.device) node.appendChild(el("div", "stage-device", s.device)); node.appendChild(el("div", "stage-state", st)); flow.appendChild(node); if (i < stages.length - 1) flow.appendChild(el("div", "stage-arrow", "→")); }); body.appendChild(flow); host.appendChild(body); }, /* 实时趋势:canvas 曲线,bind 点位为种子,2s 滚动刷新 */ TrendChart: function (host, spec) { var bind = (spec.props && spec.props.series) || ""; host.appendChild(el("div", "w-title", spec.description || "实时趋势")); var body = el("div", "w-body"); body.appendChild(el("div", "trend-bind", "bind: " + bind)); var canvas = el("canvas", "trend-canvas"); body.appendChild(canvas); host.appendChild(body); var points = []; var tick = 0; function push() { points.push(DataFeed.nextTrendPoint(bind, tick++)); if (points.length > 60) points.shift(); } for (var i = 0; i < 60; i++) push(); // 预填一窗,首屏即出曲线 function draw() { var w = canvas.clientWidth, h = canvas.clientHeight; if (!w || !h) return; canvas.width = w; canvas.height = h; var ctx = canvas.getContext("2d"); var min = Math.min.apply(null, points), max = Math.max.apply(null, points); var span = (max - min) || 1; ctx.strokeStyle = getComputedStyle(document.documentElement) .getPropertyValue("--cockpit-accent").trim() || "#1677ff"; ctx.lineWidth = 1.5; ctx.beginPath(); points.forEach(function (p, idx) { var x = idx / (points.length - 1) * w; var y = h - (p - min) / span * (h - 8) - 4; if (idx === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); ctx.fillStyle = getComputedStyle(document.documentElement) .getPropertyValue("--cockpit-fg-muted").trim() || "rgba(0,0,0,0.45)"; ctx.font = "11px sans-serif"; ctx.fillText(max.toFixed(1), 4, 12); ctx.fillText(min.toFixed(1), 4, h - 4); } draw(); push(); draw(); addTimer(setInterval(function () { push(); draw(); }, 2000)); }, /* KPI 卡片:指标值 + 标签 + 说明,5s 刷新 */ KpiCard: function (host, spec) { var props = spec.props || {}; host.appendChild(el("div", "w-title", props.label || props.metric || "KPI")); var body = el("div", "w-body"); var value = el("div", "kpi-value", "--"); body.appendChild(value); body.appendChild(el("div", "kpi-label", props.label || "")); if (spec.description) body.appendChild(el("div", "kpi-desc", spec.description)); host.appendChild(body); function refresh() { value.textContent = DataFeed.nextKpiValue(props.metric || "").toFixed(2); } refresh(); addTimer(setInterval(refresh, 5000)); }, /* 告警面板:severityColors 三级配色 / 确认 / SOP / 排序,全部由 plan props 驱动 */ AlarmPanel: function (host, spec) { host.appendChild(el("div", "w-title", spec.description || "告警面板")); var body = el("div", "w-body"); var list = el("div", "alarm-list"); body.appendChild(list); host.appendChild(body); var props = state.alarmProps || {}; var styles = props.severityStyles || FALLBACK_SEVERITY_STYLES; var alarms = DataFeed.demoAlarms(state.plan) .sort(function (a, b) { // sortBy: severity(P0 在前) var rank = { P0: 3, P1: 2, P2: 1 }; return (rank[b.severity] || 0) - (rank[a.severity] || 0); }) .slice(0, props.maxItems || 50); alarms.forEach(function (a) { var st = styles[a.severity] || {}; var item = el("div", "alarm-item"); item.style.color = st.fg || "inherit"; item.style.background = st.bg || "transparent"; if (st.border) item.style.borderLeftColor = st.border; item.appendChild(el("span", "sev", a.severity)); var msg = el("span", "msg", a.message + " · " + a.time); item.appendChild(msg); if (props.showSop && a.sop) item.title = a.sop; // SOP 联动:悬停展开处置建议 if (props.requireAck && (a.severity === "P0" || a.severity === "P1")) { var btn = el("button", "ack-btn", "确认"); btn.onclick = function () { btn.replaceWith(el("span", "acked", "已确认")); }; item.appendChild(btn); } list.appendChild(item); }); }, /* NL 查询入口:直连推理服务 /v1/chat/completions */ NlQuery: function (host, spec) { host.appendChild(el("div", "w-title", spec.description || "自然语言查询")); var body = el("div", "w-body"); body.style.display = "flex"; body.style.flexDirection = "column"; var answer = el("div", "nl-answer", "输入自然语言问题,如:氯化炉温度最近趋势如何?"); var row = el("div", "nl-input-row"); var input = document.createElement("input"); input.placeholder = (spec.props && spec.props.placeholder) || "输入自然语言查询"; var btn = el("button", null, "查询"); row.appendChild(input); row.appendChild(btn); body.appendChild(answer); body.appendChild(row); host.appendChild(body); function ask() { var q = input.value.trim(); if (!q) return; btn.disabled = true; answer.textContent = "查询中…"; InferClient.chat(q) .then(function (text) { answer.textContent = text; }) .catch(function (e) { answer.textContent = "查询失败:" + e.message + "(请确认推理服务可达)"; }) .finally(function () { btn.disabled = false; }); } btn.onclick = ask; input.addEventListener("keydown", function (e) { if (e.key === "Enter") ask(); }); } }; /* ---- WidgetHost:按 RenderPlan 逐个挂载组件 ------------------------------ */ function renderCockpit(plan) { clearTimers(); // 主题 token 注入根容器 CSS 变量(theme: dark 对齐 cockpit.ti.yaml) Object.keys(plan.themeTokens || {}).forEach(function (k) { document.documentElement.style.setProperty(k, plan.themeTokens[k]); }); document.getElementById("cockpit-title").textContent = plan.title || "iAOP 驾驶舱"; document.getElementById("plan-meta").textContent = "模板渲染计划: " + plan.$schema + " · widgets=" + plan.widgetCount + " · theme=" + plan.theme; var grid = document.getElementById("cockpit-grid"); grid.style.gridTemplateColumns = "repeat(" + (plan.grid.columns || 12) + ", 1fr)"; grid.innerHTML = ""; plan.widgets.forEach(function (spec) { var host = el("section", "widget widget-" + spec.type); host.style.cssText += spec.grid.style; // 栅格落位完全来自布局资产 host.id = spec.id; var comp = ComponentRegistry[spec.component]; if (comp) comp(host, spec); else host.appendChild(el("div", "w-title", "未注册组件: " + spec.component)); grid.appendChild(host); }); grid.setAttribute("aria-busy", "false"); } /* ---- 模板加载与切换 ------------------------------------------------------ */ function loadTemplate(tpl) { var grid = document.getElementById("cockpit-grid"); grid.setAttribute("aria-busy", "true"); var planReq = fetch(tpl.plan).then(function (r) { if (!r.ok) throw new Error("加载布局计划失败: HTTP " + r.status); return r.json(); }); var alarmReq = tpl.alarmPlan ? fetch(tpl.alarmPlan).then(function (r) { return r.ok ? r.json() : null; }) : Promise.resolve(null); Promise.all([planReq, alarmReq]) .then(function (rs) { state.plan = rs[0]; state.alarmProps = rs[1]; renderCockpit(state.plan); }) .catch(function (e) { grid.innerHTML = ""; grid.appendChild(el("div", "widget", "驾驶舱加载失败:" + e.message)); }); } /* ---- 启动 --------------------------------------------------------------- */ (function init() { var select = document.getElementById("template-select"); TEMPLATES.forEach(function (t) { var opt = document.createElement("option"); opt.value = t.id; opt.textContent = t.name; select.appendChild(opt); }); select.onchange = function () { var tpl = TEMPLATES.find(function (t) { return t.id === select.value; }); if (tpl) loadTemplate(tpl); }; InferClient.checkHealth(); InferClient.loadModel(); loadTemplate(TEMPLATES[0]); // 默认 ti-cl4,首屏全本地资源 })();