add top bar
This commit is contained in:
13
www/app.js
13
www/app.js
@@ -3,7 +3,6 @@ const el = (id) => document.getElementById(id);
|
||||
const statusEl = el("status");
|
||||
const listEl = el("lidarList");
|
||||
const lidarFormHintEl = el("lidarFormHint");
|
||||
const pageTitleEl = document.querySelector(".pageTitle");
|
||||
const navItemEls = Array.from(document.querySelectorAll(".navItem[data-page]"));
|
||||
const pageOverviewEl = el("pageOverview");
|
||||
const pageConfigEl = el("pageConfig");
|
||||
@@ -134,13 +133,6 @@ function setActivePage(page) {
|
||||
if (on) a.setAttribute("aria-current", "page");
|
||||
else a.removeAttribute("aria-current");
|
||||
});
|
||||
const titles = {
|
||||
dashboard: "Dashboard",
|
||||
config: "Cấu Hình",
|
||||
missions: "Missions",
|
||||
integrations: "Tích hợp",
|
||||
};
|
||||
if (pageTitleEl) pageTitleEl.textContent = titles[p] || "Cấu Hình";
|
||||
if (pageOverviewEl) pageOverviewEl.hidden = p !== "dashboard";
|
||||
if (pageConfigEl) pageConfigEl.hidden = p !== "config";
|
||||
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
|
||||
@@ -153,7 +145,6 @@ function setActivePage(page) {
|
||||
contentEl.classList.toggle("content--missions", p === "missions");
|
||||
contentEl.classList.toggle("content--integrations", p === "integrations");
|
||||
}
|
||||
if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config";
|
||||
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
|
||||
else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide();
|
||||
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
|
||||
@@ -3155,7 +3146,7 @@ async function loadAll() {
|
||||
}
|
||||
}
|
||||
|
||||
el("refreshBtn").addEventListener("click", async () => {
|
||||
el("refreshBtn")?.addEventListener("click", async () => {
|
||||
try {
|
||||
state.viewInitialized = false;
|
||||
await loadAll();
|
||||
@@ -3398,7 +3389,7 @@ window.addEventListener("keyup", (evt) => {
|
||||
if (evt.key === "Shift") canvasWrap.classList.remove("shift-pan");
|
||||
});
|
||||
|
||||
saveLayoutBtn.addEventListener("click", async () => {
|
||||
saveLayoutBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await saveCurrentLayout();
|
||||
setStatus(`Đã lưu layout «${state.activeLayoutName || ""}»`);
|
||||
|
||||
73
www/auth.js
73
www/auth.js
@@ -12,10 +12,8 @@
|
||||
const loginPinErrorEl = el("loginPinError");
|
||||
const loginTabPasswordEl = el("loginTabPassword");
|
||||
const loginTabPinEl = el("loginTabPin");
|
||||
const userMenuBtnEl = el("userMenuBtn");
|
||||
const userMenuPanelEl = el("userMenuPanel");
|
||||
const userMenuNameEl = el("userMenuName");
|
||||
const userMenuGroupEl = el("userMenuGroup");
|
||||
const userMenuBtnEl = el("mirUserBtn");
|
||||
const userMenuPanelEl = el("mirUserPanel");
|
||||
const changePasswordDialogEl = el("changePasswordDialog");
|
||||
const changePasswordFormEl = el("changePasswordForm");
|
||||
const changePasswordErrorEl = el("changePasswordError");
|
||||
@@ -168,17 +166,21 @@
|
||||
|
||||
function updateUserMenu() {
|
||||
if (!currentUser) return;
|
||||
if (userMenuNameEl) userMenuNameEl.textContent = currentUser.display_name || currentUser.username || "—";
|
||||
if (userMenuGroupEl) userMenuGroupEl.textContent = currentUser.group_name || "—";
|
||||
if (window.TopbarApp?.updateUserMenu) {
|
||||
window.TopbarApp.updateUserMenu(currentUser);
|
||||
return;
|
||||
}
|
||||
if (userMenuBtnEl) {
|
||||
const label = currentUser.display_name || currentUser.username || "User";
|
||||
userMenuBtnEl.textContent = label;
|
||||
userMenuBtnEl.title = `${label} (${currentUser.group_name || ""})`;
|
||||
const label = (currentUser.group_name || "USER").toUpperCase();
|
||||
userMenuBtnEl.title = `${currentUser.display_name || currentUser.username} (${currentUser.group_name || ""})`;
|
||||
const labelEl = el("mirUserLabel");
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
}
|
||||
}
|
||||
|
||||
function unlockApp() {
|
||||
setLoginLoading(false);
|
||||
document.body.classList.remove("auth-logged-out");
|
||||
if (loginScreenEl) {
|
||||
loginScreenEl.setAttribute("hidden", "");
|
||||
loginScreenEl.style.display = "none";
|
||||
@@ -188,14 +190,16 @@
|
||||
shellEl.style.display = "";
|
||||
}
|
||||
applyNavPermissions();
|
||||
updateUserMenu();
|
||||
ready = true;
|
||||
window.dispatchEvent(new CustomEvent("lm:auth-ready", { detail: { user: currentUser } }));
|
||||
updateUserMenu();
|
||||
}
|
||||
|
||||
function lockApp() {
|
||||
ready = false;
|
||||
currentUser = null;
|
||||
document.body.classList.add("auth-logged-out");
|
||||
window.TopbarApp?.hideJoystickOverlay?.();
|
||||
if (shellEl) shellEl.classList.add("auth-locked");
|
||||
if (loginScreenEl) {
|
||||
loginScreenEl.removeAttribute("hidden");
|
||||
@@ -239,6 +243,12 @@
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await window.TopbarApp?.disengageJoystick?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
window.TopbarApp?.hideJoystickOverlay?.();
|
||||
try {
|
||||
await apiJson("/api/auth/logout", { method: "POST", body: "{}" });
|
||||
} catch {
|
||||
@@ -251,6 +261,17 @@
|
||||
window.dispatchEvent(new Event("lm:auth-logout"));
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
const display_name = el("mirProfileDisplayName")?.value?.trim() || "";
|
||||
if (!display_name) throw new Error("Tên hiển thị không được trống");
|
||||
const data = await apiJson("/api/auth/profile", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ display_name }),
|
||||
});
|
||||
currentUser = data.user;
|
||||
updateUserMenu();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
loginTabPasswordEl?.addEventListener("click", (evt) => {
|
||||
evt.preventDefault();
|
||||
@@ -308,29 +329,28 @@
|
||||
}
|
||||
});
|
||||
|
||||
userMenuBtnEl?.addEventListener("click", (evt) => {
|
||||
evt.stopPropagation();
|
||||
const open = userMenuPanelEl?.hasAttribute("hidden");
|
||||
if (open) userMenuPanelEl?.removeAttribute("hidden");
|
||||
else userMenuPanelEl?.setAttribute("hidden", "");
|
||||
});
|
||||
|
||||
document.addEventListener("click", () => {
|
||||
userMenuPanelEl?.setAttribute("hidden", "");
|
||||
});
|
||||
|
||||
el("userMenuSignOutBtn")?.addEventListener("click", (evt) => {
|
||||
el("mirUserSignOutBtn")?.addEventListener("click", (evt) => {
|
||||
evt.preventDefault();
|
||||
logout();
|
||||
});
|
||||
|
||||
el("userMenuChangePasswordBtn")?.addEventListener("click", (evt) => {
|
||||
el("mirUserChangePasswordBtn")?.addEventListener("click", (evt) => {
|
||||
evt.preventDefault();
|
||||
userMenuPanelEl?.setAttribute("hidden", "");
|
||||
changePasswordErrorEl && (changePasswordErrorEl.textContent = "");
|
||||
changePasswordDialogEl?.showModal();
|
||||
});
|
||||
|
||||
el("mirProfileSaveBtn")?.addEventListener("click", async (evt) => {
|
||||
evt.preventDefault();
|
||||
try {
|
||||
await saveProfile();
|
||||
userMenuPanelEl?.setAttribute("hidden", "");
|
||||
} catch (e) {
|
||||
alert(e.message || "Lưu thông tin thất bại");
|
||||
}
|
||||
});
|
||||
|
||||
changePasswordFormEl?.addEventListener("submit", async (evt) => {
|
||||
evt.preventDefault();
|
||||
const current = el("changePasswordCurrent")?.value || "";
|
||||
@@ -368,5 +388,12 @@
|
||||
bindEvents();
|
||||
setLoginMode("password");
|
||||
shellEl?.classList.add("auth-locked");
|
||||
if (window.location.search) {
|
||||
try {
|
||||
history.replaceState({}, "", window.location.pathname);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
tryRestoreSession();
|
||||
})();
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
const store = {
|
||||
widgets: [],
|
||||
editMode: false,
|
||||
pollTimer: null,
|
||||
pollActive: false,
|
||||
queueUnsub: null,
|
||||
};
|
||||
|
||||
@@ -353,13 +353,14 @@
|
||||
stopDashboardPoll();
|
||||
missions()?.refreshQueue?.();
|
||||
store.queueUnsub = missions()?.onQueueUpdate?.(() => refreshDynamicWidgets());
|
||||
store.pollTimer = setInterval(() => missions()?.refreshQueue?.(), 2000);
|
||||
missions()?.startQueuePoll?.();
|
||||
store.pollActive = true;
|
||||
}
|
||||
|
||||
function stopDashboardPoll() {
|
||||
if (store.pollTimer) {
|
||||
clearInterval(store.pollTimer);
|
||||
store.pollTimer = null;
|
||||
if (store.pollActive) {
|
||||
missions()?.stopQueuePoll?.();
|
||||
store.pollActive = false;
|
||||
}
|
||||
if (store.queueUnsub) {
|
||||
store.queueUnsub();
|
||||
|
||||
138
www/index.html
138
www/index.html
@@ -6,7 +6,7 @@
|
||||
<title>LiDAR Manager</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<body class="auth-logged-out">
|
||||
<div id="loginScreen" class="loginScreen">
|
||||
<div class="loginFrame">
|
||||
<header class="loginHeader">
|
||||
@@ -33,7 +33,7 @@
|
||||
<p>Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.</p>
|
||||
</div>
|
||||
<div class="loginForms">
|
||||
<form id="loginForm" class="loginForm" action="#" method="post">
|
||||
<form id="loginForm" class="loginForm" action="#" method="post" novalidate>
|
||||
<label class="loginField">
|
||||
<span class="loginFieldLabel">Tên đăng nhập:</span>
|
||||
<input id="loginUsername" name="username" type="text" autocomplete="username" placeholder="Admin" required />
|
||||
@@ -131,25 +131,95 @@
|
||||
</aside>
|
||||
|
||||
<div class="body">
|
||||
<header class="topbar">
|
||||
<div class="topbarTitle">
|
||||
<div class="kicker">PhenikaaX Robotics</div>
|
||||
<div class="pageTitle">Cấu Hình</div>
|
||||
</div>
|
||||
<div class="topbarActions">
|
||||
<div class="userMenuWrap">
|
||||
<button id="userMenuBtn" type="button" class="btn subtle userMenuBtn" aria-haspopup="true">…</button>
|
||||
<div id="userMenuPanel" class="userMenuPanel" hidden>
|
||||
<div class="userMenuHeader">
|
||||
<div id="userMenuName" class="userMenuName">—</div>
|
||||
<div id="userMenuGroup" class="userMenuGroup mutedNote">—</div>
|
||||
</div>
|
||||
<button id="userMenuChangePasswordBtn" type="button" class="userMenuItem">Đổi mật khẩu</button>
|
||||
<button id="userMenuSignOutBtn" type="button" class="userMenuItem userMenuItemDanger">Đăng xuất</button>
|
||||
<header class="mirTopbar" id="mirTopbar">
|
||||
<div class="mirTopbarInner">
|
||||
<div class="mirTopbarLeft">
|
||||
<div class="mirRobotId" id="mirRobotId" title="Robot">RobotApp</div>
|
||||
|
||||
<button type="button" class="mirPauseBtn" id="mirSegControl" aria-label="Start / Pause robot" title="Start / Pause robot">
|
||||
<svg class="mirPauseBtnIcon mirPauseBtnIcon--pause" id="mirControlIconPause" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||
<rect x="6" y="5" width="4.5" height="14" rx="1" fill="#f39c12"/>
|
||||
<rect x="13.5" y="5" width="4.5" height="14" rx="1" fill="#f39c12"/>
|
||||
</svg>
|
||||
<svg class="mirPauseBtnIcon mirPauseBtnIcon--play" id="mirControlIconPlay" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" hidden>
|
||||
<path d="M9 6.5v11l9-5.5-9-5.5z" fill="#7bed9f"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="mirMissionStrip" id="mirMissionStrip">
|
||||
<span class="mirMissionMsg" id="mirMissionMsg">…</span>
|
||||
<span class="mirStatePill" id="mirControlPill">PAUSED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mirTopbarRight">
|
||||
<button type="button" class="mirSegment mirSegment--status" id="mirSegStatus" aria-haspopup="true" aria-expanded="false">
|
||||
<svg class="mirSvgIcon mirStatusSvg is-ok" id="mirStatusIcon" viewBox="0 0 20 20" width="18" height="18" aria-hidden="true">
|
||||
<circle cx="10" cy="10" r="9" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M6 10.2l2.4 2.4L14 7.2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span class="mirSegmentLabel" id="mirStatusLabel" data-i18n="topbar.allOk">ALL OK</span>
|
||||
<span class="mirCaret" aria-hidden="true">▴</span>
|
||||
</button>
|
||||
<div class="mirPanel mirPanel--status" id="mirStatusPanel" hidden>
|
||||
<div class="mirPanelBody" id="mirStatusPanelBody"></div>
|
||||
<div class="mirPanelFooter" id="mirStatusPanelFooter" hidden>
|
||||
<button type="button" class="mirBtn mirBtn--reset" id="mirErrorResetBtn" data-i18n="topbar.reset">RESET</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="mirSegment mirSegment--locale" id="mirSegLocale" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mirFlag" id="mirLocaleFlag" aria-hidden="true">🇻🇳</span>
|
||||
<span class="mirSegmentLabel" id="mirLocaleLabel">TIẾNG VIỆT</span>
|
||||
<span class="mirCaret" aria-hidden="true">▴</span>
|
||||
</button>
|
||||
<div class="mirPanel mirPanel--locale" id="mirLocalePanel" hidden>
|
||||
<button type="button" class="mirLocaleOption" data-locale="vi">🇻🇳 Tiếng Việt</button>
|
||||
<button type="button" class="mirLocaleOption" data-locale="en">🇺🇸 English</button>
|
||||
</div>
|
||||
|
||||
<button type="button" class="mirSegment mirSegment--user" id="mirUserBtn" aria-haspopup="true" aria-expanded="false">
|
||||
<svg class="mirSvgIcon mirUserSvg" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
||||
<circle cx="12" cy="8" r="4" fill="currentColor"/>
|
||||
<path d="M5 20c0-4 3.5-6 7-6s7 2 7 6" fill="currentColor"/>
|
||||
</svg>
|
||||
<span class="mirSegmentLabel" id="mirUserLabel">USER</span>
|
||||
<span class="mirCaret" aria-hidden="true">▴</span>
|
||||
</button>
|
||||
<div class="mirPanel mirPanel--user" id="mirUserPanel" hidden>
|
||||
<div class="mirUserPanelHeader">
|
||||
<div class="mirUserPanelAvatar" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="#64748b"><circle cx="12" cy="8" r="4"/><path d="M5 20c0-4 3.5-6 7-6s7 2 7 6"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mirUserPanelRole" id="mirUserPanelRole">—</div>
|
||||
<div class="mirUserPanelName mutedNote" id="mirUserPanelName">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="mirProfileField">
|
||||
<span data-i18n="topbar.displayName">Tên hiển thị</span>
|
||||
<input id="mirProfileDisplayName" type="text" autocomplete="name" />
|
||||
</label>
|
||||
<button type="button" class="mirBtn mirBtn--primary" id="mirProfileSaveBtn" data-i18n="topbar.changeUserData">Đổi thông tin</button>
|
||||
<button type="button" class="mirBtn mirBtn--primary subtle" id="mirUserChangePasswordBtn" data-i18n="topbar.changePassword">Đổi mật khẩu</button>
|
||||
<button type="button" class="mirBtn mirBtn--danger" id="mirUserSignOutBtn" data-i18n="topbar.logout">Đăng xuất</button>
|
||||
</div>
|
||||
|
||||
<button type="button" class="mirSegment mirSegment--joystick" id="mirSegJoystick" title="Engage joystick" aria-label="Joystick">
|
||||
<svg class="mirSvgIcon mirJoystickSvg" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||
<rect x="7" y="10" width="10" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
|
||||
<line x1="12" y1="10" x2="12" y2="4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="3" r="2.2" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="mirSegment mirSegment--battery" id="mirSegBattery" title="Battery">
|
||||
<span class="mirBatteryIcon" id="mirBatteryIcon" aria-hidden="true">
|
||||
<span class="mirBatteryLevel" id="mirBatteryLevel"></span>
|
||||
</span>
|
||||
<span class="mirSegmentLabel mirBatteryPct" id="mirBatteryLabel">—%</span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="refreshBtn" type="button" class="btn subtle">Tải lại</button>
|
||||
<button id="saveLayoutBtn" class="btn primary" type="button">Lưu layout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -210,6 +280,10 @@
|
||||
<div class="cardTitle">Quản lý layout</div>
|
||||
<div class="cardSub">Nhiều cấu hình robot — mỗi layout có LiDAR và model riêng.</div>
|
||||
</div>
|
||||
<div class="configPageActions">
|
||||
<button id="refreshBtn" type="button" class="btn subtle">Tải lại</button>
|
||||
<button id="saveLayoutBtn" type="button" class="btn primary">Lưu layout</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<div class="row rowWide">
|
||||
@@ -1042,8 +1116,34 @@ GET /api/v2.0.0/status</pre>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div id="joystickOverlay" class="joystickOverlay" hidden>
|
||||
<div class="joystickOverlayCard">
|
||||
<div class="joystickOverlayHeader">
|
||||
<strong data-i18n="topbar.joystickTitle">Điều khiển tay (Joystick)</strong>
|
||||
<span class="mutedNote" id="joystickSpeedLabel">fast</span>
|
||||
</div>
|
||||
<div class="joystickPadWrap">
|
||||
<div class="joystickPad" id="joystickPad">
|
||||
<div class="joystickStick" id="joystickStick"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="joystickOverlayActions">
|
||||
<label class="joystickSpeedSelect">
|
||||
<span data-i18n="topbar.joystickSpeed">Tốc độ</span>
|
||||
<select id="joystickSpeedSelect">
|
||||
<option value="slow">Slow</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="fast" selected>Fast</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="mirBtn mirBtn--danger" id="joystickDisengageBtn" data-i18n="topbar.joystickOff">Tắt joystick</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/auth.js"></script>
|
||||
<script src="/missions.js"></script>
|
||||
<script src="/topbar.js"></script>
|
||||
<script src="/dashboard.js"></script>
|
||||
<script src="/integrations.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
|
||||
@@ -87,10 +87,12 @@
|
||||
configListPath: "root",
|
||||
queue: [],
|
||||
runner: { state: "idle", message: "" },
|
||||
queuePollTimer: null,
|
||||
pendingQueueMissionId: null,
|
||||
};
|
||||
|
||||
let queuePollRefs = 0;
|
||||
let queuePollTimer = null;
|
||||
|
||||
function newId() {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
|
||||
return `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -714,15 +716,27 @@
|
||||
|
||||
function startQueuePoll() {
|
||||
if (window.AuthApp && !window.AuthApp.isReady()) return;
|
||||
stopQueuePoll();
|
||||
refreshQueue();
|
||||
store.queuePollTimer = setInterval(refreshQueue, 1500);
|
||||
queuePollRefs += 1;
|
||||
if (queuePollRefs === 1) {
|
||||
refreshQueue();
|
||||
queuePollTimer = setInterval(refreshQueue, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
function stopQueuePoll() {
|
||||
if (store.queuePollTimer) {
|
||||
clearInterval(store.queuePollTimer);
|
||||
store.queuePollTimer = null;
|
||||
if (queuePollRefs <= 0) return;
|
||||
queuePollRefs -= 1;
|
||||
if (queuePollRefs === 0 && queuePollTimer) {
|
||||
clearInterval(queuePollTimer);
|
||||
queuePollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopQueuePollForce() {
|
||||
queuePollRefs = 0;
|
||||
if (queuePollTimer) {
|
||||
clearInterval(queuePollTimer);
|
||||
queuePollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1381,5 +1395,5 @@
|
||||
}
|
||||
if (window.AuthApp?.isReady()) boot();
|
||||
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||
window.addEventListener("lm:auth-logout", stopQueuePoll);
|
||||
window.addEventListener("lm:auth-logout", stopQueuePollForce);
|
||||
})();
|
||||
|
||||
401
www/style.css
401
www/style.css
@@ -118,21 +118,397 @@ body {
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-rows: 72px 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar {
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 14px 18px;
|
||||
|
||||
/* —— MiR-style top bar —— */
|
||||
.mirTopbar {
|
||||
background: linear-gradient(180deg, #4d7fbe 0%, #2f5f9e 48%, #254f87 100%);
|
||||
color: #fff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.22);
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.mirTopbarInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
gap: 12px;
|
||||
min-height: 52px;
|
||||
padding: 0 10px 0 14px;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.mirTopbarLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.mirTopbarRight {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
.mirRobotId {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
padding-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mirPauseBtn {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(0, 0, 0, 0.28);
|
||||
background: rgba(16, 42, 82, 0.72);
|
||||
border-radius: 6px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.mirPauseBtn:hover:not(:disabled) {
|
||||
background: rgba(16, 42, 82, 0.9);
|
||||
}
|
||||
.mirPauseBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mirPauseBtnIcon { display: block; }
|
||||
.mirPauseBtnIcon[hidden] { display: none !important; }
|
||||
.mirMissionStrip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 520px;
|
||||
padding: 7px 8px 7px 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(14, 36, 72, 0.72);
|
||||
border: 1px solid rgba(0, 0, 0, 0.22);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.mirMissionMsg {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.mirStatePill {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: #e67e22;
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.mirStatePill.is-running { background: #3dba6a; }
|
||||
.mirStatePill.is-paused { background: #e67e22; }
|
||||
.mirStatePill.is-error { background: #c0392b; }
|
||||
|
||||
.mirSegment {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 14px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.18);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
min-height: 52px;
|
||||
}
|
||||
.mirTopbarRight > .mirPanel {
|
||||
top: 100%;
|
||||
}
|
||||
.mirSegment:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mirSegment:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.mirSegment--battery {
|
||||
cursor: default;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.mirSegment--battery:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.mirSegment--joystick.is-active {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.mirSegment--status.is-error {
|
||||
background: rgba(192, 57, 43, 0.35);
|
||||
}
|
||||
.mirSvgIcon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
}
|
||||
.mirStatusSvg.is-ok { color: #6ee7a0; }
|
||||
.mirStatusSvg.is-error { color: #ff8a8a; }
|
||||
.mirFlag {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.mirSegmentLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mirBatteryPct {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mirCaret {
|
||||
font-size: 9px;
|
||||
opacity: 0.85;
|
||||
margin-left: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mirPanel {
|
||||
position: absolute;
|
||||
top: calc(100% - 2px);
|
||||
min-width: 280px;
|
||||
background: #ececec;
|
||||
color: #1f2937;
|
||||
border: 1px solid #c5c5c5;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
z-index: 50;
|
||||
}
|
||||
.mirPanel[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.mirPanel--status { right: 280px; left: auto; }
|
||||
.mirPanel--locale { right: 140px; left: auto; }
|
||||
.mirPanel--user { right: 8px; left: auto; }
|
||||
.mirPanelBody { padding: 14px 16px; font-size: 13px; }
|
||||
.mirStatusOkTitle, .mirStatusErrorTitle {
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.mirStatusErrorTitle { color: #c0392b; }
|
||||
.mirStatusDesc { color: #4b5563; line-height: 1.45; }
|
||||
.mirStatusRow { font-size: 12px; margin-bottom: 4px; }
|
||||
.mirStatusMeta { margin-top: 8px; font-size: 12px; color: #6b7280; }
|
||||
.mirPanelFooter {
|
||||
padding: 0 16px 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.mirPanel--locale {
|
||||
flex-direction: column;
|
||||
padding: 6px;
|
||||
min-width: 200px;
|
||||
}
|
||||
.mirPanel--locale:not([hidden]) {
|
||||
display: flex;
|
||||
}
|
||||
.mirLocaleOption {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mirLocaleOption:hover { background: rgba(0, 0, 0, 0.06); }
|
||||
|
||||
.mirUserPanelHeader {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px 16px 8px;
|
||||
}
|
||||
.mirUserPanelAvatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 999px;
|
||||
background: #d1d5db;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
.mirUserPanelRole {
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.mirProfileField {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 0 16px 10px;
|
||||
font-size: 12px;
|
||||
color: #4b5563;
|
||||
}
|
||||
.mirProfileField input {
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mirPanel--user .mirBtn {
|
||||
margin: 0 16px 8px;
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
.mirBtn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mirBtn--primary { background: #2980b9; color: #fff; }
|
||||
.mirBtn--primary.subtle { background: #5dade2; }
|
||||
.mirBtn--danger { background: #c0392b; color: #fff; }
|
||||
.mirBtn--reset { background: #7f8c8d; color: #fff; min-width: 96px; }
|
||||
|
||||
.mirBatteryIcon {
|
||||
width: 28px;
|
||||
height: 13px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.95);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
padding: 1px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mirBatteryIcon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
top: 3px;
|
||||
width: 3px;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
.mirBatteryLevel {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 54%;
|
||||
background: #fff;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.mirSegment--battery.is-low .mirBatteryLevel { background: #ffb4b4; }
|
||||
.mirSegment--battery.is-mid .mirBatteryLevel { background: #ffe08a; }
|
||||
|
||||
.configPageActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.joystickOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.joystickOverlay[hidden],
|
||||
body.auth-logged-out .joystickOverlay {
|
||||
display: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
.joystickOverlayCard {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
width: min(360px, 100%);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.joystickOverlayHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.joystickPadWrap { display: grid; place-items: center; padding: 8px 0 14px; }
|
||||
.joystickPad {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle at 50% 50%, #f8fafc 0%, #e2e8f0 100%);
|
||||
border: 2px solid #cbd5e1;
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
}
|
||||
.joystickStick {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: -28px 0 0 -28px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #3b82f6, #1d4ed8);
|
||||
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.35);
|
||||
}
|
||||
.joystickOverlayActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.joystickSpeedSelect {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.joystickSpeedSelect select {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.mirTopbar--no-missions .mirTopbarLeft {
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
flex: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.mirTopbar--no-missions .mirTopbarRight .mirSegment--joystick,
|
||||
.mirTopbar--no-missions .mirTopbarRight .mirSegment--battery {
|
||||
display: none;
|
||||
}
|
||||
.kicker { font-size: 12px; color: var(--muted); }
|
||||
.pageTitle { font-size: 16px; font-weight: 800; letter-spacing: 0.2px; margin-top: 2px; }
|
||||
.topbarActions { display: flex; gap: 10px; align-items: center; }
|
||||
|
||||
.content {
|
||||
padding: 18px;
|
||||
@@ -986,6 +1362,13 @@ canvas {
|
||||
.dashboardInfoCard .dashboardInfoGrid { display: grid; gap: 8px; }
|
||||
.dashboardEmpty { text-align: center; padding: 12px 0 0; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.mirMissionStrip { max-width: 280px; }
|
||||
.mirPanel--status { right: 8px; left: 8px; }
|
||||
.mirPanel--locale { right: 8px; left: 8px; }
|
||||
.mirPanel--user { right: 8px; left: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: relative; height: auto; }
|
||||
|
||||
524
www/topbar.js
Normal file
524
www/topbar.js
Normal file
@@ -0,0 +1,524 @@
|
||||
(() => {
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
||||
const I18N = {
|
||||
vi: {
|
||||
"topbar.allOk": "ỔN ĐỊNH",
|
||||
"topbar.error": "LỖI",
|
||||
"topbar.paused": "TẠM DỪNG",
|
||||
"topbar.running": "ĐANG CHẠY",
|
||||
"topbar.waiting": "Đang chờ mission mới…",
|
||||
"topbar.noMissionsQueue": "Không có mission trong queue…",
|
||||
"topbar.reset": "RESET",
|
||||
"topbar.changeUserData": "Lưu thông tin",
|
||||
"topbar.changePassword": "Đổi mật khẩu",
|
||||
"topbar.logout": "Đăng xuất",
|
||||
"topbar.displayName": "Tên hiển thị",
|
||||
"topbar.reload": "Tải lại",
|
||||
"topbar.saveLayout": "Lưu layout",
|
||||
"topbar.joystickTitle": "Điều khiển tay (Joystick)",
|
||||
"topbar.joystickSpeed": "Tốc độ",
|
||||
"topbar.joystickOff": "Tắt joystick",
|
||||
"topbar.localeVi": "TIẾNG VIỆT",
|
||||
"topbar.localeEn": "ENGLISH",
|
||||
"topbar.startHint": "Bấm để START robot",
|
||||
"topbar.pauseHint": "Bấm để PAUSE robot",
|
||||
"topbar.code": "Mã",
|
||||
"topbar.module": "Module",
|
||||
},
|
||||
en: {
|
||||
"topbar.allOk": "ALL OK",
|
||||
"topbar.error": "ERROR",
|
||||
"topbar.paused": "PAUSED",
|
||||
"topbar.running": "RUNNING",
|
||||
"topbar.waiting": "Waiting for new missions…",
|
||||
"topbar.noMissionsQueue": "No missions in queue…",
|
||||
"topbar.reset": "RESET",
|
||||
"topbar.changeUserData": "Change user data",
|
||||
"topbar.changePassword": "Change password",
|
||||
"topbar.logout": "Log out",
|
||||
"topbar.displayName": "Display name",
|
||||
"topbar.reload": "Reload",
|
||||
"topbar.saveLayout": "Save layout",
|
||||
"topbar.joystickTitle": "Manual control (Joystick)",
|
||||
"topbar.joystickSpeed": "Speed",
|
||||
"topbar.joystickOff": "Disengage joystick",
|
||||
"topbar.localeVi": "TIẾNG VIỆT",
|
||||
"topbar.localeEn": "ENGLISH",
|
||||
"topbar.startHint": "Click to START the robot",
|
||||
"topbar.pauseHint": "Click to PAUSE the robot",
|
||||
"topbar.code": "Code",
|
||||
"topbar.module": "Module",
|
||||
},
|
||||
};
|
||||
|
||||
const LOCALE_META = {
|
||||
vi: { flag: "🇻🇳", labelKey: "topbar.localeVi" },
|
||||
en: { flag: "🇺🇸", labelKey: "topbar.localeEn" },
|
||||
};
|
||||
|
||||
let locale = "vi";
|
||||
let robotStatus = null;
|
||||
let pollTimer = null;
|
||||
let eventsBound = false;
|
||||
let openPanel = null;
|
||||
let joystickActive = false;
|
||||
let joystickPointerId = null;
|
||||
let joystickRaf = null;
|
||||
let lastCmd = { linear: 0, angular: 0 };
|
||||
|
||||
function t(key) {
|
||||
return I18N[locale]?.[key] ?? I18N.en[key] ?? key;
|
||||
}
|
||||
|
||||
function canSeeMissions() {
|
||||
return window.AuthApp?.canAccessPage?.("missions");
|
||||
}
|
||||
|
||||
function canControl() {
|
||||
return window.AuthApp?.canWrite?.("missions");
|
||||
}
|
||||
|
||||
async function apiJson(path, opts = {}) {
|
||||
const res = await fetch(path, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
|
||||
...opts,
|
||||
});
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
function applyLocale(next) {
|
||||
locale = LOCALE_META[next] ? next : "vi";
|
||||
try {
|
||||
localStorage.setItem("lm_locale", locale);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
document.documentElement.lang = locale;
|
||||
document.querySelectorAll("[data-i18n]").forEach((node) => {
|
||||
const key = node.dataset.i18n;
|
||||
if (key) node.textContent = t(key);
|
||||
});
|
||||
const meta = LOCALE_META[locale];
|
||||
if (el("mirLocaleFlag")) el("mirLocaleFlag").textContent = meta.flag;
|
||||
if (el("mirLocaleLabel")) el("mirLocaleLabel").textContent = t(meta.labelKey);
|
||||
window.dispatchEvent(new CustomEvent("lm:locale-change", { detail: { locale } }));
|
||||
if (robotStatus) renderAll(robotStatus);
|
||||
}
|
||||
|
||||
function loadLocale() {
|
||||
try {
|
||||
const saved = localStorage.getItem("lm_locale");
|
||||
if (saved && LOCALE_META[saved]) locale = saved;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
applyLocale(locale);
|
||||
}
|
||||
|
||||
function closePanels() {
|
||||
document.querySelectorAll(".mirPanel").forEach((p) => {
|
||||
p.hidden = true;
|
||||
});
|
||||
document.querySelectorAll(".mirSegment[aria-haspopup='true']").forEach((btn) => {
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
openPanel = null;
|
||||
}
|
||||
|
||||
function togglePanel(btn, panel) {
|
||||
if (!btn || !panel) return;
|
||||
const isOpen = btn.getAttribute("aria-expanded") === "true";
|
||||
closePanels();
|
||||
if (!isOpen) {
|
||||
panel.hidden = false;
|
||||
btn.setAttribute("aria-expanded", "true");
|
||||
openPanel = panel;
|
||||
}
|
||||
}
|
||||
|
||||
function missionStripMessage(status) {
|
||||
const pending = Number(status.queue_pending) || 0;
|
||||
const runnerState = status.runner?.state || "idle";
|
||||
const msg = status.message || "";
|
||||
if (runnerState === "running" || runnerState === "paused") {
|
||||
if (msg && msg !== t("topbar.waiting")) return msg;
|
||||
const name = status.runner?.current_action;
|
||||
if (name) return String(name);
|
||||
}
|
||||
if (pending === 0 && runnerState === "idle") return t("topbar.noMissionsQueue");
|
||||
if (msg && msg !== t("topbar.waiting")) return msg;
|
||||
return t("topbar.waiting");
|
||||
}
|
||||
|
||||
function renderControl(status) {
|
||||
const motion = status.motion || "paused";
|
||||
const running = motion === "running";
|
||||
const runnerState = status.runner?.state || "idle";
|
||||
const isError = status.health === "error" || runnerState === "error";
|
||||
|
||||
const pauseIcon = el("mirControlIconPause");
|
||||
const playIcon = el("mirControlIconPlay");
|
||||
const pillEl = el("mirControlPill");
|
||||
const msgEl = el("mirMissionMsg");
|
||||
const btnEl = el("mirSegControl");
|
||||
const stripEl = el("mirMissionStrip");
|
||||
|
||||
if (pauseIcon && playIcon) {
|
||||
pauseIcon.hidden = !running;
|
||||
playIcon.hidden = running;
|
||||
}
|
||||
if (pillEl) {
|
||||
pillEl.textContent = isError ? t("topbar.error") : running ? t("topbar.running") : t("topbar.paused");
|
||||
pillEl.classList.toggle("is-running", running && !isError);
|
||||
pillEl.classList.toggle("is-paused", !running && !isError);
|
||||
pillEl.classList.toggle("is-error", isError);
|
||||
}
|
||||
if (msgEl) msgEl.textContent = missionStripMessage(status);
|
||||
if (stripEl) stripEl.classList.toggle("is-error", isError);
|
||||
|
||||
if (btnEl) {
|
||||
btnEl.disabled = !canControl() || status.health === "error";
|
||||
btnEl.title = running ? t("topbar.pauseHint") : t("topbar.startHint");
|
||||
btnEl.classList.toggle("is-readonly", !canControl());
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
const health = status.health || "ok";
|
||||
const runnerState = status.runner?.state || "idle";
|
||||
const isError = health === "error" || runnerState === "error";
|
||||
const labelEl = el("mirStatusLabel");
|
||||
const iconEl = el("mirStatusIcon");
|
||||
const bodyEl = el("mirStatusPanelBody");
|
||||
const footerEl = el("mirStatusPanelFooter");
|
||||
const segEl = el("mirSegStatus");
|
||||
|
||||
if (labelEl) labelEl.textContent = isError ? t("topbar.error") : t("topbar.allOk");
|
||||
if (iconEl) {
|
||||
iconEl.classList.toggle("is-ok", !isError);
|
||||
iconEl.classList.toggle("is-error", isError);
|
||||
}
|
||||
if (segEl) segEl.classList.toggle("is-error", isError);
|
||||
|
||||
if (!bodyEl) return;
|
||||
const err = status.error && typeof status.error === "object" ? status.error : null;
|
||||
const runnerErr = runnerState === "error" ? status.runner?.message : "";
|
||||
const message = status.message || t("topbar.waiting");
|
||||
|
||||
if (isError && (err || runnerErr)) {
|
||||
bodyEl.innerHTML = `
|
||||
<div class="mirStatusErrorTitle">${t("topbar.error")}</div>
|
||||
${err?.code != null ? `<div class="mirStatusRow"><span>${t("topbar.code")}:</span> <strong>${err.code}</strong></div>` : ""}
|
||||
${err?.module ? `<div class="mirStatusRow"><span>${t("topbar.module")}:</span> ${err.module}</div>` : ""}
|
||||
<div class="mirStatusDesc">${err?.description || runnerErr || message}</div>`;
|
||||
if (footerEl) footerEl.hidden = !canControl();
|
||||
} else {
|
||||
bodyEl.innerHTML = `
|
||||
<div class="mirStatusOkTitle">${t("topbar.allOk")}</div>
|
||||
<div class="mirStatusDesc">${message}</div>
|
||||
${status.queue_pending > 0 ? `<div class="mirStatusMeta">${status.queue_pending} mission(s) in queue</div>` : ""}`;
|
||||
if (footerEl) footerEl.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBattery(status) {
|
||||
const pct = Math.max(0, Math.min(100, Number(status.battery_percent) || 0));
|
||||
const labelEl = el("mirBatteryLabel");
|
||||
const levelEl = el("mirBatteryLevel");
|
||||
const segEl = el("mirSegBattery");
|
||||
if (labelEl) labelEl.textContent = `${pct}%`;
|
||||
if (levelEl) levelEl.style.width = `${pct}%`;
|
||||
if (segEl) {
|
||||
segEl.classList.toggle("is-low", pct < 20);
|
||||
segEl.classList.toggle("is-mid", pct >= 20 && pct < 50);
|
||||
segEl.classList.toggle("is-charging", !!status.battery_charging);
|
||||
}
|
||||
}
|
||||
|
||||
function hideJoystickOverlay() {
|
||||
const overlay = el("joystickOverlay");
|
||||
if (overlay) overlay.hidden = true;
|
||||
joystickActive = false;
|
||||
const stick = el("joystickStick");
|
||||
if (stick) stick.style.transform = "translate(0, 0)";
|
||||
lastCmd = { linear: 0, angular: 0 };
|
||||
}
|
||||
|
||||
function renderJoystick(status) {
|
||||
if (!window.AuthApp?.isReady?.()) {
|
||||
hideJoystickOverlay();
|
||||
return;
|
||||
}
|
||||
const seg = el("mirSegJoystick");
|
||||
const engaged = !!status.joystick_engaged;
|
||||
if (seg) seg.classList.toggle("is-active", engaged);
|
||||
const overlay = el("joystickOverlay");
|
||||
if (overlay) overlay.hidden = !engaged;
|
||||
joystickActive = engaged;
|
||||
const speedSel = el("joystickSpeedSelect");
|
||||
if (speedSel && status.joystick_speed) speedSel.value = status.joystick_speed;
|
||||
if (el("joystickSpeedLabel")) el("joystickSpeedLabel").textContent = status.joystick_speed || "fast";
|
||||
}
|
||||
|
||||
function renderAll(status) {
|
||||
if (!window.AuthApp?.isReady?.()) {
|
||||
hideJoystickOverlay();
|
||||
return;
|
||||
}
|
||||
robotStatus = status;
|
||||
if (!canSeeMissions()) {
|
||||
el("mirTopbar")?.classList.add("mirTopbar--no-missions");
|
||||
return;
|
||||
}
|
||||
el("mirTopbar")?.classList.remove("mirTopbar--no-missions");
|
||||
renderControl(status);
|
||||
renderStatus(status);
|
||||
renderBattery(status);
|
||||
renderJoystick(status);
|
||||
}
|
||||
|
||||
async function fetchStatus() {
|
||||
if (!window.AuthApp?.isReady() || !canSeeMissions()) return;
|
||||
try {
|
||||
const data = await apiJson("/api/robot/status");
|
||||
renderAll(data);
|
||||
window.dispatchEvent(new CustomEvent("lm:robot-status", { detail: data }));
|
||||
} catch (e) {
|
||||
if (String(e.message || "").includes("not authenticated")) return;
|
||||
}
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
stopPoll();
|
||||
fetchStatus();
|
||||
pollTimer = setInterval(fetchStatus, 1500);
|
||||
window.MissionsApp?.startQueuePoll?.();
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
window.MissionsApp?.stopQueuePoll?.();
|
||||
}
|
||||
|
||||
async function toggleRobotMotion() {
|
||||
if (!robotStatus || !canControl()) return;
|
||||
const running = robotStatus.motion === "running";
|
||||
const path = running ? "/api/robot/pause" : "/api/robot/start";
|
||||
const data = await apiJson(path, { method: "POST", body: "{}" });
|
||||
renderAll(data);
|
||||
}
|
||||
|
||||
async function resetError() {
|
||||
const data = await apiJson("/api/robot/errors/reset", { method: "POST", body: "{}" });
|
||||
renderAll(data);
|
||||
closePanels();
|
||||
}
|
||||
|
||||
async function engageJoystick(engaged, speed) {
|
||||
const payload = { engaged };
|
||||
if (speed) payload.speed = speed;
|
||||
const data = await apiJson("/api/robot/joystick", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
renderAll(data);
|
||||
}
|
||||
|
||||
function sendCmdVel(linear, angular) {
|
||||
if (!joystickActive) return;
|
||||
if (Math.abs(linear - lastCmd.linear) < 0.02 && Math.abs(angular - lastCmd.angular) < 0.02) return;
|
||||
lastCmd = { linear, angular };
|
||||
apiJson("/api/robot/cmd_vel", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ linear, angular }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function bindJoystickPad() {
|
||||
const pad = el("joystickPad");
|
||||
const stick = el("joystickStick");
|
||||
if (!pad || !stick) return;
|
||||
|
||||
const center = () => {
|
||||
const r = pad.getBoundingClientRect();
|
||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2, radius: r.width / 2 - 24 };
|
||||
};
|
||||
|
||||
const moveStick = (clientX, clientY) => {
|
||||
const c = center();
|
||||
let dx = clientX - c.x;
|
||||
let dy = clientY - c.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist > c.radius) {
|
||||
dx = (dx / dist) * c.radius;
|
||||
dy = (dy / dist) * c.radius;
|
||||
}
|
||||
stick.style.transform = `translate(${dx}px, ${dy}px)`;
|
||||
const linear = -dy / c.radius;
|
||||
const angular = dx / c.radius;
|
||||
if (joystickRaf) cancelAnimationFrame(joystickRaf);
|
||||
joystickRaf = requestAnimationFrame(() => sendCmdVel(linear, angular));
|
||||
};
|
||||
|
||||
const resetStick = () => {
|
||||
stick.style.transform = "translate(0, 0)";
|
||||
sendCmdVel(0, 0);
|
||||
lastCmd = { linear: 0, angular: 0 };
|
||||
};
|
||||
|
||||
const onDown = (evt) => {
|
||||
if (!joystickActive) return;
|
||||
joystickPointerId = evt.pointerId;
|
||||
pad.setPointerCapture(evt.pointerId);
|
||||
moveStick(evt.clientX, evt.clientY);
|
||||
};
|
||||
const onMove = (evt) => {
|
||||
if (evt.pointerId !== joystickPointerId) return;
|
||||
moveStick(evt.clientX, evt.clientY);
|
||||
};
|
||||
const onUp = (evt) => {
|
||||
if (evt.pointerId !== joystickPointerId) return;
|
||||
joystickPointerId = null;
|
||||
resetStick();
|
||||
};
|
||||
|
||||
pad.addEventListener("pointerdown", onDown);
|
||||
pad.addEventListener("pointermove", onMove);
|
||||
pad.addEventListener("pointerup", onUp);
|
||||
pad.addEventListener("pointercancel", onUp);
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
if (eventsBound) return;
|
||||
eventsBound = true;
|
||||
|
||||
el("mirSegControl")?.addEventListener("click", () => {
|
||||
toggleRobotMotion().catch((e) => alert(e.message));
|
||||
});
|
||||
|
||||
el("mirSegStatus")?.addEventListener("click", (evt) => {
|
||||
evt.stopPropagation();
|
||||
togglePanel(el("mirSegStatus"), el("mirStatusPanel"));
|
||||
});
|
||||
|
||||
el("mirSegLocale")?.addEventListener("click", (evt) => {
|
||||
evt.stopPropagation();
|
||||
togglePanel(el("mirSegLocale"), el("mirLocalePanel"));
|
||||
});
|
||||
|
||||
el("mirUserBtn")?.addEventListener("click", (evt) => {
|
||||
evt.stopPropagation();
|
||||
togglePanel(el("mirUserBtn"), el("mirUserPanel"));
|
||||
});
|
||||
|
||||
el("mirErrorResetBtn")?.addEventListener("click", () => {
|
||||
resetError().catch((e) => alert(e.message));
|
||||
});
|
||||
|
||||
document.querySelectorAll(".mirLocaleOption").forEach((btn) => {
|
||||
btn.addEventListener("click", (evt) => {
|
||||
evt.stopPropagation();
|
||||
applyLocale(btn.dataset.locale || "vi");
|
||||
closePanels();
|
||||
});
|
||||
});
|
||||
|
||||
el("mirSegJoystick")?.addEventListener("click", async () => {
|
||||
if (!canControl()) {
|
||||
alert(locale === "vi" ? "Không có quyền điều khiển" : "No control permission");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (robotStatus?.joystick_engaged) await engageJoystick(false);
|
||||
else await engageJoystick(true, el("joystickSpeedSelect")?.value || "fast");
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
el("joystickDisengageBtn")?.addEventListener("click", () => {
|
||||
engageJoystick(false).catch((e) => alert(e.message));
|
||||
});
|
||||
|
||||
el("joystickSpeedSelect")?.addEventListener("change", (evt) => {
|
||||
if (robotStatus?.joystick_engaged) {
|
||||
engageJoystick(true, evt.target.value).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (evt) => {
|
||||
if (evt.target.closest(".mirSegment") || evt.target.closest(".mirPanel")) return;
|
||||
closePanels();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (evt) => {
|
||||
if (evt.key === "Escape") {
|
||||
closePanels();
|
||||
if (robotStatus?.joystick_engaged) engageJoystick(false).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
bindJoystickPad();
|
||||
}
|
||||
|
||||
function start() {
|
||||
loadLocale();
|
||||
bindEvents();
|
||||
if (!window.AuthApp?.isReady()) return;
|
||||
startPoll();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
stopPoll();
|
||||
closePanels();
|
||||
hideJoystickOverlay();
|
||||
if (window.AuthApp?.isReady?.()) {
|
||||
engageJoystick(false).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function disengageJoystick() {
|
||||
hideJoystickOverlay();
|
||||
if (!window.AuthApp?.isReady?.()) return;
|
||||
try {
|
||||
await engageJoystick(false);
|
||||
} catch {
|
||||
/* session may already be gone */
|
||||
}
|
||||
}
|
||||
|
||||
window.TopbarApp = {
|
||||
t,
|
||||
getLocale: () => locale,
|
||||
applyLocale,
|
||||
refresh: fetchStatus,
|
||||
getRobotStatus: () => robotStatus,
|
||||
hideJoystickOverlay,
|
||||
disengageJoystick,
|
||||
updateUserMenu(user) {
|
||||
const role = (user?.group_name || "USER").toUpperCase();
|
||||
if (el("mirUserLabel")) el("mirUserLabel").textContent = role;
|
||||
if (el("mirUserPanelRole")) el("mirUserPanelRole").textContent = role;
|
||||
if (el("mirUserPanelName")) el("mirUserPanelName").textContent = user?.display_name || user?.username || "—";
|
||||
if (el("mirProfileDisplayName")) el("mirProfileDisplayName").value = user?.display_name || user?.username || "";
|
||||
},
|
||||
};
|
||||
|
||||
if (window.AuthApp?.isReady()) start();
|
||||
else window.addEventListener("lm:auth-ready", () => start(), { once: true });
|
||||
window.addEventListener("lm:auth-logout", () => stop());
|
||||
hideJoystickOverlay();
|
||||
})();
|
||||
Reference in New Issue
Block a user