391 lines
14 KiB
JavaScript
391 lines
14 KiB
JavaScript
(() => {
|
||
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, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
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();
|
||
})();
|