(() => { 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 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: "" }, queuePollTimer: null, pendingQueueMissionId: 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; } return { type, label: 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; } } 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) => `${escapeHtml(params[key])}`) .join(" • "); } function queueStatusLabel(status) { const map = { pending: "Chờ", executing: "Đang chạy", completed: "Xong", failed: "Lỗi", cancelled: "Đã hủy", }; return map[status] || 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 = `Không tải được queue: ${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 ? "Sẵn sàng" : "Robot sẵn sàng — queue trống hoặc chờ mission mới." : "—"; } 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 ? `
Mission không có tham số biến. Bấm «Thêm vào queue» để chạy.
`; } 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; stopQueuePoll(); refreshQueue(); store.queuePollTimer = setInterval(refreshQueue, 1500); } function stopQueuePoll() { if (store.queuePollTimer) { clearInterval(store.queuePollTimer); store.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 = `Action này không có tham số cấu hình.
`; } 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, "&") .replace(//g, ">") .replace(/"/g, """); } 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ên mission đã tồn tại."); 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("Đã lưu mission."); }); 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ên không được trống."); 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(); } if (window.AuthApp?.isReady()) boot(); else window.addEventListener("lm:auth-ready", boot, { once: true }); window.addEventListener("lm:auth-logout", stopQueuePoll); })();