add top bar

This commit is contained in:
2026-06-16 11:17:28 +07:00
parent 9aee5f4100
commit 1156e1ab29
19 changed files with 1625 additions and 80 deletions

View File

@@ -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 || ""}»`);

View File

@@ -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();
})();

View File

@@ -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();

View File

@@ -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>

View File

@@ -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);
})();

View File

@@ -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
View 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();
})();