excuting misstion from queue
This commit is contained in:
@@ -142,6 +142,7 @@ function setActivePage(page) {
|
||||
}
|
||||
if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config";
|
||||
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
|
||||
else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide();
|
||||
try {
|
||||
localStorage.setItem("activePage", p);
|
||||
} catch {
|
||||
|
||||
322
www/dashboard.js
322
www/dashboard.js
@@ -1,322 +0,0 @@
|
||||
(() => {
|
||||
const el = (id) => document.getElementById(id);
|
||||
const canvasEl = el("dashboardCanvas");
|
||||
const saveBtn = el("dashboardSaveBtn");
|
||||
const modeSelectEl = el("dashboardModeSelect");
|
||||
|
||||
const store = {
|
||||
widgets: [],
|
||||
pollTimer: null,
|
||||
};
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
if (window.MissionsApp?.missionsApi) return window.MissionsApp.missionsApi(path, opts);
|
||||
const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...opts });
|
||||
if (res.status === 204) return null;
|
||||
const data = res.ok ? await res.json() : null;
|
||||
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function newWidgetId() {
|
||||
return `w_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
const data = await api("/api/dashboard");
|
||||
store.widgets = Array.isArray(data.widgets) ? data.widgets : [];
|
||||
if (!store.widgets.length) {
|
||||
store.widgets = [
|
||||
{ id: newWidgetId(), type: "mission_queue" },
|
||||
{ id: newWidgetId(), type: "pause_continue" },
|
||||
{ id: newWidgetId(), type: "action_log" },
|
||||
];
|
||||
}
|
||||
renderCanvas();
|
||||
}
|
||||
|
||||
async function saveDashboard() {
|
||||
await api("/api/dashboard", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ widgets: store.widgets }),
|
||||
});
|
||||
}
|
||||
|
||||
function missions() {
|
||||
return window.MissionsApp?.getMissions?.() || [];
|
||||
}
|
||||
|
||||
function groups() {
|
||||
const g = new Set();
|
||||
missions().forEach((m) => g.add(m.group || "Missions"));
|
||||
return [...g].sort();
|
||||
}
|
||||
|
||||
function renderCanvas() {
|
||||
if (!canvasEl) return;
|
||||
canvasEl.innerHTML = "";
|
||||
if (!store.widgets.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "mutedNote";
|
||||
empty.textContent = "Thêm widget từ panel trái.";
|
||||
canvasEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
store.widgets.forEach((widget) => {
|
||||
canvasEl.appendChild(buildWidget(widget));
|
||||
});
|
||||
}
|
||||
|
||||
function buildWidget(widget) {
|
||||
const box = document.createElement("div");
|
||||
box.className = "dashboardWidget";
|
||||
box.dataset.widgetId = widget.id;
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "dashboardWidgetHead";
|
||||
head.innerHTML = `<span class="dashboardWidgetTitle">${widget.type.replace(/_/g, " ")}</span>`;
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.className = "dashboardWidgetRemove";
|
||||
removeBtn.textContent = "×";
|
||||
removeBtn.addEventListener("click", () => {
|
||||
store.widgets = store.widgets.filter((w) => w.id !== widget.id);
|
||||
renderCanvas();
|
||||
});
|
||||
head.appendChild(removeBtn);
|
||||
box.appendChild(head);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "dashboardWidgetBody";
|
||||
|
||||
switch (widget.type) {
|
||||
case "mission_button":
|
||||
renderMissionButton(body, widget);
|
||||
break;
|
||||
case "mission_group":
|
||||
renderMissionGroup(body, widget);
|
||||
break;
|
||||
case "mission_queue":
|
||||
renderMissionQueue(body);
|
||||
break;
|
||||
case "pause_continue":
|
||||
renderPauseContinue(body);
|
||||
break;
|
||||
case "action_log":
|
||||
renderActionLog(body);
|
||||
break;
|
||||
default:
|
||||
body.textContent = "Unknown widget";
|
||||
}
|
||||
box.appendChild(body);
|
||||
return box;
|
||||
}
|
||||
|
||||
function renderMissionButton(container, widget) {
|
||||
const select = document.createElement("select");
|
||||
missions().forEach((m) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.name;
|
||||
if (m.id === widget.mission_id) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
select.addEventListener("change", () => {
|
||||
widget.mission_id = select.value;
|
||||
});
|
||||
container.appendChild(select);
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "dashboardMissionBtn";
|
||||
btn.textContent = "Start mission";
|
||||
btn.addEventListener("click", async () => {
|
||||
try {
|
||||
if (window.MissionsApp?.queueMission) {
|
||||
await window.MissionsApp.queueMission(select.value, {});
|
||||
} else {
|
||||
await api("/api/mission_queue", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ mission_id: select.value, parameters: {} }),
|
||||
});
|
||||
}
|
||||
await refreshWidgetsDynamic();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
|
||||
function renderMissionGroup(container, widget) {
|
||||
const select = document.createElement("select");
|
||||
groups().forEach((g) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = g;
|
||||
opt.textContent = g;
|
||||
if (g === (widget.group || "Missions")) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
select.addEventListener("change", () => {
|
||||
widget.group = select.value;
|
||||
renderCanvas();
|
||||
});
|
||||
container.appendChild(select);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "dashboardMissionGroup";
|
||||
missions()
|
||||
.filter((m) => (m.group || "Missions") === (widget.group || select.value))
|
||||
.forEach((m) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "btn subtle btnBlock";
|
||||
btn.textContent = m.name;
|
||||
btn.addEventListener("click", async () => {
|
||||
try {
|
||||
await window.MissionsApp.queueMission(m.id, {});
|
||||
await refreshWidgetsDynamic();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
});
|
||||
list.appendChild(btn);
|
||||
});
|
||||
container.appendChild(list);
|
||||
}
|
||||
|
||||
function renderMissionQueue(container) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "dashboardQueueMini";
|
||||
wrap.dataset.dynamic = "queue";
|
||||
container.appendChild(wrap);
|
||||
updateQueueMini(wrap);
|
||||
}
|
||||
|
||||
function renderPauseContinue(container) {
|
||||
const play = document.createElement("button");
|
||||
play.type = "button";
|
||||
play.className = "btn primary dashboardPauseBtn";
|
||||
play.textContent = "▶ Continue";
|
||||
play.addEventListener("click", async () => {
|
||||
await api("/api/mission_runner/state", { method: "PUT", body: JSON.stringify({ state_id: 3 }) });
|
||||
await refreshWidgetsDynamic();
|
||||
});
|
||||
const pause = document.createElement("button");
|
||||
pause.type = "button";
|
||||
pause.className = "btn subtle dashboardPauseBtn";
|
||||
pause.textContent = "⏸ Pause";
|
||||
pause.addEventListener("click", async () => {
|
||||
await api("/api/mission_runner/state", { method: "PUT", body: JSON.stringify({ state_id: 4 }) });
|
||||
await refreshWidgetsDynamic();
|
||||
});
|
||||
container.appendChild(play);
|
||||
container.appendChild(pause);
|
||||
}
|
||||
|
||||
function renderActionLog(container) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "dashboardQueueMini";
|
||||
wrap.dataset.dynamic = "log";
|
||||
container.appendChild(wrap);
|
||||
updateActionLogMini(wrap);
|
||||
}
|
||||
|
||||
function updateQueueMini(node) {
|
||||
const status = window.MissionsApp?.getRunnerStatus?.();
|
||||
const queue = status?.queue || [];
|
||||
node.innerHTML = queue.length
|
||||
? queue
|
||||
.map(
|
||||
(q) =>
|
||||
`<div><strong>${q.mission_name || q.mission_id}</strong> <span class="mutedNote">${q.state}</span></div>`
|
||||
)
|
||||
.join("")
|
||||
: `<span class="mutedNote">Queue trống</span>`;
|
||||
}
|
||||
|
||||
function updateActionLogMini(node) {
|
||||
const status = window.MissionsApp?.getRunnerStatus?.();
|
||||
const log = status?.action_log || [];
|
||||
node.innerHTML = [...log]
|
||||
.reverse()
|
||||
.slice(0, 12)
|
||||
.map((row) => `<div>${row.message || ""}</div>`)
|
||||
.join("") || `<span class="mutedNote">—</span>`;
|
||||
}
|
||||
|
||||
async function refreshWidgetsDynamic() {
|
||||
if (window.MissionsApp?.refreshRunnerStatus) await window.MissionsApp.refreshRunnerStatus();
|
||||
canvasEl?.querySelectorAll("[data-dynamic=queue]").forEach(updateQueueMini);
|
||||
canvasEl?.querySelectorAll("[data-dynamic=log]").forEach(updateActionLogMini);
|
||||
}
|
||||
|
||||
function addWidget(type) {
|
||||
const widget = { id: newWidgetId(), type };
|
||||
if (type === "mission_button") {
|
||||
widget.mission_id = missions()[0]?.id || "";
|
||||
}
|
||||
if (type === "mission_group") {
|
||||
widget.group = groups()[0] || "Missions";
|
||||
}
|
||||
store.widgets.push(widget);
|
||||
renderCanvas();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.querySelectorAll(".dashboardAddWidget").forEach((btn) => {
|
||||
btn.addEventListener("click", () => addWidget(btn.dataset.widget));
|
||||
});
|
||||
saveBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await saveDashboard();
|
||||
alert("Đã lưu dashboard.");
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
});
|
||||
modeSelectEl?.addEventListener("change", async () => {
|
||||
try {
|
||||
await api("/api/mission_runner/mode", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ mode: modeSelectEl.value }),
|
||||
});
|
||||
await refreshWidgetsDynamic();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (store.pollTimer) clearInterval(store.pollTimer);
|
||||
store.pollTimer = setInterval(() => {
|
||||
const page = el("pageDashboard");
|
||||
if (page && !page.hidden) refreshWidgetsDynamic();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
bindEvents();
|
||||
try {
|
||||
await loadDashboard();
|
||||
} catch {
|
||||
store.widgets = [
|
||||
{ id: newWidgetId(), type: "mission_queue" },
|
||||
{ id: newWidgetId(), type: "pause_continue" },
|
||||
];
|
||||
renderCanvas();
|
||||
}
|
||||
startPolling();
|
||||
}
|
||||
|
||||
window.DashboardApp = {
|
||||
init,
|
||||
onPageShow() {
|
||||
loadDashboard().catch(() => renderCanvas());
|
||||
refreshWidgetsDynamic();
|
||||
},
|
||||
};
|
||||
|
||||
init();
|
||||
})();
|
||||
@@ -532,6 +532,21 @@
|
||||
<div id="missionList" class="missionList"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="missionQueueCard">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">Mission queue</div>
|
||||
<div class="cardSub">Thêm mission bằng biểu tượng queue — robot chạy theo thứ tự từ trên xuống.</div>
|
||||
</div>
|
||||
<button id="missionQueueClearBtn" type="button" class="btn subtle danger">Xóa queue</button>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<div id="missionQueueRunner" class="missionQueueRunner mutedNote">—</div>
|
||||
<div id="missionQueueEmpty" class="mutedNote">Queue trống. Bấm <span class="mono">▤</span> trên mission để thêm.</div>
|
||||
<div id="missionQueueList" class="missionQueueList"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="missionEditorView" class="missionsPage" hidden>
|
||||
@@ -687,6 +702,24 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="missionQueueDialog" class="missionDialog">
|
||||
<form id="missionQueueForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3>Thêm vào mission queue</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionQueueDialog" aria-label="Đóng">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<p id="missionQueueDialogMission" class="mutedNote">—</p>
|
||||
<div id="missionQueueVarFields" class="missionConfigGrid"></div>
|
||||
<p id="missionQueueVarHint" class="mutedNote" hidden>Tham số đã chọn sẽ hiển thị màu xanh trong queue.</p>
|
||||
</div>
|
||||
<div class="missionDialogFooter">
|
||||
<button type="button" class="btn subtle" data-close-dialog="missionQueueDialog">Hủy</button>
|
||||
<button type="submit" class="btn primary">Thêm vào queue</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="/missions.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
|
||||
305
www/missions.js
305
www/missions.js
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -569,8 +569,68 @@ canvas {
|
||||
}
|
||||
.missionListItemTitle { font-weight: 700; font-size: 14px; }
|
||||
.missionListItemMeta { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||
.missionListItemActions { display: flex; gap: 8px; }
|
||||
.missionListItemActions { display: flex; gap: 8px; align-items: center; }
|
||||
.missionListItemActions .btn { padding: 6px 10px; font-size: 12px; }
|
||||
.missionQueueBtn {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
.missionQueueBtn:hover {
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.missionQueueList { display: grid; gap: 8px; margin-top: 12px; }
|
||||
.missionQueueItem {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
}
|
||||
.missionQueueItem.status-executing {
|
||||
border-color: rgba(37, 99, 235, 0.45);
|
||||
background: #eff6ff;
|
||||
}
|
||||
.missionQueueItem.status-completed { opacity: 0.72; }
|
||||
.missionQueueItem.status-failed {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: #fef2f2;
|
||||
}
|
||||
.missionQueueOrder { display: flex; flex-direction: column; gap: 4px; }
|
||||
.missionQueueOrder .iconBtn { width: 28px; height: 28px; font-size: 12px; }
|
||||
.missionQueueItemTitle { font-weight: 700; font-size: 13px; }
|
||||
.missionQueueItemMeta { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||
.missionQueueItemParams { font-size: 12px; margin-top: 6px; }
|
||||
.missionQueueParamVar { color: var(--accent); font-weight: 600; }
|
||||
.missionQueueStatus {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.missionQueueStatus.executing { background: #dbeafe; color: #1d4ed8; }
|
||||
.missionQueueStatus.completed { background: #d1fae5; color: #047857; }
|
||||
.missionQueueStatus.failed { background: #fee2e2; color: #b91c1c; }
|
||||
.missionQueueRunner {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel2);
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.missionQueueRunner.running { border-color: rgba(37, 99, 235, 0.35); background: #eff6ff; color: #1e3a8a; }
|
||||
.missionVarHint { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||||
|
||||
.missionEditorCard { overflow: hidden; }
|
||||
.missionEditorTop {
|
||||
|
||||
Reference in New Issue
Block a user