chức năng dashboard
This commit is contained in:
378
www/dashboard.js
Normal file
378
www/dashboard.js
Normal file
@@ -0,0 +1,378 @@
|
||||
(() => {
|
||||
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 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 = `
|
||||
<button type="button" class="dashboardPauseBtn ${paused ? "is-paused" : ""}" data-pause-action="${paused ? "continue" : "pause"}" ${running ? "" : "disabled"}>
|
||||
${paused ? "Continue" : "Pause"}
|
||||
</button>
|
||||
<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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
})();
|
||||
Reference in New Issue
Block a user