feat: 完成 issue #132 模板配置台 UI(web/studio:导入/超参/编排/版本,三级 RBAC)
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# web/studio — iAOP 模板配置台 UI
|
||||
|
||||
issue #132 / PRD 5.7「⑤.7 模板配置台」。纯静态单页应用(无构建链),
|
||||
覆盖 PRD 5.7 关键页面线框:导入页 / 超参配置页 / 驾驶舱编排页 / 版本页,
|
||||
三级 RBAC(Viewer / Editor / Publisher)。
|
||||
|
||||
## 使用
|
||||
|
||||
```bash
|
||||
cd web/studio && python -m http.server 8081
|
||||
# 浏览器打开 http://localhost:8081 (Chrome / Edge 最新两版)
|
||||
```
|
||||
|
||||
## 页面与后端语义对齐
|
||||
|
||||
配置台后端内核 `core/template-console/` 是纯标准库引擎(暂无 HTTP 服务),
|
||||
本 UI 在前端镜像其语义;配置与版本持久化在浏览器 localStorage(Demo 级)。
|
||||
|
||||
| 页面 | 能力 | 对齐内核模块 |
|
||||
|------|------|--------------|
|
||||
| ① 点位导入 | CSV 上传/粘贴、校验进度条、错误列表(行号+原因)、下载错误模板 | `point_importer.py`(表头列序/必填/数据类型/采样率/重复点号/OPC 节点/模板量纲收窄) |
|
||||
| ② 模型超参 | 左树 4 类模型(质量预测/异常检测/工艺优化/跨工序关联)、右表单(默认值/单位/范围)、实时 JSON 预览 | `config_store.py`(ConfigKind.MODEL_PARAM)、`core/model-framework` 示例 |
|
||||
| ③ 驾驶舱编排 | 组件库(5 种 widget)/ 12 列画布 / 数据源绑定面板,产出 `iAOP-cockpit-layout-v1` JSON | `config_store.py`(ConfigKind.LAYOUT)、`core/cockpit/layout.py` |
|
||||
| ④ 版本发布 | 版本列表(状态/时间/作者)、diff 视图、回滚(不删历史、事件可追溯)、导出模板资产包 | `release.py`(快照固化 / semver 单调递增 / 回滚可追溯) |
|
||||
|
||||
## RBAC(对齐 core/template-console/rbac.py)
|
||||
|
||||
- **Viewer(readonly)**:只读预览,全部编辑/发布按钮禁用;
|
||||
- **Editor(engineer)**:可配置(导入校验/超参/编排),不可发布与回滚;
|
||||
- **Publisher(admin)**:可配置 + 发布 + 回滚。
|
||||
|
||||
顶栏角色切换即切换权限视图(Demo 级,未接认证——认证由 issue #134 覆盖)。
|
||||
|
||||
## 耦合边界
|
||||
|
||||
配置台唯一产出物是「模板资产包」JSON(版本页导出,
|
||||
`pack_schema: iAOP-template-pack-v1`,含版本快照:超参/布局/点位校验报告),
|
||||
供内核灰度推送(对齐 `push_channel.py` 的 PushManifest 语义),
|
||||
不直接改内核运行时。
|
||||
@@ -0,0 +1,150 @@
|
||||
<!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="studio.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- iAOP 模板配置台(issue #132 / PRD 5.7「⑤.7 模板配置台」)
|
||||
纯静态单页应用(无构建链):导入页 / 超参配置页 / 驾驶舱编排页 / 版本页。
|
||||
三级 RBAC 对齐 core/template-console/rbac.py 的 readonly/engineer/admin;
|
||||
配置台只产出「模板资产包」(导出 JSON),不直接改内核运行时(耦合边界)。 -->
|
||||
<header id="topbar">
|
||||
<h1>iAOP 模板配置台 <span class="sub">Template Console · PRD 5.7</span></h1>
|
||||
<div id="topbar-right">
|
||||
<label for="role-select">当前角色</label>
|
||||
<select id="role-select">
|
||||
<option value="readonly">Viewer(只读)</option>
|
||||
<option value="engineer" selected>Editor(配置)</option>
|
||||
<option value="admin">Publisher(发布+回滚)</option>
|
||||
</select>
|
||||
<span id="role-hint" class="hint"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav id="tabs">
|
||||
<button class="tab active" data-page="import">① 点位导入</button>
|
||||
<button class="tab" data-page="hyper">② 模型超参</button>
|
||||
<button class="tab" data-page="layout">③ 驾驶舱编排</button>
|
||||
<button class="tab" data-page="version">④ 版本发布</button>
|
||||
</nav>
|
||||
|
||||
<!-- ======================= ① 导入页 ======================= -->
|
||||
<section class="page active" id="page-import">
|
||||
<div class="panel">
|
||||
<h2>点位字典 CSV 导入</h2>
|
||||
<p class="hint">字段规范对齐 PRD 5.1 / core/edge-gateway/point_dict:
|
||||
device_id, point_id, name, unit, dataType, sampleRate, qualityCode, opcNode, protocol</p>
|
||||
<div id="drop-zone" class="drop-zone">
|
||||
拖拽 CSV 文件到这里,或
|
||||
<input type="file" id="csv-file" accept=".csv,text/csv">
|
||||
<button id="btn-download-template">下载错误模板(示例 CSV)</button>
|
||||
</div>
|
||||
<textarea id="csv-text" rows="6" placeholder="或直接粘贴 CSV 内容…"></textarea>
|
||||
<div class="row">
|
||||
<label>行业模板
|
||||
<select id="import-template">
|
||||
<option value="ti">氯化(ti-cl4)</option>
|
||||
<option value="resin">树脂(resin)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="btn-validate" class="primary need-edit">开始校验</button>
|
||||
</div>
|
||||
<div id="import-progress-wrap" hidden>
|
||||
<div id="import-progress"><div id="import-progress-bar"></div></div>
|
||||
<div id="import-progress-text" class="hint"></div>
|
||||
</div>
|
||||
<div id="import-summary"></div>
|
||||
<table id="issue-table" hidden>
|
||||
<thead><tr><th>行号</th><th>级别</th><th>字段</th><th>原因</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================= ② 超参配置页 ======================= -->
|
||||
<section class="page" id="page-hyper">
|
||||
<div class="split">
|
||||
<aside class="tree panel">
|
||||
<h2>模型类型(4 类)</h2>
|
||||
<ul id="model-tree"></ul>
|
||||
</aside>
|
||||
<div class="panel" id="hyper-form-panel">
|
||||
<h2 id="hyper-title">超参配置</h2>
|
||||
<form id="hyper-form"></form>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>实时 JSON 预览</h2>
|
||||
<pre id="hyper-json" class="json-view"></pre>
|
||||
<div class="row">
|
||||
<button id="btn-hyper-save" class="primary need-edit">保存到配置项</button>
|
||||
<span id="hyper-save-hint" class="hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================= ③ 驾驶舱编排页 ======================= -->
|
||||
<section class="page" id="page-layout">
|
||||
<div class="split">
|
||||
<aside class="panel" id="widget-lib">
|
||||
<h2>组件库</h2>
|
||||
<p class="hint">点击添加到画布(组件类型对齐 iAOP-cockpit-layout-v1)</p>
|
||||
<ul id="widget-lib-list"></ul>
|
||||
</aside>
|
||||
<div class="panel">
|
||||
<h2>画布(12 列栅格)</h2>
|
||||
<div id="canvas"></div>
|
||||
<div class="row">
|
||||
<button id="btn-layout-clear" class="need-edit">清空画布</button>
|
||||
<button id="btn-layout-load-ti" class="need-edit">载入 ti-cl4 基线</button>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="panel" id="bind-panel">
|
||||
<h2>数据源绑定</h2>
|
||||
<div id="bind-form-wrap"><p class="hint">选中画布中的组件后进行绑定</p></div>
|
||||
<h2>布局 JSON</h2>
|
||||
<pre id="layout-json" class="json-view"></pre>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================= ④ 版本页 ======================= -->
|
||||
<section class="page" id="page-version">
|
||||
<div class="split-2">
|
||||
<div class="panel">
|
||||
<h2>版本列表</h2>
|
||||
<div class="row">
|
||||
<input id="release-version" placeholder="版本号(semver,如 1.0.0)" value="1.0.0">
|
||||
<input id="release-author" placeholder="作者" value="engineer">
|
||||
<button id="btn-publish" class="primary need-publish">发布当前配置</button>
|
||||
</div>
|
||||
<p class="hint">发布 = 固化当前超参 + 布局 + 校验结果为快照(对齐 core/template-console/release.py);
|
||||
配置台只产出模板资产包,不直接改内核运行时。</p>
|
||||
<table id="version-table">
|
||||
<thead><tr><th>版本</th><th>状态</th><th>时间</th><th>作者</th><th>操作</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>diff 视图</h2>
|
||||
<div class="row">
|
||||
<select id="diff-left"></select>
|
||||
<span class="hint">vs</span>
|
||||
<select id="diff-right"></select>
|
||||
<button id="btn-diff">对比</button>
|
||||
</div>
|
||||
<pre id="diff-view" class="json-view"></pre>
|
||||
<div class="row">
|
||||
<button id="btn-export-pack" class="primary">导出模板资产包</button>
|
||||
<span class="hint">资产包 = 选中版本快照(超参/布局/点位校验报告),供内核灰度推送</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="studio.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,106 @@
|
||||
/* iAOP 模板配置台样式(issue #132 / PRD 5.7)。深色主题,与 web/cockpit 一致。 */
|
||||
: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); }
|
||||
#topbar h1 { font-size: 18px; margin: 0; color: var(--cockpit-accent); }
|
||||
#topbar .sub { font-size: 12px; color: var(--cockpit-fg-muted); font-weight: 400; }
|
||||
#topbar-right { display: flex; align-items: center; gap: 8px; font-size: 13px;
|
||||
color: var(--cockpit-fg-muted); }
|
||||
|
||||
#tabs { display: flex; gap: 4px; padding: 8px 16px 0; }
|
||||
.tab { padding: 8px 18px; border: 1px solid var(--cockpit-grid-line); border-bottom: none;
|
||||
border-radius: 8px 8px 0 0; background: transparent; color: var(--cockpit-fg-muted);
|
||||
cursor: pointer; font-size: 14px; }
|
||||
.tab.active { background: var(--cockpit-surface); color: var(--cockpit-accent); }
|
||||
|
||||
.page { display: none; padding: 12px 16px 24px; }
|
||||
.page.active { display: block; }
|
||||
.panel { background: var(--cockpit-surface); border: 1px solid var(--cockpit-grid-line);
|
||||
border-radius: 8px; padding: 14px 16px; }
|
||||
.panel h2 { font-size: 14px; margin: 0 0 10px; color: var(--cockpit-accent); }
|
||||
.hint { font-size: 12px; color: var(--cockpit-fg-muted); }
|
||||
.row { display: flex; align-items: center; gap: 10px; margin: 10px 0; flex-wrap: wrap; }
|
||||
|
||||
select, input, textarea { padding: 7px 10px; border-radius: 6px; font-size: 13px;
|
||||
border: 1px solid var(--cockpit-grid-line); background: var(--cockpit-bg);
|
||||
color: var(--cockpit-fg); }
|
||||
textarea { width: 100%; font-family: Consolas, monospace; }
|
||||
button { padding: 7px 16px; border: none; border-radius: 6px; cursor: pointer;
|
||||
background: #1e293b; color: var(--cockpit-fg); font-size: 13px; }
|
||||
button.primary { background: var(--cockpit-accent); color: #04202a; }
|
||||
button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
button.danger { background: #7a1f1a; color: #ffb4ad; }
|
||||
|
||||
/* 三栏布局(超参页 / 编排页) */
|
||||
.split { display: grid; grid-template-columns: 220px 1fr 320px; gap: 12px; }
|
||||
.split-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
|
||||
/* ① 导入页 */
|
||||
.drop-zone { border: 2px dashed var(--cockpit-grid-line); border-radius: 8px;
|
||||
padding: 18px; text-align: center; color: var(--cockpit-fg-muted);
|
||||
margin-bottom: 10px; }
|
||||
.drop-zone.dragover { border-color: var(--cockpit-accent); color: var(--cockpit-accent); }
|
||||
#import-progress { height: 10px; background: var(--cockpit-bg); border-radius: 5px;
|
||||
overflow: hidden; margin-top: 8px; }
|
||||
#import-progress-bar { height: 100%; width: 0; background: var(--cockpit-accent);
|
||||
transition: width .1s; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 12px; }
|
||||
th, td { padding: 6px 8px; border-bottom: 1px solid var(--cockpit-grid-line);
|
||||
text-align: left; }
|
||||
th { color: var(--cockpit-fg-muted); font-weight: 400; }
|
||||
.sev-error { color: #ff3b30; }
|
||||
.sev-warning { color: var(--cockpit-warn); }
|
||||
#import-summary { margin-top: 10px; font-size: 13px; }
|
||||
|
||||
/* ② 超参配置页 */
|
||||
.tree ul { list-style: none; padding: 0; margin: 0; }
|
||||
.tree li { padding: 8px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.tree li.active { background: var(--cockpit-bg); color: var(--cockpit-accent); }
|
||||
.form-item { margin-bottom: 12px; }
|
||||
.form-item label { display: block; font-size: 13px; margin-bottom: 4px; }
|
||||
.form-item .meta { font-size: 11px; color: var(--cockpit-fg-muted); margin-top: 2px; }
|
||||
.form-item input { width: 100%; }
|
||||
.json-view { background: var(--cockpit-bg); border-radius: 6px; padding: 10px;
|
||||
font-size: 12px; font-family: Consolas, monospace; overflow: auto;
|
||||
max-height: 480px; white-space: pre; }
|
||||
|
||||
/* ③ 驾驶舱编排页 */
|
||||
#widget-lib-list { list-style: none; padding: 0; margin: 0; }
|
||||
#widget-lib-list li { padding: 8px 10px; margin-bottom: 6px; border-radius: 6px;
|
||||
background: var(--cockpit-bg); cursor: pointer; font-size: 13px;
|
||||
border: 1px solid var(--cockpit-grid-line); }
|
||||
#widget-lib-list li:hover { border-color: var(--cockpit-accent); }
|
||||
#canvas { display: grid; grid-template-columns: repeat(12, 1fr); grid-auto-rows: 34px;
|
||||
gap: 6px; min-height: 320px; background: var(--cockpit-bg); border-radius: 8px;
|
||||
padding: 8px; }
|
||||
.canvas-widget { border: 1px solid var(--cockpit-accent); border-radius: 6px;
|
||||
background: rgba(24,211,200,.08); font-size: 12px; padding: 4px 6px;
|
||||
cursor: pointer; overflow: hidden; position: relative; }
|
||||
.canvas-widget.selected { outline: 2px solid var(--cockpit-warn); }
|
||||
.canvas-widget .ops { position: absolute; right: 4px; top: 2px; }
|
||||
.canvas-widget .ops button { padding: 0 6px; font-size: 11px; background: transparent;
|
||||
color: var(--cockpit-fg-muted); }
|
||||
|
||||
/* ④ 版本页 */
|
||||
.status-published { color: var(--cockpit-accent); }
|
||||
.status-rolledback { color: var(--cockpit-warn); }
|
||||
.diff-add { color: #2ecc71; }
|
||||
.diff-del { color: #ff3b30; }
|
||||
.diff-ctx { color: var(--cockpit-fg-muted); }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.split, .split-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
/* iAOP 模板配置台(issue #132 / PRD 5.7「⑤.7 模板配置台」)。
|
||||
*
|
||||
* 纯静态单页应用(无构建链),Demo 级交付:
|
||||
* ① 导入页 —— 点位字典 CSV 上传/校验进度/错误列表(行号+原因)/下载错误模板
|
||||
* ② 超参配置页 —— 左树 4 类模型 + 右表单(默认值/单位/范围)+ 实时 JSON 预览
|
||||
* ③ 编排页 —— 组件库 / 12 列画布 / 数据源绑定面板,产出 iAOP-cockpit-layout-v1 JSON
|
||||
* ④ 版本页 —— 版本列表(状态/时间/作者)/ diff 视图 / 回滚 / 导出模板资产包
|
||||
*
|
||||
* 语义对齐后端内核 core/template-console(rbac/point_importer/config_store/release):
|
||||
* 后端是纯标准库引擎、暂无 HTTP 服务,本页面在前端镜像其校验与版本语义,
|
||||
* 配置与版本持久化在 localStorage;耦合边界 = 配置台只导出「模板资产包」JSON,
|
||||
* 不直接改内核运行时(PRD 5.7)。
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/* ================= RBAC(对齐 core/template-console/rbac.py) ================= */
|
||||
// 角色:readonly=Viewer(只读)/ engineer=Editor(配置)/ admin=Publisher(发布+回滚)
|
||||
var RBAC = {
|
||||
readonly: { edit: false, publish: false, label: "Viewer:只读预览" },
|
||||
engineer: { edit: true, publish: false, label: "Editor:可配置,不可发布" },
|
||||
admin: { edit: true, publish: true, label: "Publisher:可配置/发布/回滚" }
|
||||
};
|
||||
var currentRole = "engineer";
|
||||
|
||||
function applyRbac() {
|
||||
var perm = RBAC[currentRole];
|
||||
document.getElementById("role-hint").textContent = perm.label;
|
||||
document.querySelectorAll(".need-edit").forEach(function (b) { b.disabled = !perm.edit; });
|
||||
document.querySelectorAll(".need-publish").forEach(function (b) { b.disabled = !perm.publish; });
|
||||
}
|
||||
|
||||
/* ================= 工具 ================= */
|
||||
function el(tag, cls, text) {
|
||||
var n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (text !== undefined) n.textContent = text;
|
||||
return n;
|
||||
}
|
||||
function download(filename, text, mime) {
|
||||
var a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(new Blob([text], { type: mime || "application/octet-stream" }));
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
function store(key, obj) { localStorage.setItem(key, JSON.stringify(obj)); }
|
||||
function load(key, fallback) {
|
||||
try { var v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; }
|
||||
catch (e) { return fallback; }
|
||||
}
|
||||
|
||||
/* ================= ① 导入页(对齐 point_importer.py 校验维度) ================= */
|
||||
// 表头列序对齐 core/edge-gateway/point_dict/schema.py 的 CSV_HEADERS
|
||||
var CSV_HEADERS = ["device_id", "point_id", "name", "unit", "dataType",
|
||||
"sampleRate", "qualityCode", "opcNode", "protocol"];
|
||||
var VALID_DATA_TYPES = ["float", "int", "bool", "string"];
|
||||
var OPC_NODE_RE = /^ns=\d+;s=\S+$/; // OPC 节点格式(point_importer._validate_opc_node)
|
||||
// 模板级量纲收窄(point_importer._unit_set,演示版内置常用子集)
|
||||
var TEMPLATE_UNITS = {
|
||||
ti: ["℃", "kPa", "Pa", "Nm³/h", "kg/h", "t/h", "%", "kWh", "mV", "ppm", "-"],
|
||||
resin: ["℃", "kPa", "r/min", "mmol/g", "%", "kWh/t", "m³/h", "t", "-"]
|
||||
};
|
||||
|
||||
function validateCsv(text, template) {
|
||||
var issues = [];
|
||||
var lines = text.replace(/\r\n?/g, "\n").split("\n")
|
||||
.filter(function (l) { return l.trim() !== ""; });
|
||||
if (!lines.length) {
|
||||
issues.push({ line: 0, severity: "error", field: "-", reason: "CSV 内容为空" });
|
||||
return { issues: issues, total: 0 };
|
||||
}
|
||||
// 1) 表头列序校验(point_importer._validate_header_order)
|
||||
var headers = lines[0].split(",").map(function (h) { return h.trim(); });
|
||||
if (headers.join(",") !== CSV_HEADERS.join(",")) {
|
||||
issues.push({ line: 1, severity: "error", field: "header",
|
||||
reason: "表头列序不符,期望: " + CSV_HEADERS.join(",") });
|
||||
}
|
||||
// 2) 行级校验
|
||||
var seen = {};
|
||||
var units = TEMPLATE_UNITS[template] || TEMPLATE_UNITS.ti;
|
||||
for (var i = 1; i < lines.length; i++) {
|
||||
var ln = i + 1;
|
||||
var cols = lines[i].split(",").map(function (c) { return c.trim(); });
|
||||
if (cols.length !== CSV_HEADERS.length) {
|
||||
issues.push({ line: ln, severity: "error", field: "-",
|
||||
reason: "列数 " + cols.length + " ≠ " + CSV_HEADERS.length });
|
||||
continue;
|
||||
}
|
||||
var row = {};
|
||||
CSV_HEADERS.forEach(function (h, k) { row[h] = cols[k]; });
|
||||
["device_id", "point_id", "name"].forEach(function (f) {
|
||||
if (!row[f]) issues.push({ line: ln, severity: "error", field: f, reason: f + " 不能为空" });
|
||||
});
|
||||
if (row.point_id) {
|
||||
if (seen[row.point_id]) {
|
||||
issues.push({ line: ln, severity: "error", field: "point_id",
|
||||
reason: "点号重复(首次出现于第 " + seen[row.point_id] + " 行)" });
|
||||
} else seen[row.point_id] = ln;
|
||||
}
|
||||
if (VALID_DATA_TYPES.indexOf(row.dataType) < 0) {
|
||||
issues.push({ line: ln, severity: "error", field: "dataType",
|
||||
reason: "数据类型非法: " + row.dataType + "(允许 " + VALID_DATA_TYPES.join("/") + ")" });
|
||||
}
|
||||
if (!/^\d+$/.test(row.sampleRate) || parseInt(row.sampleRate, 10) <= 0) {
|
||||
issues.push({ line: ln, severity: "error", field: "sampleRate",
|
||||
reason: "采样率必须为正整数毫秒: " + row.sampleRate });
|
||||
}
|
||||
if (row.qualityCode !== "true" && row.qualityCode !== "false") {
|
||||
issues.push({ line: ln, severity: "warning", field: "qualityCode",
|
||||
reason: "qualityCode 建议 true/false: " + row.qualityCode });
|
||||
}
|
||||
if (row.opcNode && !OPC_NODE_RE.test(row.opcNode)) {
|
||||
issues.push({ line: ln, severity: "error", field: "opcNode",
|
||||
reason: "OPC 节点格式非法(期望 ns=<n>;s=<id>): " + row.opcNode });
|
||||
}
|
||||
if (row.unit && units.indexOf(row.unit) < 0) {
|
||||
issues.push({ line: ln, severity: "warning", field: "unit",
|
||||
reason: "量纲超出 " + template + " 模板收窄范围: " + row.unit });
|
||||
}
|
||||
}
|
||||
return { issues: issues, total: lines.length - 1 };
|
||||
}
|
||||
|
||||
function runImportValidation() {
|
||||
var text = document.getElementById("csv-text").value;
|
||||
var template = document.getElementById("import-template").value;
|
||||
var wrap = document.getElementById("import-progress-wrap");
|
||||
var bar = document.getElementById("import-progress-bar");
|
||||
var ptext = document.getElementById("import-progress-text");
|
||||
var summary = document.getElementById("import-summary");
|
||||
var table = document.getElementById("issue-table");
|
||||
wrap.hidden = false;
|
||||
summary.textContent = "";
|
||||
table.hidden = true;
|
||||
|
||||
// 校验进度条(按行推进,演示校验过程;行数小时立即完成)
|
||||
var totalLines = Math.max(text.split("\n").length, 1);
|
||||
var done = 0;
|
||||
var timer = setInterval(function () {
|
||||
done += Math.ceil(totalLines / 20);
|
||||
if (done >= totalLines) {
|
||||
done = totalLines;
|
||||
clearInterval(timer);
|
||||
var report = validateCsv(text, template);
|
||||
var errors = report.issues.filter(function (x) { return x.severity === "error"; });
|
||||
summary.textContent = "校验完成:共 " + report.total + " 行," +
|
||||
errors.length + " 个错误 / " + (report.issues.length - errors.length) + " 个警告" +
|
||||
(errors.length ? ",请修正后重新导入" : ",可进入下一步配置");
|
||||
summary.className = errors.length ? "sev-error" : "";
|
||||
if (report.issues.length) {
|
||||
var tbody = table.querySelector("tbody");
|
||||
tbody.innerHTML = "";
|
||||
report.issues.forEach(function (x) {
|
||||
var tr = el("tr");
|
||||
tr.appendChild(el("td", null, String(x.line)));
|
||||
tr.appendChild(el("td", x.severity === "error" ? "sev-error" : "sev-warning",
|
||||
x.severity === "error" ? "错误" : "警告"));
|
||||
tr.appendChild(el("td", null, x.field));
|
||||
tr.appendChild(el("td", null, x.reason));
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
table.hidden = false;
|
||||
}
|
||||
// 最近一次校验报告纳入资产包(版本页发布时随快照固化)
|
||||
studioState.importReport = {
|
||||
template: template, total: report.total,
|
||||
errors: errors.length, warnings: report.issues.length - errors.length,
|
||||
issues: report.issues.slice(0, 200)
|
||||
};
|
||||
}
|
||||
bar.style.width = Math.round(done / totalLines * 100) + "%";
|
||||
ptext.textContent = "校验中… " + done + "/" + totalLines + " 行";
|
||||
}, 60);
|
||||
}
|
||||
|
||||
/* ================= ② 超参配置页(4 类模型,对齐 core/model-framework) ================= */
|
||||
// 字段:key / 标签 / 默认值 / 单位 / 范围(对齐 quality_predict_ti.example.json 等内核示例)
|
||||
var MODEL_SPECS = {
|
||||
quality_forecast: {
|
||||
name: "① 质量预测(quality_forecast)",
|
||||
fields: [
|
||||
{ key: "max_depth", label: "树最大深度", def: 6, unit: "层", min: 1, max: 16, type: "int" },
|
||||
{ key: "eta", label: "学习率", def: 0.1, unit: "-", min: 0.001, max: 1, type: "float" },
|
||||
{ key: "n_estimators", label: "迭代树数", def: 300, unit: "棵", min: 10, max: 2000, type: "int" },
|
||||
{ key: "train_window", label: "训练窗口", def: "180d", unit: "天/班次", type: "str" },
|
||||
{ key: "alarm_zscore_k", label: "告警 zscore k", def: 3.0, unit: "σ", min: 1, max: 6, type: "float" },
|
||||
{ key: "drift_psi_limit", label: "漂移 PSI 上限", def: 0.2, unit: "-", min: 0.05, max: 1, type: "float" }
|
||||
]
|
||||
},
|
||||
anomaly_detection: {
|
||||
name: "② 异常检测(anomaly_detection)",
|
||||
fields: [
|
||||
{ key: "contamination", label: "异常比例先验", def: 0.05, unit: "比例", min: 0.001, max: 0.3, type: "float" },
|
||||
{ key: "window", label: "检测窗口", def: "10m", unit: "分钟", type: "str" },
|
||||
{ key: "zscore_k", label: "zscore 阈值 k", def: 3.0, unit: "σ", min: 1, max: 6, type: "float" },
|
||||
{ key: "min_train_samples", label: "最小训练样本数", def: 1000, unit: "条", min: 100, max: 100000, type: "int" }
|
||||
]
|
||||
},
|
||||
process_optimizer: {
|
||||
name: "③ 工艺优化(process_optimizer)",
|
||||
fields: [
|
||||
{ key: "learning_rate", label: "优化学习率", def: 0.05, unit: "-", min: 0.001, max: 1, type: "float" },
|
||||
{ key: "horizon", label: "优化时域", def: "1shift", unit: "班/小时", type: "str" },
|
||||
{ key: "explore_ratio", label: "探索比例", def: 0.1, unit: "比例", min: 0, max: 0.5, type: "float" },
|
||||
{ key: "constraint_penalty", label: "约束惩罚系数", def: 100, unit: "-", min: 1, max: 10000, type: "float" }
|
||||
]
|
||||
},
|
||||
cross_process_optimizer: {
|
||||
name: "④ 跨工序关联(cross_process_optimizer)",
|
||||
fields: [
|
||||
{ key: "coupling_strength", label: "工序耦合强度", def: 0.5, unit: "-", min: 0, max: 1, type: "float" },
|
||||
{ key: "sync_window", label: "同步窗口", def: "1h", unit: "小时", type: "str" },
|
||||
{ key: "max_iter", label: "最大迭代数", def: 500, unit: "次", min: 10, max: 5000, type: "int" },
|
||||
{ key: "tolerance", label: "收敛容差", def: 0.0001, unit: "-", min: 0.000001, max: 0.1, type: "float" }
|
||||
]
|
||||
}
|
||||
};
|
||||
var currentModel = "quality_forecast";
|
||||
|
||||
function renderModelTree() {
|
||||
var ul = document.getElementById("model-tree");
|
||||
ul.innerHTML = "";
|
||||
Object.keys(MODEL_SPECS).forEach(function (k) {
|
||||
var li = el("li", k === currentModel ? "active" : null, MODEL_SPECS[k].name);
|
||||
li.onclick = function () { currentModel = k; renderModelTree(); renderHyperForm(); };
|
||||
ul.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function hyperValues(model) {
|
||||
// 已保存值优先,否则用默认值
|
||||
var saved = (studioState.config.model_param || {})[model] || {};
|
||||
var values = {};
|
||||
MODEL_SPECS[model].fields.forEach(function (f) {
|
||||
values[f.key] = saved[f.key] !== undefined ? saved[f.key] : f.def;
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
function renderHyperForm() {
|
||||
var spec = MODEL_SPECS[currentModel];
|
||||
document.getElementById("hyper-title").textContent = spec.name;
|
||||
var form = document.getElementById("hyper-form");
|
||||
form.innerHTML = "";
|
||||
var values = hyperValues(currentModel);
|
||||
spec.fields.forEach(function (f) {
|
||||
var item = el("div", "form-item");
|
||||
item.appendChild(el("label", null, f.label + "(" + f.key + ")"));
|
||||
var input = document.createElement("input");
|
||||
input.value = values[f.key];
|
||||
input.dataset.key = f.key;
|
||||
input.dataset.type = f.type;
|
||||
input.disabled = !RBAC[currentRole].edit;
|
||||
input.oninput = renderHyperJson;
|
||||
item.appendChild(input);
|
||||
var meta = "默认 " + f.def + " · 单位 " + (f.unit || "-");
|
||||
if (f.min !== undefined) meta += " · 范围 [" + f.min + ", " + f.max + "]";
|
||||
item.appendChild(el("div", "meta", meta));
|
||||
form.appendChild(item);
|
||||
});
|
||||
renderHyperJson();
|
||||
}
|
||||
|
||||
function readHyperForm() {
|
||||
var out = {};
|
||||
document.querySelectorAll("#hyper-form input").forEach(function (input) {
|
||||
var v = input.value;
|
||||
if (input.dataset.type === "int") v = parseInt(v, 10);
|
||||
else if (input.dataset.type === "float") v = parseFloat(v);
|
||||
out[input.dataset.key] = v;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderHyperJson() {
|
||||
var json = { model_id: currentModel, hyperparams: readHyperForm() };
|
||||
document.getElementById("hyper-json").textContent = JSON.stringify(json, null, 2);
|
||||
}
|
||||
|
||||
function saveHyper() {
|
||||
studioState.config.model_param = studioState.config.model_param || {};
|
||||
studioState.config.model_param[currentModel] = readHyperForm();
|
||||
persistConfig();
|
||||
document.getElementById("hyper-save-hint").textContent = "已保存(待发布固化)";
|
||||
}
|
||||
|
||||
/* ================= ③ 驾驶舱编排页(iAOP-cockpit-layout-v1) ================= */
|
||||
var WIDGET_TYPES = [
|
||||
{ type: "process_view", name: "工艺流程视图", w: 12, h: 4 },
|
||||
{ type: "trend", name: "实时趋势", w: 6, h: 2 },
|
||||
{ type: "kpi_card", name: "KPI 卡片", w: 3, h: 2 },
|
||||
{ type: "alarm_panel", name: "告警面板", w: 9, h: 3 },
|
||||
{ type: "nl_query", name: "NL 查询入口", w: 3, h: 3 }
|
||||
];
|
||||
// ti-cl4 基线(对齐 templates/ti-cl4/dashboard/cockpit.ti.yaml)
|
||||
var TI_BASELINE = [
|
||||
{ type: "process_view", src: "ti_four_state.svg", x: 0, y: 0, w: 12, h: 4,
|
||||
description: "四状态工艺流程(氯化 → 精制 → 还原 → 蒸馏)" },
|
||||
{ type: "trend", bind: "CLF-01.TEMP", x: 0, y: 4, w: 6, h: 2, description: "氯化炉温度实时趋势" },
|
||||
{ type: "trend", bind: "CLF-01.CL2", x: 6, y: 4, w: 6, h: 2, description: "氯气流量实时趋势" },
|
||||
{ type: "kpi_card", metric: "ticl4_purity", bind: "RF-01.PURITY", label: "TiCl₄纯度",
|
||||
x: 0, y: 6, w: 3, h: 2, description: "还原 TiCl₄ 纯度(%)" },
|
||||
{ type: "kpi_card", metric: "ticl4_impurity", bind: "RF-01.IMP", label: "杂质含量",
|
||||
x: 3, y: 6, w: 3, h: 2, description: "还原杂质含量(%)" },
|
||||
{ type: "kpi_card", metric: "energy_per_ton", bind: "E-01.KWH", label: "累计电耗",
|
||||
x: 6, y: 6, w: 3, h: 2, description: "车间累计电耗(kWh)" },
|
||||
{ type: "kpi_card", metric: "steam_flow", bind: "ST-01.STEAM", label: "蒸汽流量",
|
||||
x: 9, y: 6, w: 3, h: 2, description: "蒸汽流量(t/h)" },
|
||||
{ type: "alarm_panel", x: 0, y: 8, w: 9, h: 3, description: "告警面板" },
|
||||
{ type: "nl_query", x: 9, y: 8, w: 3, h: 3, description: "自然语言查询入口" }
|
||||
];
|
||||
var selectedWidget = -1;
|
||||
|
||||
function nextFreeY(widgets, h) {
|
||||
// 简单自动落位:追加到当前最大行底(编排页 Demo 级,手动微调由 bind 面板改 x/y/w/h)
|
||||
var maxY = 0;
|
||||
widgets.forEach(function (w) { maxY = Math.max(maxY, w.y + w.h); });
|
||||
return maxY;
|
||||
}
|
||||
|
||||
function renderWidgetLib() {
|
||||
var ul = document.getElementById("widget-lib-list");
|
||||
ul.innerHTML = "";
|
||||
WIDGET_TYPES.forEach(function (t) {
|
||||
var li = el("li", null, t.name + "(" + t.type + ")");
|
||||
li.onclick = function () {
|
||||
if (!RBAC[currentRole].edit) return;
|
||||
studioState.config.layout.widgets.push({
|
||||
type: t.type, x: 0, y: nextFreeY(studioState.config.layout.widgets, t.h),
|
||||
w: t.w, h: t.h, description: t.name
|
||||
});
|
||||
persistConfig(); renderCanvas();
|
||||
};
|
||||
ul.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCanvas() {
|
||||
var canvas = document.getElementById("canvas");
|
||||
canvas.innerHTML = "";
|
||||
studioState.config.layout.widgets.forEach(function (w, i) {
|
||||
var card = el("div", "canvas-widget" + (i === selectedWidget ? " selected" : ""));
|
||||
card.style.gridColumn = (w.x + 1) + " / span " + w.w;
|
||||
card.style.gridRow = (w.y + 1) + " / span " + w.h;
|
||||
card.textContent = w.type + (w.label ? " · " + w.label : w.bind ? " · " + w.bind : "");
|
||||
var ops = el("span", "ops");
|
||||
var del = el("button", null, "✕");
|
||||
del.title = "删除";
|
||||
del.onclick = function (e) {
|
||||
e.stopPropagation();
|
||||
if (!RBAC[currentRole].edit) return;
|
||||
studioState.config.layout.widgets.splice(i, 1);
|
||||
selectedWidget = -1;
|
||||
persistConfig(); renderCanvas();
|
||||
};
|
||||
ops.appendChild(del);
|
||||
card.appendChild(ops);
|
||||
card.onclick = function () { selectedWidget = i; renderCanvas(); renderBindPanel(); };
|
||||
canvas.appendChild(card);
|
||||
});
|
||||
renderLayoutJson();
|
||||
}
|
||||
|
||||
function renderBindPanel() {
|
||||
var wrap = document.getElementById("bind-form-wrap");
|
||||
wrap.innerHTML = "";
|
||||
var w = studioState.config.layout.widgets[selectedWidget];
|
||||
if (!w) { wrap.appendChild(el("p", "hint", "选中画布中的组件后进行绑定")); return; }
|
||||
// 按组件类型给出绑定字段(对齐 #50 schema 条件必填)
|
||||
var fields = ["x", "y", "w", "h", "description"];
|
||||
if (w.type === "process_view") fields.push("src");
|
||||
if (w.type === "trend") fields.push("bind");
|
||||
if (w.type === "kpi_card") fields.push("metric", "bind", "label");
|
||||
fields.forEach(function (f) {
|
||||
var item = el("div", "form-item");
|
||||
item.appendChild(el("label", null, f));
|
||||
var input = document.createElement("input");
|
||||
input.value = w[f] !== undefined ? w[f] : "";
|
||||
input.disabled = !RBAC[currentRole].edit;
|
||||
input.oninput = function () {
|
||||
var v = input.value;
|
||||
if (["x", "y", "w", "h"].indexOf(f) >= 0) v = Math.max(f === "w" || f === "h" ? 1 : 0, parseInt(v, 10) || 0);
|
||||
if (v === "") delete w[f]; else w[f] = v;
|
||||
persistConfig(); renderCanvas();
|
||||
};
|
||||
item.appendChild(input);
|
||||
wrap.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function renderLayoutJson() {
|
||||
var layout = {
|
||||
"$schema": "iAOP-cockpit-layout-v1",
|
||||
title: studioState.config.layout.title,
|
||||
theme: "dark",
|
||||
widgets: studioState.config.layout.widgets
|
||||
};
|
||||
document.getElementById("layout-json").textContent = JSON.stringify(layout, null, 2);
|
||||
}
|
||||
|
||||
/* ================= ④ 版本页(对齐 release.py:快照固化/semver 递增/回滚可追溯) ================= */
|
||||
var SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
||||
|
||||
function semverKey(v) { return v.split(".").map(function (n) { return parseInt(n, 10) * 1e6; })
|
||||
.reduce(function (a, b) { return a + b; }, 0); }
|
||||
|
||||
function currentSnapshot() {
|
||||
return {
|
||||
model_param: studioState.config.model_param || {},
|
||||
layout: studioState.config.layout,
|
||||
import_report: studioState.importReport || null
|
||||
};
|
||||
}
|
||||
|
||||
function renderVersions() {
|
||||
var tbody = document.getElementById("version-table").querySelector("tbody");
|
||||
tbody.innerHTML = "";
|
||||
var selL = document.getElementById("diff-left");
|
||||
var selR = document.getElementById("diff-right");
|
||||
selL.innerHTML = ""; selR.innerHTML = "";
|
||||
studioState.releases.forEach(function (r, i) {
|
||||
var tr = el("tr");
|
||||
tr.appendChild(el("td", null, r.version));
|
||||
tr.appendChild(el("td", r.status === "published" ? "status-published" : "status-rolledback",
|
||||
r.status === "published" ? "已发布" : "回滚事件"));
|
||||
tr.appendChild(el("td", null, r.time));
|
||||
tr.appendChild(el("td", null, r.author));
|
||||
var ops = el("td");
|
||||
var rb = el("button", "danger", "回滚到此版本");
|
||||
rb.disabled = !RBAC[currentRole].publish;
|
||||
rb.onclick = function () { rollbackTo(i); };
|
||||
ops.appendChild(rb);
|
||||
tr.appendChild(ops);
|
||||
tbody.appendChild(tr);
|
||||
[selL, selR].forEach(function (sel) {
|
||||
var opt = document.createElement("option");
|
||||
opt.value = i; opt.textContent = r.version;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
});
|
||||
if (studioState.releases.length >= 2) {
|
||||
selL.value = String(studioState.releases.length - 2);
|
||||
selR.value = String(studioState.releases.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function publish() {
|
||||
var version = document.getElementById("release-version").value.trim();
|
||||
var author = document.getElementById("release-author").value.trim() || "unknown";
|
||||
if (!SEMVER_RE.test(version)) { alert("版本号必须是 semver(如 1.0.0)"); return; }
|
||||
var last = studioState.releases[studioState.releases.length - 1];
|
||||
if (last && semverKey(version) <= semverKey(last.version)) {
|
||||
alert("版本号必须单调递增(当前最新 " + last.version + ")"); return;
|
||||
}
|
||||
studioState.releases.push({
|
||||
version: version, status: "published",
|
||||
time: new Date().toLocaleString("zh-CN"), author: author,
|
||||
changelog: "发布模板资产包", snapshot: currentSnapshot()
|
||||
});
|
||||
store("studio.releases.v1", studioState.releases);
|
||||
renderVersions();
|
||||
}
|
||||
|
||||
function rollbackTo(index) {
|
||||
var target = studioState.releases[index];
|
||||
if (!target) return;
|
||||
if (!confirm("回滚到 " + target.version + "?历史版本不删除,回滚事件将记录为新条目。")) return;
|
||||
// 恢复快照到当前配置(不删历史,回滚事件可追溯——对齐 release.py)
|
||||
studioState.config.model_param = JSON.parse(JSON.stringify(target.snapshot.model_param || {}));
|
||||
studioState.config.layout = JSON.parse(JSON.stringify(target.snapshot.layout));
|
||||
studioState.importReport = target.snapshot.import_report || null;
|
||||
persistConfig();
|
||||
var last = studioState.releases[studioState.releases.length - 1];
|
||||
var parts = last.version.split(".").map(Number);
|
||||
parts[2] += 1;
|
||||
studioState.releases.push({
|
||||
version: parts.join("."), status: "rolledback",
|
||||
time: new Date().toLocaleString("zh-CN"),
|
||||
author: document.getElementById("release-author").value.trim() || "unknown",
|
||||
changelog: "回滚自 " + target.version, snapshot: currentSnapshot()
|
||||
});
|
||||
store("studio.releases.v1", studioState.releases);
|
||||
renderVersions(); renderCanvas(); renderHyperForm();
|
||||
}
|
||||
|
||||
/* 行级 diff(LCS,行数受控,版本页演示足够) */
|
||||
function diffLines(aText, bText) {
|
||||
var a = aText.split("\n"), b = bText.split("\n");
|
||||
var n = a.length, m = b.length;
|
||||
var dp = [];
|
||||
var i, j;
|
||||
for (i = 0; i <= n; i++) { dp.push(new Array(m + 1).fill(0)); }
|
||||
for (i = n - 1; i >= 0; i--) {
|
||||
for (j = m - 1; j >= 0; j--) {
|
||||
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
var out = [];
|
||||
i = 0; j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) { out.push({ cls: "diff-ctx", text: " " + a[i] }); i++; j++; }
|
||||
else if (dp[i + 1][j] >= dp[i][j + 1]) { out.push({ cls: "diff-del", text: "- " + a[i] }); i++; }
|
||||
else { out.push({ cls: "diff-add", text: "+ " + b[j] }); j++; }
|
||||
}
|
||||
while (i < n) { out.push({ cls: "diff-del", text: "- " + a[i++] }); }
|
||||
while (j < m) { out.push({ cls: "diff-add", text: "+ " + b[j++] }); }
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderDiff() {
|
||||
var view = document.getElementById("diff-view");
|
||||
view.innerHTML = "";
|
||||
var l = studioState.releases[parseInt(document.getElementById("diff-left").value, 10)];
|
||||
var r = studioState.releases[parseInt(document.getElementById("diff-right").value, 10)];
|
||||
if (!l || !r) { view.textContent = "(需要至少 2 个版本)"; return; }
|
||||
diffLines(JSON.stringify(l.snapshot, null, 2), JSON.stringify(r.snapshot, null, 2))
|
||||
.forEach(function (d) { view.appendChild(el("div", d.cls, d.text)); });
|
||||
}
|
||||
|
||||
function exportPack() {
|
||||
var idx = parseInt(document.getElementById("diff-right").value, 10);
|
||||
var r = studioState.releases[idx] || studioState.releases[studioState.releases.length - 1];
|
||||
if (!r) { alert("请先发布一个版本"); return; }
|
||||
// 模板资产包:配置台唯一产出物,供内核灰度推送(耦合边界)
|
||||
var pack = {
|
||||
pack_schema: "iAOP-template-pack-v1",
|
||||
version: r.version, author: r.author, time: r.time,
|
||||
snapshot: r.snapshot
|
||||
};
|
||||
download("template-pack-" + r.version + ".json", JSON.stringify(pack, null, 2),
|
||||
"application/json");
|
||||
}
|
||||
|
||||
/* ================= 状态与启动 ================= */
|
||||
var studioState = {
|
||||
config: load("studio.config.v1", {
|
||||
model_param: {},
|
||||
layout: { title: "新建行业驾驶舱", widgets: [] }
|
||||
}),
|
||||
releases: load("studio.releases.v1", []),
|
||||
importReport: null
|
||||
};
|
||||
function persistConfig() { store("studio.config.v1", studioState.config); }
|
||||
|
||||
(function init() {
|
||||
// 页签切换
|
||||
document.querySelectorAll(".tab").forEach(function (tab) {
|
||||
tab.onclick = function () {
|
||||
document.querySelectorAll(".tab").forEach(function (t) { t.classList.remove("active"); });
|
||||
document.querySelectorAll(".page").forEach(function (p) { p.classList.remove("active"); });
|
||||
tab.classList.add("active");
|
||||
document.getElementById("page-" + tab.dataset.page).classList.add("active");
|
||||
};
|
||||
});
|
||||
// RBAC
|
||||
document.getElementById("role-select").onchange = function (e) {
|
||||
currentRole = e.target.value;
|
||||
applyRbac(); renderHyperForm(); renderCanvas(); renderBindPanel(); renderVersions();
|
||||
};
|
||||
// ① 导入页
|
||||
var drop = document.getElementById("drop-zone");
|
||||
var fileInput = document.getElementById("csv-file");
|
||||
function readFile(file) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () { document.getElementById("csv-text").value = reader.result; };
|
||||
reader.readAsText(file, "utf-8");
|
||||
}
|
||||
fileInput.onchange = function () { if (fileInput.files[0]) readFile(fileInput.files[0]); };
|
||||
drop.ondragover = function (e) { e.preventDefault(); drop.classList.add("dragover"); };
|
||||
drop.ondragleave = function () { drop.classList.remove("dragover"); };
|
||||
drop.ondrop = function (e) {
|
||||
e.preventDefault(); drop.classList.remove("dragover");
|
||||
if (e.dataTransfer.files[0]) readFile(e.dataTransfer.files[0]);
|
||||
};
|
||||
document.getElementById("btn-download-template").onclick = function () {
|
||||
download("point_dict.template.csv",
|
||||
CSV_HEADERS.join(",") + "\n" +
|
||||
"CLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,ns=2;s=CLF.Temp,simulator\n" +
|
||||
"CLF-01,CLF-01.CL2,氯气流量,Nm³/h,float,1000,true,ns=2;s=CLF.Cl2,simulator\n",
|
||||
"text/csv");
|
||||
};
|
||||
document.getElementById("btn-validate").onclick = runImportValidation;
|
||||
// ② 超参页
|
||||
document.getElementById("btn-hyper-save").onclick = saveHyper;
|
||||
// ③ 编排页
|
||||
document.getElementById("btn-layout-clear").onclick = function () {
|
||||
studioState.config.layout.widgets = []; selectedWidget = -1;
|
||||
persistConfig(); renderCanvas(); renderBindPanel();
|
||||
};
|
||||
document.getElementById("btn-layout-load-ti").onclick = function () {
|
||||
studioState.config.layout = { title: "海绵钛车间驾驶舱", widgets: JSON.parse(JSON.stringify(TI_BASELINE)) };
|
||||
selectedWidget = -1;
|
||||
persistConfig(); renderCanvas(); renderBindPanel();
|
||||
};
|
||||
// ④ 版本页
|
||||
document.getElementById("btn-publish").onclick = publish;
|
||||
document.getElementById("btn-diff").onclick = renderDiff;
|
||||
document.getElementById("btn-export-pack").onclick = exportPack;
|
||||
|
||||
applyRbac();
|
||||
renderModelTree(); renderHyperForm();
|
||||
renderWidgetLib(); renderCanvas(); renderBindPanel();
|
||||
renderVersions();
|
||||
})();
|
||||
Reference in New Issue
Block a user