Files
iAOP/web/cockpit/cockpit.js
T

363 lines
15 KiB
JavaScript
Raw Normal View History

/* 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,首屏全本地资源
})();