/* 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=;s=): " + 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(); })();