create mission

This commit is contained in:
2026-06-13 11:38:02 +07:00
parent 853acefac1
commit 10f4c36c23
4 changed files with 1345 additions and 5 deletions

View File

@@ -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 */
}

View File

@@ -29,6 +29,14 @@
</a>
</nav>
<div class="navTitle">CÀI ĐẶT</div>
<nav class="nav">
<a class="navItem" href="#" data-page="missions">
<span class="navDot"></span>
Missions
</a>
</nav>
<div class="sidebarFooter">
<div class="statusBadge">
<span class="statusLed"></span>
@@ -509,9 +517,60 @@
</div>
<div class="page" id="pageMissions" data-page-content="missions" hidden>
<div id="missionsListView" class="missionsPage">
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">Missions</div>
<div class="cardSub">Setup → Missions — danh sách nhiệm vụ robot.</div>
</div>
<button id="missionCreateOpenBtn" type="button" class="btn primary">Create mission</button>
</div>
<div class="cardBody">
<div id="missionListEmpty" class="mutedNote" hidden>Chưa có mission. Bấm Create mission để bắt đầu.</div>
<div id="missionList" class="missionList"></div>
</div>
</section>
</div>
<div id="missionEditorView" class="missionsPage" hidden>
<section class="card missionEditorCard">
<div class="missionEditorTop">
<div class="missionEditorTitleWrap">
<button id="missionEditorBackBtn" type="button" class="btn subtle missionBackBtn" aria-label="Quay lại danh sách"></button>
<div>
<div class="missionEditorKicker">Mission editor</div>
<div class="missionEditorTitleRow">
<h2 id="missionEditorTitle" class="missionEditorTitle"></h2>
<button id="missionSettingsBtn" type="button" class="iconBtn" title="Cài đặt mission" aria-label="Cài đặt mission"></button>
</div>
<div id="missionEditorMeta" class="missionEditorMeta"></div>
</div>
</div>
<div class="missionEditorTopActions">
<span id="missionEditorDirty" class="missionDirtyBadge" hidden>Chưa lưu</span>
<button id="missionSaveAsBtn" type="button" class="btn subtle">Save as</button>
<button id="missionSaveBtn" type="button" class="btn primary">Save</button>
</div>
</div>
<div class="missionActionBar" id="missionActionBar" role="toolbar" aria-label="Thêm action">
<div class="missionGroupTabs" id="missionGroupTabs"></div>
</div>
<div class="missionEditorBody">
<p class="missionFlowHint">Thực thi từ trên xuống dưới. Kéo biểu tượng ↔ để đổi thứ tự. Với Loop: kéo action vào vùng bên trong.</p>
<div id="missionActionList" class="missionActionList"></div>
<div id="missionActionListEmpty" class="missionActionListEmpty mutedNote">Chọn action từ menu phía trên để bắt đầu.</div>
</div>
</section>
</div>
</div>
<div id="configSplitter" class="splitter" role="separator" aria-orientation="vertical" tabindex="0"></div>
<div class="contentRight">
<div class="contentRight" id="contentRight">
<section class="card">
<div class="cardHeader">
<div>
@@ -537,6 +596,98 @@
</div>
</div>
<dialog id="missionCreateDialog" class="missionDialog">
<form id="missionCreateForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Create mission</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionCreateDialog" aria-label="Đóng">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
<label for="missionCreateName">Tên mission</label>
<input id="missionCreateName" type="text" required placeholder="VD: Go to charging station" />
</div>
<div class="row rowWide">
<label for="missionCreateGroup">Nhóm mission</label>
<select id="missionCreateGroup"></select>
</div>
<div class="row rowWide">
<label for="missionCreateGroupNew">Hoặc nhóm mới</label>
<input id="missionCreateGroupNew" type="text" placeholder="Tùy chọn" />
</div>
<div class="row rowWide">
<label for="missionCreateDesc">Mô tả</label>
<textarea id="missionCreateDesc" rows="2" placeholder="Tùy chọn"></textarea>
</div>
</div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle" data-close-dialog="missionCreateDialog">Hủy</button>
<button type="submit" class="btn primary">Create mission</button>
</div>
</form>
</dialog>
<dialog id="missionSettingsDialog" class="missionDialog">
<form id="missionSettingsForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Cài đặt mission</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionSettingsDialog" aria-label="Đóng">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
<label for="missionSettingsName">Tên</label>
<input id="missionSettingsName" type="text" required />
</div>
<div class="row rowWide">
<label for="missionSettingsGroup">Nhóm</label>
<select id="missionSettingsGroup"></select>
</div>
<div class="row rowWide">
<label for="missionSettingsDesc">Mô tả</label>
<textarea id="missionSettingsDesc" rows="2"></textarea>
</div>
</div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle" data-close-dialog="missionSettingsDialog">Hủy</button>
<button type="submit" class="btn primary">Áp dụng</button>
</div>
</form>
</dialog>
<dialog id="missionSaveAsDialog" class="missionDialog">
<form id="missionSaveAsForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Save mission as</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionSaveAsDialog" aria-label="Đóng">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
<label for="missionSaveAsName">Tên mission mới</label>
<input id="missionSaveAsName" type="text" required />
</div>
</div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle" data-close-dialog="missionSaveAsDialog">Hủy</button>
<button type="submit" class="btn primary">Lưu bản sao</button>
</div>
</form>
</dialog>
<dialog id="missionActionConfigDialog" class="missionDialog missionDialogWide">
<form id="missionActionConfigForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3 id="missionActionConfigTitle">Cấu hình action</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionActionConfigDialog" aria-label="Đóng">×</button>
</div>
<div class="missionDialogBody" id="missionActionConfigBody"></div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle" data-close-dialog="missionActionConfigDialog">Hủy</button>
<button type="submit" class="btn primary">Áp dụng</button>
</div>
</form>
</dialog>
<script src="/missions.js"></script>
<script src="/app.js"></script>
</body>
</html>

911
www/missions.js Normal file
View 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, "&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();
})();

View File

@@ -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; }