feat: 完成 issue #131 配置化驾驶舱 Web 前端(web/cockpit)
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# web/cockpit — iAOP 配置化驾驶舱 Web 前端
|
||||
|
||||
issue #131 / PRD 5.5「⑤ 配置化驾驶舱」。纯静态 HTML + 原生 JS(无构建链),
|
||||
按布局资产渲染:四状态工艺流程视图、实时趋势、KPI 卡片、告警面板、NL 查询入口。
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
web/cockpit/
|
||||
├── index.html # 驾驶舱页面(顶栏模板切换 + 栅格容器)
|
||||
├── cockpit.css # 样式(调色由 themeTokens CSS 变量注入)
|
||||
├── cockpit.js # 渲染客户端:组件注册表 + WidgetHost + 推理服务对接
|
||||
├── plans/ # 编译产物(前端唯一数据源,fetch 加载)
|
||||
│ ├── ti-cl4.json # ← templates/ti-cl4/dashboard/cockpit.ti.yaml
|
||||
│ ├── resin.json # ← templates/resin/dashboard/cockpit.resin.yaml
|
||||
│ └── alarm_panel.ti.json # ← templates/ti-cl4/dashboard/alarm_panel.ti.yaml
|
||||
└── scripts/
|
||||
└── build_plans.py # 布局/告警 YAML → RenderPlan JSON 编译脚本
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
```bash
|
||||
# 1) 模板资产变更后重新编译渲染计划(开发期依赖 PyYAML)
|
||||
python web/cockpit/scripts/build_plans.py
|
||||
|
||||
# 2) 本地起静态服务(首屏全本地资源,≤ 2s,PRD 9)
|
||||
cd web/cockpit && python -m http.server 8080
|
||||
# 浏览器打开 http://localhost:8080 (Chrome / Edge 最新两版)
|
||||
```
|
||||
|
||||
## 设计要点
|
||||
|
||||
- **切换模板零改码**(PRD 5.5 验收):前端只消费 `plans/*.json` 渲染计划,
|
||||
组件注册表(`ProcessView/TrendChart/KpiCard/AlarmPanel/NlQuery`,键名对齐
|
||||
`core/cockpit/renderer.py` 的 `WIDGET_COMPONENT`)+ 通用 WidgetHost 逐个挂载;
|
||||
ti-cl4 ↔ resin 切换 = 加载另一份 plan JSON,布局/主题/绑定全部随之重排。
|
||||
- **主题**:`theme: dark` 的调色由 plan JSON 的 `themeTokens` 注入 CSS 变量,
|
||||
切主题 = 换一套变量,组件样式不动。
|
||||
- **告警面板**:severity P0/P1/P2 配色、`requireAck` 确认、`showSop` 处置建议、
|
||||
排序与最大条数全部来自 `alarm_panel.ti.json`(#52 `render_alarm_panel_props`)。
|
||||
- **推理服务对接**:`http://39.101.182.167:30800` —— `/health`(顶栏状态灯)、
|
||||
`/v1/models`(取模型名)、`/v1/chat/completions`(NL 查询)。
|
||||
- **模拟数据**:时序总线尚未提供点位订阅接口,趋势/KPI/告警暂用按 bind 点 ID
|
||||
确定性播种的模拟源(`DataFeed`),接入真实总线只需替换该对象的三个方法。
|
||||
|
||||
## 浏览器兼容
|
||||
|
||||
Chrome / Edge 最新两版(PRD 9):使用了 `fetch`、`Promise`、`classList`、
|
||||
CSS Grid、`String.prototype.replaceWith` 等基准特性,无 ES 模块/构建依赖。
|
||||
@@ -0,0 +1,122 @@
|
||||
/* iAOP 配置化驾驶舱样式(issue #131 / PRD 5.5)。
|
||||
* 调色全部由 plan JSON 的 themeTokens 注入(--cockpit-* CSS 变量),
|
||||
* 这里只写默认值(= #51 THEME_TOKENS 的 dark 方案),切主题 = 换一套变量。 */
|
||||
:root {
|
||||
--cockpit-bg: #0b1220;
|
||||
--cockpit-surface: #13203a;
|
||||
--cockpit-fg: #e6edf6;
|
||||
--cockpit-fg-muted: #8aa0bd;
|
||||
--cockpit-accent: #18d3c8;
|
||||
--cockpit-warn: #f5a623;
|
||||
--cockpit-grid-line: rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
background: var(--cockpit-bg);
|
||||
color: var(--cockpit-fg);
|
||||
}
|
||||
|
||||
/* ---- 顶栏 -------------------------------------------------------------- */
|
||||
#topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 16px; background: var(--cockpit-surface);
|
||||
border-bottom: 1px solid var(--cockpit-grid-line);
|
||||
}
|
||||
#cockpit-title { font-size: 18px; margin: 0; color: var(--cockpit-accent); }
|
||||
#topbar-right { display: flex; align-items: center; gap: 8px; font-size: 13px;
|
||||
color: var(--cockpit-fg-muted); }
|
||||
#template-select { padding: 6px 10px; border-radius: 6px; border: 1px solid var(--cockpit-grid-line);
|
||||
background: var(--cockpit-bg); color: var(--cockpit-fg); }
|
||||
.status-dot { font-size: 14px; }
|
||||
.status-dot.ok { color: #2ecc71; }
|
||||
.status-dot.down { color: #ff3b30; }
|
||||
|
||||
/* ---- 驾驶舱栅格(12 列基线,行高固定,widget 按 plan 的 grid.style 落位) -- */
|
||||
#cockpit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
grid-auto-rows: 90px;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* ---- 通用 widget 卡片 --------------------------------------------------- */
|
||||
.widget {
|
||||
background: var(--cockpit-surface);
|
||||
border: 1px solid var(--cockpit-grid-line);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
min-width: 0; min-height: 0;
|
||||
}
|
||||
.widget .w-title { font-size: 13px; color: var(--cockpit-fg-muted); margin-bottom: 8px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.widget .w-body { flex: 1; min-height: 0; }
|
||||
|
||||
/* ---- ProcessView:四状态工艺流程 ---------------------------------------- */
|
||||
.process-flow { display: flex; align-items: stretch; gap: 8px; height: 100%; }
|
||||
.stage {
|
||||
flex: 1; border-radius: 8px; padding: 10px;
|
||||
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||
border: 1px solid var(--cockpit-grid-line); text-align: center;
|
||||
}
|
||||
.stage .stage-name { font-size: 16px; font-weight: 600; }
|
||||
.stage .stage-device { font-size: 11px; color: var(--cockpit-fg-muted); margin-top: 4px; }
|
||||
.stage .stage-state { font-size: 11px; margin-top: 6px; padding: 2px 8px; border-radius: 10px; }
|
||||
.stage-arrow { align-self: center; color: var(--cockpit-fg-muted); font-size: 18px; }
|
||||
/* 四状态配色(running/warning/alarm/offline,对齐 #51 states 语义) */
|
||||
.state-running { box-shadow: inset 0 0 0 1px #2ecc71; }
|
||||
.state-running .stage-state { background: rgba(46,204,113,.15); color: #2ecc71; }
|
||||
.state-warning { box-shadow: inset 0 0 0 1px var(--cockpit-warn); }
|
||||
.state-warning .stage-state { background: rgba(245,166,35,.15); color: var(--cockpit-warn); }
|
||||
.state-alarm { box-shadow: inset 0 0 0 1px #ff3b30; }
|
||||
.state-alarm .stage-state { background: rgba(255,59,48,.15); color: #ff3b30; }
|
||||
.state-offline { opacity: .55; }
|
||||
.state-offline .stage-state { background: rgba(138,160,189,.15); color: var(--cockpit-fg-muted); }
|
||||
|
||||
/* ---- TrendChart --------------------------------------------------------- */
|
||||
.trend-bind { font-size: 11px; color: var(--cockpit-fg-muted); margin-bottom: 4px; }
|
||||
.trend-canvas { width: 100%; height: calc(100% - 18px); display: block; }
|
||||
|
||||
/* ---- KpiCard ------------------------------------------------------------ */
|
||||
.kpi-value { font-size: 28px; font-weight: 700; color: var(--cockpit-accent); }
|
||||
.kpi-label { font-size: 13px; color: var(--cockpit-fg); margin-top: 2px; }
|
||||
.kpi-desc { font-size: 11px; color: var(--cockpit-fg-muted); margin-top: 6px; }
|
||||
|
||||
/* ---- AlarmPanel ---------------------------------------------------------- */
|
||||
.alarm-list { overflow-y: auto; height: 100%; display: flex; flex-direction: column; gap: 6px; }
|
||||
.alarm-item { border-radius: 6px; padding: 6px 10px; font-size: 12px;
|
||||
display: flex; align-items: center; gap: 8px; border-left: 3px solid transparent; }
|
||||
.alarm-item .sev { font-weight: 700; }
|
||||
.alarm-item .msg { flex: 1; }
|
||||
.alarm-item .ack-btn { padding: 2px 10px; border: none; border-radius: 4px; cursor: pointer;
|
||||
background: var(--cockpit-accent); color: #04202a; font-size: 11px; }
|
||||
.alarm-item .acked { color: var(--cockpit-fg-muted); font-size: 11px; }
|
||||
.alarm-sop { font-size: 11px; color: var(--cockpit-fg-muted); margin-top: 2px; }
|
||||
|
||||
/* ---- NlQuery ------------------------------------------------------------- */
|
||||
.nl-answer { flex: 1; overflow-y: auto; font-size: 12px; color: var(--cockpit-fg);
|
||||
background: var(--cockpit-bg); border-radius: 6px; padding: 8px; margin-bottom: 8px;
|
||||
white-space: pre-wrap; min-height: 0; }
|
||||
.nl-input-row { display: flex; gap: 6px; }
|
||||
.nl-input-row input { flex: 1; padding: 6px 8px; border-radius: 6px; font-size: 12px;
|
||||
border: 1px solid var(--cockpit-grid-line);
|
||||
background: var(--cockpit-bg); color: var(--cockpit-fg); }
|
||||
.nl-input-row button { padding: 6px 12px; border: none; border-radius: 6px; cursor: pointer;
|
||||
background: var(--cockpit-accent); color: #04202a; font-size: 12px; }
|
||||
.nl-input-row button:disabled { opacity: .5; }
|
||||
|
||||
/* ---- 底栏 ---------------------------------------------------------------- */
|
||||
#bottombar { display: flex; justify-content: space-between; padding: 8px 16px;
|
||||
font-size: 11px; color: var(--cockpit-fg-muted);
|
||||
border-top: 1px solid var(--cockpit-grid-line); }
|
||||
|
||||
/* 窄屏降级:保持栅格语义,列宽过小时允许横向滚动而非挤压变形 */
|
||||
@media (max-width: 900px) {
|
||||
#cockpit-grid { grid-auto-rows: 80px; min-width: 860px; }
|
||||
body { overflow-x: auto; }
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/* 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,首屏全本地资源
|
||||
})();
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>iAOP 配置化驾驶舱</title>
|
||||
<link rel="stylesheet" href="cockpit.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- iAOP 配置化驾驶舱(issue #131 / PRD 5.5「⑤ 配置化驾驶舱」)
|
||||
纯静态 HTML + 原生 JS(无构建链)。页面只消费 plans/*.json 渲染计划,
|
||||
切换行业模板 = 加载另一份 plan JSON,前端代码零改动。 -->
|
||||
<header id="topbar">
|
||||
<h1 id="cockpit-title">iAOP 驾驶舱</h1>
|
||||
<div id="topbar-right">
|
||||
<label for="template-select">行业模板</label>
|
||||
<select id="template-select"></select>
|
||||
<span id="infer-status" class="status-dot" title="推理服务 /health">●</span>
|
||||
<span id="infer-status-text">推理服务检测中…</span>
|
||||
</div>
|
||||
</header>
|
||||
<main id="cockpit-grid" aria-busy="true"></main>
|
||||
<footer id="bottombar">
|
||||
<span id="plan-meta"></span>
|
||||
<span>布局 Schema: iAOP-cockpit-layout-v1 · 首屏静态资源本地化,目标 ≤ 2s(PRD 9)</span>
|
||||
</footer>
|
||||
<script src="cockpit.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"subscribe": "alarm_stream",
|
||||
"severityStyles": {
|
||||
"P0": {
|
||||
"fg": "#ff3b30",
|
||||
"bg": "rgba(255,59,48,0.12)",
|
||||
"icon": "alert-octagon",
|
||||
"border": "#ff3b30"
|
||||
},
|
||||
"P1": {
|
||||
"fg": "#f5a623",
|
||||
"bg": "rgba(245,166,35,0.12)",
|
||||
"icon": "alert-triangle",
|
||||
"border": "#f5a623"
|
||||
},
|
||||
"P2": {
|
||||
"fg": "#3aa0ff",
|
||||
"bg": "rgba(58,160,255,0.10)",
|
||||
"icon": "info"
|
||||
}
|
||||
},
|
||||
"rulesSource": {
|
||||
"kind": "asset",
|
||||
"ref": "ti-cl4/impurity-forecast/config/alert_rules.template.yaml"
|
||||
},
|
||||
"showSop": true,
|
||||
"requireAck": true,
|
||||
"muteLower": "P2",
|
||||
"groupBy": "severity",
|
||||
"sortBy": "severity",
|
||||
"maxItems": 50,
|
||||
"thresholdsSource": {
|
||||
"kind": "asset",
|
||||
"ref": "ti-cl4/impurity-forecast/config/alert_rules.template.yaml"
|
||||
},
|
||||
"ackTimeoutS": 300
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
{
|
||||
"$schema": "iAOP-cockpit-layout-v1",
|
||||
"title": "吸附树脂车间驾驶舱",
|
||||
"theme": "dark",
|
||||
"themeTokens": {
|
||||
"--cockpit-bg": "#0b1220",
|
||||
"--cockpit-surface": "#13203a",
|
||||
"--cockpit-fg": "#e6edf6",
|
||||
"--cockpit-fg-muted": "#8aa0bd",
|
||||
"--cockpit-accent": "#18d3c8",
|
||||
"--cockpit-warn": "#f5a623",
|
||||
"--cockpit-grid-line": "rgba(255,255,255,0.06)"
|
||||
},
|
||||
"grid": {
|
||||
"columns": 12
|
||||
},
|
||||
"widgets": [
|
||||
{
|
||||
"id": "process_view-0",
|
||||
"component": "ProcessView",
|
||||
"type": "process_view",
|
||||
"grid": {
|
||||
"columnStart": 1,
|
||||
"columnSpan": 12,
|
||||
"rowStart": 1,
|
||||
"rowSpan": 4,
|
||||
"widthPct": 100.0,
|
||||
"style": "grid-column: 1 / span 12; grid-row: 1 / span 4;"
|
||||
},
|
||||
"description": "四状态工艺流程(合成 → 交联 → 洗涤 → 干燥)",
|
||||
"props": {
|
||||
"src": "resin_four_state.svg",
|
||||
"states": [
|
||||
"running",
|
||||
"warning",
|
||||
"alarm",
|
||||
"offline"
|
||||
]
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trend-1",
|
||||
"component": "TrendChart",
|
||||
"type": "trend",
|
||||
"grid": {
|
||||
"columnStart": 1,
|
||||
"columnSpan": 6,
|
||||
"rowStart": 5,
|
||||
"rowSpan": 2,
|
||||
"widthPct": 50.0,
|
||||
"style": "grid-column: 1 / span 6; grid-row: 5 / span 2;"
|
||||
},
|
||||
"description": "反应釜温度实时趋势",
|
||||
"props": {
|
||||
"series": "R-801.TEMP",
|
||||
"window": "PT30M"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trend-2",
|
||||
"component": "TrendChart",
|
||||
"type": "trend",
|
||||
"grid": {
|
||||
"columnStart": 7,
|
||||
"columnSpan": 6,
|
||||
"rowStart": 5,
|
||||
"rowSpan": 2,
|
||||
"widthPct": 50.0,
|
||||
"style": "grid-column: 7 / span 6; grid-row: 5 / span 2;"
|
||||
},
|
||||
"description": "搅拌转速实时趋势",
|
||||
"props": {
|
||||
"series": "R-801.AGIT",
|
||||
"window": "PT30M"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kpi_card-3",
|
||||
"component": "KpiCard",
|
||||
"type": "kpi_card",
|
||||
"grid": {
|
||||
"columnStart": 1,
|
||||
"columnSpan": 3,
|
||||
"rowStart": 7,
|
||||
"rowSpan": 2,
|
||||
"widthPct": 25.0,
|
||||
"style": "grid-column: 1 / span 3; grid-row: 7 / span 2;"
|
||||
},
|
||||
"description": "当批平均交换容量(mmol/g)",
|
||||
"props": {
|
||||
"metric": "resin_exchange_capacity",
|
||||
"label": "交换容量"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kpi_card-4",
|
||||
"component": "KpiCard",
|
||||
"type": "kpi_card",
|
||||
"grid": {
|
||||
"columnStart": 4,
|
||||
"columnSpan": 3,
|
||||
"rowStart": 7,
|
||||
"rowSpan": 2,
|
||||
"widthPct": 25.0,
|
||||
"style": "grid-column: 4 / span 3; grid-row: 7 / span 2;"
|
||||
},
|
||||
"description": "当批平均交联度(%)",
|
||||
"props": {
|
||||
"metric": "resin_crosslink_degree",
|
||||
"label": "交联度"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kpi_card-5",
|
||||
"component": "KpiCard",
|
||||
"type": "kpi_card",
|
||||
"grid": {
|
||||
"columnStart": 7,
|
||||
"columnSpan": 3,
|
||||
"rowStart": 7,
|
||||
"rowSpan": 2,
|
||||
"widthPct": 25.0,
|
||||
"style": "grid-column: 7 / span 3; grid-row: 7 / span 2;"
|
||||
},
|
||||
"description": "当批产率(%)",
|
||||
"props": {
|
||||
"metric": "batch_yield",
|
||||
"label": "批产率"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kpi_card-6",
|
||||
"component": "KpiCard",
|
||||
"type": "kpi_card",
|
||||
"grid": {
|
||||
"columnStart": 10,
|
||||
"columnSpan": 3,
|
||||
"rowStart": 7,
|
||||
"rowSpan": 2,
|
||||
"widthPct": 25.0,
|
||||
"style": "grid-column: 10 / span 3; grid-row: 7 / span 2;"
|
||||
},
|
||||
"description": "单吨树脂综合能耗(kWh/t)",
|
||||
"props": {
|
||||
"metric": "energy_per_ton",
|
||||
"label": "单吨能耗"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "alarm_panel-7",
|
||||
"component": "AlarmPanel",
|
||||
"type": "alarm_panel",
|
||||
"grid": {
|
||||
"columnStart": 1,
|
||||
"columnSpan": 9,
|
||||
"rowStart": 9,
|
||||
"rowSpan": 3,
|
||||
"widthPct": 75.0,
|
||||
"style": "grid-column: 1 / span 9; grid-row: 9 / span 3;"
|
||||
},
|
||||
"description": "告警面板(温度/交联度/质量预测异常)",
|
||||
"props": {
|
||||
"subscribe": "alarm_stream"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nl_query-8",
|
||||
"component": "NlQuery",
|
||||
"type": "nl_query",
|
||||
"grid": {
|
||||
"columnStart": 10,
|
||||
"columnSpan": 3,
|
||||
"rowStart": 9,
|
||||
"rowSpan": 3,
|
||||
"widthPct": 25.0,
|
||||
"style": "grid-column: 10 / span 3; grid-row: 9 / span 3;"
|
||||
},
|
||||
"description": "自然语言查询入口(配方/质量/能耗问答)",
|
||||
"props": {
|
||||
"placeholder": "输入自然语言查询(配方 / 质量 / 能耗)"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgetCount": 9,
|
||||
"perfFlags": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
},
|
||||
"perfHint": null
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"$schema": "iAOP-cockpit-layout-v1",
|
||||
"title": "海绵钛车间驾驶舱",
|
||||
"theme": "dark",
|
||||
"themeTokens": {
|
||||
"--cockpit-bg": "#0b1220",
|
||||
"--cockpit-surface": "#13203a",
|
||||
"--cockpit-fg": "#e6edf6",
|
||||
"--cockpit-fg-muted": "#8aa0bd",
|
||||
"--cockpit-accent": "#18d3c8",
|
||||
"--cockpit-warn": "#f5a623",
|
||||
"--cockpit-grid-line": "rgba(255,255,255,0.06)"
|
||||
},
|
||||
"grid": {
|
||||
"columns": 12
|
||||
},
|
||||
"widgets": [
|
||||
{
|
||||
"id": "process_view-0",
|
||||
"component": "ProcessView",
|
||||
"type": "process_view",
|
||||
"grid": {
|
||||
"columnStart": 1,
|
||||
"columnSpan": 12,
|
||||
"rowStart": 1,
|
||||
"rowSpan": 4,
|
||||
"widthPct": 100.0,
|
||||
"style": "grid-column: 1 / span 12; grid-row: 1 / span 4;"
|
||||
},
|
||||
"description": "四状态工艺流程(氯化 → 精制 → 还原 → 蒸馏)",
|
||||
"props": {
|
||||
"src": "ti_four_state.svg",
|
||||
"states": [
|
||||
"running",
|
||||
"warning",
|
||||
"alarm",
|
||||
"offline"
|
||||
],
|
||||
"stages": [
|
||||
{
|
||||
"id": "chlorination",
|
||||
"name": "氯化",
|
||||
"device": "CLF-01",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"id": "purification",
|
||||
"name": "精制",
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"id": "reduction",
|
||||
"name": "还原",
|
||||
"device": "RF-01",
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"id": "distillation",
|
||||
"name": "蒸馏",
|
||||
"order": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trend-1",
|
||||
"component": "TrendChart",
|
||||
"type": "trend",
|
||||
"grid": {
|
||||
"columnStart": 1,
|
||||
"columnSpan": 6,
|
||||
"rowStart": 5,
|
||||
"rowSpan": 2,
|
||||
"widthPct": 50.0,
|
||||
"style": "grid-column: 1 / span 6; grid-row: 5 / span 2;"
|
||||
},
|
||||
"description": "氯化炉温度实时趋势(沸腾氯化炉温 850±50℃)",
|
||||
"props": {
|
||||
"series": "CLF-01.TEMP",
|
||||
"window": "PT30M"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trend-2",
|
||||
"component": "TrendChart",
|
||||
"type": "trend",
|
||||
"grid": {
|
||||
"columnStart": 7,
|
||||
"columnSpan": 6,
|
||||
"rowStart": 5,
|
||||
"rowSpan": 2,
|
||||
"widthPct": 50.0,
|
||||
"style": "grid-column: 7 / span 6; grid-row: 5 / span 2;"
|
||||
},
|
||||
"description": "氯气流量实时趋势(流态化监控)",
|
||||
"props": {
|
||||
"series": "CLF-01.CL2",
|
||||
"window": "PT30M"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kpi_card-3",
|
||||
"component": "KpiCard",
|
||||
"type": "kpi_card",
|
||||
"grid": {
|
||||
"columnStart": 1,
|
||||
"columnSpan": 3,
|
||||
"rowStart": 7,
|
||||
"rowSpan": 2,
|
||||
"widthPct": 25.0,
|
||||
"style": "grid-column: 1 / span 3; grid-row: 7 / span 2;"
|
||||
},
|
||||
"description": "还原 TiCl₄ 纯度(%,工艺 ≥ 99.9%)",
|
||||
"props": {
|
||||
"metric": "ticl4_purity",
|
||||
"label": "TiCl₄纯度"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kpi_card-4",
|
||||
"component": "KpiCard",
|
||||
"type": "kpi_card",
|
||||
"grid": {
|
||||
"columnStart": 4,
|
||||
"columnSpan": 3,
|
||||
"rowStart": 7,
|
||||
"rowSpan": 2,
|
||||
"widthPct": 25.0,
|
||||
"style": "grid-column: 4 / span 3; grid-row: 7 / span 2;"
|
||||
},
|
||||
"description": "还原杂质含量(%,越低越好)",
|
||||
"props": {
|
||||
"metric": "ticl4_impurity",
|
||||
"label": "杂质含量"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kpi_card-5",
|
||||
"component": "KpiCard",
|
||||
"type": "kpi_card",
|
||||
"grid": {
|
||||
"columnStart": 7,
|
||||
"columnSpan": 3,
|
||||
"rowStart": 7,
|
||||
"rowSpan": 2,
|
||||
"widthPct": 25.0,
|
||||
"style": "grid-column: 7 / span 3; grid-row: 7 / span 2;"
|
||||
},
|
||||
"description": "车间累计电耗(kWh,单吨海绵钛综合能耗输入)",
|
||||
"props": {
|
||||
"metric": "energy_per_ton",
|
||||
"label": "累计电耗"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kpi_card-6",
|
||||
"component": "KpiCard",
|
||||
"type": "kpi_card",
|
||||
"grid": {
|
||||
"columnStart": 10,
|
||||
"columnSpan": 3,
|
||||
"rowStart": 7,
|
||||
"rowSpan": 2,
|
||||
"widthPct": 25.0,
|
||||
"style": "grid-column: 10 / span 3; grid-row: 7 / span 2;"
|
||||
},
|
||||
"description": "蒸汽流量(t/h,公用工程监控)",
|
||||
"props": {
|
||||
"metric": "steam_flow",
|
||||
"label": "蒸汽流量"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "alarm_panel-7",
|
||||
"component": "AlarmPanel",
|
||||
"type": "alarm_panel",
|
||||
"grid": {
|
||||
"columnStart": 1,
|
||||
"columnSpan": 9,
|
||||
"rowStart": 9,
|
||||
"rowSpan": 3,
|
||||
"widthPct": 75.0,
|
||||
"style": "grid-column: 1 / span 9; grid-row: 9 / span 3;"
|
||||
},
|
||||
"description": "告警面板(氯化炉温/还原真空度/纯度/杂质异常)",
|
||||
"props": {
|
||||
"subscribe": "alarm_stream"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nl_query-8",
|
||||
"component": "NlQuery",
|
||||
"type": "nl_query",
|
||||
"grid": {
|
||||
"columnStart": 10,
|
||||
"columnSpan": 3,
|
||||
"rowStart": 9,
|
||||
"rowSpan": 3,
|
||||
"widthPct": 25.0,
|
||||
"style": "grid-column: 10 / span 3; grid-row: 9 / span 3;"
|
||||
},
|
||||
"description": "自然语言查询入口(工艺/质量/能耗问答)",
|
||||
"props": {
|
||||
"placeholder": "输入自然语言查询(配方 / 质量 / 能耗)"
|
||||
},
|
||||
"perf": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgetCount": 9,
|
||||
"perfFlags": {
|
||||
"virtualScroll": false,
|
||||
"downsample": false,
|
||||
"worker": false
|
||||
},
|
||||
"perfHint": null
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""驾驶舱前端渲染计划编译脚本(issue #131 / PRD 5.5「⑤ 配置化驾驶舱」)。
|
||||
|
||||
把行业模板的布局 / 告警面板 **YAML 资产**编译成前端可直接 ``fetch`` 的
|
||||
**RenderPlan JSON**,落到 ``web/cockpit/plans/``:
|
||||
|
||||
- ``plans/ti-cl4.json`` ← ``templates/ti-cl4/dashboard/cockpit.ti.yaml``
|
||||
- ``plans/resin.json`` ← ``templates/resin/dashboard/cockpit.resin.yaml``
|
||||
- ``plans/alarm_panel.ti.json``← ``templates/ti-cl4/dashboard/alarm_panel.ti.yaml``
|
||||
|
||||
切换行业模板 = 前端加载另一份 plan JSON,**前端代码零改动**(PRD 5.5 验收口径)。
|
||||
|
||||
编译在开发期离线完成,前端页面保持纯静态(无构建链、无后端依赖)。
|
||||
本脚本是唯一依赖 PyYAML 的环节(开发期依赖,不进运行时);渲染逻辑全部复用
|
||||
``core/cockpit``(#50 布局校验 / #51 渲染计划 / #52 告警面板 props),
|
||||
保证前端看到的 JSON 与后端 Schema 严格一致。
|
||||
|
||||
用法(仓库根目录下):
|
||||
python web/cockpit/scripts/build_plans.py
|
||||
退出码:0 = 全部编译成功;1 = 存在失败项。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml # 开发期依赖(PyYAML),仅本脚本使用;前端运行时不依赖
|
||||
|
||||
# 仓库根目录加入 sys.path,以便 `from core.cockpit import ...`
|
||||
_REPO_ROOT = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
)
|
||||
sys.path.insert(0, _REPO_ROOT)
|
||||
|
||||
from core.cockpit import ( # noqa: E402
|
||||
load_alarm_config,
|
||||
load_layout,
|
||||
plan_to_dict,
|
||||
render_alarm_panel_props,
|
||||
render_layout,
|
||||
)
|
||||
|
||||
# 布局资产 → plan JSON 文件名
|
||||
_LAYOUTS = {
|
||||
"ti-cl4": "templates/ti-cl4/dashboard/cockpit.ti.yaml",
|
||||
"resin": "templates/resin/dashboard/cockpit.resin.yaml",
|
||||
}
|
||||
|
||||
# 告警面板配置资产 → plan JSON 文件名
|
||||
_ALARM_PANELS = {
|
||||
"alarm_panel.ti": "templates/ti-cl4/dashboard/alarm_panel.ti.yaml",
|
||||
}
|
||||
|
||||
_OUT_DIR = os.path.join(_REPO_ROOT, "web", "cockpit", "plans")
|
||||
|
||||
|
||||
def _load_yaml(path: str) -> dict:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def _write_json(name: str, data: dict) -> str:
|
||||
os.makedirs(_OUT_DIR, exist_ok=True)
|
||||
out_path = os.path.join(_OUT_DIR, name + ".json")
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
return out_path
|
||||
|
||||
|
||||
def _enrich_process_view_stages(plan_dict: dict, layout_data: dict) -> None:
|
||||
"""把布局资产里 process_view 的 ``stages``(四状态工艺语义)注入 plan JSON。
|
||||
|
||||
#51 ``_build_props`` 只透传 ``src``/``states``,四状态清单(氯化→精制→
|
||||
还原→蒸馏)留在布局资产里;前端 ProcessView 组件需要它来渲染流程节点,
|
||||
因此在编译期按 widget 顺序补进 ``props.stages``(specs 与源 widgets 同序)。
|
||||
"""
|
||||
src_widgets = layout_data.get("widgets") or []
|
||||
specs = plan_dict.get("widgets") or []
|
||||
for spec, raw in zip(specs, src_widgets):
|
||||
if spec.get("type") == "process_view" and isinstance(raw, dict):
|
||||
stages = raw.get("stages")
|
||||
if stages:
|
||||
spec.setdefault("props", {})["stages"] = stages
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures = 0
|
||||
|
||||
# 1) 布局资产 → RenderPlan JSON(#50 校验 + #51 渲染计划)
|
||||
for name, rel in _LAYOUTS.items():
|
||||
try:
|
||||
data = _load_yaml(os.path.join(_REPO_ROOT, rel))
|
||||
plan = render_layout(load_layout(data))
|
||||
plan_dict = plan_to_dict(plan)
|
||||
_enrich_process_view_stages(plan_dict, data)
|
||||
out = _write_json(name, plan_dict)
|
||||
print(f"[OK] {rel} -> {os.path.relpath(out, _REPO_ROOT)} "
|
||||
f"({plan.widget_count} widgets, theme={plan.theme})")
|
||||
except Exception as exc: # noqa: BLE001 - 编译脚本需聚合全部失败
|
||||
failures += 1
|
||||
print(f"[FAIL] {rel}: {exc}")
|
||||
|
||||
# 2) 告警面板配置资产 → alarm_panel props JSON(#52)
|
||||
for name, rel in _ALARM_PANELS.items():
|
||||
try:
|
||||
data = _load_yaml(os.path.join(_REPO_ROOT, rel))
|
||||
props = render_alarm_panel_props(load_alarm_config(data))
|
||||
out = _write_json(name, props)
|
||||
print(f"[OK] {rel} -> {os.path.relpath(out, _REPO_ROOT)} "
|
||||
f"(severities={sorted(props['severityStyles'])})")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures += 1
|
||||
print(f"[FAIL] {rel}: {exc}")
|
||||
|
||||
if failures:
|
||||
print(f"共 {failures} 项编译失败")
|
||||
return 1
|
||||
print("全部渲染计划编译完成")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user