Files
iAOP/web/cockpit/cockpit.js

363 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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)
var FALLBACK_SEVERITY_STYLES = {
P0: { fg: "#ff3b30", bg: "rgba(255,59,48,0.12)", border: "#ff3b30" },
P1: { fg: "#f5a623", bg: "rgba(245,166,35,0.12)", border: "#f5a623" },
P2: { fg: "#3aa0ff", bg: "rgba(58,160,255,0.10)" }
};
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() || "#18d3c8";
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 = "#8aa0bd";
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,首屏全本地资源
})();