excuting misstion from queue

This commit is contained in:
2026-06-13 12:21:29 +07:00
parent 7c505e919c
commit c116b30bea
14 changed files with 1089 additions and 1145 deletions

View File

@@ -56,6 +56,25 @@
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: [],
@@ -66,6 +85,10 @@
drag: null,
configActionId: null,
configListPath: "root",
queue: [],
runner: { state: "idle", message: "" },
queuePollTimer: null,
pendingQueueMissionId: null,
};
function newId() {
@@ -209,11 +232,12 @@
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: ${p.position}${p.check_free ? " • kiểm tra trống" : ""}`;
return `Position: ${fmtVar("position", p.position)}${p.check_free ? " • kiểm tra trống" : ""}`;
case "move_to_marker":
return `Marker: ${p.marker}`;
return `Marker: ${fmtVar("marker", p.marker)}`;
case "wait":
return `${p.seconds}s`;
case "set_speed":
@@ -230,7 +254,7 @@
return `Reg ${p.register}: ${p.action} ${p.value}`;
case "pick_cart":
case "drop_cart":
return `${action.type === "pick_cart" ? "Pick" : "Drop"} @ ${p.position}`;
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":
@@ -323,6 +347,253 @@
renderMissionEditor();
}
async function missionApi(path, opts = {}) {
const res = await fetch(path, {
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: "Chờ",
executing: "Đang chạy",
completed: "Xong",
failed: "Lỗi",
};
return map[status] || status;
}
async function refreshQueue() {
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();
} catch (e) {
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `Không tải được queue: ${e.message}`;
}
}
function renderQueuePanel() {
if (!missionQueueListEl) return;
missionQueueListEl.innerHTML = "";
if (missionQueueEmptyEl) missionQueueEmptyEl.hidden = store.queue.length > 0;
if (missionQueueRunnerEl) {
const st = store.runner.state || "idle";
missionQueueRunnerEl.classList.toggle("running", st === "running");
const action = store.runner.current_action ? `${store.runner.current_action}` : "";
missionQueueRunnerEl.textContent = store.runner.message
? `${store.runner.message}${action}`
: st === "idle"
? "Robot sẵn sàng — queue trống hoặc chờ mission mới."
: "—";
}
store.queue.forEach((entry, index) => {
const row = document.createElement("div");
row.className = `missionQueueItem status-${entry.status || "pending"}`;
const paramHtml = formatQueueParameters(entry);
const canReorder = entry.status === "pending";
row.innerHTML = `
<div class="missionQueueOrder">
<button type="button" class="iconBtn" data-queue-up="${entry.id}" title="Lên" ${canReorder && index > 0 ? "" : "disabled"}>↑</button>
<button type="button" class="iconBtn" data-queue-down="${entry.id}" title="Xuống" ${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}">Xóa</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));
missionQueueListEl.appendChild(row);
});
}
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("Xóa các mission đang chờ trong queue?")) return;
try {
await missionApi("/api/mission_queue", { method: "DELETE" });
await refreshQueue();
} catch (e) {
alert(e.message);
}
}
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() {
stopQueuePoll();
refreshQueue();
store.queuePollTimer = setInterval(refreshQueue, 1500);
}
function stopQueuePoll() {
if (store.queuePollTimer) {
clearInterval(store.queuePollTimer);
store.queuePollTimer = null;
}
}
function renderMissionList() {
if (!missionListEl) return;
missionListEl.innerHTML = "";
@@ -338,6 +609,7 @@
<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}">Sửa</button>
<button type="button" class="btn subtle danger" data-delete="${mission.id}">Xóa</button>
</div>`;
@@ -345,6 +617,10 @@
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);
@@ -722,10 +998,17 @@
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`;
@@ -734,6 +1017,7 @@
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"));
@@ -748,6 +1032,7 @@
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));
@@ -775,10 +1060,13 @@
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`;
@@ -891,6 +1179,9 @@
document.addEventListener("click", (evt) => {
if (!evt.target.closest(".missionGroupTab")) closeAllPaletteMenus();
});
el("missionQueueClearBtn")?.addEventListener("click", clearQueue);
el("missionQueueForm")?.addEventListener("submit", submitQueueDialog);
}
function init() {
@@ -903,7 +1194,13 @@
init,
onPageShow() {
if (!missionEditorViewEl?.hidden) renderMissionEditor();
else renderMissionList();
else {
renderMissionList();
startQueuePoll();
}
},
onPageHide() {
stopQueuePoll();
},
};