Files
iAOP/web/studio/studio.js

629 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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);
if (typeof UserStore !== "undefined") UserStore.audit("publish", "release", version); // #134 写操作审计
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);
if (typeof UserStore !== "undefined") UserStore.audit("rollback", "release", "回滚自 " + target.version);
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() {
// 认证门控(issue #134,会话 API 来自 #150 的 IAOP_AUTH):未登录跳登录页
// (后端 core/auth /auth/me 校验);登录后角色以会话为准(角色下拉锁定)。
// auth.js 缺失(独立起服务于 web/studio 的纯静态演示)时降级为手动角色切换。
if (typeof IAOP_AUTH !== "undefined") {
IAOP_AUTH.requireLoginElseRedirect("../auth/login.html").then(function (user) {
if (!user) return; // 正在跳转登录页
currentRole = user.role;
var roleSelect = document.getElementById("role-select");
roleSelect.value = user.role;
roleSelect.disabled = true;
document.getElementById("role-hint").textContent =
user.username + " · " + RBAC[currentRole].label +
(user.role === "admin" ? " · 用户管理 →" : "");
applyRbac(); renderHyperForm(); renderCanvas(); renderBindPanel(); renderVersions();
if (user.role === "admin") {
document.getElementById("role-hint").style.cursor = "pointer";
document.getElementById("role-hint").onclick = function () {
location.href = "../auth/users.html";
};
}
});
}
// 页签切换
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();
})();