create mission
This commit is contained in:
911
www/missions.js
Normal file
911
www/missions.js
Normal file
@@ -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 = `
|
||||
<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="btn subtle" data-edit="${mission.id}">Sửa</button>
|
||||
<button type="button" class="btn subtle danger" data-delete="${mission.id}">Xóa</button>
|
||||
</div>`;
|
||||
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 = `<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");
|
||||
});
|
||||
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 = `<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");
|
||||
});
|
||||
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="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>
|
||||
<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="Xóa">×</button>`;
|
||||
|
||||
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.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 = `<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));
|
||||
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 = `<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" : ""} /> 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 = `<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, "&")
|
||||
.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();
|
||||
})();
|
||||
Reference in New Issue
Block a user