2026-08-05 10:32:40 +08:00
|
|
|
|
/* iAOP 移动端只读驾驶舱 + 交接班摘要(issue #136 / PRD 2.2 场景C、7.5、9)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 纯静态(无构建链),iOS14+/Android 10+(PRD 9:viewport-fit、env(safe-area)、
|
|
|
|
|
|
* 无 ES2022+ 语法);只读(无写操作入口,200 并发只读用户基线 = 纯静态资源
|
|
|
|
|
|
* 天然可水平扩展,PRD 9 性能)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 与 Web 驾驶舱共享布局资产(不重复实现):直接 fetch 编译产物
|
|
|
|
|
|
* ../cockpit/plans/ti-cl4.json —— 布局 RenderPlan(四状态/KPI/告警位)
|
|
|
|
|
|
* ../cockpit/plans/alarm_panel.ti.json—— severityColors 三级配色
|
|
|
|
|
|
* 交接班摘要卡片 props 对齐 core/shift-handover/handover.py 的
|
|
|
|
|
|
* render_handover_brief 输出(sections/events/todos/collapseThreshold/
|
|
|
|
|
|
* fontSize/requireConfirm)。
|
|
|
|
|
|
* 摘要生成直连推理服务 /v1/chat/completions,计时对照 PRD 验收线 ≤ 2 分钟。
|
|
|
|
|
|
*/
|
|
|
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
|
|
var INFER_BASE = "http://39.101.182.167:30800";
|
|
|
|
|
|
var PLAN_URL = "../cockpit/plans/ti-cl4.json";
|
|
|
|
|
|
var ALARM_PLAN_URL = "../cockpit/plans/alarm_panel.ti.json";
|
|
|
|
|
|
|
|
|
|
|
|
var severityStyles = {
|
2026-08-06 08:20:17 +08:00
|
|
|
|
P0: { fg: "#ff4d4f", bg: "rgba(255,77,79,0.12)", border: "#ff4d4f" },
|
|
|
|
|
|
P1: { fg: "#faad14", bg: "rgba(250,173,20,0.12)", border: "#faad14" },
|
|
|
|
|
|
P2: { fg: "#1677ff", bg: "rgba(22,119,255,0.10)" }
|
2026-08-05 10:32:40 +08:00
|
|
|
|
};
|
|
|
|
|
|
var brief = null; // 当前交接班摘要 props(render_handover_brief 同构)
|
|
|
|
|
|
|
|
|
|
|
|
function el(tag, cls, text) {
|
|
|
|
|
|
var n = document.createElement(tag);
|
|
|
|
|
|
if (cls) n.className = cls;
|
|
|
|
|
|
if (text !== undefined) n.textContent = text;
|
|
|
|
|
|
return n;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* 与 Web 驾驶舱一致的确定性模拟数据源(接真实总线时替换) */
|
|
|
|
|
|
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 renderMobileCockpit(plan) {
|
|
|
|
|
|
document.getElementById("page-title").textContent = plan.title || "iAOP 移动驾驶舱";
|
|
|
|
|
|
Object.keys(plan.themeTokens || {}).forEach(function (k) {
|
|
|
|
|
|
document.documentElement.style.setProperty(k, plan.themeTokens[k]);
|
|
|
|
|
|
});
|
|
|
|
|
|
// 四状态工艺(process_view.stages,与 Web 端同源)
|
|
|
|
|
|
var stagesBox = document.getElementById("m-stages");
|
|
|
|
|
|
stagesBox.innerHTML = "";
|
|
|
|
|
|
var pv = null;
|
|
|
|
|
|
plan.widgets.forEach(function (w) { if (w.type === "process_view") pv = w; });
|
|
|
|
|
|
var states = (pv && pv.props.states) || ["running", "warning", "alarm", "offline"];
|
|
|
|
|
|
((pv && pv.props.stages) || []).forEach(function (s, i) {
|
|
|
|
|
|
var st = states[i % states.length];
|
|
|
|
|
|
var chip = el("div", "stage-chip s-" + st);
|
|
|
|
|
|
chip.appendChild(el("div", "n", s.name || s.id));
|
|
|
|
|
|
if (s.device) chip.appendChild(el("div", "d", s.device));
|
|
|
|
|
|
chip.appendChild(el("div", "s", st));
|
|
|
|
|
|
stagesBox.appendChild(chip);
|
|
|
|
|
|
});
|
|
|
|
|
|
// KPI 卡片(两列网格,只读)
|
|
|
|
|
|
var kpiBox = document.getElementById("m-kpis");
|
|
|
|
|
|
kpiBox.innerHTML = "";
|
|
|
|
|
|
plan.widgets.forEach(function (w) {
|
|
|
|
|
|
if (w.type !== "kpi_card") return;
|
2026-08-05 10:50:05 +08:00
|
|
|
|
// 30s 时间桶入种子:刷新/定时重渲染时数值滚动(接真实总线后替换)
|
|
|
|
|
|
var rnd = seededRandom((w.props.metric || "") + "|" + Math.floor(Date.now() / 30000));
|
2026-08-05 10:32:40 +08:00
|
|
|
|
var card = el("div", "kpi");
|
|
|
|
|
|
card.appendChild(el("div", "v", (60 + rnd() * 40).toFixed(2)));
|
|
|
|
|
|
card.appendChild(el("div", "l", w.props.label || w.props.metric));
|
|
|
|
|
|
if (w.description) card.appendChild(el("div", "d", w.description));
|
|
|
|
|
|
kpiBox.appendChild(card);
|
|
|
|
|
|
});
|
|
|
|
|
|
// 告警(只读,配色与 Web 端同源)
|
|
|
|
|
|
var alarmBox = document.getElementById("m-alarms");
|
|
|
|
|
|
alarmBox.innerHTML = "";
|
|
|
|
|
|
var demo = [
|
|
|
|
|
|
{ severity: "P0", msg: "CLF-01.TEMP 温度骤升越上限" },
|
|
|
|
|
|
{ severity: "P1", msg: "RF-01.VAC 真空度波动" },
|
|
|
|
|
|
{ severity: "P2", msg: "ST-01.STEAM 流量偏低" }
|
|
|
|
|
|
];
|
|
|
|
|
|
demo.forEach(function (a) {
|
|
|
|
|
|
var st = severityStyles[a.severity] || {};
|
|
|
|
|
|
var item = el("div", "alarm");
|
|
|
|
|
|
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));
|
|
|
|
|
|
item.appendChild(el("span", null, a.msg));
|
|
|
|
|
|
alarmBox.appendChild(item);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------- 交接班摘要(render_handover_brief props 同构渲染) ---------------- */
|
|
|
|
|
|
function renderBrief(p) {
|
|
|
|
|
|
document.body.className = "fontsize-" + (p.fontSize || "md");
|
|
|
|
|
|
var box = document.getElementById("brief");
|
|
|
|
|
|
box.innerHTML = "";
|
|
|
|
|
|
var head = el("div", "card");
|
|
|
|
|
|
head.appendChild(el("div", "card-title",
|
|
|
|
|
|
p.shift + " · 交班人 " + p.operator + " → " + p.nextShift));
|
|
|
|
|
|
head.appendChild(el("div", "hint",
|
|
|
|
|
|
"生成于 " + p.generatedAt + " · Prompt " + p.promptTemplate + " v" + p.promptVersion));
|
|
|
|
|
|
box.appendChild(head);
|
|
|
|
|
|
|
|
|
|
|
|
// 章节开关完全由配置驱动(showOverview 等,切换模板零改码)
|
|
|
|
|
|
function section(show, title, bodyText) {
|
|
|
|
|
|
if (!show || !bodyText) return;
|
|
|
|
|
|
var card = el("div", "card brief-sec");
|
|
|
|
|
|
card.appendChild(el("div", "sec-title", title));
|
|
|
|
|
|
card.appendChild(el("div", "sec-body", bodyText));
|
|
|
|
|
|
box.appendChild(card);
|
|
|
|
|
|
}
|
|
|
|
|
|
var parts = p.summary.split(/\n(?=[一二三四五]、)/); // 按章节模板切分摘要
|
|
|
|
|
|
section(p.showOverview, "生产概况", parts[0] || p.summary);
|
|
|
|
|
|
section(p.showAbnormal, "异常事项", parts[1] || "");
|
|
|
|
|
|
section(p.showSafety, "安全注意事项", parts[2] || "");
|
|
|
|
|
|
section(p.showEnergy, "能耗", parts[3] || "");
|
|
|
|
|
|
if (p.showTodos && p.todos.length) {
|
|
|
|
|
|
var card = el("div", "card brief-sec");
|
|
|
|
|
|
card.appendChild(el("div", "sec-title", "待办(交下一班)"));
|
|
|
|
|
|
var threshold = p.collapseThreshold || 4;
|
|
|
|
|
|
var shown = p.todos.slice(0, threshold);
|
|
|
|
|
|
var rest = p.todos.slice(threshold);
|
|
|
|
|
|
shown.forEach(function (t) {
|
|
|
|
|
|
card.appendChild(el("div", "todo", "· " + t.title +
|
|
|
|
|
|
(t.priority ? "(" + t.priority + ")" : "") + (t.due ? " · " + t.due : "")));
|
|
|
|
|
|
});
|
|
|
|
|
|
if (rest.length || p.todoOverflow) {
|
|
|
|
|
|
var fold = el("button", "fold-btn",
|
|
|
|
|
|
"展开更多 +" + (rest.length + (p.todoOverflow || 0)));
|
|
|
|
|
|
fold.onclick = function () {
|
|
|
|
|
|
rest.forEach(function (t) {
|
|
|
|
|
|
card.insertBefore(el("div", "todo", "· " + t.title), fold);
|
|
|
|
|
|
});
|
|
|
|
|
|
fold.remove();
|
|
|
|
|
|
};
|
|
|
|
|
|
card.appendChild(fold);
|
|
|
|
|
|
}
|
|
|
|
|
|
box.appendChild(card);
|
|
|
|
|
|
}
|
|
|
|
|
|
// 关键事件(含折叠策略)
|
|
|
|
|
|
if (p.events.length) {
|
|
|
|
|
|
var ec = el("div", "card brief-sec");
|
|
|
|
|
|
ec.appendChild(el("div", "sec-title", "关键事件"));
|
|
|
|
|
|
p.events.forEach(function (e) {
|
|
|
|
|
|
var row = el("div", "event");
|
|
|
|
|
|
row.appendChild(el("span", "t", e.time));
|
|
|
|
|
|
row.appendChild(el("span", null, "[" + e.severity + "] " + e.title));
|
|
|
|
|
|
ec.appendChild(row);
|
|
|
|
|
|
});
|
|
|
|
|
|
if (p.eventOverflow) ec.appendChild(el("div", "hint", "另有 " + p.eventOverflow + " 条已折叠"));
|
|
|
|
|
|
box.appendChild(ec);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (p.requireConfirm) {
|
|
|
|
|
|
box.appendChild(el("div", "confirm-tag",
|
|
|
|
|
|
"⚠ 本班含 P0/安全事件,摘要需值班长确认后生效"));
|
|
|
|
|
|
}
|
|
|
|
|
|
document.getElementById("push-card").hidden = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function generateBrief() {
|
|
|
|
|
|
var shift = document.getElementById("shift-select").value;
|
|
|
|
|
|
var btn = document.getElementById("btn-gen");
|
|
|
|
|
|
var status = document.getElementById("gen-status");
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
var started = Date.now();
|
|
|
|
|
|
status.textContent = "生成中…(PRD 验收线 ≤ 2 分钟)";
|
|
|
|
|
|
fetch(INFER_BASE + "/v1/chat/completions", {
|
|
|
|
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ model: "iaop-ti-cl4-v1", max_tokens: 800, messages: [
|
|
|
|
|
|
{ role: "system", content: "你是交接班报告生成助手。按章节输出:一、生产概况;"
|
|
|
|
|
|
+ "二、异常事项;三、安全注意事项;四、能耗;五、待办。每章 1-3 条,简体中文。" },
|
|
|
|
|
|
{ role: "user", content: "生成" + shift + "交接班摘要:氯化炉运行平稳,"
|
|
|
|
|
|
+ "P1 真空度告警 1 起已确认;电耗 8200kWh;待办:更换 CLF-01 热电偶。" }
|
|
|
|
|
|
] })
|
|
|
|
|
|
}).then(function (r) { return r.json(); })
|
|
|
|
|
|
.then(function (d) {
|
|
|
|
|
|
var summary = (d.choices && d.choices[0] && d.choices[0].message.content) || "(空响应)";
|
|
|
|
|
|
var cost = ((Date.now() - started) / 1000).toFixed(1);
|
|
|
|
|
|
status.textContent = "生成完成,耗时 " + cost + "s" +
|
|
|
|
|
|
(cost <= 120 ? "(达标 ≤2min)" : "(超出验收线)");
|
|
|
|
|
|
brief = {
|
|
|
|
|
|
schema: "iAOP-cockpit-handover-brief-v1",
|
|
|
|
|
|
shift: shift, operator: "张工", nextShift: "下一班",
|
|
|
|
|
|
summary: summary, generatedAt: new Date().toISOString(),
|
|
|
|
|
|
promptTemplate: "shift_handover", promptVersion: "1.0.1",
|
|
|
|
|
|
sections: ["overview", "abnormal", "safety", "energy", "todos"],
|
|
|
|
|
|
events: [
|
|
|
|
|
|
{ time: "09:12", title: "真空度波动告警", severity: "P1", detail: "RF-01.VAC" },
|
|
|
|
|
|
{ time: "10:40", title: "更换热电偶备件确认", severity: "P2", detail: "CLF-01" }
|
|
|
|
|
|
],
|
|
|
|
|
|
todos: [
|
|
|
|
|
|
{ title: "更换 CLF-01 热电偶", priority: "高", due: "下一班" },
|
|
|
|
|
|
{ title: "复核真空泵运行曲线", priority: "中", due: "24h 内" }
|
|
|
|
|
|
],
|
|
|
|
|
|
eventOverflow: 0, todoOverflow: 0, collapseThreshold: 4, fontSize: "md",
|
|
|
|
|
|
requireConfirm: false,
|
|
|
|
|
|
showOverview: true, showAbnormal: true, showSafety: true,
|
|
|
|
|
|
showEnergy: true, showTodos: true
|
|
|
|
|
|
};
|
|
|
|
|
|
renderBrief(brief);
|
2026-08-05 10:52:51 +08:00
|
|
|
|
saveBriefToHistory(brief);
|
2026-08-05 10:32:40 +08:00
|
|
|
|
})
|
|
|
|
|
|
.catch(function (e) { status.textContent = "生成失败:" + e.message; })
|
|
|
|
|
|
.finally(function () { btn.disabled = false; });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------------- 启动 ---------------- */
|
2026-08-05 10:52:51 +08:00
|
|
|
|
/* issue #158:历史摘要本地保存与查看(最近 10 次生成) */
|
|
|
|
|
|
var BRIEFS_KEY = "iaop.mobile.briefs.v1";
|
|
|
|
|
|
function getBriefHistory() {
|
|
|
|
|
|
try { return JSON.parse(localStorage.getItem(BRIEFS_KEY)) || []; }
|
|
|
|
|
|
catch (e) { return []; }
|
|
|
|
|
|
}
|
|
|
|
|
|
function saveBriefToHistory(p) {
|
|
|
|
|
|
var list = getBriefHistory();
|
|
|
|
|
|
list.unshift(p);
|
|
|
|
|
|
localStorage.setItem(BRIEFS_KEY, JSON.stringify(list.slice(0, 10)));
|
|
|
|
|
|
renderBriefHistory();
|
|
|
|
|
|
}
|
|
|
|
|
|
function renderBriefHistory() {
|
|
|
|
|
|
var list = getBriefHistory();
|
|
|
|
|
|
var row = document.getElementById("brief-history-row");
|
|
|
|
|
|
var sel = document.getElementById("brief-history");
|
|
|
|
|
|
if (!list.length) { row.hidden = true; return; }
|
|
|
|
|
|
row.hidden = false;
|
|
|
|
|
|
sel.innerHTML = "";
|
|
|
|
|
|
var ph = document.createElement("option");
|
|
|
|
|
|
ph.value = "-1"; ph.textContent = "查看历史摘要(" + list.length + " 条)…";
|
|
|
|
|
|
sel.appendChild(ph);
|
|
|
|
|
|
list.forEach(function (p, i) {
|
|
|
|
|
|
var opt = document.createElement("option");
|
|
|
|
|
|
opt.value = String(i);
|
|
|
|
|
|
opt.textContent = p.shift + " · " + (p.generatedAt || "").slice(0, 16).replace("T", " ");
|
|
|
|
|
|
sel.appendChild(opt);
|
|
|
|
|
|
});
|
|
|
|
|
|
sel.onchange = function () {
|
|
|
|
|
|
var idx = parseInt(sel.value, 10);
|
|
|
|
|
|
if (idx >= 0 && list[idx]) {
|
|
|
|
|
|
brief = list[idx];
|
|
|
|
|
|
renderBrief(brief);
|
|
|
|
|
|
document.getElementById("gen-status").textContent = "(查看历史摘要)";
|
|
|
|
|
|
document.getElementById("push-status").textContent = "";
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-08-05 10:32:40 +08:00
|
|
|
|
(function init() {
|
|
|
|
|
|
document.querySelectorAll("#tabbar .tab").forEach(function (tab) {
|
|
|
|
|
|
tab.onclick = function () {
|
|
|
|
|
|
document.querySelectorAll("#tabbar .tab").forEach(function (t) { t.classList.remove("active"); });
|
|
|
|
|
|
document.querySelectorAll(".view").forEach(function (v) { v.classList.remove("active"); });
|
|
|
|
|
|
tab.classList.add("active");
|
|
|
|
|
|
document.getElementById("view-" + tab.dataset.view).classList.add("active");
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
document.getElementById("btn-gen").onclick = generateBrief;
|
|
|
|
|
|
document.getElementById("btn-push").onclick = function () {
|
|
|
|
|
|
// 推送下一班(PRD 场景C):真实推送通道就绪前以状态回执演示
|
|
|
|
|
|
document.getElementById("push-status").textContent =
|
|
|
|
|
|
"已推送下一班(" + new Date().toLocaleTimeString("zh-CN") + ")";
|
|
|
|
|
|
};
|
|
|
|
|
|
fetch(ALARM_PLAN_URL).then(function (r) { return r.ok ? r.json() : null; })
|
|
|
|
|
|
.then(function (p) { if (p && p.severityStyles) severityStyles = p.severityStyles; })
|
|
|
|
|
|
.catch(function () {});
|
2026-08-05 10:50:05 +08:00
|
|
|
|
// issue #157:只读数据手动刷新(重新加载布局资产并重渲染 KPI/告警)
|
|
|
|
|
|
function loadPlan() {
|
|
|
|
|
|
fetch(PLAN_URL).then(function (r) { return r.json(); })
|
|
|
|
|
|
.then(renderMobileCockpit)
|
|
|
|
|
|
.catch(function (e) {
|
|
|
|
|
|
document.getElementById("m-stages").innerHTML = "";
|
|
|
|
|
|
document.getElementById("m-stages").appendChild(
|
|
|
|
|
|
el("div", "hint", "布局资产加载失败:" + e.message + "(请从 web/ 根目录起服务)"));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
document.getElementById("btn-refresh").onclick = loadPlan;
|
|
|
|
|
|
loadPlan();
|
2026-08-05 10:52:51 +08:00
|
|
|
|
renderBriefHistory(); // issue #158:启动时恢复历史摘要入口
|
2026-08-05 10:32:40 +08:00
|
|
|
|
})();
|