From 92e075c9f1d51e3c152caf29f17649e344c0866c Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 09:32:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20issue=20#131=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8C=96=E9=A9=BE=E9=A9=B6=E8=88=B1=20Web=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=88web/cockpit=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/cockpit/README.md | 50 ++++ web/cockpit/cockpit.css | 122 +++++++++ web/cockpit/cockpit.js | 362 ++++++++++++++++++++++++++ web/cockpit/index.html | 29 +++ web/cockpit/plans/alarm_panel.ti.json | 37 +++ web/cockpit/plans/resin.json | 236 +++++++++++++++++ web/cockpit/plans/ti-cl4.json | 260 ++++++++++++++++++ web/cockpit/scripts/build_plans.py | 124 +++++++++ 8 files changed, 1220 insertions(+) create mode 100644 web/cockpit/README.md create mode 100644 web/cockpit/cockpit.css create mode 100644 web/cockpit/cockpit.js create mode 100644 web/cockpit/index.html create mode 100644 web/cockpit/plans/alarm_panel.ti.json create mode 100644 web/cockpit/plans/resin.json create mode 100644 web/cockpit/plans/ti-cl4.json create mode 100644 web/cockpit/scripts/build_plans.py diff --git a/web/cockpit/README.md b/web/cockpit/README.md new file mode 100644 index 0000000..f48b092 --- /dev/null +++ b/web/cockpit/README.md @@ -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 模块/构建依赖。 diff --git a/web/cockpit/cockpit.css b/web/cockpit/cockpit.css new file mode 100644 index 0000000..33a9d06 --- /dev/null +++ b/web/cockpit/cockpit.css @@ -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; } +} diff --git a/web/cockpit/cockpit.js b/web/cockpit/cockpit.js new file mode 100644 index 0000000..eec955d --- /dev/null +++ b/web/cockpit/cockpit.js @@ -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,首屏全本地资源 +})(); diff --git a/web/cockpit/index.html b/web/cockpit/index.html new file mode 100644 index 0000000..5f09940 --- /dev/null +++ b/web/cockpit/index.html @@ -0,0 +1,29 @@ + + + + + +iAOP 配置化驾驶舱 + + + + +
+

iAOP 驾驶舱

+
+ + + ● + 推理服务检测中… +
+
+
+ + + + diff --git a/web/cockpit/plans/alarm_panel.ti.json b/web/cockpit/plans/alarm_panel.ti.json new file mode 100644 index 0000000..9ce60ea --- /dev/null +++ b/web/cockpit/plans/alarm_panel.ti.json @@ -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 +} \ No newline at end of file diff --git a/web/cockpit/plans/resin.json b/web/cockpit/plans/resin.json new file mode 100644 index 0000000..0d07b87 --- /dev/null +++ b/web/cockpit/plans/resin.json @@ -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 +} \ No newline at end of file diff --git a/web/cockpit/plans/ti-cl4.json b/web/cockpit/plans/ti-cl4.json new file mode 100644 index 0000000..232d762 --- /dev/null +++ b/web/cockpit/plans/ti-cl4.json @@ -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 +} \ No newline at end of file diff --git a/web/cockpit/scripts/build_plans.py b/web/cockpit/scripts/build_plans.py new file mode 100644 index 0000000..cbb1679 --- /dev/null +++ b/web/cockpit/scripts/build_plans.py @@ -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())