1418 lines
49 KiB
JavaScript
1418 lines
49 KiB
JavaScript
(() => {
|
||
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 t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||
|
||
const missionListEl = el("missionList");
|
||
const missionListEmptyEl = el("missionListEmpty");
|
||
const missionsListViewEl = el("missionsListView");
|
||
const missionEditorViewEl = el("missionEditorView");
|
||
const missionActionBarEl = el("missionActionBar");
|
||
const missionGroupTabsEl = el("missionGroupTabs");
|
||
const missionActionListEl = el("missionActionList");
|
||
const missionEditorTitleEl = el("missionEditorTitle");
|
||
const missionEditorMetaEl = el("missionEditorMeta");
|
||
const missionEditorDirtyEl = el("missionEditorDirty");
|
||
const missionCreateDialogEl = el("missionCreateDialog");
|
||
const missionSettingsDialogEl = el("missionSettingsDialog");
|
||
const missionSaveAsDialogEl = el("missionSaveAsDialog");
|
||
const missionActionConfigDialogEl = el("missionActionConfigDialog");
|
||
const missionActionConfigBodyEl = el("missionActionConfigBody");
|
||
const missionActionConfigTitleEl = el("missionActionConfigTitle");
|
||
const missionQueueListEl = el("missionQueueList");
|
||
const missionQueueEmptyEl = el("missionQueueEmpty");
|
||
const missionQueueRunnerEl = el("missionQueueRunner");
|
||
const missionQueueDialogEl = el("missionQueueDialog");
|
||
const missionQueueVarFieldsEl = el("missionQueueVarFields");
|
||
const missionQueueDialogMissionEl = el("missionQueueDialogMission");
|
||
const missionQueueVarHintEl = el("missionQueueVarHint");
|
||
|
||
const VARIABLE_FIELD_DEFS = {
|
||
move_to_position: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
|
||
adjust_localization: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
|
||
move_to_marker: [{ key: "marker", label: "Marker", options: SAMPLE_MARKERS }],
|
||
if: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
|
||
pick_cart: [
|
||
{ key: "position", label: "Position", options: SAMPLE_POSITIONS },
|
||
{ key: "cart", label: "Cart", options: SAMPLE_CARTS },
|
||
],
|
||
drop_cart: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
|
||
};
|
||
|
||
const store = {
|
||
missions: [],
|
||
groups: [...DEFAULT_GROUPS],
|
||
editingId: null,
|
||
draft: null,
|
||
dirty: false,
|
||
drag: null,
|
||
configActionId: null,
|
||
configListPath: "root",
|
||
queue: [],
|
||
runner: { state: "idle", message: "" },
|
||
pendingQueueMissionId: null,
|
||
};
|
||
|
||
let queuePollRefs = 0;
|
||
let queuePollTimer = null;
|
||
|
||
function newId() {
|
||
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
|
||
return `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
function defaultParams(type) {
|
||
switch (type) {
|
||
case "move_to_position":
|
||
return { position: SAMPLE_POSITIONS[0], check_free: true };
|
||
case "move_to_marker":
|
||
return { marker: SAMPLE_MARKERS[0] };
|
||
case "adjust_localization":
|
||
return { position: SAMPLE_POSITIONS[0] };
|
||
case "wait":
|
||
return { seconds: 1 };
|
||
case "set_speed":
|
||
return { speed: "normal" };
|
||
case "if":
|
||
return { condition: "position_free", position: SAMPLE_POSITIONS[0] };
|
||
case "loop":
|
||
return { count: 1, mode: "count" };
|
||
case "set_digital_output":
|
||
return { module: SAMPLE_IO_MODULES[0], pin: 1, value: true };
|
||
case "wait_digital_input":
|
||
return { module: SAMPLE_IO_MODULES[0], pin: 1, expected: true, timeout_s: 30 };
|
||
case "set_plc_register":
|
||
return { register: 1, action: "set", value: 0 };
|
||
case "pick_cart":
|
||
return { position: SAMPLE_POSITIONS[0], cart: SAMPLE_CARTS[0] };
|
||
case "drop_cart":
|
||
return { position: SAMPLE_POSITIONS[0], collision_check: true };
|
||
case "user_log":
|
||
return { message: "Mission step" };
|
||
case "play_sound":
|
||
return { sound: "beep" };
|
||
default:
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function actionMeta(type) {
|
||
for (const items of Object.values(ACTION_GROUPS)) {
|
||
const hit = items.find((a) => a.type === type);
|
||
if (hit) return { ...hit, label: t(`missions.action.${type}`) || hit.label };
|
||
}
|
||
return { type, label: t(`missions.action.${type}`) || type };
|
||
}
|
||
|
||
function createAction(type, overrides = {}) {
|
||
const meta = actionMeta(type);
|
||
return {
|
||
id: newId(),
|
||
kind: "action",
|
||
type,
|
||
label: meta.label,
|
||
params: defaultParams(type),
|
||
children: meta.isLoop ? [] : undefined,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function createMissionRef(mission) {
|
||
return {
|
||
id: newId(),
|
||
kind: "mission",
|
||
refId: mission.id,
|
||
label: mission.name,
|
||
params: {},
|
||
};
|
||
}
|
||
|
||
function createMission(name, group, description) {
|
||
return {
|
||
id: newId(),
|
||
name: name.trim(),
|
||
group: group.trim() || "Missions",
|
||
description: (description || "").trim(),
|
||
actions: [],
|
||
updated_at: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
function loadStoreLocal() {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
if (!raw) return;
|
||
const data = JSON.parse(raw);
|
||
if (Array.isArray(data.missions)) store.missions = data.missions;
|
||
if (Array.isArray(data.groups)) store.groups = data.groups;
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
ensureDefaultGroups();
|
||
}
|
||
|
||
async function loadStoreFromBackend() {
|
||
try {
|
||
const res = await fetch("/api/missions", { credentials: "include" });
|
||
if (!res.ok) return false;
|
||
const data = await res.json();
|
||
if (Array.isArray(data.missions)) store.missions = data.missions;
|
||
if (Array.isArray(data.groups)) store.groups = data.groups;
|
||
ensureDefaultGroups();
|
||
localStorage.setItem(
|
||
STORAGE_KEY,
|
||
JSON.stringify({ missions: store.missions, groups: store.groups })
|
||
);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
let persistTimer = null;
|
||
|
||
async function syncStoreToBackend() {
|
||
try {
|
||
await fetch("/api/missions", {
|
||
credentials: "include",
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ missions: store.missions, groups: store.groups }),
|
||
});
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
function loadStore() {
|
||
loadStoreLocal();
|
||
}
|
||
|
||
function persistStore() {
|
||
localStorage.setItem(
|
||
STORAGE_KEY,
|
||
JSON.stringify({ missions: store.missions, groups: store.groups })
|
||
);
|
||
clearTimeout(persistTimer);
|
||
persistTimer = setTimeout(syncStoreToBackend, 400);
|
||
}
|
||
|
||
function ensureDefaultGroups() {
|
||
DEFAULT_GROUPS.forEach((g) => {
|
||
if (!store.groups.includes(g)) store.groups.push(g);
|
||
});
|
||
}
|
||
|
||
function allGroups() {
|
||
const fromMissions = store.missions.map((m) => m.group).filter(Boolean);
|
||
return [...new Set([...store.groups, ...fromMissions])].sort((a, b) => a.localeCompare(b));
|
||
}
|
||
|
||
function fillGroupSelect(selectEl, selected) {
|
||
if (!selectEl) return;
|
||
selectEl.innerHTML = "";
|
||
allGroups().forEach((g) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = g;
|
||
opt.textContent = g;
|
||
if (g === selected) opt.selected = true;
|
||
selectEl.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
function findMission(id) {
|
||
return store.missions.find((m) => m.id === id) || null;
|
||
}
|
||
|
||
function getDraft() {
|
||
return store.draft;
|
||
}
|
||
|
||
function setDirty(flag) {
|
||
store.dirty = !!flag;
|
||
if (missionEditorDirtyEl) missionEditorDirtyEl.hidden = !store.dirty;
|
||
}
|
||
|
||
function actionSummary(action) {
|
||
if (action.kind === "mission") return `Mission con: ${action.label}`;
|
||
const p = action.params || {};
|
||
const fmtVar = (key, val) => (p[`${key}_var`] ? `${val} (biến)` : val);
|
||
switch (action.type) {
|
||
case "move_to_position":
|
||
return `Position: ${fmtVar("position", p.position)}${p.check_free ? " • kiểm tra trống" : ""}`;
|
||
case "move_to_marker":
|
||
return `Marker: ${fmtVar("marker", p.marker)}`;
|
||
case "wait":
|
||
return `${p.seconds}s`;
|
||
case "set_speed":
|
||
return `Speed: ${p.speed}`;
|
||
case "loop":
|
||
return p.mode === "endless" ? "Lặp vô hạn" : `Lặp ${p.count} lần • ${action.children?.length || 0} bước`;
|
||
case "if":
|
||
return `If ${p.condition} @ ${p.position || "—"}`;
|
||
case "set_digital_output":
|
||
return `${p.module} pin ${p.pin} → ${p.value ? "ON" : "OFF"}`;
|
||
case "wait_digital_input":
|
||
return `${p.module} pin ${p.pin} = ${p.expected ? "ON" : "OFF"}`;
|
||
case "set_plc_register":
|
||
return `Reg ${p.register}: ${p.action} ${p.value}`;
|
||
case "pick_cart":
|
||
case "drop_cart":
|
||
return `${action.type === "pick_cart" ? "Pick" : "Drop"} @ ${fmtVar("position", p.position)}${action.type === "pick_cart" ? ` • ${fmtVar("cart", p.cart)}` : ""}`;
|
||
case "user_log":
|
||
return p.message || "—";
|
||
case "play_sound":
|
||
return p.sound || "—";
|
||
default:
|
||
return action.label;
|
||
}
|
||
}
|
||
|
||
function normalizeActionTree(actions) {
|
||
if (!Array.isArray(actions)) return;
|
||
actions.forEach((action) => {
|
||
if (action.type === "loop" && !Array.isArray(action.children)) {
|
||
action.children = [];
|
||
}
|
||
if (Array.isArray(action.children)) normalizeActionTree(action.children);
|
||
});
|
||
}
|
||
|
||
function findActionList(path) {
|
||
const draft = getDraft();
|
||
if (!draft) return null;
|
||
if (path === "root") return draft.actions;
|
||
const parts = path.split(".").filter((p) => p !== "root");
|
||
let list = draft.actions;
|
||
for (const part of parts) {
|
||
const node = list.find((a) => a.id === part);
|
||
if (!node || !Array.isArray(node.children)) return null;
|
||
list = node.children;
|
||
}
|
||
return list;
|
||
}
|
||
|
||
function findActionWithParent(actionId, list = getDraft()?.actions, path = "root", parent = null) {
|
||
if (!list) return null;
|
||
for (let i = 0; i < list.length; i += 1) {
|
||
const action = list[i];
|
||
if (action.id === actionId) return { action, list, index: i, path, parent };
|
||
if (Array.isArray(action.children)) {
|
||
const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action);
|
||
if (hit) return { ...hit, label: t(`missions.action.${type}`) || hit.label };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function removeActionFromTree(actionId) {
|
||
const hit = findActionWithParent(actionId);
|
||
if (!hit) return false;
|
||
hit.list.splice(hit.index, 1);
|
||
setDirty(true);
|
||
return true;
|
||
}
|
||
|
||
function moveAction(actionId, targetPath, targetIndex) {
|
||
const hit = findActionWithParent(actionId);
|
||
if (!hit) return false;
|
||
const targetList = findActionList(targetPath);
|
||
if (!targetList) return false;
|
||
|
||
if (hit.path === targetPath) {
|
||
const [item] = hit.list.splice(hit.index, 1);
|
||
const idx = hit.index < targetIndex ? targetIndex - 1 : targetIndex;
|
||
targetList.splice(idx, 0, item);
|
||
setDirty(true);
|
||
return true;
|
||
}
|
||
|
||
const movingIntoLoop = targetPath !== "root";
|
||
if (movingIntoLoop) {
|
||
const loopId = targetPath.split(".").pop();
|
||
if (loopId === actionId) return false;
|
||
let cursor = findActionWithParent(loopId);
|
||
while (cursor) {
|
||
if (cursor.action.id === actionId) return false;
|
||
cursor = cursor.parent ? findActionWithParent(cursor.parent.id) : null;
|
||
}
|
||
}
|
||
|
||
const [item] = hit.list.splice(hit.index, 1);
|
||
targetList.splice(targetIndex, 0, item);
|
||
setDirty(true);
|
||
return true;
|
||
}
|
||
|
||
function addActionToList(type, listPath = "root") {
|
||
const list = findActionList(listPath);
|
||
if (!list) return;
|
||
list.push(createAction(type));
|
||
setDirty(true);
|
||
renderMissionEditor();
|
||
}
|
||
|
||
function addMissionRefToList(missionId, listPath = "root") {
|
||
const mission = findMission(missionId);
|
||
const list = findActionList(listPath);
|
||
if (!mission || !list || mission.id === store.editingId) return;
|
||
list.push(createMissionRef(mission));
|
||
setDirty(true);
|
||
renderMissionEditor();
|
||
}
|
||
|
||
async function missionApi(path, opts = {}) {
|
||
if (window.AuthApp && !window.AuthApp.isReady()) {
|
||
throw new Error("not authenticated");
|
||
}
|
||
const res = await fetch(path, {
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
|
||
...opts,
|
||
});
|
||
if (!res.ok) {
|
||
let msg = res.statusText;
|
||
try {
|
||
const err = await res.json();
|
||
if (err.error) msg = err.error;
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
throw new Error(msg);
|
||
}
|
||
if (res.status === 204) return null;
|
||
return res.json();
|
||
}
|
||
|
||
function walkActions(actions, visit, depth = 0) {
|
||
if (!Array.isArray(actions) || depth > 12) return;
|
||
actions.forEach((action) => {
|
||
visit(action, depth);
|
||
if (Array.isArray(action.children)) walkActions(action.children, visit, depth + 1);
|
||
});
|
||
}
|
||
|
||
function resolveActionSnapshot(action, depth = 0) {
|
||
const copy = JSON.parse(JSON.stringify(action));
|
||
if (copy.kind === "mission") {
|
||
const ref = findMission(copy.refId);
|
||
if (ref && depth < 8) copy.resolved_mission = resolveMissionSnapshot(ref, depth + 1);
|
||
}
|
||
if (Array.isArray(copy.children)) {
|
||
copy.children = copy.children.map((child) => resolveActionSnapshot(child, depth));
|
||
}
|
||
return copy;
|
||
}
|
||
|
||
function resolveMissionSnapshot(mission, depth = 0) {
|
||
const copy = JSON.parse(JSON.stringify(mission));
|
||
copy.actions = (copy.actions || []).map((a) => resolveActionSnapshot(a, depth));
|
||
return copy;
|
||
}
|
||
|
||
function collectMissionVariables(mission) {
|
||
const vars = [];
|
||
walkActions(mission.actions, (action) => {
|
||
if (action.kind === "mission" || !action.params) return;
|
||
const defs = VARIABLE_FIELD_DEFS[action.type] || [];
|
||
defs.forEach((def) => {
|
||
if (!action.params[`${def.key}_var`]) return;
|
||
vars.push({
|
||
key: `${action.id}:${def.key}`,
|
||
label: `${action.label} — ${def.label}`,
|
||
options: def.options,
|
||
default: action.params[def.key] || def.options[0] || "",
|
||
});
|
||
});
|
||
});
|
||
return vars;
|
||
}
|
||
|
||
function formatQueueParameters(entry) {
|
||
const params = entry.parameters || {};
|
||
const keys = Object.keys(params);
|
||
if (!keys.length) return "";
|
||
return keys
|
||
.map((key) => `<span class="missionQueueParamVar">${escapeHtml(params[key])}</span>`)
|
||
.join(" • ");
|
||
}
|
||
|
||
function queueStatusLabel(status) {
|
||
const map = {
|
||
pending: "missions.queue.status.pending",
|
||
executing: "missions.queue.status.executing",
|
||
completed: "missions.queue.status.done",
|
||
failed: "missions.queue.status.error",
|
||
cancelled: "missions.queue.status.cancelled",
|
||
};
|
||
const key = map[status];
|
||
return key ? t(key) : status;
|
||
}
|
||
|
||
async function refreshQueue() {
|
||
if (window.AuthApp && !window.AuthApp.isReady()) return;
|
||
try {
|
||
const data = await missionApi("/api/mission_queue");
|
||
store.queue = Array.isArray(data.queue) ? data.queue : [];
|
||
store.runner = data.runner && typeof data.runner === "object" ? data.runner : { state: "idle", message: "" };
|
||
renderQueuePanel();
|
||
notifyQueueUpdate();
|
||
} catch (e) {
|
||
if (String(e.message || "").includes("not authenticated")) return;
|
||
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `${t("common.error", { msg: e.message })}`;
|
||
}
|
||
}
|
||
|
||
const queueListeners = new Set();
|
||
function notifyQueueUpdate() {
|
||
queueListeners.forEach((fn) => {
|
||
try {
|
||
fn(getQueueSnapshot());
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
});
|
||
}
|
||
|
||
function getQueueSnapshot() {
|
||
return {
|
||
queue: JSON.parse(JSON.stringify(store.queue)),
|
||
runner: JSON.parse(JSON.stringify(store.runner)),
|
||
};
|
||
}
|
||
|
||
function renderQueueInto(target, options = {}) {
|
||
const listEl = target.listEl;
|
||
const runnerEl = target.runnerEl;
|
||
const emptyEl = target.emptyEl;
|
||
const compact = !!options.compact;
|
||
if (!listEl) return;
|
||
|
||
listEl.innerHTML = "";
|
||
if (emptyEl) emptyEl.hidden = store.queue.length > 0;
|
||
|
||
if (runnerEl) {
|
||
const st = store.runner.state || "idle";
|
||
runnerEl.classList.toggle("running", st === "running" || st === "paused");
|
||
runnerEl.classList.toggle("paused", st === "paused");
|
||
const action = store.runner.current_action ? ` • ${store.runner.current_action}` : "";
|
||
runnerEl.textContent = store.runner.message
|
||
? `${store.runner.message}${action}`
|
||
: st === "idle"
|
||
? compact
|
||
? t("missions.queue.ready")
|
||
: t("missions.queue.idleMessage")
|
||
: "—";
|
||
}
|
||
|
||
store.queue.forEach((entry, index) => {
|
||
const row = document.createElement("div");
|
||
row.className = `missionQueueItem status-${entry.status || "pending"}${compact ? " compact" : ""}`;
|
||
const paramHtml = formatQueueParameters(entry);
|
||
const canReorder = entry.status === "pending" && !compact;
|
||
row.innerHTML = compact
|
||
? `
|
||
<div>
|
||
<div class="missionQueueItemTitle">${escapeHtml(entry.mission_name || "Mission")}</div>
|
||
<div class="missionQueueItemMeta">${queueStatusLabel(entry.status)} • #${index + 1}</div>
|
||
${paramHtml ? `<div class="missionQueueItemParams">${paramHtml}</div>` : ""}
|
||
</div>
|
||
<div class="missionQueueWidgetActions">
|
||
${entry.status === "pending" ? `<button type="button" class="iconBtn danger" data-queue-remove="${entry.id}" title="" data-i18n-title="common.delete">×</button>` : `<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>`}
|
||
</div>`
|
||
: `
|
||
<div class="missionQueueOrder">
|
||
<button type="button" class="iconBtn" data-queue-up="${entry.id}" title="${t("missions.queue.moveUp")}" ${canReorder && index > 0 ? "" : "disabled"}>↑</button>
|
||
<button type="button" class="iconBtn" data-queue-down="${entry.id}" title="${t("missions.queue.moveDown")}" ${canReorder && index < store.queue.length - 1 ? "" : "disabled"}>↓</button>
|
||
</div>
|
||
<div>
|
||
<div class="missionQueueItemTitle">${escapeHtml(entry.mission_name || "Mission")}</div>
|
||
<div class="missionQueueItemMeta">${escapeHtml(entry.mission_group || "")} • #${index + 1}</div>
|
||
${paramHtml ? `<div class="missionQueueItemParams">${paramHtml}</div>` : ""}
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px;">
|
||
<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>
|
||
${entry.status === "pending" ? `<button type="button" class="btn subtle danger" data-queue-remove="${entry.id}">${t("common.delete")}</button>` : ""}
|
||
</div>`;
|
||
|
||
row.querySelector("[data-queue-up]")?.addEventListener("click", () => moveQueueItem(entry.id, -1));
|
||
row.querySelector("[data-queue-down]")?.addEventListener("click", () => moveQueueItem(entry.id, 1));
|
||
row.querySelector("[data-queue-remove]")?.addEventListener("click", () => removeQueueItem(entry.id));
|
||
listEl.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function renderQueuePanel() {
|
||
renderQueueInto(
|
||
{
|
||
listEl: missionQueueListEl,
|
||
runnerEl: missionQueueRunnerEl,
|
||
emptyEl: missionQueueEmptyEl,
|
||
},
|
||
{ compact: false }
|
||
);
|
||
}
|
||
|
||
async function moveQueueItem(id, delta) {
|
||
const ids = store.queue.map((q) => q.id);
|
||
const idx = ids.indexOf(id);
|
||
if (idx < 0) return;
|
||
if (store.queue[idx].status !== "pending") return;
|
||
const next = idx + delta;
|
||
if (next < 0 || next >= store.queue.length) return;
|
||
if (store.queue[next].status !== "pending") return;
|
||
[ids[idx], ids[next]] = [ids[next], ids[idx]];
|
||
try {
|
||
await missionApi("/api/mission_queue/reorder", {
|
||
method: "PUT",
|
||
body: JSON.stringify({ ordered_ids: ids }),
|
||
});
|
||
await refreshQueue();
|
||
} catch (e) {
|
||
alert(e.message);
|
||
}
|
||
}
|
||
|
||
async function removeQueueItem(id) {
|
||
try {
|
||
await missionApi(`/api/mission_queue/${id}`, { method: "DELETE" });
|
||
await refreshQueue();
|
||
} catch (e) {
|
||
alert(e.message);
|
||
}
|
||
}
|
||
|
||
async function clearQueue() {
|
||
if (!confirm(t("missions.queue.clearConfirm"))) return;
|
||
try {
|
||
await missionApi("/api/mission_queue", { method: "DELETE" });
|
||
await refreshQueue();
|
||
} catch (e) {
|
||
alert(e.message);
|
||
}
|
||
}
|
||
|
||
async function enqueueMission(missionId, parameters = {}) {
|
||
const mission = findMission(missionId);
|
||
if (!mission) throw new Error("Mission không tồn tại");
|
||
const payload = {
|
||
mission: resolveMissionSnapshot(mission),
|
||
parameters,
|
||
};
|
||
await missionApi("/api/mission_queue", { method: "POST", body: JSON.stringify(payload) });
|
||
await refreshQueue();
|
||
}
|
||
|
||
async function pauseRunner() {
|
||
await missionApi("/api/mission_queue/pause", { method: "POST", body: "{}" });
|
||
await refreshQueue();
|
||
}
|
||
|
||
async function continueRunner() {
|
||
await missionApi("/api/mission_queue/continue", { method: "POST", body: "{}" });
|
||
await refreshQueue();
|
||
}
|
||
|
||
async function cancelRunner() {
|
||
if (!confirm(t("missions.queue.cancelConfirm"))) return;
|
||
await missionApi("/api/mission_queue/cancel", { method: "POST", body: "{}" });
|
||
await refreshQueue();
|
||
}
|
||
|
||
function openQueueDialog(missionId) {
|
||
const mission = findMission(missionId);
|
||
if (!mission) return;
|
||
store.pendingQueueMissionId = missionId;
|
||
const vars = collectMissionVariables(mission);
|
||
if (missionQueueDialogMissionEl) {
|
||
missionQueueDialogMissionEl.textContent = `Mission: ${mission.name}`;
|
||
}
|
||
if (missionQueueVarFieldsEl) {
|
||
missionQueueVarFieldsEl.innerHTML = "";
|
||
if (!vars.length) {
|
||
missionQueueVarFieldsEl.innerHTML = `<p class="mutedNote">Mission không có tham số biến. Bấm «Thêm vào queue» để chạy.</p>`;
|
||
} else {
|
||
vars.forEach((v) => {
|
||
const row = document.createElement("div");
|
||
row.className = "row rowWide";
|
||
const lab = document.createElement("label");
|
||
lab.textContent = v.label;
|
||
const sel = document.createElement("select");
|
||
sel.dataset.varKey = v.key;
|
||
v.options.forEach((opt) => {
|
||
const o = document.createElement("option");
|
||
o.value = opt;
|
||
o.textContent = opt;
|
||
if (opt === v.default) o.selected = true;
|
||
sel.appendChild(o);
|
||
});
|
||
row.appendChild(lab);
|
||
row.appendChild(sel);
|
||
missionQueueVarFieldsEl.appendChild(row);
|
||
});
|
||
}
|
||
}
|
||
if (missionQueueVarHintEl) missionQueueVarHintEl.hidden = vars.length === 0;
|
||
missionQueueDialogEl.showModal();
|
||
}
|
||
|
||
async function submitQueueDialog(evt) {
|
||
evt.preventDefault();
|
||
const mission = findMission(store.pendingQueueMissionId);
|
||
if (!mission) return;
|
||
const parameters = {};
|
||
missionQueueVarFieldsEl?.querySelectorAll("[data-var-key]").forEach((sel) => {
|
||
parameters[sel.dataset.varKey] = sel.value;
|
||
});
|
||
const payload = {
|
||
mission: resolveMissionSnapshot(mission),
|
||
parameters,
|
||
};
|
||
try {
|
||
await missionApi("/api/mission_queue", { method: "POST", body: JSON.stringify(payload) });
|
||
missionQueueDialogEl.close();
|
||
store.pendingQueueMissionId = null;
|
||
await refreshQueue();
|
||
} catch (e) {
|
||
alert(e.message);
|
||
}
|
||
}
|
||
|
||
function startQueuePoll() {
|
||
if (window.AuthApp && !window.AuthApp.isReady()) return;
|
||
queuePollRefs += 1;
|
||
if (queuePollRefs === 1) {
|
||
refreshQueue();
|
||
queuePollTimer = setInterval(refreshQueue, 1500);
|
||
}
|
||
}
|
||
|
||
function stopQueuePoll() {
|
||
if (queuePollRefs <= 0) return;
|
||
queuePollRefs -= 1;
|
||
if (queuePollRefs === 0 && queuePollTimer) {
|
||
clearInterval(queuePollTimer);
|
||
queuePollTimer = null;
|
||
}
|
||
}
|
||
|
||
function stopQueuePollForce() {
|
||
queuePollRefs = 0;
|
||
if (queuePollTimer) {
|
||
clearInterval(queuePollTimer);
|
||
queuePollTimer = null;
|
||
}
|
||
}
|
||
|
||
function renderMissionList() {
|
||
if (!missionListEl) return;
|
||
missionListEl.innerHTML = "";
|
||
const items = [...store.missions].sort((a, b) => a.name.localeCompare(b.name));
|
||
missionListEmptyEl.hidden = items.length > 0;
|
||
|
||
items.forEach((mission) => {
|
||
const row = document.createElement("div");
|
||
row.className = "missionListItem";
|
||
row.innerHTML = `
|
||
<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="iconBtn missionQueueBtn" data-queue="${mission.id}" title="Thêm vào mission queue" aria-label="Thêm vào queue">▤</button>
|
||
<button type="button" class="btn subtle" data-edit="${mission.id}">${t("common.edit")}</button>
|
||
<button type="button" class="btn subtle danger" data-delete="${mission.id}">${t("common.delete")}</button>
|
||
</div>`;
|
||
row.addEventListener("click", (evt) => {
|
||
if (evt.target.closest("button")) return;
|
||
openEditor(mission.id);
|
||
});
|
||
row.querySelector("[data-queue]").addEventListener("click", (evt) => {
|
||
evt.stopPropagation();
|
||
openQueueDialog(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(t("missions.deleteConfirm", { name: mission.name }))) return;
|
||
store.missions = store.missions.filter((m) => m.id !== mission.id);
|
||
persistStore();
|
||
renderMissionList();
|
||
});
|
||
missionListEl.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function groupLabel(name) {
|
||
const key = `missions.group.${name}`;
|
||
const v = t(key);
|
||
return v !== key ? v : name;
|
||
}
|
||
|
||
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.draggable = true;
|
||
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");
|
||
});
|
||
item.addEventListener("dragstart", (evt) => {
|
||
onPaletteDragStart(evt, { actionType: def.type });
|
||
});
|
||
item.addEventListener("dragend", onDragEnd);
|
||
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.draggable = true;
|
||
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");
|
||
});
|
||
item.addEventListener("dragstart", (evt) => {
|
||
onPaletteDragStart(evt, { missionId: m.id });
|
||
});
|
||
item.addEventListener("dragend", onDragEnd);
|
||
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="missionActionTop">
|
||
<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>
|
||
<div class="missionActionBtns">
|
||
<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="" data-i18n-title="common.delete">×</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
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.appendChild(loop);
|
||
}
|
||
|
||
row.querySelector("[data-config]").addEventListener("click", (evt) => {
|
||
evt.stopPropagation();
|
||
openActionConfig(action.id);
|
||
});
|
||
row.querySelector("[data-remove]").addEventListener("click", (evt) => {
|
||
evt.stopPropagation();
|
||
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);
|
||
}
|
||
|
||
function onPaletteDragStart(evt, payload) {
|
||
store.drag = { mode: "palette", ...payload };
|
||
evt.dataTransfer.effectAllowed = "copy";
|
||
evt.dataTransfer.setData("text/plain", payload.actionType || payload.missionId || "palette");
|
||
evt.currentTarget.classList.add("dragging");
|
||
}
|
||
|
||
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(".missionPaletteItem.dragging").forEach((n) => n.classList.remove("dragging"));
|
||
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));
|
||
normalizeActionTree(store.draft.actions);
|
||
setDirty(false);
|
||
missionsListViewEl.hidden = true;
|
||
missionEditorViewEl.hidden = false;
|
||
renderMissionEditor();
|
||
}
|
||
|
||
function closeEditor() {
|
||
if (store.dirty && !confirm(t("missions.editor.discardConfirm"))) 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("missions.error.nameRequired"));
|
||
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("missions.error.nameDuplicate"));
|
||
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;
|
||
};
|
||
|
||
const addVariableToggle = (paramKey, fieldLabel) => {
|
||
const chk = document.createElement("label");
|
||
chk.innerHTML = `<input type="checkbox" data-param="${paramKey}_var" ${p[`${paramKey}_var`] ? "checked" : ""} /> Biến — hỏi khi thêm vào queue`;
|
||
addField(`${fieldLabel} (biến)`, chk);
|
||
};
|
||
|
||
switch (action.type) {
|
||
case "move_to_position":
|
||
case "adjust_localization":
|
||
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
||
addVariableToggle("position", "Position");
|
||
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));
|
||
addVariableToggle("marker", "Marker");
|
||
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));
|
||
addVariableToggle("position", "Position");
|
||
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" : ""} /> ${t("missions.action.waitOnLevel")}`;
|
||
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));
|
||
addVariableToggle("position", "Position");
|
||
addField("Cart", selectInput("cart", p.cart, SAMPLE_CARTS));
|
||
addVariableToggle("cart", "Cart");
|
||
break;
|
||
case "drop_cart":
|
||
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
||
addVariableToggle("position", "Position");
|
||
{
|
||
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("missions.error.nameDuplicate"));
|
||
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(t("missions.saveSuccess"));
|
||
});
|
||
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("missions.error.nameEmpty"));
|
||
return;
|
||
}
|
||
setDirty(true);
|
||
missionSettingsDialogEl.close();
|
||
renderMissionEditor();
|
||
});
|
||
|
||
el("missionSaveAsForm")?.addEventListener("submit", (evt) => {
|
||
evt.preventDefault();
|
||
if (saveDraftAs(el("missionSaveAsName").value.trim())) {
|
||
missionSaveAsDialogEl.close();
|
||
}
|
||
});
|
||
|
||
el("missionActionConfigForm")?.addEventListener("submit", (evt) => {
|
||
evt.preventDefault();
|
||
applyActionConfig();
|
||
missionActionConfigDialogEl.close();
|
||
});
|
||
|
||
document.querySelectorAll("[data-close-dialog]").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const id = btn.getAttribute("data-close-dialog");
|
||
el(id)?.close();
|
||
});
|
||
});
|
||
|
||
document.addEventListener("click", (evt) => {
|
||
if (!evt.target.closest(".missionGroupTab")) closeAllPaletteMenus();
|
||
});
|
||
|
||
el("missionQueueClearBtn")?.addEventListener("click", clearQueue);
|
||
el("missionQueueCancelBtn")?.addEventListener("click", () => {
|
||
cancelRunner().catch((e) => alert(e.message));
|
||
});
|
||
el("missionQueueForm")?.addEventListener("submit", submitQueueDialog);
|
||
}
|
||
|
||
async function init() {
|
||
loadStore();
|
||
await loadStoreFromBackend();
|
||
bindEvents();
|
||
renderMissionList();
|
||
}
|
||
|
||
window.MissionsApp = {
|
||
init,
|
||
getMissions: () => [...store.missions],
|
||
getGroups: () => allGroups(),
|
||
getMissionById: findMission,
|
||
queueMission: openQueueDialog,
|
||
enqueueMission,
|
||
pauseRunner,
|
||
continueRunner,
|
||
cancelRunner,
|
||
refreshQueue,
|
||
clearQueue,
|
||
getQueueSnapshot,
|
||
renderQueueInto,
|
||
onQueueUpdate(fn) {
|
||
queueListeners.add(fn);
|
||
return () => queueListeners.delete(fn);
|
||
},
|
||
startQueuePoll,
|
||
stopQueuePoll,
|
||
onPageShow() {
|
||
if (!missionEditorViewEl?.hidden) renderMissionEditor();
|
||
else {
|
||
renderMissionList();
|
||
startQueuePoll();
|
||
}
|
||
},
|
||
onPageHide() {
|
||
stopQueuePoll();
|
||
},
|
||
};
|
||
|
||
function boot() {
|
||
init();
|
||
}
|
||
function onLocaleChange() {
|
||
if (!missionEditorViewEl?.hidden) renderMissionEditor();
|
||
else {
|
||
renderMissionList();
|
||
renderQueuePanel();
|
||
}
|
||
renderActionPalette();
|
||
}
|
||
window.addEventListener("lm:locale-change", onLocaleChange);
|
||
|
||
if (window.AuthApp?.isReady()) boot();
|
||
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||
window.addEventListener("lm:auth-logout", stopQueuePollForce);
|
||
})();
|