Files
App/www/missions.js
HiepLM a2e87aeb29
Some checks failed
Test / test (push) Has been cancelled
Add function Language
2026-06-16 16:44:04 +07:00

1418 lines
49 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.
(() => {
const STORAGE_KEY = "phenikaax_missions_v1";
const ACTION_GROUPS = {
Move: [
{ type: "move_to_position", label: "Go to position" },
{ type: "move_to_marker", label: "Go to marker" },
{ type: "adjust_localization", label: "Adjust localization" },
{ type: "wait", label: "Wait" },
{ type: "set_speed", label: "Set speed" },
],
Logic: [
{ type: "if", label: "If" },
{ type: "loop", label: "Loop", isLoop: true },
{ type: "break", label: "Break" },
{ type: "continue", label: "Continue" },
{ type: "pause", label: "Pause" },
],
"I/O": [
{ type: "set_digital_output", label: "Set digital output" },
{ type: "wait_digital_input", label: "Wait for digital input" },
{ type: "set_plc_register", label: "Set PLC register" },
],
Cart: [
{ type: "pick_cart", label: "Pick cart" },
{ type: "drop_cart", label: "Drop cart" },
],
Misc: [
{ type: "user_log", label: "User log" },
{ type: "play_sound", label: "Play sound" },
],
};
const DEFAULT_GROUPS = ["Missions", "Move", "Logic", "I/O", "Cart", "Misc"];
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"];
const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"];
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const missionListEl = el("missionList");
const missionListEmptyEl = el("missionListEmpty");
const missionsListViewEl = el("missionsListView");
const missionEditorViewEl = el("missionEditorView");
const missionActionBarEl = el("missionActionBar");
const missionGroupTabsEl = el("missionGroupTabs");
const missionActionListEl = el("missionActionList");
const missionEditorTitleEl = el("missionEditorTitle");
const missionEditorMetaEl = el("missionEditorMeta");
const missionEditorDirtyEl = el("missionEditorDirty");
const missionCreateDialogEl = el("missionCreateDialog");
const missionSettingsDialogEl = el("missionSettingsDialog");
const missionSaveAsDialogEl = el("missionSaveAsDialog");
const missionActionConfigDialogEl = el("missionActionConfigDialog");
const missionActionConfigBodyEl = el("missionActionConfigBody");
const missionActionConfigTitleEl = el("missionActionConfigTitle");
const missionQueueListEl = el("missionQueueList");
const missionQueueEmptyEl = el("missionQueueEmpty");
const missionQueueRunnerEl = el("missionQueueRunner");
const missionQueueDialogEl = el("missionQueueDialog");
const missionQueueVarFieldsEl = el("missionQueueVarFields");
const missionQueueDialogMissionEl = el("missionQueueDialogMission");
const missionQueueVarHintEl = el("missionQueueVarHint");
const VARIABLE_FIELD_DEFS = {
move_to_position: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
adjust_localization: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
move_to_marker: [{ key: "marker", label: "Marker", options: SAMPLE_MARKERS }],
if: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
pick_cart: [
{ key: "position", label: "Position", options: SAMPLE_POSITIONS },
{ key: "cart", label: "Cart", options: SAMPLE_CARTS },
],
drop_cart: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
};
const store = {
missions: [],
groups: [...DEFAULT_GROUPS],
editingId: null,
draft: null,
dirty: false,
drag: null,
configActionId: null,
configListPath: "root",
queue: [],
runner: { state: "idle", message: "" },
pendingQueueMissionId: null,
};
let queuePollRefs = 0;
let queuePollTimer = null;
function newId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
return `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
function defaultParams(type) {
switch (type) {
case "move_to_position":
return { position: SAMPLE_POSITIONS[0], check_free: true };
case "move_to_marker":
return { marker: SAMPLE_MARKERS[0] };
case "adjust_localization":
return { position: SAMPLE_POSITIONS[0] };
case "wait":
return { seconds: 1 };
case "set_speed":
return { speed: "normal" };
case "if":
return { condition: "position_free", position: SAMPLE_POSITIONS[0] };
case "loop":
return { count: 1, mode: "count" };
case "set_digital_output":
return { module: SAMPLE_IO_MODULES[0], pin: 1, value: true };
case "wait_digital_input":
return { module: SAMPLE_IO_MODULES[0], pin: 1, expected: true, timeout_s: 30 };
case "set_plc_register":
return { register: 1, action: "set", value: 0 };
case "pick_cart":
return { position: SAMPLE_POSITIONS[0], cart: SAMPLE_CARTS[0] };
case "drop_cart":
return { position: SAMPLE_POSITIONS[0], collision_check: true };
case "user_log":
return { message: "Mission step" };
case "play_sound":
return { sound: "beep" };
default:
return {};
}
}
function actionMeta(type) {
for (const items of Object.values(ACTION_GROUPS)) {
const hit = items.find((a) => a.type === type);
if (hit) return { ...hit, label: t(`missions.action.${type}`) || hit.label };
}
return { type, label: t(`missions.action.${type}`) || type };
}
function createAction(type, overrides = {}) {
const meta = actionMeta(type);
return {
id: newId(),
kind: "action",
type,
label: meta.label,
params: defaultParams(type),
children: meta.isLoop ? [] : undefined,
...overrides,
};
}
function createMissionRef(mission) {
return {
id: newId(),
kind: "mission",
refId: mission.id,
label: mission.name,
params: {},
};
}
function createMission(name, group, description) {
return {
id: newId(),
name: name.trim(),
group: group.trim() || "Missions",
description: (description || "").trim(),
actions: [],
updated_at: new Date().toISOString(),
};
}
function loadStoreLocal() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (Array.isArray(data.missions)) store.missions = data.missions;
if (Array.isArray(data.groups)) store.groups = data.groups;
} catch {
/* ignore */
}
ensureDefaultGroups();
}
async function loadStoreFromBackend() {
try {
const res = await fetch("/api/missions", { credentials: "include" });
if (!res.ok) return false;
const data = await res.json();
if (Array.isArray(data.missions)) store.missions = data.missions;
if (Array.isArray(data.groups)) store.groups = data.groups;
ensureDefaultGroups();
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ missions: store.missions, groups: store.groups })
);
return true;
} catch {
return false;
}
}
let persistTimer = null;
async function syncStoreToBackend() {
try {
await fetch("/api/missions", {
credentials: "include",
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ missions: store.missions, groups: store.groups }),
});
} catch {
/* ignore */
}
}
function loadStore() {
loadStoreLocal();
}
function persistStore() {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ missions: store.missions, groups: store.groups })
);
clearTimeout(persistTimer);
persistTimer = setTimeout(syncStoreToBackend, 400);
}
function ensureDefaultGroups() {
DEFAULT_GROUPS.forEach((g) => {
if (!store.groups.includes(g)) store.groups.push(g);
});
}
function allGroups() {
const fromMissions = store.missions.map((m) => m.group).filter(Boolean);
return [...new Set([...store.groups, ...fromMissions])].sort((a, b) => a.localeCompare(b));
}
function fillGroupSelect(selectEl, selected) {
if (!selectEl) return;
selectEl.innerHTML = "";
allGroups().forEach((g) => {
const opt = document.createElement("option");
opt.value = g;
opt.textContent = g;
if (g === selected) opt.selected = true;
selectEl.appendChild(opt);
});
}
function findMission(id) {
return store.missions.find((m) => m.id === id) || null;
}
function getDraft() {
return store.draft;
}
function setDirty(flag) {
store.dirty = !!flag;
if (missionEditorDirtyEl) missionEditorDirtyEl.hidden = !store.dirty;
}
function actionSummary(action) {
if (action.kind === "mission") return `Mission con: ${action.label}`;
const p = action.params || {};
const fmtVar = (key, val) => (p[`${key}_var`] ? `${val} (biến)` : val);
switch (action.type) {
case "move_to_position":
return `Position: ${fmtVar("position", p.position)}${p.check_free ? " • kiểm tra trống" : ""}`;
case "move_to_marker":
return `Marker: ${fmtVar("marker", p.marker)}`;
case "wait":
return `${p.seconds}s`;
case "set_speed":
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":
return `If ${p.condition} @ ${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 "set_plc_register":
return `Reg ${p.register}: ${p.action} ${p.value}`;
case "pick_cart":
case "drop_cart":
return `${action.type === "pick_cart" ? "Pick" : "Drop"} @ ${fmtVar("position", p.position)}${action.type === "pick_cart" ? `${fmtVar("cart", p.cart)}` : ""}`;
case "user_log":
return p.message || "—";
case "play_sound":
return p.sound || "—";
default:
return action.label;
}
}
function normalizeActionTree(actions) {
if (!Array.isArray(actions)) return;
actions.forEach((action) => {
if (action.type === "loop" && !Array.isArray(action.children)) {
action.children = [];
}
if (Array.isArray(action.children)) normalizeActionTree(action.children);
});
}
function findActionList(path) {
const draft = getDraft();
if (!draft) return null;
if (path === "root") return draft.actions;
const parts = path.split(".").filter((p) => p !== "root");
let list = draft.actions;
for (const part of parts) {
const node = list.find((a) => a.id === part);
if (!node || !Array.isArray(node.children)) return null;
list = node.children;
}
return list;
}
function findActionWithParent(actionId, list = getDraft()?.actions, path = "root", parent = null) {
if (!list) return null;
for (let i = 0; i < list.length; i += 1) {
const action = list[i];
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 };
}
}
return null;
}
function removeActionFromTree(actionId) {
const hit = findActionWithParent(actionId);
if (!hit) return false;
hit.list.splice(hit.index, 1);
setDirty(true);
return true;
}
function moveAction(actionId, targetPath, targetIndex) {
const hit = findActionWithParent(actionId);
if (!hit) return false;
const targetList = findActionList(targetPath);
if (!targetList) return false;
if (hit.path === targetPath) {
const [item] = hit.list.splice(hit.index, 1);
const idx = hit.index < targetIndex ? targetIndex - 1 : targetIndex;
targetList.splice(idx, 0, item);
setDirty(true);
return true;
}
const movingIntoLoop = targetPath !== "root";
if (movingIntoLoop) {
const loopId = targetPath.split(".").pop();
if (loopId === actionId) return false;
let cursor = findActionWithParent(loopId);
while (cursor) {
if (cursor.action.id === actionId) return false;
cursor = cursor.parent ? findActionWithParent(cursor.parent.id) : null;
}
}
const [item] = hit.list.splice(hit.index, 1);
targetList.splice(targetIndex, 0, item);
setDirty(true);
return true;
}
function addActionToList(type, listPath = "root") {
const list = findActionList(listPath);
if (!list) return;
list.push(createAction(type));
setDirty(true);
renderMissionEditor();
}
function addMissionRefToList(missionId, listPath = "root") {
const mission = findMission(missionId);
const list = findActionList(listPath);
if (!mission || !list || mission.id === store.editingId) return;
list.push(createMissionRef(mission));
setDirty(true);
renderMissionEditor();
}
async function missionApi(path, opts = {}) {
if (window.AuthApp && !window.AuthApp.isReady()) {
throw new Error("not authenticated");
}
const res = await fetch(path, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
});
if (!res.ok) {
let msg = res.statusText;
try {
const err = await res.json();
if (err.error) msg = err.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function walkActions(actions, visit, depth = 0) {
if (!Array.isArray(actions) || depth > 12) return;
actions.forEach((action) => {
visit(action, depth);
if (Array.isArray(action.children)) walkActions(action.children, visit, depth + 1);
});
}
function resolveActionSnapshot(action, depth = 0) {
const copy = JSON.parse(JSON.stringify(action));
if (copy.kind === "mission") {
const ref = findMission(copy.refId);
if (ref && depth < 8) copy.resolved_mission = resolveMissionSnapshot(ref, depth + 1);
}
if (Array.isArray(copy.children)) {
copy.children = copy.children.map((child) => resolveActionSnapshot(child, depth));
}
return copy;
}
function resolveMissionSnapshot(mission, depth = 0) {
const copy = JSON.parse(JSON.stringify(mission));
copy.actions = (copy.actions || []).map((a) => resolveActionSnapshot(a, depth));
return copy;
}
function collectMissionVariables(mission) {
const vars = [];
walkActions(mission.actions, (action) => {
if (action.kind === "mission" || !action.params) return;
const defs = VARIABLE_FIELD_DEFS[action.type] || [];
defs.forEach((def) => {
if (!action.params[`${def.key}_var`]) return;
vars.push({
key: `${action.id}:${def.key}`,
label: `${action.label}${def.label}`,
options: def.options,
default: action.params[def.key] || def.options[0] || "",
});
});
});
return vars;
}
function formatQueueParameters(entry) {
const params = entry.parameters || {};
const keys = Object.keys(params);
if (!keys.length) return "";
return keys
.map((key) => `<span class="missionQueueParamVar">${escapeHtml(params[key])}</span>`)
.join(" • ");
}
function queueStatusLabel(status) {
const map = {
pending: "missions.queue.status.pending",
executing: "missions.queue.status.executing",
completed: "missions.queue.status.done",
failed: "missions.queue.status.error",
cancelled: "missions.queue.status.cancelled",
};
const key = map[status];
return key ? t(key) : status;
}
async function refreshQueue() {
if (window.AuthApp && !window.AuthApp.isReady()) return;
try {
const data = await missionApi("/api/mission_queue");
store.queue = Array.isArray(data.queue) ? data.queue : [];
store.runner = data.runner && typeof data.runner === "object" ? data.runner : { state: "idle", message: "" };
renderQueuePanel();
notifyQueueUpdate();
} catch (e) {
if (String(e.message || "").includes("not authenticated")) return;
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `${t("common.error", { msg: e.message })}`;
}
}
const queueListeners = new Set();
function notifyQueueUpdate() {
queueListeners.forEach((fn) => {
try {
fn(getQueueSnapshot());
} catch {
/* ignore */
}
});
}
function getQueueSnapshot() {
return {
queue: JSON.parse(JSON.stringify(store.queue)),
runner: JSON.parse(JSON.stringify(store.runner)),
};
}
function renderQueueInto(target, options = {}) {
const listEl = target.listEl;
const runnerEl = target.runnerEl;
const emptyEl = target.emptyEl;
const compact = !!options.compact;
if (!listEl) return;
listEl.innerHTML = "";
if (emptyEl) emptyEl.hidden = store.queue.length > 0;
if (runnerEl) {
const st = store.runner.state || "idle";
runnerEl.classList.toggle("running", st === "running" || st === "paused");
runnerEl.classList.toggle("paused", st === "paused");
const action = store.runner.current_action ? `${store.runner.current_action}` : "";
runnerEl.textContent = store.runner.message
? `${store.runner.message}${action}`
: st === "idle"
? compact
? t("missions.queue.ready")
: t("missions.queue.idleMessage")
: "—";
}
store.queue.forEach((entry, index) => {
const row = document.createElement("div");
row.className = `missionQueueItem status-${entry.status || "pending"}${compact ? " compact" : ""}`;
const paramHtml = formatQueueParameters(entry);
const canReorder = entry.status === "pending" && !compact;
row.innerHTML = compact
? `
<div>
<div class="missionQueueItemTitle">${escapeHtml(entry.mission_name || "Mission")}</div>
<div class="missionQueueItemMeta">${queueStatusLabel(entry.status)} • #${index + 1}</div>
${paramHtml ? `<div class="missionQueueItemParams">${paramHtml}</div>` : ""}
</div>
<div class="missionQueueWidgetActions">
${entry.status === "pending" ? `<button type="button" class="iconBtn danger" data-queue-remove="${entry.id}" title="" data-i18n-title="common.delete">×</button>` : `<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>`}
</div>`
: `
<div class="missionQueueOrder">
<button type="button" class="iconBtn" data-queue-up="${entry.id}" title="${t("missions.queue.moveUp")}" ${canReorder && index > 0 ? "" : "disabled"}>↑</button>
<button type="button" class="iconBtn" data-queue-down="${entry.id}" title="${t("missions.queue.moveDown")}" ${canReorder && index < store.queue.length - 1 ? "" : "disabled"}>↓</button>
</div>
<div>
<div class="missionQueueItemTitle">${escapeHtml(entry.mission_name || "Mission")}</div>
<div class="missionQueueItemMeta">${escapeHtml(entry.mission_group || "")} • #${index + 1}</div>
${paramHtml ? `<div class="missionQueueItemParams">${paramHtml}</div>` : ""}
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px;">
<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>
${entry.status === "pending" ? `<button type="button" class="btn subtle danger" data-queue-remove="${entry.id}">${t("common.delete")}</button>` : ""}
</div>`;
row.querySelector("[data-queue-up]")?.addEventListener("click", () => moveQueueItem(entry.id, -1));
row.querySelector("[data-queue-down]")?.addEventListener("click", () => moveQueueItem(entry.id, 1));
row.querySelector("[data-queue-remove]")?.addEventListener("click", () => removeQueueItem(entry.id));
listEl.appendChild(row);
});
}
function renderQueuePanel() {
renderQueueInto(
{
listEl: missionQueueListEl,
runnerEl: missionQueueRunnerEl,
emptyEl: missionQueueEmptyEl,
},
{ compact: false }
);
}
async function moveQueueItem(id, delta) {
const ids = store.queue.map((q) => q.id);
const idx = ids.indexOf(id);
if (idx < 0) return;
if (store.queue[idx].status !== "pending") return;
const next = idx + delta;
if (next < 0 || next >= store.queue.length) return;
if (store.queue[next].status !== "pending") return;
[ids[idx], ids[next]] = [ids[next], ids[idx]];
try {
await missionApi("/api/mission_queue/reorder", {
method: "PUT",
body: JSON.stringify({ ordered_ids: ids }),
});
await refreshQueue();
} catch (e) {
alert(e.message);
}
}
async function removeQueueItem(id) {
try {
await missionApi(`/api/mission_queue/${id}`, { method: "DELETE" });
await refreshQueue();
} catch (e) {
alert(e.message);
}
}
async function clearQueue() {
if (!confirm(t("missions.queue.clearConfirm"))) return;
try {
await missionApi("/api/mission_queue", { method: "DELETE" });
await refreshQueue();
} catch (e) {
alert(e.message);
}
}
async function enqueueMission(missionId, parameters = {}) {
const mission = findMission(missionId);
if (!mission) throw new Error("Mission không tồn tại");
const payload = {
mission: resolveMissionSnapshot(mission),
parameters,
};
await missionApi("/api/mission_queue", { method: "POST", body: JSON.stringify(payload) });
await refreshQueue();
}
async function pauseRunner() {
await missionApi("/api/mission_queue/pause", { method: "POST", body: "{}" });
await refreshQueue();
}
async function continueRunner() {
await missionApi("/api/mission_queue/continue", { method: "POST", body: "{}" });
await refreshQueue();
}
async function cancelRunner() {
if (!confirm(t("missions.queue.cancelConfirm"))) return;
await missionApi("/api/mission_queue/cancel", { method: "POST", body: "{}" });
await refreshQueue();
}
function openQueueDialog(missionId) {
const mission = findMission(missionId);
if (!mission) return;
store.pendingQueueMissionId = missionId;
const vars = collectMissionVariables(mission);
if (missionQueueDialogMissionEl) {
missionQueueDialogMissionEl.textContent = `Mission: ${mission.name}`;
}
if (missionQueueVarFieldsEl) {
missionQueueVarFieldsEl.innerHTML = "";
if (!vars.length) {
missionQueueVarFieldsEl.innerHTML = `<p class="mutedNote">Mission không có tham số biến. Bấm «Thêm vào queue» để chạy.</p>`;
} else {
vars.forEach((v) => {
const row = document.createElement("div");
row.className = "row rowWide";
const lab = document.createElement("label");
lab.textContent = v.label;
const sel = document.createElement("select");
sel.dataset.varKey = v.key;
v.options.forEach((opt) => {
const o = document.createElement("option");
o.value = opt;
o.textContent = opt;
if (opt === v.default) o.selected = true;
sel.appendChild(o);
});
row.appendChild(lab);
row.appendChild(sel);
missionQueueVarFieldsEl.appendChild(row);
});
}
}
if (missionQueueVarHintEl) missionQueueVarHintEl.hidden = vars.length === 0;
missionQueueDialogEl.showModal();
}
async function submitQueueDialog(evt) {
evt.preventDefault();
const mission = findMission(store.pendingQueueMissionId);
if (!mission) return;
const parameters = {};
missionQueueVarFieldsEl?.querySelectorAll("[data-var-key]").forEach((sel) => {
parameters[sel.dataset.varKey] = sel.value;
});
const payload = {
mission: resolveMissionSnapshot(mission),
parameters,
};
try {
await missionApi("/api/mission_queue", { method: "POST", body: JSON.stringify(payload) });
missionQueueDialogEl.close();
store.pendingQueueMissionId = null;
await refreshQueue();
} catch (e) {
alert(e.message);
}
}
function startQueuePoll() {
if (window.AuthApp && !window.AuthApp.isReady()) return;
queuePollRefs += 1;
if (queuePollRefs === 1) {
refreshQueue();
queuePollTimer = setInterval(refreshQueue, 1500);
}
}
function stopQueuePoll() {
if (queuePollRefs <= 0) return;
queuePollRefs -= 1;
if (queuePollRefs === 0 && queuePollTimer) {
clearInterval(queuePollTimer);
queuePollTimer = null;
}
}
function stopQueuePollForce() {
queuePollRefs = 0;
if (queuePollTimer) {
clearInterval(queuePollTimer);
queuePollTimer = null;
}
}
function renderMissionList() {
if (!missionListEl) return;
missionListEl.innerHTML = "";
const items = [...store.missions].sort((a, b) => a.name.localeCompare(b.name));
missionListEmptyEl.hidden = items.length > 0;
items.forEach((mission) => {
const row = document.createElement("div");
row.className = "missionListItem";
row.innerHTML = `
<div>
<div class="missionListItemTitle">${escapeHtml(mission.name)}</div>
<div class="missionListItemMeta">${escapeHtml(mission.group)}${mission.actions.length} action(s)${mission.description ? `${escapeHtml(mission.description)}` : ""}</div>
</div>
<div class="missionListItemActions">
<button type="button" class="iconBtn missionQueueBtn" data-queue="${mission.id}" title="Thêm vào mission queue" aria-label="Thêm vào queue">▤</button>
<button type="button" class="btn subtle" data-edit="${mission.id}">${t("common.edit")}</button>
<button type="button" class="btn subtle danger" data-delete="${mission.id}">${t("common.delete")}</button>
</div>`;
row.addEventListener("click", (evt) => {
if (evt.target.closest("button")) return;
openEditor(mission.id);
});
row.querySelector("[data-queue]").addEventListener("click", (evt) => {
evt.stopPropagation();
openQueueDialog(mission.id);
});
row.querySelector("[data-edit]").addEventListener("click", (evt) => {
evt.stopPropagation();
openEditor(mission.id);
});
row.querySelector("[data-delete]").addEventListener("click", (evt) => {
evt.stopPropagation();
if (!confirm(t("missions.deleteConfirm", { name: mission.name }))) return;
store.missions = store.missions.filter((m) => m.id !== mission.id);
persistStore();
renderMissionList();
});
missionListEl.appendChild(row);
});
}
function groupLabel(name) {
const key = `missions.group.${name}`;
const v = t(key);
return v !== key ? v : name;
}
function renderActionPalette() {
if (!missionGroupTabsEl) return;
missionGroupTabsEl.innerHTML = "";
Object.entries(ACTION_GROUPS).forEach(([groupName, actions]) => {
const tab = document.createElement("div");
tab.className = "missionGroupTab";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "missionGroupTabBtn";
btn.textContent = groupName;
btn.dataset.group = groupName;
const menu = document.createElement("div");
menu.className = "missionGroupMenu";
menu.hidden = true;
actions.forEach((def) => {
const item = document.createElement("button");
item.type = "button";
item.className = "missionPaletteItem";
item.draggable = true;
item.innerHTML = `<span class="missionActionIcon">${def.isLoop ? "↻" : "▶"}</span><span>${escapeHtml(def.label)}</span>`;
item.addEventListener("click", () => {
addActionToList(def.type, "root");
menu.hidden = true;
btn.classList.remove("open");
});
item.addEventListener("dragstart", (evt) => {
onPaletteDragStart(evt, { actionType: def.type });
});
item.addEventListener("dragend", onDragEnd);
menu.appendChild(item);
});
const embeddable = store.missions.filter((m) => m.id !== store.editingId);
if (embeddable.length) {
const sep = document.createElement("div");
sep.className = "mutedNote";
sep.style.padding = "6px 10px";
sep.textContent = "Missions có sẵn";
menu.appendChild(sep);
embeddable.forEach((m) => {
const item = document.createElement("button");
item.type = "button";
item.className = "missionPaletteItem missionRef";
item.draggable = true;
item.innerHTML = `<span class="missionActionIcon kind-mission">◎</span><span>${escapeHtml(m.name)}</span>`;
item.addEventListener("click", () => {
addMissionRefToList(m.id, "root");
menu.hidden = true;
btn.classList.remove("open");
});
item.addEventListener("dragstart", (evt) => {
onPaletteDragStart(evt, { missionId: m.id });
});
item.addEventListener("dragend", onDragEnd);
menu.appendChild(item);
});
}
btn.addEventListener("click", (evt) => {
evt.stopPropagation();
const open = !menu.hidden;
closeAllPaletteMenus();
menu.hidden = open;
btn.classList.toggle("open", !open);
});
tab.appendChild(btn);
tab.appendChild(menu);
missionGroupTabsEl.appendChild(tab);
});
}
function closeAllPaletteMenus() {
document.querySelectorAll(".missionGroupMenu").forEach((m) => {
m.hidden = true;
});
document.querySelectorAll(".missionGroupTabBtn.open").forEach((b) => b.classList.remove("open"));
}
function renderActionRows(actions, listPath, container) {
actions.forEach((action, index) => {
const row = document.createElement("div");
row.className = "missionActionRow";
row.dataset.actionId = action.id;
row.dataset.listPath = listPath;
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" ? "↻" : "▶";
row.innerHTML = `
<div class="missionDragHandle" draggable="true" title="Kéo để sắp xếp" aria-label="Kéo để sắp xếp">↕</div>
<div class="missionActionTop">
<div class="missionActionMain">
<div class="missionActionLabelRow">
<span class="missionActionIcon ${iconClass}">${iconChar}</span>
<span class="missionActionLabel">${escapeHtml(action.label)}</span>
</div>
<div class="missionActionSummary">${escapeHtml(actionSummary(action))}</div>
</div>
<div class="missionActionBtns">
<button type="button" class="iconBtn" data-config="${action.id}" title="Cấu hình">⚙</button>
<button type="button" class="iconBtn danger" data-remove="${action.id}" title="" data-i18n-title="common.delete">×</button>
</div>
</div>`;
if (action.type === "loop" && 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>`;
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";
drop.appendChild(empty);
} else {
renderActionRows(action.children, `${listPath}.${action.id}`, drop);
}
loop.appendChild(drop);
row.appendChild(loop);
}
row.querySelector("[data-config]").addEventListener("click", (evt) => {
evt.stopPropagation();
openActionConfig(action.id);
});
row.querySelector("[data-remove]").addEventListener("click", (evt) => {
evt.stopPropagation();
removeActionFromTree(action.id);
renderMissionEditor();
});
const handle = row.querySelector(".missionDragHandle");
handle.addEventListener("dragstart", (evt) => onDragStart(evt, action.id, listPath));
handle.addEventListener("dragend", onDragEnd);
row.addEventListener("dragover", onRowDragOver);
row.addEventListener("dragleave", onRowDragLeave);
row.addEventListener("drop", onRowDrop);
const loopDrop = row.querySelector(".missionLoopDrop");
if (loopDrop) {
loopDrop.addEventListener("dragover", onLoopDragOver);
loopDrop.addEventListener("dragleave", onLoopDragLeave);
loopDrop.addEventListener("drop", onLoopDrop);
}
container.appendChild(row);
});
}
function renderMissionEditor() {
const draft = getDraft();
if (!draft) return;
missionEditorTitleEl.textContent = draft.name;
missionEditorMetaEl.textContent = `${draft.group}${draft.description ? `${draft.description}` : ""}`;
renderActionPalette();
missionActionListEl.innerHTML = "";
renderActionRows(draft.actions, "root", missionActionListEl);
}
function onPaletteDragStart(evt, payload) {
store.drag = { mode: "palette", ...payload };
evt.dataTransfer.effectAllowed = "copy";
evt.dataTransfer.setData("text/plain", payload.actionType || payload.missionId || "palette");
evt.currentTarget.classList.add("dragging");
}
function onDragStart(evt, actionId, listPath) {
store.drag = { actionId, listPath, mode: "reorder" };
evt.dataTransfer.effectAllowed = "move";
evt.dataTransfer.setData("text/plain", actionId);
evt.target.closest(".missionActionRow")?.classList.add("dragging");
}
function onDragEnd(evt) {
document.querySelectorAll(".missionActionRow.dragging, .missionActionRow.dropBefore, .missionActionRow.dropAfter").forEach((n) => {
n.classList.remove("dragging", "dropBefore", "dropAfter");
});
document.querySelectorAll(".missionPaletteItem.dragging").forEach((n) => n.classList.remove("dragging"));
document.querySelectorAll(".missionLoopDrop.dragOver").forEach((n) => n.classList.remove("dragOver"));
store.drag = null;
}
function onRowDragOver(evt) {
if (!store.drag || store.drag.mode !== "reorder") return;
evt.preventDefault();
const row = evt.currentTarget;
const rect = row.getBoundingClientRect();
const before = evt.clientY < rect.top + rect.height / 2;
row.classList.toggle("dropBefore", before);
row.classList.toggle("dropAfter", !before);
}
function onRowDragLeave(evt) {
evt.currentTarget.classList.remove("dropBefore", "dropAfter");
}
function onRowDrop(evt) {
evt.preventDefault();
evt.stopPropagation();
if (!store.drag || store.drag.mode !== "reorder") return;
const row = evt.currentTarget;
const targetPath = row.dataset.listPath;
let targetIndex = Number(row.dataset.index);
if (row.classList.contains("dropAfter")) targetIndex += 1;
row.classList.remove("dropBefore", "dropAfter");
moveAction(store.drag.actionId, targetPath, targetIndex);
renderMissionEditor();
}
function onLoopDragOver(evt) {
evt.preventDefault();
evt.stopPropagation();
evt.currentTarget.classList.add("dragOver");
}
function onLoopDragLeave(evt) {
evt.currentTarget.classList.remove("dragOver");
}
function onLoopDrop(evt) {
evt.preventDefault();
evt.stopPropagation();
evt.currentTarget.classList.remove("dragOver");
if (!store.drag) return;
const loopPath = evt.currentTarget.dataset.loopPath;
const list = findActionList(loopPath);
if (!list) return;
if (store.drag.mode === "reorder") {
moveAction(store.drag.actionId, loopPath, list.length);
} else if (store.drag.mode === "palette") {
if (store.drag.missionId) addMissionRefToList(store.drag.missionId, loopPath);
else if (store.drag.actionType) {
list.push(createAction(store.drag.actionType));
setDirty(true);
}
}
renderMissionEditor();
}
function openEditor(missionId) {
const mission = findMission(missionId);
if (!mission) return;
store.editingId = missionId;
store.draft = JSON.parse(JSON.stringify(mission));
normalizeActionTree(store.draft.actions);
setDirty(false);
missionsListViewEl.hidden = true;
missionEditorViewEl.hidden = false;
renderMissionEditor();
}
function closeEditor() {
if (store.dirty && !confirm(t("missions.editor.discardConfirm"))) return;
store.editingId = null;
store.draft = null;
setDirty(false);
missionEditorViewEl.hidden = true;
missionsListViewEl.hidden = false;
renderMissionList();
}
function saveDraft() {
const draft = getDraft();
if (!draft) return false;
if (!draft.name.trim()) {
alert(t("missions.error.nameRequired"));
return false;
}
draft.updated_at = new Date().toISOString();
const idx = store.missions.findIndex((m) => m.id === draft.id);
if (idx >= 0) store.missions[idx] = JSON.parse(JSON.stringify(draft));
else store.missions.push(JSON.parse(JSON.stringify(draft)));
persistStore();
setDirty(false);
renderMissionList();
return true;
}
function saveDraftAs(newName) {
const draft = getDraft();
if (!draft) return false;
const name = newName.trim();
if (!name) return false;
if (store.missions.some((m) => m.name === name && m.id !== draft.id)) {
alert(t("missions.error.nameDuplicate"));
return false;
}
const copy = JSON.parse(JSON.stringify(draft));
copy.id = newId();
copy.name = name;
copy.updated_at = new Date().toISOString();
store.missions.push(copy);
persistStore();
store.editingId = copy.id;
store.draft = copy;
setDirty(false);
renderMissionEditor();
renderMissionList();
return true;
}
function openCreateDialog() {
fillGroupSelect(el("missionCreateGroup"), "Missions");
el("missionCreateName").value = "";
el("missionCreateGroupNew").value = "";
el("missionCreateDesc").value = "";
missionCreateDialogEl.showModal();
}
function openSettingsDialog() {
const draft = getDraft();
if (!draft) return;
fillGroupSelect(el("missionSettingsGroup"), draft.group);
el("missionSettingsName").value = draft.name;
el("missionSettingsDesc").value = draft.description || "";
missionSettingsDialogEl.showModal();
}
function openSaveAsDialog() {
const draft = getDraft();
if (!draft) return;
el("missionSaveAsName").value = `${draft.name} (copy)`;
missionSaveAsDialogEl.showModal();
}
function openActionConfig(actionId) {
const hit = findActionWithParent(actionId);
if (!hit || hit.action.kind === "mission") return;
store.configActionId = actionId;
missionActionConfigTitleEl.textContent = `Cấu hình: ${hit.action.label}`;
missionActionConfigBodyEl.innerHTML = buildConfigForm(hit.action);
missionActionConfigDialogEl.showModal();
}
function buildConfigForm(action) {
const p = action.params || {};
const grid = document.createElement("div");
grid.className = "missionConfigGrid";
const addField = (label, node) => {
const row = document.createElement("div");
row.className = "row rowWide";
const lab = document.createElement("label");
lab.textContent = label;
row.appendChild(lab);
row.appendChild(node);
grid.appendChild(row);
};
const textInput = (key, value, type = "text") => {
const input = document.createElement("input");
input.type = type;
input.dataset.param = key;
input.value = value ?? "";
return input;
};
const selectInput = (key, value, options) => {
const select = document.createElement("select");
select.dataset.param = key;
options.forEach((opt) => {
const o = document.createElement("option");
o.value = opt;
o.textContent = opt;
if (opt === value) o.selected = true;
select.appendChild(o);
});
return select;
};
const addVariableToggle = (paramKey, fieldLabel) => {
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="${paramKey}_var" ${p[`${paramKey}_var`] ? "checked" : ""} /> Biến — hỏi khi thêm vào queue`;
addField(`${fieldLabel} (biến)`, chk);
};
switch (action.type) {
case "move_to_position":
case "adjust_localization":
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
addVariableToggle("position", "Position");
if (action.type === "move_to_position") {
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="check_free" ${p.check_free ? "checked" : ""} /> Kiểm tra vị trí trống`;
addField("Tuỳ chọn", chk);
}
break;
case "move_to_marker":
addField("Marker", selectInput("marker", p.marker, SAMPLE_MARKERS));
addVariableToggle("marker", "Marker");
break;
case "wait":
addField("Giây", textInput("seconds", p.seconds, "number"));
break;
case "set_speed":
addField("Tốc độ", selectInput("speed", p.speed, ["slow", "normal", "fast"]));
break;
case "loop":
addField("Chế độ", selectInput("mode", p.mode, ["count", "endless"]));
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", selectInput("position", p.position, SAMPLE_POSITIONS));
addVariableToggle("position", "Position");
break;
case "set_digital_output":
addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES));
addField("Pin", textInput("pin", p.pin, "number"));
{
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="value" ${p.value ? "checked" : ""} /> Bật (ON)`;
addField("Trạng thái", chk);
}
break;
case "wait_digital_input":
addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES));
addField("Pin", textInput("pin", p.pin, "number"));
addField("Timeout (s)", textInput("timeout_s", p.timeout_s, "number"));
{
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_plc_register":
addField("Register", textInput("register", p.register, "number"));
addField("Hành động", selectInput("action", p.action, ["set", "add", "subtract"]));
addField("Giá trị", textInput("value", p.value, "number"));
break;
case "pick_cart":
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
addVariableToggle("position", "Position");
addField("Cart", selectInput("cart", p.cart, SAMPLE_CARTS));
addVariableToggle("cart", "Cart");
break;
case "drop_cart":
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
addVariableToggle("position", "Position");
{
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="collision_check" ${p.collision_check ? "checked" : ""} /> Kiểm tra va chạm`;
addField("An toàn", chk);
}
break;
case "user_log":
addField("Message", textInput("message", p.message));
break;
case "play_sound":
addField("Sound", selectInput("sound", p.sound, ["beep", "horn", "chime"]));
break;
default:
grid.innerHTML = `<p class="mutedNote">Action này không có tham số cấu hình.</p>`;
}
return grid.outerHTML;
}
function applyActionConfig() {
const hit = findActionWithParent(store.configActionId);
if (!hit) return;
const params = { ...hit.action.params };
missionActionConfigBodyEl.querySelectorAll("[data-param]").forEach((node) => {
const key = node.dataset.param;
if (node.type === "checkbox") params[key] = node.checked;
else if (node.type === "number") params[key] = Number(node.value);
else params[key] = node.value;
});
hit.action.params = params;
setDirty(true);
renderMissionEditor();
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function bindEvents() {
el("missionCreateOpenBtn")?.addEventListener("click", openCreateDialog);
el("missionCreateForm")?.addEventListener("submit", (evt) => {
evt.preventDefault();
const name = el("missionCreateName").value.trim();
if (!name) return;
let group = el("missionCreateGroup").value;
const groupNew = el("missionCreateGroupNew").value.trim();
if (groupNew) {
group = groupNew;
if (!store.groups.includes(group)) store.groups.push(group);
}
if (store.missions.some((m) => m.name === name)) {
alert(t("missions.error.nameDuplicate"));
return;
}
const mission = createMission(name, group, el("missionCreateDesc").value);
store.missions.push(mission);
persistStore();
missionCreateDialogEl.close();
renderMissionList();
openEditor(mission.id);
});
el("missionEditorBackBtn")?.addEventListener("click", closeEditor);
el("missionSaveBtn")?.addEventListener("click", () => {
if (saveDraft()) alert(t("missions.saveSuccess"));
});
el("missionSaveAsBtn")?.addEventListener("click", openSaveAsDialog);
el("missionSettingsBtn")?.addEventListener("click", openSettingsDialog);
el("missionSettingsForm")?.addEventListener("submit", (evt) => {
evt.preventDefault();
const draft = getDraft();
if (!draft) return;
draft.name = el("missionSettingsName").value.trim();
draft.group = el("missionSettingsGroup").value;
draft.description = el("missionSettingsDesc").value.trim();
if (!draft.name) {
alert(t("missions.error.nameEmpty"));
return;
}
setDirty(true);
missionSettingsDialogEl.close();
renderMissionEditor();
});
el("missionSaveAsForm")?.addEventListener("submit", (evt) => {
evt.preventDefault();
if (saveDraftAs(el("missionSaveAsName").value.trim())) {
missionSaveAsDialogEl.close();
}
});
el("missionActionConfigForm")?.addEventListener("submit", (evt) => {
evt.preventDefault();
applyActionConfig();
missionActionConfigDialogEl.close();
});
document.querySelectorAll("[data-close-dialog]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-close-dialog");
el(id)?.close();
});
});
document.addEventListener("click", (evt) => {
if (!evt.target.closest(".missionGroupTab")) closeAllPaletteMenus();
});
el("missionQueueClearBtn")?.addEventListener("click", clearQueue);
el("missionQueueCancelBtn")?.addEventListener("click", () => {
cancelRunner().catch((e) => alert(e.message));
});
el("missionQueueForm")?.addEventListener("submit", submitQueueDialog);
}
async function init() {
loadStore();
await loadStoreFromBackend();
bindEvents();
renderMissionList();
}
window.MissionsApp = {
init,
getMissions: () => [...store.missions],
getGroups: () => allGroups(),
getMissionById: findMission,
queueMission: openQueueDialog,
enqueueMission,
pauseRunner,
continueRunner,
cancelRunner,
refreshQueue,
clearQueue,
getQueueSnapshot,
renderQueueInto,
onQueueUpdate(fn) {
queueListeners.add(fn);
return () => queueListeners.delete(fn);
},
startQueuePoll,
stopQueuePoll,
onPageShow() {
if (!missionEditorViewEl?.hidden) renderMissionEditor();
else {
renderMissionList();
startQueuePoll();
}
},
onPageHide() {
stopQueuePoll();
},
};
function boot() {
init();
}
function onLocaleChange() {
if (!missionEditorViewEl?.hidden) renderMissionEditor();
else {
renderMissionList();
renderQueuePanel();
}
renderActionPalette();
}
window.addEventListener("lm:locale-change", onLocaleChange);
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
window.addEventListener("lm:auth-logout", stopQueuePollForce);
})();