This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# LiDAR Manager Web (Test3)
|
||||
# Robot App Web (Test3)
|
||||
|
||||
Chức năng:
|
||||
- Đăng ký danh sách cảm biến LiDAR (tên, ip, port)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"health": "ok",
|
||||
"joystick_engaged": false,
|
||||
"joystick_speed": "fast",
|
||||
"message": "Robot paused",
|
||||
"motion": "paused",
|
||||
"updated_at": "2026-06-16T03:40:34Z"
|
||||
"message": "Waiting for new missions...",
|
||||
"motion": "running",
|
||||
"updated_at": "2026-06-16T10:33:19Z"
|
||||
}
|
||||
554
www/dashboard.js
554
www/dashboard.js
@@ -1,16 +1,27 @@
|
||||
(() => {
|
||||
const STORAGE_KEY = "phenikaax_dashboard_v1";
|
||||
|
||||
function widgetTypeLabel(type) {
|
||||
return t(`dashboard.widget.${type}`) || type;
|
||||
}
|
||||
const STORAGE_KEY_V3 = "phenikaax_dashboard_v3";
|
||||
const STORAGE_KEY_V2 = "phenikaax_dashboard_v2";
|
||||
const PAGE_SIZE = 10;
|
||||
const DEFAULT_ID = "dashboard_default";
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
|
||||
const listViewEl = el("dashboardListView");
|
||||
const createViewEl = el("dashboardCreateView");
|
||||
const designerViewEl = el("dashboardDesignerView");
|
||||
const gridEl = el("dashboardGrid");
|
||||
const emptyEl = el("dashboardEmpty");
|
||||
const designerEmptyEl = el("dashboardDesignerEmpty");
|
||||
const tableBodyEl = el("dashboardTableBody");
|
||||
const tableWrapEl = el("dashboardTableWrap");
|
||||
const filterInputEl = el("dashboardFilterInput");
|
||||
const listCountEl = el("dashboardListCount");
|
||||
const pageLabelEl = el("dashboardPageLabel");
|
||||
const designerTitleEl = el("dashboardDesignerTitle");
|
||||
const editDialogEl = el("dashboardEditDialog");
|
||||
const permissionsDialogEl = el("dashboardPermissionsDialog");
|
||||
const addDialogEl = el("dashboardAddWidgetDialog");
|
||||
const editDialogEl = el("dashboardEditWidgetDialog");
|
||||
const editWidgetDialogEl = el("dashboardEditWidgetDialog");
|
||||
const addTypeEl = el("dashboardWidgetType");
|
||||
const addFieldsEl = el("dashboardAddWidgetFields");
|
||||
const editFieldsEl = el("dashboardEditWidgetFields");
|
||||
@@ -18,50 +29,57 @@
|
||||
const editWidgetTypeEl = el("dashboardEditWidgetType");
|
||||
|
||||
const store = {
|
||||
widgets: [],
|
||||
dashboards: [],
|
||||
activeDashboardId: null,
|
||||
view: "list",
|
||||
filter: "",
|
||||
page: 1,
|
||||
editMode: false,
|
||||
pollActive: false,
|
||||
queueUnsub: null,
|
||||
userGroups: [],
|
||||
};
|
||||
|
||||
function newId() {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
|
||||
return `w_${Date.now().toString(36)}`;
|
||||
function widgetTypeLabel(type) {
|
||||
return t(`dashboard.widget.${type}`) || type;
|
||||
}
|
||||
|
||||
function newId(prefix = "d") {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) return `${prefix}_${crypto.randomUUID()}`;
|
||||
return `${prefix}_${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 currentUser() {
|
||||
return window.AuthApp?.getUser?.() || null;
|
||||
}
|
||||
|
||||
function persistStore() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ widgets: store.widgets }));
|
||||
function canEditDashboardsModule() {
|
||||
const user = currentUser();
|
||||
if (!user?.permissions) return true;
|
||||
const level = user.permissions.dashboard;
|
||||
return level === "write" || level === undefined;
|
||||
}
|
||||
|
||||
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: "" },
|
||||
{ id: newId(), type: "pause_continue", title: "" },
|
||||
];
|
||||
persistStore();
|
||||
function activeDashboard() {
|
||||
return store.dashboards.find((d) => d.id === store.activeDashboardId) || null;
|
||||
}
|
||||
|
||||
function dashboardCanEdit(dashboard) {
|
||||
if (!dashboard) return false;
|
||||
if (!canEditDashboardsModule()) return false;
|
||||
const user = currentUser();
|
||||
if (!user) return true;
|
||||
const groups = Array.isArray(dashboard.editGroups) ? dashboard.editGroups : [];
|
||||
if (!groups.length) return true;
|
||||
return groups.includes(user.group_id);
|
||||
}
|
||||
|
||||
function normalizeStr(value) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
@@ -72,6 +90,284 @@
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function activeWidgets() {
|
||||
return activeDashboard()?.widgets || [];
|
||||
}
|
||||
|
||||
function setActiveWidgets(widgets) {
|
||||
const db = activeDashboard();
|
||||
if (db) db.widgets = widgets;
|
||||
}
|
||||
|
||||
function bootstrapDefaultDashboard(widgets = []) {
|
||||
store.dashboards = [
|
||||
{
|
||||
id: DEFAULT_ID,
|
||||
name: "Default Dashboard",
|
||||
createdBy: t("dashboard.createdBy.system"),
|
||||
createdByUser: null,
|
||||
isDefault: true,
|
||||
editGroups: ["group_administrators", "group_distributors", "group_users"],
|
||||
widgets: Array.isArray(widgets) ? widgets : [],
|
||||
},
|
||||
];
|
||||
store.activeDashboardId = DEFAULT_ID;
|
||||
persistStore();
|
||||
}
|
||||
|
||||
function migrateFromV2() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_V2);
|
||||
if (!raw) return false;
|
||||
const data = JSON.parse(raw);
|
||||
const widgets = Array.isArray(data.widgets) ? data.widgets : [];
|
||||
bootstrapDefaultDashboard(widgets);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadStore() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_V3);
|
||||
if (!raw) {
|
||||
if (!migrateFromV2()) bootstrapDefaultDashboard();
|
||||
return;
|
||||
}
|
||||
const data = JSON.parse(raw);
|
||||
store.dashboards = Array.isArray(data.dashboards) ? data.dashboards : [];
|
||||
store.activeDashboardId = data.activeDashboardId || store.dashboards[0]?.id || null;
|
||||
if (!store.dashboards.length) bootstrapDefaultDashboard();
|
||||
else if (!store.activeDashboardId) store.activeDashboardId = store.dashboards[0].id;
|
||||
} catch {
|
||||
bootstrapDefaultDashboard();
|
||||
}
|
||||
}
|
||||
|
||||
function persistStore() {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY_V3,
|
||||
JSON.stringify({
|
||||
dashboards: store.dashboards,
|
||||
activeDashboardId: store.activeDashboardId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function loadUserGroups() {
|
||||
try {
|
||||
const res = await fetch("/api/user_groups", { credentials: "include" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
store.userGroups = Array.isArray(data.groups) ? data.groups : [];
|
||||
} catch {
|
||||
store.userGroups = [];
|
||||
}
|
||||
}
|
||||
|
||||
function filteredDashboards() {
|
||||
const q = normalizeStr(store.filter).toLowerCase();
|
||||
const list = [...store.dashboards];
|
||||
list.sort((a, b) => {
|
||||
if (a.isDefault && !b.isDefault) return -1;
|
||||
if (!a.isDefault && b.isDefault) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
if (!q) return list;
|
||||
return list.filter((d) => d.name.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
function paginatedDashboards() {
|
||||
const all = filteredDashboards();
|
||||
const totalPages = Math.max(1, Math.ceil(all.length / PAGE_SIZE));
|
||||
if (store.page > totalPages) store.page = totalPages;
|
||||
if (store.page < 1) store.page = 1;
|
||||
const start = (store.page - 1) * PAGE_SIZE;
|
||||
return { all, pageItems: all.slice(start, start + PAGE_SIZE), totalPages };
|
||||
}
|
||||
|
||||
function renderPermissionsChecklist(container, selected = []) {
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
const selectedSet = new Set(selected);
|
||||
store.userGroups.forEach((group) => {
|
||||
const label = document.createElement("label");
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.value = group.id;
|
||||
input.checked = selectedSet.has(group.id);
|
||||
label.appendChild(input);
|
||||
label.appendChild(document.createTextNode(group.name || group.id));
|
||||
container.appendChild(label);
|
||||
});
|
||||
if (!store.userGroups.length) {
|
||||
const note = document.createElement("p");
|
||||
note.className = "mutedNote";
|
||||
note.textContent = "—";
|
||||
container.appendChild(note);
|
||||
}
|
||||
}
|
||||
|
||||
function readPermissionsChecklist(container) {
|
||||
const ids = [];
|
||||
container?.querySelectorAll('input[type="checkbox"]:checked').forEach((node) => {
|
||||
ids.push(node.value);
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
function refreshNav() {
|
||||
window.NavApp?.refreshFlyout?.();
|
||||
}
|
||||
|
||||
function setView(view) {
|
||||
store.view = view;
|
||||
if (listViewEl) listViewEl.hidden = view !== "list";
|
||||
if (createViewEl) createViewEl.hidden = view !== "create";
|
||||
if (designerViewEl) designerViewEl.hidden = view !== "designer";
|
||||
if (view === "list") {
|
||||
stopDashboardPoll();
|
||||
renderListUI();
|
||||
} else if (view === "create") {
|
||||
stopDashboardPoll();
|
||||
} else {
|
||||
renderDesignerChrome();
|
||||
renderDashboard();
|
||||
startDashboardPoll();
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveDashboard(id) {
|
||||
if (!store.dashboards.some((d) => d.id === id)) return;
|
||||
store.activeDashboardId = id;
|
||||
persistStore();
|
||||
refreshNav();
|
||||
}
|
||||
|
||||
function renderListUI() {
|
||||
const { all, pageItems, totalPages } = paginatedDashboards();
|
||||
|
||||
if (listCountEl) listCountEl.textContent = t("dashboard.list.itemsFound", { count: all.length });
|
||||
if (pageLabelEl) pageLabelEl.textContent = t("dashboard.list.pageOf", { page: store.page, total: totalPages });
|
||||
|
||||
document.querySelectorAll(".dashboardPageBtn").forEach((btn) => {
|
||||
const action = btn.dataset.pageAction;
|
||||
if (action === "first" || action === "prev") btn.disabled = store.page <= 1;
|
||||
else if (action === "next" || action === "last") btn.disabled = store.page >= totalPages;
|
||||
});
|
||||
|
||||
if (tableBodyEl) {
|
||||
tableBodyEl.replaceChildren();
|
||||
if (!pageItems.length) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "dashboardTableEmptyRow";
|
||||
tr.innerHTML = `<td colspan="4">${escapeHtml(t("dashboard.list.empty"))}</td>`;
|
||||
tableBodyEl.appendChild(tr);
|
||||
} else {
|
||||
pageItems.forEach((dashboard) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "dashboardTableRow";
|
||||
if (dashboard.isDefault) tr.classList.add("is-default");
|
||||
if (dashboard.id === store.activeDashboardId) tr.classList.add("is-active");
|
||||
const canEdit = dashboardCanEdit(dashboard);
|
||||
const isActive = dashboard.id === store.activeDashboardId;
|
||||
tr.innerHTML = `
|
||||
<td class="dashboardTableStatusCol">
|
||||
${isActive ? `<span class="dashboardActiveMark" aria-label="${escapeHtml(t("dashboard.list.active"))}">✓</span>` : `<span class="dashboardActiveMark dashboardActiveMark--placeholder" aria-hidden="true"></span>`}
|
||||
</td>
|
||||
<td class="dashboardTableNameCell">${escapeHtml(dashboard.name)}</td>
|
||||
<td class="dashboardTableCreatedCell">${escapeHtml(dashboard.createdBy || "—")}</td>
|
||||
<td class="dashboardTableFuncCol">
|
||||
<div class="dashboardFuncBtns">
|
||||
<button type="button" class="dashboardFuncBtn" data-action="design" data-id="${escapeHtml(dashboard.id)}" title="${escapeHtml(t("dashboard.list.design"))}" aria-label="${escapeHtml(t("dashboard.list.design"))}">
|
||||
<span class="dashboardFuncIcon dashboardFuncIcon--design" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button type="button" class="dashboardFuncBtn" data-action="edit" data-id="${escapeHtml(dashboard.id)}" title="${escapeHtml(t("dashboard.list.edit"))}" aria-label="${escapeHtml(t("dashboard.list.edit"))}" ${canEdit ? "" : "disabled"}>
|
||||
<span class="dashboardFuncIcon dashboardFuncIcon--edit" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button type="button" class="dashboardFuncBtn dashboardFuncBtn--delete" data-action="delete" data-id="${escapeHtml(dashboard.id)}" title="${escapeHtml(t("dashboard.list.delete"))}" aria-label="${escapeHtml(t("dashboard.list.delete"))}" ${canEdit && !dashboard.isDefault ? "" : "disabled"}>
|
||||
<span class="dashboardFuncIcon dashboardFuncIcon--delete" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>`;
|
||||
tableBodyEl.appendChild(tr);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tableWrapEl) tableWrapEl.classList.toggle("is-empty", all.length === 0);
|
||||
|
||||
const createBtn = el("dashboardCreateBtn");
|
||||
if (createBtn) createBtn.disabled = !canEditDashboardsModule();
|
||||
}
|
||||
|
||||
function renderDesignerChrome() {
|
||||
const db = activeDashboard();
|
||||
if (designerTitleEl) designerTitleEl.textContent = db?.name || "—";
|
||||
}
|
||||
|
||||
function openCreateView() {
|
||||
if (!canEditDashboardsModule()) return;
|
||||
const nameInput = el("dashboardCreateName");
|
||||
if (nameInput) nameInput.value = "";
|
||||
renderPermissionsChecklist(el("dashboardCreatePermissions"), currentUser()?.group_id ? [currentUser().group_id] : []);
|
||||
setView("create");
|
||||
requestAnimationFrame(() => nameInput?.focus());
|
||||
}
|
||||
|
||||
function openCreatePermissionsDialog() {
|
||||
const container = el("dashboardCreatePermissions");
|
||||
const current = readPermissionsChecklist(container);
|
||||
const defaults = current.length ? current : currentUser()?.group_id ? [currentUser().group_id] : [];
|
||||
renderPermissionsChecklist(container, defaults);
|
||||
permissionsDialogEl?.showModal();
|
||||
}
|
||||
|
||||
function closeCreateView() {
|
||||
setView("list");
|
||||
window.NavApp?.syncDashboardSection?.("dashboard-list");
|
||||
}
|
||||
|
||||
function openEditDashboardDialog(id) {
|
||||
const dashboard = store.dashboards.find((d) => d.id === id);
|
||||
if (!dashboard || !dashboardCanEdit(dashboard)) {
|
||||
alert(t("dashboard.list.noEditPermission"));
|
||||
return;
|
||||
}
|
||||
el("dashboardEditId").value = dashboard.id;
|
||||
el("dashboardEditName").value = dashboard.name;
|
||||
renderPermissionsChecklist(el("dashboardEditPermissions"), dashboard.editGroups || []);
|
||||
editDialogEl?.showModal();
|
||||
}
|
||||
|
||||
function openDesignerFor(id) {
|
||||
setActiveDashboard(id);
|
||||
setView("designer");
|
||||
window.NavApp?.syncDashboardSection?.(`dashboard-${id}`);
|
||||
}
|
||||
|
||||
function deleteDashboard(id) {
|
||||
const dashboard = store.dashboards.find((d) => d.id === id);
|
||||
if (!dashboard) return;
|
||||
if (dashboard.isDefault) {
|
||||
alert(t("dashboard.list.cannotDeleteDefault"));
|
||||
return;
|
||||
}
|
||||
if (!dashboardCanEdit(dashboard)) {
|
||||
alert(t("dashboard.list.noEditPermission"));
|
||||
return;
|
||||
}
|
||||
if (!confirm(t("dashboard.list.deleteConfirm", { name: dashboard.name }))) return;
|
||||
store.dashboards = store.dashboards.filter((d) => d.id !== id);
|
||||
if (store.activeDashboardId === id) {
|
||||
store.activeDashboardId = store.dashboards[0]?.id || null;
|
||||
}
|
||||
persistStore();
|
||||
refreshNav();
|
||||
renderListUI();
|
||||
}
|
||||
|
||||
function widgetTitle(widget) {
|
||||
if (widget.title) return widget.title;
|
||||
return widgetTypeLabel(widget.type);
|
||||
@@ -105,7 +401,7 @@
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label>${t("dashboard.widget.field.title")}</label>
|
||||
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="${t(\"dashboard.widget.titlePlaceholder\")}" />
|
||||
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="${escapeHtml(t("dashboard.widget.titlePlaceholder"))}" />
|
||||
</div>`;
|
||||
} else if (type === "mission_group") {
|
||||
container.innerHTML = `
|
||||
@@ -129,7 +425,7 @@
|
||||
<label>${t("dashboard.widget.field.title")}</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>`;
|
||||
<p class="mutedNote">${escapeHtml(t("dashboard.widget.pauseHint"))}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,8 +536,8 @@
|
||||
<div class="dashboardWidgetHeader">
|
||||
<div class="dashboardWidgetTitle">${escapeHtml(widgetTitle(widget))}</div>
|
||||
<div class="dashboardWidgetChrome" hidden>
|
||||
<button type="button" class="iconBtn" data-widget-config title="${t(\"common.configure\")}">⚙</button>
|
||||
<button type="button" class="iconBtn danger" data-widget-delete title="${t(\"common.delete\")}">×</button>
|
||||
<button type="button" class="iconBtn" data-widget-config title="${escapeHtml(t("common.configure"))}">⚙</button>
|
||||
<button type="button" class="iconBtn danger" data-widget-delete title="${escapeHtml(t("common.delete"))}">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboardWidgetBody"></div>`;
|
||||
@@ -261,7 +557,7 @@
|
||||
renderPauseContinueWidget(widget, bodyEl);
|
||||
break;
|
||||
default:
|
||||
bodyEl.innerHTML = `<p class="mutedNote">Widget không hỗ trợ.</p>`;
|
||||
bodyEl.innerHTML = `<p class="mutedNote">${t("dashboard.widget.unsupported")}</p>`;
|
||||
}
|
||||
|
||||
card.querySelector("[data-widget-config]")?.addEventListener("click", () => openEditDialog(widget.id));
|
||||
@@ -271,17 +567,18 @@
|
||||
|
||||
function renderDashboard() {
|
||||
if (!gridEl) return;
|
||||
const widgets = activeWidgets();
|
||||
gridEl.innerHTML = "";
|
||||
if (emptyEl) emptyEl.hidden = store.widgets.length > 0;
|
||||
if (designerEmptyEl) designerEmptyEl.hidden = widgets.length > 0;
|
||||
gridEl.classList.toggle("dashboardGrid--edit", store.editMode);
|
||||
store.widgets.forEach((w) => gridEl.appendChild(renderWidget(w)));
|
||||
widgets.forEach((w) => gridEl.appendChild(renderWidget(w)));
|
||||
gridEl.querySelectorAll(".dashboardWidgetChrome").forEach((n) => {
|
||||
n.hidden = !store.editMode;
|
||||
});
|
||||
}
|
||||
|
||||
function refreshDynamicWidgets() {
|
||||
store.widgets.forEach((widget) => {
|
||||
activeWidgets().forEach((widget) => {
|
||||
const card = gridEl?.querySelector(`[data-widget-id="${widget.id}"]`);
|
||||
if (!card) return;
|
||||
const bodyEl = card.querySelector(".dashboardWidgetBody");
|
||||
@@ -290,43 +587,143 @@
|
||||
});
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
fillTypeFields(addFieldsEl, addTypeEl.value);
|
||||
addDialogEl.showModal();
|
||||
}
|
||||
|
||||
function openEditDialog(widgetId) {
|
||||
const widget = store.widgets.find((w) => w.id === widgetId);
|
||||
const widget = activeWidgets().find((w) => w.id === widgetId);
|
||||
if (!widget) return;
|
||||
editWidgetIdEl.value = widget.id;
|
||||
editWidgetTypeEl.value = widgetTypeLabel(widget.type);
|
||||
fillTypeFields(editFieldsEl, widget.type, widget);
|
||||
editDialogEl.showModal();
|
||||
editWidgetDialogEl.showModal();
|
||||
}
|
||||
|
||||
function deleteWidget(widgetId) {
|
||||
if (!confirm(t("dashboard.widget.deleteConfirm"))) return;
|
||||
store.widgets = store.widgets.filter((w) => w.id !== widgetId);
|
||||
const db = activeDashboard();
|
||||
if (!db) return;
|
||||
db.widgets = db.widgets.filter((w) => w.id !== widgetId);
|
||||
persistStore();
|
||||
renderDashboard();
|
||||
editDialogEl.close();
|
||||
editWidgetDialogEl.close();
|
||||
}
|
||||
|
||||
function handleNav(section) {
|
||||
if (section === "dashboard-list") {
|
||||
setView("list");
|
||||
return;
|
||||
}
|
||||
if (section.startsWith("dashboard-")) {
|
||||
const id = section.slice("dashboard-".length);
|
||||
if (store.dashboards.some((d) => d.id === id)) {
|
||||
setActiveDashboard(id);
|
||||
setView("designer");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getNavItems() {
|
||||
const items = [{ section: "dashboard-list", page: "dashboard", label: t("nav.dashboardsList") }];
|
||||
store.dashboards.forEach((dashboard) => {
|
||||
items.push({
|
||||
section: `dashboard-${dashboard.id}`,
|
||||
page: "dashboard",
|
||||
label: dashboard.name,
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
el("dashboardAddWidgetBtn")?.addEventListener("click", openAddDialog);
|
||||
el("dashboardEditBtn")?.addEventListener("click", () => {
|
||||
store.editMode = !store.editMode;
|
||||
el("dashboardEditBtn").textContent = store.editMode ? t("dashboard.editDone") : t("dashboard.editLayout");
|
||||
renderDashboard();
|
||||
el("dashboardCreateBtn")?.addEventListener("click", openCreateView);
|
||||
el("dashboardCreateBackBtn")?.addEventListener("click", closeCreateView);
|
||||
el("dashboardCreateCancelBtn")?.addEventListener("click", closeCreateView);
|
||||
el("dashboardCreatePermissionsBtn")?.addEventListener("click", openCreatePermissionsDialog);
|
||||
el("dashboardPermissionsForm")?.addEventListener("submit", (evt) => {
|
||||
evt.preventDefault();
|
||||
permissionsDialogEl?.close();
|
||||
});
|
||||
el("dashboardClearFiltersBtn")?.addEventListener("click", () => {
|
||||
store.filter = "";
|
||||
store.page = 1;
|
||||
if (filterInputEl) filterInputEl.value = "";
|
||||
renderListUI();
|
||||
});
|
||||
filterInputEl?.addEventListener("input", () => {
|
||||
store.filter = filterInputEl.value;
|
||||
store.page = 1;
|
||||
renderListUI();
|
||||
});
|
||||
|
||||
document.getElementById("dashboardPagination")?.addEventListener("click", (evt) => {
|
||||
const btn = evt.target.closest("[data-page-action]");
|
||||
if (!btn || btn.disabled) return;
|
||||
const { totalPages } = paginatedDashboards();
|
||||
const action = btn.dataset.pageAction;
|
||||
if (action === "first") store.page = 1;
|
||||
else if (action === "prev") store.page = Math.max(1, store.page - 1);
|
||||
else if (action === "next") store.page = Math.min(totalPages, store.page + 1);
|
||||
else if (action === "last") store.page = totalPages;
|
||||
renderListUI();
|
||||
});
|
||||
|
||||
tableBodyEl?.addEventListener("click", (evt) => {
|
||||
const btn = evt.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
const id = btn.dataset.id;
|
||||
const action = btn.dataset.action;
|
||||
if (action === "design") openDesignerFor(id);
|
||||
else if (action === "edit") openEditDashboardDialog(id);
|
||||
else if (action === "delete") deleteDashboard(id);
|
||||
});
|
||||
|
||||
el("dashboardBackToListBtn")?.addEventListener("click", () => {
|
||||
setView("list");
|
||||
window.NavApp?.syncDashboardSection?.("dashboard-list");
|
||||
});
|
||||
|
||||
el("dashboardCreateForm")?.addEventListener("submit", (evt) => {
|
||||
evt.preventDefault();
|
||||
const name = normalizeStr(el("dashboardCreateName").value);
|
||||
if (!name) return;
|
||||
const user = currentUser();
|
||||
const dashboard = {
|
||||
id: newId("dashboard"),
|
||||
name,
|
||||
createdBy: user?.group_name || user?.display_name || user?.username || "—",
|
||||
createdByUser: user?.username || null,
|
||||
isDefault: false,
|
||||
editGroups: readPermissionsChecklist(el("dashboardCreatePermissions")),
|
||||
widgets: [],
|
||||
};
|
||||
store.dashboards.push(dashboard);
|
||||
store.activeDashboardId = dashboard.id;
|
||||
persistStore();
|
||||
refreshNav();
|
||||
openDesignerFor(dashboard.id);
|
||||
});
|
||||
|
||||
el("dashboardEditForm")?.addEventListener("submit", (evt) => {
|
||||
evt.preventDefault();
|
||||
const id = el("dashboardEditId").value;
|
||||
const dashboard = store.dashboards.find((d) => d.id === id);
|
||||
if (!dashboard || !dashboardCanEdit(dashboard)) return;
|
||||
dashboard.name = normalizeStr(el("dashboardEditName").value);
|
||||
dashboard.editGroups = readPermissionsChecklist(el("dashboardEditPermissions"));
|
||||
persistStore();
|
||||
editDialogEl?.close();
|
||||
refreshNav();
|
||||
if (store.view === "list") renderListUI();
|
||||
else renderDesignerChrome();
|
||||
});
|
||||
|
||||
addTypeEl?.addEventListener("change", () => fillTypeFields(addFieldsEl, addTypeEl.value));
|
||||
|
||||
el("dashboardAddWidgetForm")?.addEventListener("submit", (evt) => {
|
||||
evt.preventDefault();
|
||||
const db = activeDashboard();
|
||||
if (!db) return;
|
||||
const type = addTypeEl.value;
|
||||
const fields = readFields(addFieldsEl);
|
||||
store.widgets.push({ id: newId(), type, ...fields });
|
||||
db.widgets.push({ id: newId("w"), type, ...fields });
|
||||
persistStore();
|
||||
addDialogEl.close();
|
||||
renderDashboard();
|
||||
@@ -335,11 +732,11 @@
|
||||
el("dashboardEditWidgetForm")?.addEventListener("submit", (evt) => {
|
||||
evt.preventDefault();
|
||||
const id = editWidgetIdEl.value;
|
||||
const widget = store.widgets.find((w) => w.id === id);
|
||||
const widget = activeWidgets().find((w) => w.id === id);
|
||||
if (!widget) return;
|
||||
Object.assign(widget, readFields(editFieldsEl));
|
||||
persistStore();
|
||||
editDialogEl.close();
|
||||
editWidgetDialogEl.close();
|
||||
renderDashboard();
|
||||
});
|
||||
|
||||
@@ -348,6 +745,7 @@
|
||||
|
||||
function startDashboardPoll() {
|
||||
if (window.AuthApp && !window.AuthApp.isReady()) return;
|
||||
if (store.view !== "designer") return;
|
||||
stopDashboardPoll();
|
||||
missions()?.refreshQueue?.();
|
||||
store.queueUnsub = missions()?.onQueueUpdate?.(() => refreshDynamicWidgets());
|
||||
@@ -366,33 +764,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
async function init() {
|
||||
loadStore();
|
||||
await loadUserGroups();
|
||||
bindEvents();
|
||||
renderDashboard();
|
||||
setView("list");
|
||||
}
|
||||
|
||||
window.DashboardApp = {
|
||||
init,
|
||||
getNavItems,
|
||||
handleNav,
|
||||
onPageShow() {
|
||||
renderDashboard();
|
||||
startDashboardPoll();
|
||||
if (store.view === "designer") {
|
||||
renderDesignerChrome();
|
||||
renderDashboard();
|
||||
startDashboardPoll();
|
||||
} else {
|
||||
renderListUI();
|
||||
}
|
||||
},
|
||||
onPageHide() {
|
||||
stopDashboardPoll();
|
||||
},
|
||||
refresh() {
|
||||
renderDashboard();
|
||||
if (store.view === "list") renderListUI();
|
||||
else renderDashboard();
|
||||
},
|
||||
};
|
||||
|
||||
function boot() {
|
||||
init();
|
||||
async function boot() {
|
||||
await init();
|
||||
refreshNav();
|
||||
}
|
||||
|
||||
window.addEventListener("lm:locale-change", () => {
|
||||
renderDashboard();
|
||||
const editBtn = el("dashboardEditBtn");
|
||||
if (editBtn) editBtn.textContent = store.editMode ? t("dashboard.editDone") : t("dashboard.editLayout");
|
||||
if (store.view === "list") renderListUI();
|
||||
else {
|
||||
renderDesignerChrome();
|
||||
renderDashboard();
|
||||
}
|
||||
refreshNav();
|
||||
});
|
||||
|
||||
if (window.AuthApp?.isReady()) boot();
|
||||
|
||||
76
www/i18n.js
76
www/i18n.js
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Central i18n for LiDAR Manager — vi / en.
|
||||
* Central i18n for Robot App — vi / en.
|
||||
* Static DOM: data-i18n, data-i18n-placeholder, data-i18n-title, data-i18n-aria
|
||||
* Dynamic JS: I18n.t("key") or I18n.t("key", { name: "..." })
|
||||
*/
|
||||
(() => {
|
||||
const MESSAGES = {
|
||||
vi: {
|
||||
"app.title": "LiDAR Manager",
|
||||
"app.title": "Robot App",
|
||||
"app.robotName": "RobotApp",
|
||||
"app.status.ready": "Sẵn sàng",
|
||||
"app.status.reloaded": "Đã tải lại",
|
||||
@@ -66,6 +66,7 @@
|
||||
"nav.help": "Help",
|
||||
"nav.logout": "Log out",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.dashboardsList": "Dashboards",
|
||||
"nav.missions": "Missions",
|
||||
"nav.maps": "Maps & layout",
|
||||
"nav.monitoring-log": "System log",
|
||||
@@ -116,6 +117,40 @@
|
||||
|
||||
"dashboard.title": "Dashboard",
|
||||
"dashboard.subtitle": "Widget mission — chạy, xếp hàng và tạm dừng giống MiR Fleet.",
|
||||
"dashboard.list.title": "Dashboards",
|
||||
"dashboard.list.subtitle": "Tạo và chỉnh sửa dashboard cho robot.",
|
||||
"dashboard.list.create": "+ Tạo dashboard",
|
||||
"dashboard.list.clearFilters": "Xóa bộ lọc",
|
||||
"dashboard.list.filterLabel": "Lọc:",
|
||||
"dashboard.list.filterPlaceholder": "Nhập tên để lọc…",
|
||||
"dashboard.list.itemsFound": "{count} mục",
|
||||
"dashboard.list.pageOf": "Trang {page} / {total}",
|
||||
"dashboard.list.col.name": "Tên",
|
||||
"dashboard.list.col.createdBy": "Tạo bởi",
|
||||
"dashboard.list.col.functions": "Chức năng",
|
||||
"dashboard.list.empty": "Không có dashboard nào.",
|
||||
"dashboard.list.back": "← Danh sách",
|
||||
"dashboard.list.design": "Thiết kế",
|
||||
"dashboard.list.active": "Dashboard đang active",
|
||||
"dashboard.list.edit": "Sửa",
|
||||
"dashboard.list.delete": "Xóa",
|
||||
"dashboard.list.deleteConfirm": "Xóa dashboard «{name}»?",
|
||||
"dashboard.list.cannotDeleteDefault": "Không thể xóa Default Dashboard.",
|
||||
"dashboard.list.noEditPermission": "Bạn không có quyền chỉnh sửa dashboard này.",
|
||||
"dashboard.dialog.create.title": "Tạo dashboard",
|
||||
"dashboard.create.title": "Tạo dashboard",
|
||||
"dashboard.create.subtitle": "Tạo dashboard mới trên robot.",
|
||||
"dashboard.create.backToList": "← Quay lại danh sách",
|
||||
"dashboard.create.name": "Tên",
|
||||
"dashboard.create.namePlaceholder": "VD: John's Dashboard",
|
||||
"dashboard.create.permissions": "Chọn user groups được phép chỉnh sửa dashboard này.",
|
||||
"dashboard.create.permissionsBtn": "Quyền",
|
||||
"dashboard.create.permissionsTitle": "Quyền chỉnh sửa",
|
||||
"dashboard.create.submit": "Tạo dashboard",
|
||||
"dashboard.create.cancel": "Hủy",
|
||||
"dashboard.dialog.editDashboard.title": "Sửa dashboard",
|
||||
"dashboard.designer.empty": "Chưa có widget. Phase B sẽ thêm designer đầy đủ.",
|
||||
"dashboard.createdBy.system": "MiR",
|
||||
"dashboard.addWidget": "Thêm widget",
|
||||
"dashboard.editLayout": "Sửa layout",
|
||||
"dashboard.editDone": "Xong",
|
||||
@@ -341,7 +376,7 @@
|
||||
"help.api.body2": "Reference Guide MiR rev 1.9: docs/Reference guide.pdf",
|
||||
},
|
||||
en: {
|
||||
"app.title": "LiDAR Manager",
|
||||
"app.title": "Robot App",
|
||||
"app.robotName": "RobotApp",
|
||||
"app.status.ready": "Ready",
|
||||
"app.status.reloaded": "Reloaded",
|
||||
@@ -401,6 +436,7 @@
|
||||
"nav.help": "Help",
|
||||
"nav.logout": "Log out",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.dashboardsList": "Dashboards",
|
||||
"nav.missions": "Missions",
|
||||
"nav.maps": "Maps & layout",
|
||||
"nav.monitoring-log": "System log",
|
||||
@@ -451,6 +487,40 @@
|
||||
|
||||
"dashboard.title": "Dashboard",
|
||||
"dashboard.subtitle": "Mission widgets — run, queue and pause like MiR Fleet.",
|
||||
"dashboard.list.title": "Dashboards",
|
||||
"dashboard.list.subtitle": "Create and edit dashboards for the robot.",
|
||||
"dashboard.list.create": "+ Create dashboard",
|
||||
"dashboard.list.clearFilters": "Clear filters",
|
||||
"dashboard.list.filterLabel": "Filter:",
|
||||
"dashboard.list.filterPlaceholder": "Write name to filter by…",
|
||||
"dashboard.list.itemsFound": "{count} item(s) found",
|
||||
"dashboard.list.pageOf": "Page {page} of {total}",
|
||||
"dashboard.list.col.name": "Name",
|
||||
"dashboard.list.col.createdBy": "Created by",
|
||||
"dashboard.list.col.functions": "Functions",
|
||||
"dashboard.list.empty": "No dashboards found.",
|
||||
"dashboard.list.back": "← Back to list",
|
||||
"dashboard.list.design": "Design",
|
||||
"dashboard.list.active": "Active dashboard",
|
||||
"dashboard.list.edit": "Edit",
|
||||
"dashboard.list.delete": "Delete",
|
||||
"dashboard.list.deleteConfirm": "Delete dashboard «{name}»?",
|
||||
"dashboard.list.cannotDeleteDefault": "Cannot delete Default Dashboard.",
|
||||
"dashboard.list.noEditPermission": "You do not have permission to edit this dashboard.",
|
||||
"dashboard.dialog.create.title": "Create dashboard",
|
||||
"dashboard.create.title": "Create dashboard",
|
||||
"dashboard.create.subtitle": "Create a new dashboard in the robot.",
|
||||
"dashboard.create.backToList": "← Back to the list",
|
||||
"dashboard.create.name": "Name",
|
||||
"dashboard.create.namePlaceholder": "John's Dashboard",
|
||||
"dashboard.create.permissions": "Select user groups allowed to edit this dashboard.",
|
||||
"dashboard.create.permissionsBtn": "Permissions",
|
||||
"dashboard.create.permissionsTitle": "Permissions",
|
||||
"dashboard.create.submit": "Create dashboard",
|
||||
"dashboard.create.cancel": "Cancel",
|
||||
"dashboard.dialog.editDashboard.title": "Edit dashboard",
|
||||
"dashboard.designer.empty": "No widgets yet. Full designer coming in Phase B.",
|
||||
"dashboard.createdBy.system": "MiR",
|
||||
"dashboard.addWidget": "Add widget",
|
||||
"dashboard.editLayout": "Edit layout",
|
||||
"dashboard.editDone": "Done",
|
||||
|
||||
163
www/index.html
163
www/index.html
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>LiDAR Manager</title>
|
||||
<title>Robot App</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body class="auth-logged-out">
|
||||
@@ -248,50 +248,94 @@
|
||||
|
||||
<main class="content">
|
||||
<div class="page" id="pageOverview" data-page-content="dashboard" hidden>
|
||||
<div class="dashboardPage">
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle" data-i18n="dashboard.title">Dashboard</div>
|
||||
<div class="cardSub" data-i18n="dashboard.subtitle">Widget mission — chạy, xếp hàng và tạm dừng giống MiR Fleet.</div>
|
||||
<div class="dashboardShell">
|
||||
<div class="dashboardContent">
|
||||
<div id="dashboardListView" class="dashboardListView">
|
||||
<header class="dashboardListHeader">
|
||||
<div class="dashboardListHeaderIntro">
|
||||
<h2 class="dashboardListTitle" data-i18n="dashboard.list.title">Dashboards</h2>
|
||||
<p class="dashboardListSub">
|
||||
<span data-i18n="dashboard.list.subtitle">Tạo và chỉnh sửa dashboard cho robot.</span>
|
||||
<span class="dashboardListHelp" title="?" aria-hidden="true">?</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="dashboardListHeaderActions">
|
||||
<button id="dashboardCreateBtn" type="button" class="btn primary dashboardCreateBtn" data-i18n="dashboard.list.create">+ Tạo dashboard</button>
|
||||
<button id="dashboardClearFiltersBtn" type="button" class="btn subtle dashboardClearFiltersBtn" data-i18n="dashboard.list.clearFilters">Xóa bộ lọc</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="dashboardListBar">
|
||||
<label class="dashboardFilter">
|
||||
<span class="dashboardFilterLabel" data-i18n="dashboard.list.filterLabel">Lọc:</span>
|
||||
<input id="dashboardFilterInput" type="search" class="dashboardFilterInput" data-i18n-placeholder="dashboard.list.filterPlaceholder" placeholder="Nhập tên để lọc…" autocomplete="off" />
|
||||
</label>
|
||||
<span id="dashboardListCount" class="dashboardListCount">—</span>
|
||||
<div class="dashboardPagination" id="dashboardPagination">
|
||||
<button type="button" class="iconBtn dashboardPageBtn" data-page-action="first" title="First">«</button>
|
||||
<button type="button" class="iconBtn dashboardPageBtn" data-page-action="prev" title="Previous">‹</button>
|
||||
<span id="dashboardPageLabel" class="dashboardPageLabel">—</span>
|
||||
<button type="button" class="iconBtn dashboardPageBtn" data-page-action="next" title="Next">›</button>
|
||||
<button type="button" class="iconBtn dashboardPageBtn" data-page-action="last" title="Last">»</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboardToolbar">
|
||||
<button id="dashboardAddWidgetBtn" type="button" class="btn subtle" data-i18n="dashboard.addWidget">Thêm widget</button>
|
||||
<button id="dashboardEditBtn" type="button" class="btn subtle" data-i18n="dashboard.editLayout">Sửa layout</button>
|
||||
<div class="dashboardTableWrap" id="dashboardTableWrap">
|
||||
<table class="dashboardTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="dashboardTableStatusCol" aria-hidden="true"></th>
|
||||
<th scope="col" data-i18n="dashboard.list.col.name">Tên</th>
|
||||
<th scope="col" data-i18n="dashboard.list.col.createdBy">Tạo bởi</th>
|
||||
<th scope="col" class="dashboardTableFuncCol" data-i18n="dashboard.list.col.functions">Chức năng</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dashboardTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<div id="dashboardGrid" class="dashboardGrid"></div>
|
||||
<p id="dashboardEmpty" class="mutedNote dashboardEmpty" hidden data-i18n="dashboard.empty">Chưa có widget. Bấm «Thêm widget» để bắt đầu.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card dashboardInfoCard">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle" data-i18n="dashboard.system.title">Hệ thống</div>
|
||||
<div class="cardSub" data-i18n="dashboard.system.subtitle">Trạng thái backend và layout đang active.</div>
|
||||
<div id="dashboardCreateView" class="dashboardCreateView" hidden>
|
||||
<header class="dashboardCreateHeader">
|
||||
<div class="dashboardCreateHeaderIntro">
|
||||
<h2 class="dashboardCreateTitle" data-i18n="dashboard.create.title">Tạo dashboard</h2>
|
||||
<p class="dashboardCreateSub">
|
||||
<span data-i18n="dashboard.create.subtitle">Tạo dashboard mới trên robot.</span>
|
||||
<span class="dashboardListHelp" title="?" aria-hidden="true">?</span>
|
||||
</p>
|
||||
</div>
|
||||
<button id="dashboardCreateBackBtn" type="button" class="btn subtle dashboardCreateBackBtn" data-i18n="dashboard.create.backToList">← Quay lại danh sách</button>
|
||||
</header>
|
||||
<form id="dashboardCreateForm" class="dashboardCreateForm">
|
||||
<div class="dashboardCreatePanel">
|
||||
<div class="dashboardCreateField">
|
||||
<label for="dashboardCreateName" data-i18n="dashboard.create.name">Tên</label>
|
||||
<input id="dashboardCreateName" type="text" required autocomplete="off" data-i18n-placeholder="dashboard.create.namePlaceholder" placeholder="VD: John's Dashboard" />
|
||||
</div>
|
||||
<div class="dashboardCreateActions">
|
||||
<button type="button" id="dashboardCreatePermissionsBtn" class="btn subtle dashboardMirBtn dashboardCreatePermissionsBtn" data-i18n="dashboard.create.permissionsBtn">Quyền</button>
|
||||
<button type="submit" class="btn dashboardMirBtn dashboardCreateSubmitBtn">
|
||||
<span class="dashboardCreateSubmitIcon" aria-hidden="true">✓</span>
|
||||
<span data-i18n="dashboard.create.submit">Tạo dashboard</span>
|
||||
</button>
|
||||
<button type="button" id="dashboardCreateCancelBtn" class="btn subtle dashboardMirBtn dashboardCreateCancelBtn">
|
||||
<span class="dashboardCreateCancelIcon" aria-hidden="true">←</span>
|
||||
<span data-i18n="dashboard.create.cancel">Hủy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="dashboardDesignerView" class="dashboardDesignerView" hidden>
|
||||
<header class="dashboardDesignerHeader">
|
||||
<button id="dashboardBackToListBtn" type="button" class="btn subtle dashboardBackBtn" data-i18n="dashboard.list.back">← Danh sách</button>
|
||||
<h2 id="dashboardDesignerTitle" class="dashboardDesignerTitle">—</h2>
|
||||
</header>
|
||||
<div class="dashboardDesignerBody">
|
||||
<div id="dashboardGrid" class="dashboardGrid"></div>
|
||||
<p id="dashboardDesignerEmpty" class="mutedNote dashboardDesignerEmpty" data-i18n="dashboard.designer.empty">Chưa có widget. Phase B sẽ thêm designer đầy đủ.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody dashboardInfoGrid">
|
||||
<div class="row rowWide">
|
||||
<label data-i18n="dashboard.system.backend">Backend</label>
|
||||
<div id="overviewBackend" class="mutedNote">—</div>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label data-i18n="dashboard.system.layout">Layout</label>
|
||||
<div id="overviewActiveLayout" class="mutedNote">—</div>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label data-i18n="dashboard.system.model">Model robot</label>
|
||||
<div id="overviewActiveModel" class="mutedNote">—</div>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label data-i18n="dashboard.system.sensors">LiDAR / IMU</label>
|
||||
<div id="overviewActiveSensors" class="mutedNote">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1024,6 +1068,47 @@ GET /api/v2.0.0/status</pre>
|
||||
|
||||
</dialog>
|
||||
|
||||
<dialog id="dashboardPermissionsDialog" class="missionDialog dashboardPermissionsDialog">
|
||||
<form id="dashboardPermissionsForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3 data-i18n="dashboard.create.permissionsTitle">Quyền chỉnh sửa</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardPermissionsDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<p class="dashboardPermissionsIntro" data-i18n="dashboard.create.permissions">Chọn user groups được phép chỉnh sửa dashboard này.</p>
|
||||
<div id="dashboardCreatePermissions" class="dashboardPermissionsList"></div>
|
||||
</div>
|
||||
<div class="missionDialogFooter">
|
||||
<button type="button" class="btn subtle" data-close-dialog="dashboardPermissionsDialog" data-i18n="common.cancel">Hủy</button>
|
||||
<button type="submit" class="btn dashboardMirBtn dashboardPermissionsApplyBtn" data-i18n="common.apply">Áp dụng</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="dashboardEditDialog" class="missionDialog">
|
||||
<form id="dashboardEditForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3 data-i18n="dashboard.dialog.editDashboard.title">Sửa dashboard</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardEditDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<input type="hidden" id="dashboardEditId" />
|
||||
<div class="row rowWide">
|
||||
<label for="dashboardEditName" data-i18n="dashboard.create.name">Tên</label>
|
||||
<input id="dashboardEditName" type="text" required />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label data-i18n="dashboard.create.permissions">Quyền chỉnh sửa</label>
|
||||
<div id="dashboardEditPermissions" class="dashboardPermissionsList"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="missionDialogFooter">
|
||||
<button type="button" class="btn subtle" data-close-dialog="dashboardEditDialog" data-i18n="common.cancel">Hủy</button>
|
||||
<button type="submit" class="btn primary" data-i18n="common.save">Lưu</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="dashboardAddWidgetDialog" class="missionDialog">
|
||||
<form id="dashboardAddWidgetForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
|
||||
38
www/nav.js
38
www/nav.js
@@ -8,7 +8,8 @@
|
||||
|
||||
const MODULES = {
|
||||
dashboards: {
|
||||
items: [{ section: "dashboard", page: "dashboard" }],
|
||||
items: [{ section: "dashboard-list", page: "dashboard" }],
|
||||
dynamic: true,
|
||||
},
|
||||
setup: {
|
||||
items: [
|
||||
@@ -28,7 +29,7 @@
|
||||
};
|
||||
|
||||
const PAGE_NAV = {
|
||||
dashboard: { module: "dashboards", section: "dashboard" },
|
||||
dashboard: { module: "dashboards", section: "dashboard-list" },
|
||||
config: { module: "setup", section: "maps" },
|
||||
missions: { module: "setup", section: "missions" },
|
||||
integrations: { module: "system", section: "integrations" },
|
||||
@@ -54,10 +55,24 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function moduleItems(moduleId) {
|
||||
const mod = MODULES[moduleId];
|
||||
if (!mod) return [];
|
||||
if (mod.dynamic && moduleId === "dashboards" && window.DashboardApp?.getNavItems) {
|
||||
return window.DashboardApp.getNavItems();
|
||||
}
|
||||
return mod.items;
|
||||
}
|
||||
|
||||
function visibleItems(moduleId) {
|
||||
const mod = MODULES[moduleId];
|
||||
if (!mod) return [];
|
||||
return mod.items.filter((item) => canAccessPage(item.page));
|
||||
return moduleItems(moduleId).filter((item) => canAccessPage(item.page));
|
||||
}
|
||||
|
||||
function itemLabel(item) {
|
||||
if (item.label) return item.label;
|
||||
return t(item.section);
|
||||
}
|
||||
|
||||
function moduleHasAccess(moduleId) {
|
||||
@@ -89,7 +104,7 @@
|
||||
btn.className = "mirNavFlyoutItem";
|
||||
btn.dataset.section = item.section;
|
||||
btn.dataset.page = item.page;
|
||||
btn.textContent = t(item.section);
|
||||
btn.textContent = itemLabel(item);
|
||||
if (item.section === activeSection) {
|
||||
btn.classList.add("is-active");
|
||||
btn.setAttribute("aria-current", "page");
|
||||
@@ -159,6 +174,14 @@
|
||||
saveState();
|
||||
updateRailUI();
|
||||
navigateToPage(page);
|
||||
if (page === "dashboard") window.DashboardApp?.handleNav?.(section);
|
||||
}
|
||||
|
||||
function syncDashboardSection(section) {
|
||||
activeModule = "dashboards";
|
||||
activeSection = section.startsWith("dashboard-") || section === "dashboard-list" ? section : `dashboard-${section}`;
|
||||
saveState();
|
||||
updateRailUI();
|
||||
}
|
||||
|
||||
function navigateToPage(page) {
|
||||
@@ -172,6 +195,7 @@
|
||||
activeSection = nav.section;
|
||||
saveState();
|
||||
updateRailUI();
|
||||
if (page === "dashboard") window.DashboardApp?.handleNav?.(activeSection);
|
||||
}
|
||||
|
||||
function toggleFlyout() {
|
||||
@@ -231,6 +255,10 @@
|
||||
navigateToPage(page);
|
||||
}
|
||||
|
||||
function refreshFlyout() {
|
||||
updateRailUI();
|
||||
}
|
||||
|
||||
function refreshLabels() {
|
||||
window.I18n?.applyDOM?.();
|
||||
updateRailUI();
|
||||
@@ -260,9 +288,11 @@
|
||||
window.NavApp = {
|
||||
init,
|
||||
syncFromPage,
|
||||
syncDashboardSection,
|
||||
applyPermissions,
|
||||
selectModule,
|
||||
selectSection,
|
||||
toggleFlyout,
|
||||
refreshFlyout,
|
||||
};
|
||||
})();
|
||||
|
||||
398
www/style.css
398
www/style.css
@@ -26,6 +26,8 @@ body {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* —— MiR 3-column navigation (rail + flyout + content) —— */
|
||||
@@ -200,6 +202,8 @@ body {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
box-shadow: -2px 0 12px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
@@ -1002,7 +1006,18 @@ canvas {
|
||||
}
|
||||
.content.content--dashboard {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
max-width: 1100px;
|
||||
max-width: none;
|
||||
align-items: stretch;
|
||||
align-content: stretch;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.content.content--dashboard > #pageOverview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.content.content--config {
|
||||
grid-template-columns: var(--leftPaneW, 460px) 10px 1fr;
|
||||
@@ -1339,8 +1354,384 @@ canvas {
|
||||
.missionConfigGrid { display: grid; gap: 12px; }
|
||||
.missionConfigGrid .rowWide { grid-template-columns: 1fr; gap: 6px; }
|
||||
|
||||
.dashboardPage { display: grid; gap: 16px; min-width: 0; width: 100%; }
|
||||
.dashboardToolbar { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.dashboardShell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
.dashboardContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
.dashboardListView,
|
||||
.dashboardCreateView,
|
||||
.dashboardDesignerView {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dashboardListView[hidden],
|
||||
.dashboardCreateView[hidden],
|
||||
.dashboardDesignerView[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.dashboardListHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dashboardListTitle {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.dashboardListSub {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.dashboardListHelp {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
}
|
||||
.dashboardListHeaderActions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.dashboardCreateBtn { font-weight: 700; }
|
||||
|
||||
.dashboardCreateHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dashboardCreateTitle {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.dashboardCreateSub {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.dashboardCreateBackBtn { white-space: nowrap; }
|
||||
.dashboardCreateForm {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
}
|
||||
.dashboardCreatePanel {
|
||||
border: 1px solid #c8d4e0;
|
||||
background: #fff;
|
||||
padding: 20px 24px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
.dashboardCreateField {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.dashboardCreateField label {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #334155;
|
||||
}
|
||||
.dashboardCreateField input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid #c8d4e0;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
color: #64748b;
|
||||
}
|
||||
.dashboardCreateField input:focus {
|
||||
outline: none;
|
||||
border-color: #64748b;
|
||||
color: #0f172a;
|
||||
}
|
||||
.dashboardCreateActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dashboardMirBtn {
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #c8d4e0;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
}
|
||||
.dashboardMirBtn:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
.dashboardCreateSubmitBtn,
|
||||
.dashboardPermissionsApplyBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700;
|
||||
background: #22a06b !important;
|
||||
border-color: #1a8f5c !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.dashboardCreateSubmitBtn:hover,
|
||||
.dashboardPermissionsApplyBtn:hover {
|
||||
background: #1a8f5c !important;
|
||||
border-color: #178052 !important;
|
||||
}
|
||||
.dashboardCreateSubmitIcon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
.dashboardCreateCancelBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.dashboardCreateCancelIcon { font-size: 13px; line-height: 1; }
|
||||
|
||||
.dashboardPermissionsDialog .missionDialogBody { display: grid; gap: 14px; }
|
||||
.dashboardPermissionsIntro {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.dashboardPermissionsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
.dashboardPermissionsList label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #0f172a;
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
}
|
||||
.dashboardPermissionsList input[type="checkbox"] {
|
||||
width: 16px !important;
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 3px;
|
||||
accent-color: #2563eb;
|
||||
}
|
||||
|
||||
.dashboardListBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dashboardFilter { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 240px; }
|
||||
.dashboardFilterLabel { font-size: 13px; color: var(--muted); white-space: nowrap; }
|
||||
.dashboardFilterInput {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: none;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
.dashboardListCount { font-size: 13px; color: var(--muted); white-space: nowrap; }
|
||||
.dashboardPagination { display: flex; align-items: center; gap: 4px; margin-left: auto; }
|
||||
.dashboardPageLabel { font-size: 13px; color: var(--muted); padding: 0 8px; white-space: nowrap; }
|
||||
.dashboardPageBtn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.dashboardTableWrap {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
overflow: visible;
|
||||
border: 1px solid #c8d4e0;
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
}
|
||||
.dashboardTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.dashboardTable thead {
|
||||
background: #eef2f6;
|
||||
}
|
||||
.dashboardTable th,
|
||||
.dashboardTable td {
|
||||
padding: 0 16px;
|
||||
height: 52px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid #d8e0e8;
|
||||
border-right: 1px solid #d8e0e8;
|
||||
}
|
||||
.dashboardTable th:last-child,
|
||||
.dashboardTable td:last-child { border-right: none; }
|
||||
.dashboardTable th {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #334155;
|
||||
}
|
||||
.dashboardTableStatusCol { width: 56px; text-align: center; padding: 0 8px; }
|
||||
.dashboardTable th.dashboardTableStatusCol { width: 56px; }
|
||||
.dashboardTableNameCell { width: auto; font-weight: 600; color: #0f172a; }
|
||||
.dashboardTableCreatedCell { width: 24%; color: #334155; }
|
||||
.dashboardTableFuncCol { width: 168px; }
|
||||
.dashboardTable tbody tr:last-child td { border-bottom: none; }
|
||||
.dashboardTable tbody tr:hover:not(.dashboardTableEmptyRow) { background: #f8fafc; }
|
||||
.dashboardTableRow.is-default .dashboardTableNameCell,
|
||||
.dashboardTableRow.is-default .dashboardTableCreatedCell { color: #94a3b8; }
|
||||
.dashboardTableRow.is-active .dashboardTableNameCell { color: #0f172a; }
|
||||
.dashboardTableEmptyRow td {
|
||||
height: 120px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.dashboardActiveMark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
background: #22a06b;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.dashboardActiveMark--placeholder {
|
||||
visibility: hidden;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.dashboardFuncBtns { display: flex; gap: 8px; align-items: center; justify-content: flex-start; }
|
||||
.dashboardFuncBtn {
|
||||
appearance: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid #c8d4e0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
.dashboardFuncBtn:hover:not(:disabled) { border-color: #94a3b8; background: #f8fafc; }
|
||||
.dashboardFuncBtn:disabled { opacity: 0.28; cursor: not-allowed; }
|
||||
.dashboardFuncIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
background: currentColor;
|
||||
color: #64748b;
|
||||
}
|
||||
.dashboardFuncBtn:hover:not(:disabled) .dashboardFuncIcon { color: #334155; }
|
||||
.dashboardFuncIcon--design {
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M12 3c-1.5 2.4-3.6 4.5-6 6 1.5 1.5 3.6 3.6 6 6 1.5-2.4 3.6-4.5 6-6-2.4-1.5-4.5-3.6-6-6zm0 3.8c.9 1.1 2 2.2 3.2 3.2-1.2 1-2.3 2.1-3.2 3.2-.9-1.1-2-2.2-3.2-3.2 1.2-1 2.3-2.1 3.2-3.2z'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M12 3c-1.5 2.4-3.6 4.5-6 6 1.5 1.5 3.6 3.6 6 6 1.5-2.4 3.6-4.5 6-6-2.4-1.5-4.5-3.6-6-6zm0 3.8c.9 1.1 2 2.2 3.2 3.2-1.2 1-2.3 2.1-3.2 3.2-.9-1.1-2-2.2-3.2-3.2 1.2-1 2.3-2.1 3.2-3.2z'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
}
|
||||
.dashboardFuncIcon--edit {
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M4 17.5V20h2.5L17 9.5 14.5 7 4 17.5zm14.7-9.8a1 1 0 0 0 0-1.4l-1.8-1.8a1 1 0 0 0-1.4 0l-1.3 1.3 3.2 3.2 1.3-1.3z'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M4 17.5V20h2.5L17 9.5 14.5 7 4 17.5zm14.7-9.8a1 1 0 0 0 0-1.4l-1.8-1.8a1 1 0 0 0-1.4 0l-1.3 1.3 3.2 3.2 1.3-1.3z'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
}
|
||||
.dashboardFuncIcon--delete {
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M6 6l12 12M18 6L6 18' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M6 6l12 12M18 6L6 18' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
}
|
||||
.dashboardTableWrap.is-empty .dashboardTable thead { display: none; }
|
||||
.dashboardDesignerHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dashboardDesignerTitle { margin: 0; font-size: 18px; font-weight: 800; }
|
||||
.dashboardDesignerBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.dashboardDesignerEmpty { text-align: center; padding: 24px 0; }
|
||||
.dashboardPage { display: grid; gap: 0; min-width: 0; width: 100%; }
|
||||
.dashboardMain { min-width: 0; }
|
||||
.dashboardGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
@@ -1441,7 +1832,6 @@ canvas {
|
||||
}
|
||||
.dashboardCancelBtn:hover:not(:disabled) { background: #fee2e2; }
|
||||
.dashboardCancelBtn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.dashboardInfoCard .dashboardInfoGrid { display: grid; gap: 8px; }
|
||||
.dashboardEmpty { text-align: center; padding: 12px 0 0; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
|
||||
Reference in New Issue
Block a user