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 @@ + + +
+ + +