Files
App/www/missions.js
2026-06-13 11:38:02 +07:00

912 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
const STORAGE_KEY = "phenikaax_missions_v1";
const ACTION_GROUPS = {
Move: [
{ type: "move_to_position", label: "Go to position" },
{ type: "move_to_marker", label: "Go to marker" },
{ type: "adjust_localization", label: "Adjust localization" },
{ type: "wait", label: "Wait" },
{ type: "set_speed", label: "Set speed" },
],
Logic: [
{ type: "if", label: "If" },
{ type: "loop", label: "Loop", isLoop: true },
{ type: "break", label: "Break" },
{ type: "continue", label: "Continue" },
{ type: "pause", label: "Pause" },
],
"I/O": [
{ type: "set_digital_output", label: "Set digital output" },
{ type: "wait_digital_input", label: "Wait for digital input" },
{ type: "set_plc_register", label: "Set PLC register" },
],
Cart: [
{ type: "pick_cart", label: "Pick cart" },
{ type: "drop_cart", label: "Drop cart" },
],
Misc: [
{ type: "user_log", label: "User log" },
{ type: "play_sound", label: "Play sound" },
],
};
const DEFAULT_GROUPS = ["Missions", "Move", "Logic", "I/O", "Cart", "Misc"];
const SAMPLE_POSITIONS = ["Charging station", "Warehouse", "Production line 1", "Dock A"];
const SAMPLE_MARKERS = ["Marker 1", "Marker 2", "Home"];
const SAMPLE_IO_MODULES = ["GPIO module 1", "PLC I/O 1"];
const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"];
const el = (id) => document.getElementById(id);
const 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function bindEvents() {
el("missionCreateOpenBtn")?.addEventListener("click", openCreateDialog);
el("missionCreateForm")?.addEventListener("submit", (evt) => {
evt.preventDefault();
const name = el("missionCreateName").value.trim();
if (!name) return;
let group = el("missionCreateGroup").value;
const groupNew = el("missionCreateGroupNew").value.trim();
if (groupNew) {
group = groupNew;
if (!store.groups.includes(group)) store.groups.push(group);
}
if (store.missions.some((m) => m.name === name)) {
alert("Tê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();
})();