chức năng dashboard

This commit is contained in:
2026-06-13 13:20:57 +07:00
parent c116b30bea
commit 6f6d925fdd
9 changed files with 746 additions and 167 deletions

378
www/dashboard.js Normal file
View 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, "&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 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();
})();