Files
App/www/dashboard.js
HiepLM 4b372100eb
Some checks failed
Test / test (push) Has been cancelled
update mission cancel
2026-06-15 10:30:00 +07:00

391 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
const STORAGE_KEY = "phenikaax_dashboard_v1";
const WIDGET_LABELS = {
mission_button: "Mission button",
mission_group: "Mission group",
mission_queue: "Mission queue",
pause_continue: "Pause / Continue",
};
const el = (id) => document.getElementById(id);
const gridEl = el("dashboardGrid");
const emptyEl = el("dashboardEmpty");
const addDialogEl = el("dashboardAddWidgetDialog");
const editDialogEl = el("dashboardEditWidgetDialog");
const addTypeEl = el("dashboardWidgetType");
const addFieldsEl = el("dashboardAddWidgetFields");
const editFieldsEl = el("dashboardEditWidgetFields");
const editWidgetIdEl = el("dashboardEditWidgetId");
const editWidgetTypeEl = el("dashboardEditWidgetType");
const store = {
widgets: [],
editMode: false,
pollTimer: null,
queueUnsub: null,
};
function newId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
return `w_${Date.now().toString(36)}`;
}
function missions() {
return window.MissionsApp || null;
}
function loadStore() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
bootstrapDefaults();
return;
}
const data = JSON.parse(raw);
store.widgets = Array.isArray(data.widgets) ? data.widgets : [];
if (!store.widgets.length) bootstrapDefaults();
} catch {
bootstrapDefaults();
}
}
function persistStore() {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ widgets: store.widgets }));
}
function bootstrapDefaults() {
const m = missions()?.getMissions?.() || [];
const firstId = m[0]?.id || "";
store.widgets = [
{ id: newId(), type: "mission_button", mission_id: firstId, title: "" },
{ id: newId(), type: "mission_group", group: "Missions", title: "" },
{ id: newId(), type: "mission_queue", title: "Mission queue" },
{ id: newId(), type: "pause_continue", title: "" },
];
persistStore();
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function widgetTitle(widget) {
if (widget.title) return widget.title;
return WIDGET_LABELS[widget.type] || widget.type;
}
function missionOptions(selected) {
const list = missions()?.getMissions?.() || [];
return list
.map(
(m) =>
`<option value="${escapeHtml(m.id)}" ${m.id === selected ? "selected" : ""}>${escapeHtml(m.name)} (${escapeHtml(m.group)})</option>`
)
.join("");
}
function groupOptions(selected) {
const groups = missions()?.getGroups?.() || ["Missions"];
return groups
.map((g) => `<option value="${escapeHtml(g)}" ${g === selected ? "selected" : ""}>${escapeHtml(g)}</option>`)
.join("");
}
function fillTypeFields(container, type, widget = {}) {
if (!container) return;
container.innerHTML = "";
if (type === "mission_button") {
container.innerHTML = `
<div class="row rowWide">
<label>Mission</label>
<select data-field="mission_id">${missionOptions(widget.mission_id || "")}</select>
</div>
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="VD: Go to charging" />
</div>`;
} else if (type === "mission_group") {
container.innerHTML = `
<div class="row rowWide">
<label>Nhóm mission</label>
<select data-field="group">${groupOptions(widget.group || "Missions")}</select>
</div>
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" />
</div>`;
} else if (type === "mission_queue") {
container.innerHTML = `
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "Mission queue")}" />
</div>`;
} else if (type === "pause_continue") {
container.innerHTML = `
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" />
</div>
<p class="mutedNote">Tạm dừng / tiếp tục / hủy mission đang chạy trên robot.</p>`;
}
}
function readFields(container) {
const out = {};
container?.querySelectorAll("[data-field]").forEach((node) => {
out[node.dataset.field] = node.value;
});
return out;
}
function renderMissionButtonWidget(widget, bodyEl) {
const m = missions()?.getMissionById?.(widget.mission_id);
const label = m?.name || "Chọn mission…";
bodyEl.innerHTML = `
<button type="button" class="dashboardMissionBtn" data-run-mission="${escapeHtml(widget.mission_id || "")}">
<span class="dashboardMissionBtnIcon">▶</span>
<span>${escapeHtml(label)}</span>
</button>
${!m ? `<p class="mutedNote dashboardWidgetHint">Cấu hình widget và chọn mission.</p>` : ""}`;
bodyEl.querySelector("[data-run-mission]")?.addEventListener("click", () => {
if (!widget.mission_id) return;
missions()?.queueMission?.(widget.mission_id);
});
}
function renderMissionGroupWidget(widget, bodyEl) {
const group = widget.group || "Missions";
const list = (missions()?.getMissions?.() || []).filter((m) => m.group === group);
if (!list.length) {
bodyEl.innerHTML = `<p class="mutedNote">Không có mission trong nhóm «${escapeHtml(group)}».</p>`;
return;
}
bodyEl.innerHTML = `<div class="dashboardMissionGroupList"></div>`;
const listEl = bodyEl.querySelector(".dashboardMissionGroupList");
list.forEach((m) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "dashboardMissionGroupBtn";
btn.innerHTML = `<span class="dashboardMissionBtnIcon">▶</span><span>${escapeHtml(m.name)}</span>`;
btn.addEventListener("click", () => missions()?.queueMission?.(m.id));
listEl.appendChild(btn);
});
}
function renderMissionQueueWidget(widget, bodyEl) {
bodyEl.innerHTML = `
<div class="dashboardQueueRunner mutedNote" data-role="runner">—</div>
<div class="dashboardQueueList" data-role="list"></div>
<p class="mutedNote dashboardQueueEmpty" data-role="empty">Queue trống</p>
<button type="button" class="btn subtle btnBlock dashboardQueueClear">Xóa queue chờ</button>`;
bodyEl.querySelector(".dashboardQueueClear")?.addEventListener("click", () => missions()?.clearQueue?.());
refreshQueueWidget(bodyEl);
}
function refreshQueueWidget(bodyEl) {
const snap = missions()?.getQueueSnapshot?.();
if (!snap) return;
missions()?.renderQueueInto?.(
{
listEl: bodyEl.querySelector('[data-role="list"]'),
runnerEl: bodyEl.querySelector('[data-role="runner"]'),
emptyEl: bodyEl.querySelector('[data-role="empty"]'),
},
{ compact: true }
);
}
function renderPauseContinueWidget(widget, bodyEl) {
const snap = missions()?.getQueueSnapshot?.();
const state = snap?.runner?.state || "idle";
const paused = state === "paused" || snap?.runner?.paused;
const running = state === "running" || paused;
bodyEl.innerHTML = `
<div class="dashboardRunnerControls">
<button type="button" class="dashboardPauseBtn ${paused ? "is-paused" : ""}" data-pause-action="${paused ? "continue" : "pause"}" ${running ? "" : "disabled"}>
${paused ? "Continue" : "Pause"}
</button>
<button type="button" class="dashboardCancelBtn" data-cancel-mission ${running ? "" : "disabled"}>
Hủy mission
</button>
</div>
<p class="mutedNote dashboardWidgetHint">${running ? (paused ? "Mission đang tạm dừng" : "Mission đang chạy") : "Không có mission đang chạy"}</p>`;
bodyEl.querySelector("[data-pause-action]")?.addEventListener("click", async (evt) => {
const action = evt.currentTarget.dataset.pauseAction;
try {
if (action === "pause") await missions()?.pauseRunner?.();
else await missions()?.continueRunner?.();
} catch (e) {
alert(e.message);
}
});
bodyEl.querySelector("[data-cancel-mission]")?.addEventListener("click", async () => {
try {
await missions()?.cancelRunner?.();
} catch (e) {
alert(e.message);
}
});
}
function renderWidget(widget) {
const card = document.createElement("article");
card.className = `dashboardWidget dashboardWidget--${widget.type}`;
card.dataset.widgetId = widget.id;
card.innerHTML = `
<div class="dashboardWidgetHeader">
<div class="dashboardWidgetTitle">${escapeHtml(widgetTitle(widget))}</div>
<div class="dashboardWidgetChrome" hidden>
<button type="button" class="iconBtn" data-widget-config title="Cấu hình">⚙</button>
<button type="button" class="iconBtn danger" data-widget-delete title="Xóa">×</button>
</div>
</div>
<div class="dashboardWidgetBody"></div>`;
const bodyEl = card.querySelector(".dashboardWidgetBody");
switch (widget.type) {
case "mission_button":
renderMissionButtonWidget(widget, bodyEl);
break;
case "mission_group":
renderMissionGroupWidget(widget, bodyEl);
break;
case "mission_queue":
renderMissionQueueWidget(widget, bodyEl);
break;
case "pause_continue":
renderPauseContinueWidget(widget, bodyEl);
break;
default:
bodyEl.innerHTML = `<p class="mutedNote">Widget không hỗ trợ.</p>`;
}
card.querySelector("[data-widget-config]")?.addEventListener("click", () => openEditDialog(widget.id));
card.querySelector("[data-widget-delete]")?.addEventListener("click", () => deleteWidget(widget.id));
return card;
}
function renderDashboard() {
if (!gridEl) return;
gridEl.innerHTML = "";
if (emptyEl) emptyEl.hidden = store.widgets.length > 0;
gridEl.classList.toggle("dashboardGrid--edit", store.editMode);
store.widgets.forEach((w) => gridEl.appendChild(renderWidget(w)));
gridEl.querySelectorAll(".dashboardWidgetChrome").forEach((n) => {
n.hidden = !store.editMode;
});
}
function refreshDynamicWidgets() {
store.widgets.forEach((widget) => {
const card = gridEl?.querySelector(`[data-widget-id="${widget.id}"]`);
if (!card) return;
const bodyEl = card.querySelector(".dashboardWidgetBody");
if (widget.type === "mission_queue") refreshQueueWidget(bodyEl);
if (widget.type === "pause_continue") renderPauseContinueWidget(widget, bodyEl);
});
}
function openAddDialog() {
fillTypeFields(addFieldsEl, addTypeEl.value);
addDialogEl.showModal();
}
function openEditDialog(widgetId) {
const widget = store.widgets.find((w) => w.id === widgetId);
if (!widget) return;
editWidgetIdEl.value = widget.id;
editWidgetTypeEl.value = WIDGET_LABELS[widget.type] || widget.type;
fillTypeFields(editFieldsEl, widget.type, widget);
editDialogEl.showModal();
}
function deleteWidget(widgetId) {
if (!confirm("Xóa widget này?")) return;
store.widgets = store.widgets.filter((w) => w.id !== widgetId);
persistStore();
renderDashboard();
editDialogEl.close();
}
function bindEvents() {
el("dashboardAddWidgetBtn")?.addEventListener("click", openAddDialog);
el("dashboardEditBtn")?.addEventListener("click", () => {
store.editMode = !store.editMode;
el("dashboardEditBtn").textContent = store.editMode ? "Xong" : "Sửa layout";
renderDashboard();
});
addTypeEl?.addEventListener("change", () => fillTypeFields(addFieldsEl, addTypeEl.value));
el("dashboardAddWidgetForm")?.addEventListener("submit", (evt) => {
evt.preventDefault();
const type = addTypeEl.value;
const fields = readFields(addFieldsEl);
store.widgets.push({ id: newId(), type, ...fields });
persistStore();
addDialogEl.close();
renderDashboard();
});
el("dashboardEditWidgetForm")?.addEventListener("submit", (evt) => {
evt.preventDefault();
const id = editWidgetIdEl.value;
const widget = store.widgets.find((w) => w.id === id);
if (!widget) return;
Object.assign(widget, readFields(editFieldsEl));
persistStore();
editDialogEl.close();
renderDashboard();
});
el("dashboardDeleteWidgetBtn")?.addEventListener("click", () => deleteWidget(editWidgetIdEl.value));
}
function startDashboardPoll() {
stopDashboardPoll();
missions()?.refreshQueue?.();
store.queueUnsub = missions()?.onQueueUpdate?.(() => refreshDynamicWidgets());
store.pollTimer = setInterval(() => missions()?.refreshQueue?.(), 2000);
}
function stopDashboardPoll() {
if (store.pollTimer) {
clearInterval(store.pollTimer);
store.pollTimer = null;
}
if (store.queueUnsub) {
store.queueUnsub();
store.queueUnsub = null;
}
}
function init() {
loadStore();
bindEvents();
renderDashboard();
}
window.DashboardApp = {
init,
onPageShow() {
renderDashboard();
startDashboardPoll();
},
onPageHide() {
stopDashboardPoll();
},
refresh() {
renderDashboard();
},
};
init();
})();