From 10f4c36c235759e2fe2d0a3f5a654cc8a5b3a7b5 Mon Sep 17 00:00:00 2001 From: HiepLM Date: Sat, 13 Jun 2026 11:38:02 +0700 Subject: [PATCH] create mission --- www/app.js | 21 +- www/index.html | 153 +++++++- www/missions.js | 911 ++++++++++++++++++++++++++++++++++++++++++++++++ www/style.css | 265 +++++++++++++- 4 files changed, 1345 insertions(+), 5 deletions(-) create mode 100644 www/missions.js diff --git a/www/app.js b/www/app.js index f8da904..3595c4d 100644 --- a/www/app.js +++ b/www/app.js @@ -7,6 +7,9 @@ const pageTitleEl = document.querySelector(".pageTitle"); const navItemEls = Array.from(document.querySelectorAll(".navItem[data-page]")); const pageOverviewEl = el("pageOverview"); const pageConfigEl = el("pageConfig"); +const pageMissionsEl = el("pageMissions"); +const contentEl = document.querySelector(".content"); +const contentRightEl = el("contentRight"); const overviewBackendEl = el("overviewBackend"); const overviewActiveLayoutEl = el("overviewActiveLayout"); const overviewActiveModelEl = el("overviewActiveModel"); @@ -117,16 +120,28 @@ const state = { }; function setActivePage(page) { - const p = page === "overview" ? "overview" : "config"; + const valid = ["overview", "config", "missions"]; + const p = valid.includes(page) ? page : "config"; navItemEls.forEach((a) => { const on = (a.dataset.page || "") === p; a.classList.toggle("active", on); if (on) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); }); - if (pageTitleEl) pageTitleEl.textContent = p === "overview" ? "Tổng quan" : "Cấu Hình"; + const titles = { overview: "Tổng quan", config: "Cấu Hình", missions: "Missions" }; + if (pageTitleEl) pageTitleEl.textContent = titles[p] || "Cấu Hình"; if (pageOverviewEl) pageOverviewEl.hidden = p !== "overview"; if (pageConfigEl) pageConfigEl.hidden = p !== "config"; + if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions"; + if (configSplitterEl) configSplitterEl.hidden = p !== "config"; + if (contentRightEl) contentRightEl.hidden = p !== "config"; + if (contentEl) { + contentEl.classList.toggle("content--overview", p === "overview"); + contentEl.classList.toggle("content--config", p === "config"); + contentEl.classList.toggle("content--missions", p === "missions"); + } + if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config"; + if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow(); try { localStorage.setItem("activePage", p); } catch { @@ -145,7 +160,7 @@ function initNavigation() { let initial = "config"; try { const saved = localStorage.getItem("activePage"); - if (saved === "overview" || saved === "config") initial = saved; + if (saved === "overview" || saved === "config" || saved === "missions") initial = saved; } catch { /* ignore */ } diff --git a/www/index.html b/www/index.html index 4ae7026..82e440b 100644 --- a/www/index.html +++ b/www/index.html @@ -29,6 +29,14 @@ + + +
@@ -509,9 +517,60 @@
+ + -
+
@@ -537,6 +596,98 @@
+ +
+
+

Create mission

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+

Cài đặt mission

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+

Save mission as

+ +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+

Cấu hình action

+ +
+
+
+ + +
+
+
+ + diff --git a/www/missions.js b/www/missions.js new file mode 100644 index 0000000..489c3c7 --- /dev/null +++ b/www/missions.js @@ -0,0 +1,911 @@ +(() => { + 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 store = { + missions: [], + groups: [...DEFAULT_GROUPS], + editingId: null, + draft: null, + dirty: false, + drag: null, + configActionId: null, + configListPath: "root", + }; + + 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 loadStore() { + 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(); + } + + function persistStore() { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ missions: store.missions, groups: store.groups }) + ); + } + + 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 || {}; + switch (action.type) { + case "move_to_position": + return `Position: ${p.position}${p.check_free ? " • kiểm tra trống" : ""}`; + case "move_to_marker": + return `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"} @ ${p.position}`; + case "user_log": + return p.message || "—"; + case "play_sound": + return p.sound || "—"; + default: + return action.label; + } + } + + function findActionList(path) { + const draft = getDraft(); + if (!draft) return null; + if (path === "root") return draft.actions; + const parts = path.split("."); + 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 (action.children?.length) { + 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(); + } + + 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 = ` +
+
${escapeHtml(mission.name)}
+
${escapeHtml(mission.group)} • ${mission.actions.length} action(s)${mission.description ? ` • ${escapeHtml(mission.description)}` : ""}
+
+
+ + +
`; + row.addEventListener("click", (evt) => { + if (evt.target.closest("button")) return; + openEditor(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(`Xóa mission «${mission.name}»?`)) return; + store.missions = store.missions.filter((m) => m.id !== mission.id); + persistStore(); + renderMissionList(); + }); + missionListEl.appendChild(row); + }); + } + + 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.innerHTML = `${def.isLoop ? "↻" : "▶"}${escapeHtml(def.label)}`; + item.addEventListener("click", () => { + addActionToList(def.type, "root"); + menu.hidden = true; + btn.classList.remove("open"); + }); + 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.innerHTML = `${escapeHtml(m.name)}`; + item.addEventListener("click", () => { + addMissionRefToList(m.id, "root"); + menu.hidden = true; + btn.classList.remove("open"); + }); + 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 = ` +
+
+
+ ${iconChar} + ${escapeHtml(action.label)} +
+
${escapeHtml(actionSummary(action))}
+
+ + `; + + if (action.type === "loop" && Array.isArray(action.children)) { + const loop = document.createElement("div"); + loop.className = "missionLoopBlock"; + loop.innerHTML = `
Loop body — kéo action vào đây
`; + 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.querySelector(".missionActionMain").appendChild(loop); + } + + row.querySelector("[data-config]").addEventListener("click", () => openActionConfig(action.id)); + row.querySelector("[data-remove]").addEventListener("click", () => { + 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); + bindDragPaletteItems(); + } + + function bindDragPaletteItems() { + document.querySelectorAll(".missionLoopDrop").forEach((drop) => { + drop.addEventListener("dragover", onLoopDragOver); + drop.addEventListener("dragleave", onLoopDragLeave); + drop.addEventListener("drop", onLoopDrop); + }); + } + + 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(".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)); + setDirty(false); + missionsListViewEl.hidden = true; + missionEditorViewEl.hidden = false; + renderMissionEditor(); + } + + function closeEditor() { + if (store.dirty && !confirm("Bỏ thay đổi chưa lưu?")) 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ên mission không được trống."); + 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ên mission đã tồn tại."); + 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; + }; + + switch (action.type) { + case "move_to_position": + case "adjust_localization": + addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS)); + if (action.type === "move_to_position") { + const chk = document.createElement("label"); + chk.innerHTML = ` 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)); + 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)); + 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 = ` 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 = ` Chờ mức ON`; + 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)); + addField("Cart", selectInput("cart", p.cart, SAMPLE_CARTS)); + break; + case "drop_cart": + addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS)); + { + const chk = document.createElement("label"); + chk.innerHTML = ` 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 = `

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(); + }); + } + + function init() { + loadStore(); + bindEvents(); + renderMissionList(); + } + + window.MissionsApp = { + init, + onPageShow() { + if (!missionEditorViewEl?.hidden) renderMissionEditor(); + else renderMissionList(); + }, + }; + + init(); +})(); diff --git a/www/style.css b/www/style.css index 0422167..22d62ba 100644 --- a/www/style.css +++ b/www/style.css @@ -137,7 +137,6 @@ body { .content { padding: 18px; display: grid; - grid-template-columns: var(--leftPaneW, 460px) 10px 1fr; gap: 16px; align-items: start; height: 100%; @@ -538,6 +537,270 @@ canvas { .viewHint { color: var(--muted); font-size: 12px; width: 100%; } .canvasWrap canvas.edit-footprint { cursor: crosshair; } +.content.content--missions { + grid-template-columns: minmax(0, 1fr); + max-width: 1100px; +} +.content.content--overview { + grid-template-columns: minmax(0, 1fr); + max-width: 900px; +} +.content.content--config { + grid-template-columns: var(--leftPaneW, 460px) 10px 1fr; +} + +.missionsPage { min-width: 0; width: 100%; } +.missionList { display: grid; gap: 10px; } +.missionListItem { + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; + align-items: center; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--panel2); + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.missionListItem:hover { + border-color: rgba(37, 99, 235, 0.35); + box-shadow: var(--shadow); +} +.missionListItemTitle { font-weight: 700; font-size: 14px; } +.missionListItemMeta { font-size: 12px; color: var(--muted); margin-top: 4px; } +.missionListItemActions { display: flex; gap: 8px; } +.missionListItemActions .btn { padding: 6px 10px; font-size: 12px; } + +.missionEditorCard { overflow: hidden; } +.missionEditorTop { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, #fff, #f8fafc); +} +.missionEditorTitleWrap { display: flex; gap: 12px; align-items: flex-start; min-width: 0; } +.missionBackBtn { padding: 8px 12px; flex-shrink: 0; } +.missionEditorKicker { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; } +.missionEditorTitleRow { display: flex; align-items: center; gap: 8px; margin-top: 4px; } +.missionEditorTitle { margin: 0; font-size: 18px; font-weight: 800; } +.missionEditorMeta { font-size: 12px; color: var(--muted); margin-top: 6px; } +.missionEditorTopActions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } +.missionDirtyBadge { + font-size: 11px; + font-weight: 700; + color: #b45309; + background: #fef3c7; + border: 1px solid #fcd34d; + padding: 4px 8px; + border-radius: 999px; +} + +.missionActionBar { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px 18px; + border-bottom: 1px solid var(--border); + background: #0f172a; +} +.missionGroupTab { + position: relative; +} +.missionGroupTabBtn { + appearance: none; + border: 1px solid rgba(255,255,255,0.12); + background: rgba(255,255,255,0.06); + color: #e2e8f0; + border-radius: 10px; + padding: 8px 12px; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} +.missionGroupTabBtn:hover, +.missionGroupTabBtn.open { + background: rgba(37, 99, 235, 0.35); + border-color: rgba(37, 99, 235, 0.5); + color: #fff; +} +.missionGroupMenu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 220px; + max-height: 280px; + overflow: auto; + z-index: 20; + background: #fff; + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: var(--shadow2); + padding: 6px; +} +.missionGroupMenu[hidden] { display: none; } +.missionPaletteItem { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + text-align: left; + border: none; + background: transparent; + border-radius: 8px; + padding: 8px 10px; + font-size: 13px; + cursor: pointer; + color: var(--text); +} +.missionPaletteItem:hover { background: #eff6ff; } +.missionPaletteItem.missionRef .missionActionIcon { background: rgba(16, 185, 129, 0.15); color: #059669; } + +.missionEditorBody { padding: 16px 18px 20px; } +.missionFlowHint { margin: 0 0 12px; font-size: 12px; color: var(--muted); } +.missionActionList { display: grid; gap: 8px; min-height: 48px; } +.missionActionListEmpty { padding: 24px; text-align: center; border: 1px dashed var(--border); border-radius: 12px; } +.missionActionListEmpty[hidden], +.missionActionList:not(:empty) + .missionActionListEmpty { display: none; } + +.missionActionRow { + display: grid; + grid-template-columns: 32px 1fr auto auto; + gap: 10px; + align-items: start; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: #fff; + box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04); +} +.missionActionRow.dragging { opacity: 0.45; } +.missionActionRow.dropBefore { box-shadow: inset 0 3px 0 var(--accent); } +.missionActionRow.dropAfter { box-shadow: inset 0 -3px 0 var(--accent); } +.missionDragHandle { + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid var(--border); + background: #f8fafc; + color: var(--muted); + cursor: grab; + display: grid; + place-items: center; + font-size: 14px; + user-select: none; +} +.missionDragHandle:active { cursor: grabbing; } +.missionActionMain { min-width: 0; } +.missionActionLabelRow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.missionActionLabel { font-weight: 700; font-size: 13px; } +.missionActionSummary { font-size: 12px; color: var(--muted); margin-top: 4px; } +.missionActionIcon { + width: 22px; + height: 22px; + border-radius: 6px; + display: grid; + place-items: center; + font-size: 11px; + font-weight: 800; + background: rgba(37, 99, 235, 0.12); + color: var(--accent); + flex-shrink: 0; +} +.missionActionIcon.kind-mission { background: rgba(16, 185, 129, 0.15); color: #059669; } +.missionActionIcon.kind-loop { background: rgba(139, 92, 246, 0.15); color: #7c3aed; } +.iconBtn { + appearance: none; + border: 1px solid var(--border); + background: #fff; + width: 34px; + height: 34px; + border-radius: 10px; + cursor: pointer; + font-size: 16px; + line-height: 1; + color: var(--muted); +} +.iconBtn:hover { border-color: rgba(37, 99, 235, 0.35); color: var(--accent); background: #eff6ff; } +.iconBtn.danger:hover { border-color: rgba(239, 68, 68, 0.35); color: var(--danger); background: #fef2f2; } + +.missionLoopBlock { + margin-top: 10px; + border-radius: 10px; + border: 1px dashed rgba(124, 58, 237, 0.35); + background: rgba(124, 58, 237, 0.04); + padding: 10px; +} +.missionLoopLabel { + font-size: 11px; + font-weight: 700; + color: #7c3aed; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 8px; +} +.missionLoopDrop { + display: grid; + gap: 8px; + min-height: 44px; + border-radius: 8px; + padding: 4px; + transition: background 0.15s ease; +} +.missionLoopDrop.dragOver { + background: rgba(37, 99, 235, 0.08); + outline: 2px dashed rgba(37, 99, 235, 0.35); + outline-offset: 2px; +} +.missionLoopEmpty { + font-size: 12px; + color: var(--muted); + text-align: center; + padding: 10px; +} + +.missionDialog { + border: none; + border-radius: 16px; + padding: 0; + width: min(480px, calc(100vw - 32px)); + box-shadow: var(--shadow2); +} +.missionDialogWide { width: min(560px, calc(100vw - 32px)); } +.missionDialog::backdrop { background: rgba(15, 23, 42, 0.45); } +.missionDialogForm { display: grid; } +.missionDialogHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--border); +} +.missionDialogHeader h3 { margin: 0; font-size: 16px; } +.missionDialogBody { padding: 16px 18px; display: grid; gap: 12px; } +.missionDialogBody textarea { + width: 100%; + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + font: inherit; + resize: vertical; +} +.missionDialogFooter { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 18px; + border-top: 1px solid var(--border); + background: #f8fafc; +} +.missionConfigGrid { display: grid; gap: 12px; } +.missionConfigGrid .rowWide { grid-template-columns: 1fr; gap: 6px; } + @media (max-width: 980px) { .shell { grid-template-columns: 1fr; } .sidebar { position: relative; height: auto; }