update User, User group, IO modules
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-23 11:06:26 +07:00
parent 523f98b74c
commit 50a2587cef
32 changed files with 4728 additions and 52 deletions

View File

@@ -12,6 +12,7 @@
],
Logic: [
{ type: "if", label: "If" },
{ type: "while", label: "While", isLoop: true },
{ type: "loop", label: "Loop", isLoop: true },
{ type: "break", label: "Break" },
{ type: "continue", label: "Continue" },
@@ -20,6 +21,8 @@
"I/O": [
{ type: "set_digital_output", label: "Set digital output" },
{ type: "wait_digital_input", label: "Wait for digital input" },
{ type: "connect_bluetooth", label: "Connect Bluetooth module" },
{ type: "disconnect_bluetooth", label: "Disconnect Bluetooth module" },
{ type: "set_plc_register", label: "Set PLC register" },
],
Cart: [
@@ -37,6 +40,42 @@
const SAMPLE_POSITIONS = ["Charging station", "Warehouse", "Production line 1", "Dock A"];
const SAMPLE_MARKERS = ["Marker 1", "Marker 2", "Home"];
const SAMPLE_IO_MODULES = ["GPIO module 1", "PLC I/O 1"];
let ioModuleCatalog = [];
function ioModuleNames() {
const names = ioModuleCatalog.map((m) => m.name).filter(Boolean);
return names.length ? names : SAMPLE_IO_MODULES;
}
function ioModuleByName(name) {
return ioModuleCatalog.find((m) => m.name === name || m.id === name) || null;
}
function ioOutputPorts(moduleName) {
const mod = ioModuleByName(moduleName);
return mod?.module_type === "wise" ? [0, 1, 2, 3] : [1, 2, 3, 4];
}
function ioInputPorts(moduleName) {
return ioOutputPorts(moduleName);
}
async function loadIoModuleCatalog() {
try {
if (window.IoModulesCatalog?.refresh) {
ioModuleCatalog = await window.IoModulesCatalog.refresh();
} else {
const res = await fetch("/api/io_modules", { credentials: "include" });
if (res.ok) {
const data = await res.json();
ioModuleCatalog = Array.isArray(data.io_modules) ? data.io_modules : [];
}
}
} catch {
ioModuleCatalog = [];
}
return ioModuleCatalog;
}
const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"];
let missionPositionCatalog = [];
@@ -170,13 +209,18 @@
case "switch_map":
return { map_id: "", entry_position_id: "" };
case "if":
return { condition: "position_free", position: positionIds()[0] || SAMPLE_POSITIONS[0] };
return { condition: "io_input", module: ioModuleNames()[0] || "", input: 1, expected: true };
case "while":
return { condition: "io_input", module: ioModuleNames()[0] || "", input: 1, expected: true };
case "loop":
return { count: 1, mode: "count" };
case "set_digital_output":
return { module: SAMPLE_IO_MODULES[0], pin: 1, value: true };
return { module: ioModuleNames()[0] || "", output: 1, value: true, operation: "set", timeout_ms: 0 };
case "wait_digital_input":
return { module: SAMPLE_IO_MODULES[0], pin: 1, expected: true, timeout_s: 30 };
return { module: ioModuleNames()[0] || "", input: 1, expected: true, timeout_s: 30 };
case "connect_bluetooth":
case "disconnect_bluetooth":
return { module: ioModuleNames()[0] || "" };
case "set_plc_register":
return { register: 1, action: "set", value: 0 };
case "pick_cart":
@@ -344,12 +388,28 @@
return `Speed: ${p.speed}`;
case "loop":
return p.mode === "endless" ? "Lặp vô hạn" : `Lặp ${p.count} lần • ${action.children?.length || 0} bước`;
case "if":
case "if": {
if (p.condition === "io_input") {
const port = p.input != null ? p.input : p.pin;
return `If I/O ${p.module} in ${port} = ${p.expected ? "ON" : "OFF"}`;
}
return `If ${p.condition} @ ${positionLabel(p.position)}`;
case "set_digital_output":
return `${p.module} pin ${p.pin}${p.value ? "ON" : "OFF"}`;
case "wait_digital_input":
return `${p.module} pin ${p.pin} = ${p.expected ? "ON" : "OFF"}`;
}
case "while":
return `While I/O ${p.module} in ${p.input != null ? p.input : p.pin} = ${p.expected ? "ON" : "OFF"}${action.children?.length || 0} bước`;
case "set_digital_output": {
const port = p.output != null ? p.output : p.pin;
const op = p.operation === "timed" ? ` (${p.timeout_ms || 0}ms)` : "";
return `${p.module} out ${port}${p.value ? "ON" : "OFF"}${op}`;
}
case "wait_digital_input": {
const port = p.input != null ? p.input : p.pin;
return `${p.module} in ${port} = ${p.expected ? "ON" : "OFF"} (${p.timeout_s}s)`;
}
case "connect_bluetooth":
return `Connect ${p.module || "?"}`;
case "disconnect_bluetooth":
return `Disconnect ${p.module || "?"}`;
case "set_plc_register":
return `Reg ${p.register}: ${p.action} ${p.value}`;
case "pick_cart":
@@ -374,10 +434,15 @@
function normalizeActionTree(actions) {
if (!Array.isArray(actions)) return;
actions.forEach((action) => {
if (action.type === "loop" && !Array.isArray(action.children)) {
if ((action.type === "loop" || action.type === "while") && !Array.isArray(action.children)) {
action.children = [];
}
if (action.type === "if") {
if (!Array.isArray(action.children)) action.children = [];
if (!Array.isArray(action.else_children)) action.else_children = [];
}
if (Array.isArray(action.children)) normalizeActionTree(action.children);
if (Array.isArray(action.else_children)) normalizeActionTree(action.else_children);
});
}
@@ -387,9 +452,24 @@
if (path === "root") return draft.actions;
const parts = path.split(".").filter((p) => p !== "root");
let list = draft.actions;
for (const part of parts) {
let i = 0;
while (i < parts.length) {
const part = parts[i];
if (part === "else") return null;
const node = list.find((a) => a.id === part);
if (!node || !Array.isArray(node.children)) return null;
if (!node) return null;
i += 1;
if (i < parts.length && parts[i] === "else") {
if (!Array.isArray(node.else_children)) return null;
list = node.else_children;
i += 1;
continue;
}
if (i >= parts.length) {
if (!Array.isArray(node.children)) return null;
return node.children;
}
if (!Array.isArray(node.children)) return null;
list = node.children;
}
return list;
@@ -402,7 +482,11 @@
if (action.id === actionId) return { action, list, index: i, path, parent };
if (Array.isArray(action.children)) {
const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action);
if (hit) return { ...hit, label: t(`missions.action.${type}`) || hit.label };
if (hit) return hit;
}
if (Array.isArray(action.else_children)) {
const hit = findActionWithParent(actionId, action.else_children, `${path}.${action.id}.else`, action);
if (hit) return hit;
}
}
return null;
@@ -492,6 +576,7 @@
actions.forEach((action) => {
visit(action, depth);
if (Array.isArray(action.children)) walkActions(action.children, visit, depth + 1);
if (Array.isArray(action.else_children)) walkActions(action.else_children, visit, depth + 1);
});
}
@@ -504,6 +589,9 @@
if (Array.isArray(copy.children)) {
copy.children = copy.children.map((child) => resolveActionSnapshot(child, depth));
}
if (Array.isArray(copy.else_children)) {
copy.else_children = copy.else_children.map((child) => resolveActionSnapshot(child, depth));
}
return copy;
}
@@ -949,8 +1037,8 @@
row.dataset.index = String(index);
const iconClass =
action.kind === "mission" ? "kind-mission" : action.type === "loop" ? "kind-loop" : "";
const iconChar = action.kind === "mission" ? "◎" : action.type === "loop" ? "↻" : "▶";
action.kind === "mission" ? "kind-mission" : action.type === "loop" || action.type === "while" ? "kind-loop" : action.type === "if" ? "kind-if" : "";
const iconChar = action.kind === "mission" ? "◎" : action.type === "loop" || action.type === "while" ? "↻" : action.type === "if" ? "?" : "▶";
row.innerHTML = `
<div class="missionDragHandle" draggable="true" title="Kéo để sắp xếp" aria-label="Kéo để sắp xếp">↕</div>
@@ -968,17 +1056,17 @@
</div>
</div>`;
if (action.type === "loop" && Array.isArray(action.children)) {
if ((action.type === "loop" || action.type === "while") && Array.isArray(action.children)) {
const loop = document.createElement("div");
loop.className = "missionLoopBlock";
loop.innerHTML = `<div class="missionLoopLabel">Loop body — kéo action vào đây</div>`;
loop.innerHTML = `<div class="missionLoopLabel">${action.type === "while" ? "While body" : "Loop body"} — kéo action vào đây</div>`;
const drop = document.createElement("div");
drop.className = "missionLoopDrop";
drop.dataset.loopPath = `${listPath}.${action.id}`;
if (!action.children.length) {
const empty = document.createElement("div");
empty.className = "missionLoopEmpty";
empty.textContent = "Kéo action hoặc mission vào loop";
empty.textContent = "Kéo action hoặc mission vào đây";
drop.appendChild(empty);
} else {
renderActionRows(action.children, `${listPath}.${action.id}`, drop);
@@ -987,6 +1075,31 @@
row.appendChild(loop);
}
if (action.type === "if" && Array.isArray(action.children)) {
const makeBranch = (label, branchPath, children) => {
const block = document.createElement("div");
block.className = "missionLoopBlock missionIfBlock";
block.innerHTML = `<div class="missionLoopLabel">${label}</div>`;
const drop = document.createElement("div");
drop.className = "missionLoopDrop";
drop.dataset.loopPath = branchPath;
if (!children.length) {
const empty = document.createElement("div");
empty.className = "missionLoopEmpty";
empty.textContent = "Kéo action vào nhánh này";
drop.appendChild(empty);
} else {
renderActionRows(children, branchPath, drop);
}
block.appendChild(drop);
return block;
};
row.appendChild(makeBranch("Then", `${listPath}.${action.id}`, action.children));
row.appendChild(
makeBranch("Else", `${listPath}.${action.id}.else`, Array.isArray(action.else_children) ? action.else_children : [])
);
}
row.querySelector("[data-config]").addEventListener("click", (evt) => {
evt.stopPropagation();
openActionConfig(action.id);
@@ -1282,13 +1395,35 @@
addField("Số lần lặp", textInput("count", p.count, "number"));
break;
case "if":
addField("Điều kiện", selectInput("condition", p.condition, ["position_free", "position_occupied", "register_equals"]));
addField("Position", selectInputLabeled("position", p.position, positionSelectOptions()));
addVariableToggle("position", "Position");
addField("Điều kiện", selectInput("condition", p.condition, ["io_input", "position_free", "position_occupied", "register_equals"]));
if (p.condition === "io_input") {
addField("Module", selectInput("module", p.module, ioModuleNames()));
addField("Input", selectInput("input", String(p.input != null ? p.input : p.pin || 1), ioInputPorts(p.module).map(String)));
{
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> ${t("missions.action.waitOnLevel")}`;
addField("Kỳ vọng", chk);
}
} else {
addField("Position", selectInputLabeled("position", p.position, positionSelectOptions()));
addVariableToggle("position", "Position");
}
break;
case "while":
addField("Điều kiện", selectInput("condition", p.condition || "io_input", ["io_input"]));
addField("Module", selectInput("module", p.module, ioModuleNames()));
addField("Input", selectInput("input", String(p.input != null ? p.input : p.pin || 1), ioInputPorts(p.module).map(String)));
{
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> ${t("missions.action.waitOnLevel")}`;
addField("Kỳ vọng", chk);
}
break;
case "set_digital_output":
addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES));
addField("Pin", textInput("pin", p.pin, "number"));
addField("Module", selectInput("module", p.module, ioModuleNames()));
addField("Output", selectInput("output", String(p.output != null ? p.output : p.pin || 1), ioOutputPorts(p.module).map(String)));
addField("Operation", selectInput("operation", p.operation || "set", ["set", "timed"]));
addField("Timeout (ms)", textInput("timeout_ms", p.timeout_ms != null ? p.timeout_ms : 0, "number"));
{
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="value" ${p.value ? "checked" : ""} /> Bật (ON)`;
@@ -1296,8 +1431,8 @@
}
break;
case "wait_digital_input":
addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES));
addField("Pin", textInput("pin", p.pin, "number"));
addField("Module", selectInput("module", p.module, ioModuleNames()));
addField("Input", selectInput("input", String(p.input != null ? p.input : p.pin || 1), ioInputPorts(p.module).map(String)));
addField("Timeout (s)", textInput("timeout_s", p.timeout_s, "number"));
{
const chk = document.createElement("label");
@@ -1305,6 +1440,10 @@
addField("Kỳ vọng", chk);
}
break;
case "connect_bluetooth":
case "disconnect_bluetooth":
addField("Module", selectInput("module", p.module, ioModuleNames()));
break;
case "set_plc_register":
addField("Register", textInput("register", p.register, "number"));
addField("Hành động", selectInput("action", p.action, ["set", "add", "subtract"]));
@@ -1364,6 +1503,8 @@
else params[key] = node.value;
});
hit.action.params = params;
if (params.input != null && params.input !== "") params.input = Number(params.input);
if (params.output != null && params.output !== "") params.output = Number(params.output);
setDirty(true);
renderMissionEditor();
}
@@ -1464,6 +1605,7 @@
/* ignore */
}
}
await loadIoModuleCatalog();
try {
const maps = await fetch("/api/maps", { credentials: "include" });
if (maps.ok) {
@@ -1503,11 +1645,13 @@
startQueuePoll,
stopQueuePoll,
onPageShow() {
if (!missionEditorViewEl?.hidden) renderMissionEditor();
else {
renderMissionList();
startQueuePoll();
}
void loadIoModuleCatalog().then(() => {
if (!missionEditorViewEl?.hidden) renderMissionEditor();
else {
renderMissionList();
startQueuePoll();
}
});
},
onPageHide() {
stopQueuePoll();
@@ -1527,6 +1671,12 @@
}
window.addEventListener("lm:locale-change", onLocaleChange);
window.addEventListener("lm:io-modules-changed", () => {
void loadIoModuleCatalog().then(() => {
if (!missionEditorViewEl?.hidden) renderMissionEditor();
});
});
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
window.addEventListener("lm:auth-logout", stopQueuePollForce);