Add function Language
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-16 16:44:04 +07:00
parent 1156e1ab29
commit a2e87aeb29
11 changed files with 1790 additions and 474 deletions

View File

@@ -711,6 +711,173 @@
"source": "ui",
"started_at": "2026-06-15T03:25:12Z",
"status": "cancelled"
},
{
"created_at": "2026-06-16T09:41:27Z",
"finished_at": "2026-06-16T09:41:41Z",
"id": "29d42c51d3a96bec",
"log": [
{
"level": "info",
"message": "Loop endless (simulated, max 10000)",
"ts": "2026-06-16T09:41:28Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:28Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:28Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:29Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:29Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:30Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:31Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:32Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:32Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:33Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:34Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:35Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:35Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:36Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:36Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:37Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:38Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:39Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:39Z"
},
{
"level": "info",
"message": "Set PLC register (set_plc_register) simulated",
"ts": "2026-06-16T09:41:40Z"
},
{
"level": "info",
"message": "Wait 1000ms",
"ts": "2026-06-16T09:41:41Z"
},
{
"level": "warn",
"message": "Mission hủy bởi operator",
"ts": "2026-06-16T09:41:41Z"
}
],
"mission": {
"actions": [
{
"children": [
{
"id": "c6c40563-0755-4e97-a48a-bb91ac8b0a9c",
"kind": "action",
"label": "Set PLC register",
"params": {
"action": "set",
"register": 1,
"value": 0
},
"type": "set_plc_register"
},
{
"id": "a1",
"kind": "action",
"label": "Wait",
"params": {
"seconds": 1
},
"type": "wait"
}
],
"id": "65f3cf0b-73fa-4f51-8774-1c5d4c83d8c4",
"kind": "action",
"label": "Loop",
"params": {
"count": 0,
"mode": "endless"
},
"type": "loop"
}
],
"description": "",
"group": "Missions",
"id": "5ae9dbcb0722dffb",
"name": "Test run",
"updated_at": "2026-06-15T03:08:55.138Z"
},
"mission_group": "Missions",
"mission_id": "5ae9dbcb0722dffb",
"mission_name": "Test run",
"parameters": {},
"priority": 0,
"robot_id": "default",
"source": "ui",
"started_at": "2026-06-16T09:41:28Z",
"status": "cancelled"
}
],
"runner": {
@@ -719,6 +886,6 @@
"message": "Đã hủy: Test run",
"paused": false,
"state": "idle",
"updated_at": "2026-06-15T03:26:42Z"
"updated_at": "2026-06-16T09:41:41Z"
}
}

View File

@@ -1,13 +1,16 @@
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const statusEl = el("status");
const listEl = el("lidarList");
const lidarFormHintEl = el("lidarFormHint");
const navItemEls = Array.from(document.querySelectorAll(".navItem[data-page]"));
const pageOverviewEl = el("pageOverview");
const pageConfigEl = el("pageConfig");
const pageMissionsEl = el("pageMissions");
const pageIntegrationsEl = el("pageIntegrations");
const pageMonitoringEl = el("pageMonitoring");
const pageHelpEl = el("pageHelp");
const contentEl = document.querySelector(".content");
const contentRightEl = el("contentRight");
const overviewBackendEl = el("overviewBackend");
@@ -120,23 +123,19 @@ const state = {
};
function setActivePage(page) {
const valid = ["dashboard", "config", "missions", "integrations"];
const valid = ["dashboard", "config", "missions", "integrations", "monitoring", "help"];
let p = valid.includes(page) ? page : "config";
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
p = fallback || "dashboard";
}
if (page === "overview") p = "dashboard";
navItemEls.forEach((a) => {
const on = (a.dataset.page || "") === p;
a.classList.toggle("active", on);
if (on) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
});
if (pageOverviewEl) pageOverviewEl.hidden = p !== "dashboard";
if (pageConfigEl) pageConfigEl.hidden = p !== "config";
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
if (configSplitterEl) configSplitterEl.hidden = p !== "config";
if (contentRightEl) contentRightEl.hidden = p !== "config";
if (contentEl) {
@@ -144,6 +143,8 @@ function setActivePage(page) {
contentEl.classList.toggle("content--config", p === "config");
contentEl.classList.toggle("content--missions", p === "missions");
contentEl.classList.toggle("content--integrations", p === "integrations");
contentEl.classList.toggle("content--monitoring", p === "monitoring");
contentEl.classList.toggle("content--help", p === "help");
}
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide();
@@ -151,6 +152,7 @@ function setActivePage(page) {
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
else if (window.IntegrationsApp?.onPageHide) window.IntegrationsApp.onPageHide();
window.NavApp?.syncFromPage?.(p);
try {
localStorage.setItem("activePage", p);
} catch {
@@ -159,25 +161,12 @@ function setActivePage(page) {
}
function initNavigation() {
navItemEls.forEach((a) => {
a.addEventListener("click", (evt) => {
evt.preventDefault();
setActivePage(a.dataset.page || "config");
});
});
// Restore last page, default to config (màn hình chính).
let initial = "config";
try {
const saved = localStorage.getItem("activePage");
if (saved === "dashboard" || saved === "overview" || saved === "config" || saved === "missions" || saved === "integrations") {
initial = saved === "overview" ? "dashboard" : saved;
}
} catch {
/* ignore */
}
setActivePage(initial);
if (window.NavApp?.init) window.NavApp.init();
else setActivePage("config");
}
window.LmApp = { setActivePage };
function setLeftPaneWidth(px) {
const v = Math.round(clamp(Number(px), 320, 720));
document.documentElement.style.setProperty("--leftPaneW", `${v}px`);
@@ -629,7 +618,7 @@ function findDuplicateImuFrame(frameId, excludeId = null) {
function clearCanvasSelection() {
state.selectedId = null;
state.selectedImuId = null;
selectedText.textContent = "none";
selectedText.textContent = t("common.none");
setSelectedRelText();
}
@@ -1697,7 +1686,10 @@ function updateLayoutActiveHint() {
if (!layoutActiveHintEl) return;
const name = state.activeLayoutName || "—";
const dirty = state.layoutDirty ? " • chưa lưu" : "";
layoutActiveHintEl.textContent = `Đang chỉnh: ${name}${dirty}`;
layoutActiveHintEl.textContent = t("config.layout.editingHint", {
name,
dirty: dirty ? t("config.layout.unsavedDirty") : "",
});
}
function renderLayoutSelect() {
@@ -1783,7 +1775,7 @@ async function deleteActiveLayoutFromUI() {
return;
}
const name = state.activeLayoutName || state.activeLayoutId;
if (!window.confirm(`Xóa layout «${name}»? Hành động không hoàn tác.`)) return;
if (!window.confirm(t("config.layout.deleteConfirm", { name }))) return;
await api(`/api/layouts/${state.activeLayoutId}`, { method: "DELETE" });
state.viewInitialized = false;
await loadAll();
@@ -2138,7 +2130,7 @@ function setSelectedRelText() {
function renderList() {
if (!state.lidars.length) {
listEl.innerHTML = `<div class="item"><div class="itemName">Chưa có LiDAR</div><div class="itemMeta">Hãy thêm LiDAR ở form phía trên.</div></div>`;
listEl.innerHTML = `<div class="item"><div class="itemName">${t("config.lidar.empty")}</div><div class="itemMeta">${t("config.lidar.emptyHint")}</div></div>`;
return;
}
@@ -2244,7 +2236,7 @@ function updateImuItemPoseUI(id) {
function renderImuList() {
if (!imuListEl) return;
if (!state.imus.length) {
imuListEl.innerHTML = `<div class="item"><div class="itemName">Chưa có IMU</div><div class="itemMeta">Thêm IMU ở form phía trên.</div></div>`;
imuListEl.innerHTML = `<div class="item"><div class="itemName">${t("config.imu.empty")}</div><div class="itemMeta">${t("config.imu.emptyHint")}</div></div>`;
return;
}
@@ -3117,7 +3109,7 @@ async function loadAll() {
state.selectedImuId = null;
}
if (!state.selectedId && !state.selectedImuId) {
selectedText.textContent = "none";
selectedText.textContent = t("common.none");
}
setSelectedRelText();
renderList();
@@ -3130,7 +3122,10 @@ async function loadAll() {
overviewActiveModelEl.textContent = state.layout?.robot?.model || "diff";
}
if (overviewActiveSensorsEl) {
overviewActiveSensorsEl.textContent = `${state.lidars.length} LiDAR • ${state.imus.length} IMU`;
overviewActiveSensorsEl.textContent = t("dashboard.system.sensorCount", {
lidars: state.lidars.length,
imus: state.imus.length,
});
}
if (!state.viewInitialized) {
fitViewToWorld();
@@ -3404,16 +3399,16 @@ saveLayoutBtn?.addEventListener("click", async () => {
await api("/api/health");
await loadMotorCatalog();
await loadAll();
selectedText.textContent = "none";
selectedText.textContent = t("common.none");
selectedRelText.textContent = "—";
setStatus("Sẵn sàng");
setStatus(t("app.status.ready"));
} catch (e) {
const msg = String(e.message || e);
if (overviewBackendEl) overviewBackendEl.textContent = `Lỗi: ${msg}`;
if (overviewBackendEl) overviewBackendEl.textContent = t("common.error", { msg });
if (msg.includes("stack") || msg.includes("Maximum call")) {
setStatus(`Lỗi JavaScript: ${msg}`);
setStatus(`${t("app.status.jsError")}: ${msg}`);
} else {
setStatus(`Không kết nối được backend: ${msg}`);
setStatus(`${t("app.status.backendError")}: ${msg}`);
}
}
};
@@ -3421,3 +3416,15 @@ saveLayoutBtn?.addEventListener("click", async () => {
else window.AuthApp?.whenReady(() => { boot(); });
})();
window.addEventListener("lm:locale-change", () => {
if (typeof renderList === "function") renderList();
if (typeof renderImuList === "function") renderImuList();
if (typeof renderLayoutSelect === "function") renderLayoutSelect();
if (typeof renderLayoutSelect === "function") renderLayoutSelect();
if (typeof updateLayoutActiveHint === "function") updateLayoutActiveHint();
if (typeof renderMotorWheels === "function") renderMotorWheels();
if (typeof renderBicycleMotorWheels === "function") renderBicycleMotorWheels();
if (typeof updateOverview === "function") updateOverview();
window.I18n?.applyDOM?.();
});

View File

@@ -24,6 +24,8 @@
let pinDigits = [];
let pinSubmitting = false;
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
async function apiJson(path, opts = {}) {
const res = await fetch(path, {
credentials: "include",
@@ -82,9 +84,9 @@
} catch (e) {
const msg = String(e.message || "");
if (msg.includes("invalid pin") || msg.includes("401")) {
showError("Mã PIN không hợp lệ. Liên hệ quản trị viên.", "pin");
showError(t("login.error.invalidPin"), "pin");
} else {
showError(msg || "Mã PIN không hợp lệ", "pin");
showError(msg || t("login.error.invalidPinShort"), "pin");
}
resetPin();
setLoginLoading(false);
@@ -110,7 +112,7 @@
function setLoginLoading(loading) {
loginScreenEl?.classList.toggle("is-loading", loading);
document.querySelectorAll(".loginSubmitLabel").forEach((label) => {
label.textContent = loading ? "Đang đăng nhập…" : "Đăng nhập";
label.textContent = loading ? t("login.submitting") : t("login.submit");
});
}
@@ -153,12 +155,9 @@
}
function applyNavPermissions() {
document.querySelectorAll(".navItem[data-page]").forEach((a) => {
const page = a.dataset.page || "";
const allowed = canAccessPage(page);
a.hidden = !allowed;
a.style.display = allowed ? "" : "none";
});
if (window.NavApp?.applyPermissions) {
window.NavApp.applyPermissions();
}
document.body.classList.toggle("auth-readonly-config", !canWrite("config"));
document.body.classList.toggle("auth-readonly-missions", !canWrite("missions"));
document.body.classList.toggle("auth-readonly-integrations", !canWrite("integrations"));
@@ -263,7 +262,7 @@
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");
if (!display_name) throw new Error(t("auth.profile.displayNameRequired"));
const data = await apiJson("/api/auth/profile", {
method: "PUT",
body: JSON.stringify({ display_name }),
@@ -287,7 +286,7 @@
const username = el("loginUsername")?.value?.trim() || "";
const password = el("loginPasswordInput")?.value || "";
if (!username || !password) {
showError("Nhập tên đăng nhập và mật khẩu", "password");
showError(t("login.error.missingCredentials"), "password");
return;
}
setLoginLoading(true);
@@ -297,11 +296,11 @@
} catch (e) {
const msg = String(e.message || "");
if (msg.includes("credentials") || msg.includes("401")) {
showError("Sai tên đăng nhập hoặc mật khẩu. Thử Admin / admin", "password");
showError(t("login.error.badCredentials"), "password");
} else if (msg.includes("fetch") || msg.includes("Failed")) {
showError("Không kết nối được server. Kiểm tra http://localhost:8080", "password");
showError(t("login.error.serverUnreachable"), "password");
} else {
showError(msg || "Đăng nhập thất bại", "password");
showError(msg || t("login.error.failed"), "password");
}
setLoginLoading(false);
}
@@ -347,7 +346,7 @@
await saveProfile();
userMenuPanelEl?.setAttribute("hidden", "");
} catch (e) {
alert(e.message || "Lưu thông tin thất bại");
alert(e.message || t("auth.profile.saveFailed"));
}
});
@@ -357,7 +356,7 @@
const next = el("changePasswordNew")?.value || "";
const confirm = el("changePasswordConfirm")?.value || "";
if (next !== confirm) {
if (changePasswordErrorEl) changePasswordErrorEl.textContent = "Mật khẩu mới không khớp";
if (changePasswordErrorEl) changePasswordErrorEl.textContent = t("auth.changePassword.mismatch");
return;
}
try {
@@ -368,7 +367,7 @@
changePasswordDialogEl?.close();
changePasswordFormEl.reset();
} catch (e) {
if (changePasswordErrorEl) changePasswordErrorEl.textContent = e.message || "Đổi mật khẩu thất bại";
if (changePasswordErrorEl) changePasswordErrorEl.textContent = e.message || t("auth.changePassword.failed");
}
});
}
@@ -386,6 +385,10 @@
};
bindEvents();
window.addEventListener("lm:locale-change", () => {
const loading = loginScreenEl?.classList.contains("is-loading");
setLoginLoading(loading);
});
setLoginMode("password");
shellEl?.classList.add("auth-locked");
if (window.location.search) {

View File

@@ -1,14 +1,12 @@
(() => {
const STORAGE_KEY = "phenikaax_dashboard_v1";
const WIDGET_LABELS = {
mission_button: "Mission button",
mission_group: "Mission group",
mission_queue: "Mission queue",
pause_continue: "Pause / Continue",
};
function widgetTypeLabel(type) {
return t(`dashboard.widget.${type}`) || type;
}
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const gridEl = el("dashboardGrid");
const emptyEl = el("dashboardEmpty");
const addDialogEl = el("dashboardAddWidgetDialog");
@@ -60,7 +58,7 @@
store.widgets = [
{ id: newId(), type: "mission_button", mission_id: firstId, title: "" },
{ id: newId(), type: "mission_group", group: "Missions", title: "" },
{ id: newId(), type: "mission_queue", title: "Mission queue" },
{ id: newId(), type: "mission_queue", title: "" },
{ id: newId(), type: "pause_continue", title: "" },
];
persistStore();
@@ -76,7 +74,7 @@
function widgetTitle(widget) {
if (widget.title) return widget.title;
return WIDGET_LABELS[widget.type] || widget.type;
return widgetTypeLabel(widget.type);
}
function missionOptions(selected) {
@@ -102,33 +100,33 @@
if (type === "mission_button") {
container.innerHTML = `
<div class="row rowWide">
<label>Mission</label>
<label>${t("dashboard.widget.field.mission")}</label>
<select data-field="mission_id">${missionOptions(widget.mission_id || "")}</select>
</div>
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="VD: Go to charging" />
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="${t(\"dashboard.widget.titlePlaceholder\")}" />
</div>`;
} else if (type === "mission_group") {
container.innerHTML = `
<div class="row rowWide">
<label>Nhóm mission</label>
<label>${t("dashboard.widget.field.group")}</label>
<select data-field="group">${groupOptions(widget.group || "Missions")}</select>
</div>
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" />
</div>`;
} else if (type === "mission_queue") {
container.innerHTML = `
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "Mission queue")}" />
</div>`;
} else if (type === "pause_continue") {
container.innerHTML = `
<div class="row rowWide">
<label>Tiêu đề widget (tùy chọn)</label>
<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>`;
@@ -145,13 +143,13 @@
function renderMissionButtonWidget(widget, bodyEl) {
const m = missions()?.getMissionById?.(widget.mission_id);
const label = m?.name || "Chọn mission";
const label = m?.name || t("dashboard.widget.selectMission");
bodyEl.innerHTML = `
<button type="button" class="dashboardMissionBtn" data-run-mission="${escapeHtml(widget.mission_id || "")}">
<span class="dashboardMissionBtnIcon">▶</span>
<span>${escapeHtml(label)}</span>
</button>
${!m ? `<p class="mutedNote dashboardWidgetHint">Cấu hình widget và chọn mission.</p>` : ""}`;
${!m ? `<p class="mutedNote dashboardWidgetHint">${t("dashboard.widget.configHint")}</p>` : ""}`;
bodyEl.querySelector("[data-run-mission]")?.addEventListener("click", () => {
if (!widget.mission_id) return;
missions()?.queueMission?.(widget.mission_id);
@@ -162,7 +160,7 @@
const group = widget.group || "Missions";
const list = (missions()?.getMissions?.() || []).filter((m) => m.group === group);
if (!list.length) {
bodyEl.innerHTML = `<p class="mutedNote">Không có mission trong nhóm «${escapeHtml(group)}».</p>`;
bodyEl.innerHTML = `<p class="mutedNote">${t("dashboard.widget.emptyGroup", { group })}</p>`;
return;
}
bodyEl.innerHTML = `<div class="dashboardMissionGroupList"></div>`;
@@ -181,8 +179,8 @@
bodyEl.innerHTML = `
<div class="dashboardQueueRunner mutedNote" data-role="runner">—</div>
<div class="dashboardQueueList" data-role="list"></div>
<p class="mutedNote dashboardQueueEmpty" data-role="empty">Queue trống</p>
<button type="button" class="btn subtle btnBlock dashboardQueueClear">Xóa queue chờ</button>`;
<p class="mutedNote dashboardQueueEmpty" data-role="empty">${t("dashboard.widget.queueEmpty")}</p>
<button type="button" class="btn subtle btnBlock dashboardQueueClear">${t("dashboard.widget.clearQueue")}</button>`;
bodyEl.querySelector(".dashboardQueueClear")?.addEventListener("click", () => missions()?.clearQueue?.());
refreshQueueWidget(bodyEl);
}
@@ -208,13 +206,13 @@
bodyEl.innerHTML = `
<div class="dashboardRunnerControls">
<button type="button" class="dashboardPauseBtn ${paused ? "is-paused" : ""}" data-pause-action="${paused ? "continue" : "pause"}" ${running ? "" : "disabled"}>
${paused ? "Continue" : "Pause"}
${paused ? t("dashboard.widget.continue") : t("dashboard.widget.pause")}
</button>
<button type="button" class="dashboardCancelBtn" data-cancel-mission ${running ? "" : "disabled"}>
Hủy mission
${t("dashboard.widget.cancelMission")}
</button>
</div>
<p class="mutedNote dashboardWidgetHint">${running ? (paused ? "Mission đang tạm dừng" : "Mission đang chạy") : "Không có mission đang chạy"}</p>`;
<p class="mutedNote dashboardWidgetHint">${running ? (paused ? t("dashboard.widget.runner.paused") : t("dashboard.widget.runner.running")) : t("dashboard.widget.runner.idle")}</p>`;
bodyEl.querySelector("[data-pause-action]")?.addEventListener("click", async (evt) => {
const action = evt.currentTarget.dataset.pauseAction;
try {
@@ -242,8 +240,8 @@
<div class="dashboardWidgetHeader">
<div class="dashboardWidgetTitle">${escapeHtml(widgetTitle(widget))}</div>
<div class="dashboardWidgetChrome" hidden>
<button type="button" class="iconBtn" data-widget-config title="Cấu hình">⚙</button>
<button type="button" class="iconBtn danger" data-widget-delete title="Xóa">×</button>
<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>
</div>
</div>
<div class="dashboardWidgetBody"></div>`;
@@ -301,13 +299,13 @@
const widget = store.widgets.find((w) => w.id === widgetId);
if (!widget) return;
editWidgetIdEl.value = widget.id;
editWidgetTypeEl.value = WIDGET_LABELS[widget.type] || widget.type;
editWidgetTypeEl.value = widgetTypeLabel(widget.type);
fillTypeFields(editFieldsEl, widget.type, widget);
editDialogEl.showModal();
}
function deleteWidget(widgetId) {
if (!confirm("Xóa widget này?")) return;
if (!confirm(t("dashboard.widget.deleteConfirm"))) return;
store.widgets = store.widgets.filter((w) => w.id !== widgetId);
persistStore();
renderDashboard();
@@ -318,7 +316,7 @@
el("dashboardAddWidgetBtn")?.addEventListener("click", openAddDialog);
el("dashboardEditBtn")?.addEventListener("click", () => {
store.editMode = !store.editMode;
el("dashboardEditBtn").textContent = store.editMode ? "Xong" : "Sửa layout";
el("dashboardEditBtn").textContent = store.editMode ? t("dashboard.editDone") : t("dashboard.editLayout");
renderDashboard();
});
@@ -391,6 +389,12 @@
function boot() {
init();
}
window.addEventListener("lm:locale-change", () => {
renderDashboard();
const editBtn = el("dashboardEditBtn");
if (editBtn) editBtn.textContent = store.editMode ? t("dashboard.editDone") : t("dashboard.editLayout");
});
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
window.addEventListener("lm:auth-logout", stopDashboardPoll);

771
www/i18n.js Normal file
View File

@@ -0,0 +1,771 @@
/**
* Central i18n for LiDAR Manager — 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.robotName": "RobotApp",
"app.status.ready": "Sẵn sàng",
"app.status.reloaded": "Đã tải lại",
"app.status.backendError": "Không kết nối được backend",
"app.status.jsError": "Lỗi JavaScript",
"common.cancel": "Hủy",
"common.close": "Đóng",
"common.save": "Lưu",
"common.add": "Thêm",
"common.delete": "Xóa",
"common.apply": "Áp dụng",
"common.reload": "Tải lại",
"common.select": "Chọn",
"common.edit": "Sửa",
"common.enabled": "Bật",
"common.disabled": "Tắt",
"common.configure": "Cấu hình",
"common.error": "Lỗi: {msg}",
"common.none": "none",
"common.optional": "Tùy chọn",
"login.prompt": "Chọn cách đăng nhập:",
"login.tab.password": "Tên đăng nhập và mật khẩu",
"login.tab.pin": "Mã PIN",
"login.password.title": "Đăng nhập bằng tên và mật khẩu",
"login.password.help1": "Nhập tên đăng nhập và mật khẩu để truy cập robot.",
"login.password.help2": "Tài khoản do quản trị viên cấp hoặc xem trong tài liệu hướng dẫn robot.",
"login.password.help3": "Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.",
"login.field.username": "Tên đăng nhập:",
"login.field.password": "Mật khẩu:",
"login.placeholder.username": "Admin",
"login.submit": "Đăng nhập",
"login.submitting": "Đang đăng nhập…",
"login.pin.title": "Đăng nhập bằng mã PIN",
"login.pin.help1": "Người dùng được kích hoạt PIN có thể đăng nhập tại đây.",
"login.pin.help2": "Nếu chưa có mã PIN 4 chữ số, vui lòng liên hệ quản trị viên robot.",
"login.pin.helpNote": "Không có mã PIN cấu hình sẵn — quản trị viên phải gán PIN trước.",
"login.pin.aria.group": "Mã PIN 4 chữ số",
"login.pin.aria.keypad": "Bàn phím số",
"login.pin.aria.backspace": "Xóa",
"login.error.invalidPin": "Mã PIN không hợp lệ. Liên hệ quản trị viên.",
"login.error.invalidPinShort": "Mã PIN không hợp lệ",
"login.error.missingCredentials": "Nhập tên đăng nhập và mật khẩu",
"login.error.badCredentials": "Sai tên đăng nhập hoặc mật khẩu. Thử Admin / admin",
"login.error.serverUnreachable": "Không kết nối được server. Kiểm tra http://localhost:8080",
"login.error.failed": "Đăng nhập thất bại",
"nav.aria.main": "Điều hướng chính",
"nav.aria.submenu": "Menu phụ",
"nav.collapse": "Thu gọn menu",
"nav.expand": "Mở menu",
"nav.dashboards": "Dashboards",
"nav.setup": "Setup",
"nav.monitoring": "Monitoring",
"nav.system": "System",
"nav.help": "Help",
"nav.logout": "Log out",
"nav.dashboard": "Dashboard",
"nav.missions": "Missions",
"nav.maps": "Maps & layout",
"nav.monitoring-log": "System log",
"nav.integrations": "Tích hợp",
"nav.help-api": "API documentation",
"topbar.robotTitle": "Robot",
"topbar.controlAria": "Start / Pause robot",
"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.joystickTitle": "Điều khiển tay (Joystick)",
"topbar.joystickSpeed": "Tốc độ",
"topbar.joystickOff": "Tắt joystick",
"topbar.joystickAria": "Joystick",
"topbar.batteryTitle": "Pin",
"topbar.localeVi": "TIẾNG VIỆT",
"topbar.localeEn": "ENGLISH",
"topbar.localeOption.vi": "🇻🇳 Tiếng Việt",
"topbar.localeOption.en": "🇺🇸 English",
"topbar.userDefault": "USER",
"topbar.noControlPermission": "Không có quyền điều khiển",
"topbar.queueCount": "{n} mission trong queue",
"topbar.code": "Mã",
"topbar.module": "Module",
"topbar.joystickSpeed.slow": "Chậm",
"topbar.joystickSpeed.medium": "Trung bình",
"topbar.joystickSpeed.fast": "Nhanh",
"topbar.startHint": "Bấm để START robot",
"topbar.pauseHint": "Bấm để PAUSE robot",
"auth.profile.displayNameRequired": "Tên hiển thị không được trống",
"auth.profile.saveFailed": "Lưu thông tin thất bại",
"auth.changePassword.title": "Đổi mật khẩu",
"auth.changePassword.current": "Mật khẩu hiện tại",
"auth.changePassword.new": "Mật khẩu mới",
"auth.changePassword.confirm": "Xác nhận mật khẩu mới",
"auth.changePassword.mismatch": "Mật khẩu mới không khớp",
"auth.changePassword.failed": "Đổi mật khẩu thất bại",
"dashboard.title": "Dashboard",
"dashboard.subtitle": "Widget mission — chạy, xếp hàng và tạm dừng giống MiR Fleet.",
"dashboard.addWidget": "Thêm widget",
"dashboard.editLayout": "Sửa layout",
"dashboard.editDone": "Xong",
"dashboard.empty": "Chưa có widget. Bấm «Thêm widget» để bắt đầu.",
"dashboard.system.title": "Hệ thống",
"dashboard.system.subtitle": "Trạng thái backend và layout đang active.",
"dashboard.system.backend": "Backend",
"dashboard.system.layout": "Layout",
"dashboard.system.model": "Model robot",
"dashboard.system.sensors": "LiDAR / IMU",
"dashboard.system.sensorCount": "{lidars} LiDAR • {imus} IMU",
"dashboard.dialog.add.title": "Thêm widget",
"dashboard.dialog.add.type": "Loại widget",
"dashboard.dialog.edit.title": "Cấu hình widget",
"dashboard.dialog.edit.type": "Loại",
"dashboard.dialog.edit.delete": "Xóa widget",
"dashboard.widget.mission_button": "Nút mission",
"dashboard.widget.mission_group": "Nhóm mission",
"dashboard.widget.mission_queue": "Mission queue",
"dashboard.widget.pause_continue": "Tạm dừng / Tiếp tục",
"dashboard.widget.field.mission": "Mission",
"dashboard.widget.field.group": "Nhóm mission",
"dashboard.widget.field.title": "Tiêu đề widget (tùy chọn)",
"dashboard.widget.titlePlaceholder": "VD: Go to charging",
"dashboard.widget.pauseHint": "Tạm dừng / tiếp tục / hủy mission đang chạy trên robot.",
"dashboard.widget.selectMission": "Chọn mission…",
"dashboard.widget.configHint": "Cấu hình widget và chọn mission.",
"dashboard.widget.emptyGroup": "Không có mission trong nhóm «{group}».",
"dashboard.widget.queueEmpty": "Queue trống",
"dashboard.widget.clearQueue": "Xóa queue chờ",
"dashboard.widget.continue": "Tiếp tục",
"dashboard.widget.pause": "Tạm dừng",
"dashboard.widget.cancelMission": "Hủy mission",
"dashboard.widget.runner.paused": "Mission đang tạm dừng",
"dashboard.widget.runner.running": "Mission đang chạy",
"dashboard.widget.runner.idle": "Không có mission đang chạy",
"dashboard.widget.unsupported": "Widget không hỗ trợ.",
"dashboard.widget.deleteConfirm": "Xóa widget này?",
"config.layout.title": "Quản lý layout",
"config.layout.subtitle": "Nhiều cấu hình robot — mỗi layout có LiDAR và model riêng.",
"config.layout.save": "Lưu layout",
"config.layout.current": "Layout hiện tại",
"config.layout.newName": "Tên layout mới",
"config.layout.newNamePlaceholder": "VD: AGV kho A",
"config.layout.cloneCurrent": "Sao chép từ layout đang mở",
"config.layout.create": "Tạo layout",
"config.layout.editingHint": "Đang chỉnh: {name}{dirty}",
"config.layout.unsavedDirty": " • chưa lưu",
"config.layout.unsavedSwitchConfirm": "Layout hiện tại có thay đổi chưa lưu. Tiếp tục?",
"config.layout.deleteConfirm": "Xóa layout «{name}»? Hành động không hoàn tác.",
"config.lidar.title": "LiDARs",
"config.lidar.subtitle": "Đăng ký tên, IP, port và chỉnh pose theo robot frame.",
"config.lidar.field.name": "Tên",
"config.lidar.field.ip": "IP",
"config.lidar.field.port": "Port",
"config.lidar.placeholder.name": "Lidar trước",
"config.lidar.placeholder.ip": "192.168.0.10",
"config.lidar.empty": "Chưa có LiDAR",
"config.lidar.emptyHint": "Hãy thêm LiDAR ở form phía trên.",
"config.lidar.deleteConfirm": "Xóa LiDAR này?",
"config.imu.title": "IMU",
"config.imu.subtitle": "Cảm biến quán tính — frame, topic và pose trên robot.",
"config.imu.field.name": "Tên",
"config.imu.field.frame": "Frame ID",
"config.imu.field.topic": "Topic",
"config.imu.field.source": "Nguồn",
"config.imu.source.external": "Ngoài (ROS topic)",
"config.imu.source.lidarBuiltin": "Tích hợp LiDAR",
"config.imu.source.onboard": "Onboard robot",
"config.imu.field.rate": "Tần số (Hz)",
"config.imu.enabled": "Bật IMU",
"config.imu.add": "Thêm IMU",
"config.imu.placeholder.name": "IMU chính",
"config.imu.placeholder.frame": "imu_link",
"config.imu.placeholder.topic": "imu/data",
"config.imu.empty": "Chưa có IMU",
"config.imu.emptyHint": "Thêm IMU ở form phía trên.",
"config.imu.deleteConfirm": "Xóa IMU này?",
"config.robot.title": "Model robot",
"config.robot.subtitle": "Kinematic differential — bánh, động cơ và giới hạn vận tốc.",
"config.robot.model.diff": "Differential (2 bánh)",
"config.robot.model.bicycle": "Bicycle",
"config.canvas.title": "Bố trí trên robot",
"config.canvas.viewHint": "Cuộn chuột: zoom • Shift + kéo: di chuyển vùng nhìn",
"config.canvas.robotCenter": "Robot center:",
"config.canvas.selected": "Selected:",
"config.canvas.pose": "Pose:",
"config.pose.notSet": "chưa đặt pose",
"config.selected.lidar": "LiDAR: {name}",
"config.selected.imu": "IMU: {name}",
"config.motor.wheelRight": "Bánh phải",
"config.motor.wheelLeft": "Bánh trái",
"config.motor.wheelSteer": "Bánh trước (steer)",
"config.motor.wheelDrive": "Bánh sau (drive)",
"config.motor.vendor": "Hãng",
"config.motor.model": "Model",
"config.motor.joint": "Joint (ROS)",
"config.motor.ratio": "Tỷ số hộp số",
"config.motor.invert": "Đảo chiều quay",
"config.motor.invertSteer": "Đảo chiều",
"config.motor.custom": "Tùy chỉnh",
"config.motor.customMotor": "Motor tùy chỉnh",
"missions.title": "Missions",
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
"missions.create": "Tạo mission",
"missions.empty": "Chưa có mission. Bấm Tạo mission để bắt đầu.",
"missions.queue.title": "Mission queue",
"missions.queue.subtitle": "Thêm mission bằng biểu tượng queue — robot chạy theo thứ tự từ trên xuống.",
"missions.queue.cancel": "Hủy chạy",
"missions.queue.clear": "Xóa queue",
"missions.queue.empty": "Queue trống. Bấm ▤ trên mission để thêm.",
"missions.editor.kicker": "Mission editor",
"missions.editor.unsaved": "Chưa lưu",
"missions.editor.saveAs": "Save as",
"missions.editor.save": "Save",
"missions.editor.flowHint": "Thực thi từ trên xuống dưới. Kéo biểu tượng ↔ để đổi thứ tự. Với Loop: kéo action vào vùng bên trong.",
"missions.editor.emptyActions": "Chọn action từ menu phía trên để bắt đầu.",
"missions.editor.backAria": "Quay lại danh sách",
"missions.editor.settingsAria": "Cài đặt mission",
"missions.editor.addActionAria": "Thêm action",
"missions.queue.status.pending": "Chờ",
"missions.queue.status.running": "Đang chạy",
"missions.queue.status.done": "Xong",
"missions.queue.status.error": "Lỗi",
"missions.queue.status.cancelled": "Đã hủy",
"missions.queue.ready": "Sẵn sàng",
"missions.queue.idleMessage": "Robot sẵn sàng — queue trống hoặc chờ mission mới.",
"missions.queue.moveUp": "Lên",
"missions.queue.moveDown": "Xuống",
"missions.queue.addAria": "Thêm vào mission queue",
"missions.deleteConfirm": "Xóa mission «{name}»?",
"missions.queue.clearConfirm": "Xóa các mission đang chờ trong queue?",
"missions.queue.cancelConfirm": "Hủy mission đang chạy? (thoát loop nếu đang lặp)",
"missions.dialog.create.title": "Tạo mission",
"missions.dialog.create.name": "Tên mission",
"missions.dialog.create.group": "Nhóm mission",
"missions.dialog.create.groupNew": "Hoặc nhóm mới",
"missions.dialog.create.desc": "Mô tả",
"missions.dialog.create.namePlaceholder": "VD: Go to charging station",
"missions.dialog.settings.title": "Cài đặt mission",
"missions.dialog.settings.name": "Tên",
"missions.dialog.settings.group": "Nhóm",
"missions.dialog.settings.desc": "Mô tả",
"missions.dialog.saveAs.title": "Save mission as",
"missions.dialog.saveAs.name": "Tên mission mới",
"missions.dialog.saveAs.submit": "Lưu bản sao",
"missions.dialog.actionConfig.title": "Cấu hình action",
"missions.dialog.queue.title": "Thêm vào mission queue",
"missions.group.Move": "Move",
"missions.group.Logic": "Logic",
"missions.group.IO": "I/O",
"missions.group.Cart": "Cart",
"missions.group.Misc": "Misc",
"missions.action.move_to_position": "Go to position",
"missions.action.move_to_marker": "Go to marker",
"missions.action.adjust_localization": "Adjust localization",
"missions.action.wait": "Wait",
"missions.action.set_speed": "Set speed",
"missions.action.if": "If",
"missions.action.loop": "Loop",
"missions.action.break": "Break",
"missions.action.continue": "Continue",
"missions.action.pause": "Pause",
"missions.action.set_digital_output": "Set digital output",
"missions.action.wait_digital_input": "Wait for digital input",
"missions.action.set_plc_register": "Set PLC register",
"missions.action.pick_cart": "Pick cart",
"missions.action.drop_cart": "Drop cart",
"missions.action.user_log": "User log",
"missions.action.play_sound": "Play sound",
"missions.error.nameRequired": "Tên mission không được trống.",
"missions.error.nameDuplicate": "Tên mission đã tồn tại.",
"missions.error.nameEmpty": "Tên không được trống.",
"missions.saveSuccess": "Đã lưu mission.",
"missions.editor.discardConfirm": "Bỏ thay đổi chưa lưu?",
"missions.queue.status.executing": "Đang chạy",
"missions.action.waitOnLevel": "Chờ mức ON",
"integrations.modbus.title": "Modbus trigger",
"integrations.modbus.subtitle": "System → Triggers — coil 10012000 gắn mission_id. Thiết bị remote bật coil (Modbus TCP :5502) → mission vào queue.",
"integrations.modbus.add": "Thêm trigger",
"integrations.modbus.empty": "Chưa có trigger Modbus.",
"integrations.modbus.coilsLabel": "Coil đã gán (bấm để mô phỏng rising edge)",
"integrations.rest.title": "REST API — MiR v2.0.0",
"integrations.rest.subtitle": "Hệ thống bên ngoài POST mission vào queue qua REST.",
"integrations.rest.baseUrl": "Base URL",
"integrations.rest.quickTest": "Thử nhanh",
"integrations.rest.postQueue": "POST queue",
"integrations.fleet.title": "MiRFleet — Lên lịch mission",
"integrations.fleet.subtitle": "Ưu tiên, gán robot, chạy ASAP hoặc theo thời gian.",
"integrations.fleet.addSchedule": "Thêm lịch",
"integrations.fleet.empty": "Chưa có lịch fleet.",
"integrations.noMissions": "— Chưa có mission —",
"integrations.defaultRobot": "Robot chính",
"integrations.fireTrigger": "Kích hoạt",
"integrations.coilsEmpty": "Chưa gán coil. Thêm trigger bên trên (10012000).",
"integrations.coilState": "coil hiện tại: {state}",
"integrations.confirm.deleteTrigger": "Xóa trigger Modbus này?",
"integrations.confirm.deleteSchedule": "Xóa lịch fleet này?",
"integrations.dialog.trigger.title": "Modbus trigger",
"integrations.dialog.trigger.name": "Tên trigger",
"integrations.dialog.trigger.coil": "Coil ID",
"integrations.dialog.trigger.mission": "Mission",
"integrations.dialog.schedule.title": "Lịch MiRFleet",
"integrations.dialog.schedule.name": "Tên lịch",
"integrations.dialog.schedule.robot": "Robot",
"integrations.dialog.schedule.priority": "Ưu tiên",
"integrations.dialog.schedule.mode": "Chế độ",
"integrations.dialog.schedule.asap": "ASAP",
"integrations.dialog.schedule.scheduled": "Lên lịch",
"integrations.dialog.schedule.startTime": "Thời gian bắt đầu",
"integrations.schedule.runNow": "Chạy ngay",
"monitoring.log.title": "System log",
"monitoring.log.subtitle": "Monitoring → System log — nhật ký hệ thống (đang phát triển).",
"monitoring.log.placeholder": "Tính năng monitoring sẽ hiển thị log robot, cảnh báo và lịch sử mission tại đây.",
"help.api.title": "API documentation",
"help.api.subtitle": "Help → API — tham chiếu REST MiR v2.0.0 cho tích hợp bên ngoài.",
"help.api.body1": "Xem chi tiết endpoint tại System → Tích hợp hoặc tài liệu /api/v2.0.0/.",
"help.api.body2": "Reference Guide MiR rev 1.9: docs/Reference guide.pdf",
},
en: {
"app.title": "LiDAR Manager",
"app.robotName": "RobotApp",
"app.status.ready": "Ready",
"app.status.reloaded": "Reloaded",
"app.status.backendError": "Cannot connect to backend",
"app.status.jsError": "JavaScript error",
"common.cancel": "Cancel",
"common.close": "Close",
"common.save": "Save",
"common.add": "Add",
"common.delete": "Delete",
"common.apply": "Apply",
"common.reload": "Reload",
"common.select": "Select",
"common.edit": "Edit",
"common.enabled": "On",
"common.disabled": "Off",
"common.configure": "Configure",
"common.error": "Error: {msg}",
"common.none": "none",
"common.optional": "Optional",
"login.prompt": "Choose sign-in method:",
"login.tab.password": "Username and password",
"login.tab.pin": "PIN code",
"login.password.title": "Sign in with username and password",
"login.password.help1": "Enter your username and password to access the robot.",
"login.password.help2": "Accounts are provided by an administrator or in the robot manual.",
"login.password.help3": "If you do not have an account, contact the robot administrator.",
"login.field.username": "Username:",
"login.field.password": "Password:",
"login.placeholder.username": "Admin",
"login.submit": "Sign in",
"login.submitting": "Signing in…",
"login.pin.title": "Sign in with PIN",
"login.pin.help1": "Users with PIN enabled can sign in here.",
"login.pin.help2": "If you do not have a 4-digit PIN, contact the robot administrator.",
"login.pin.helpNote": "No PIN is preconfigured — an administrator must assign one first.",
"login.pin.aria.group": "4-digit PIN",
"login.pin.aria.keypad": "Numeric keypad",
"login.pin.aria.backspace": "Delete",
"login.error.invalidPin": "Invalid PIN. Contact the administrator.",
"login.error.invalidPinShort": "Invalid PIN",
"login.error.missingCredentials": "Enter username and password",
"login.error.badCredentials": "Invalid username or password. Try Admin / admin",
"login.error.serverUnreachable": "Cannot reach server. Check http://localhost:8080",
"login.error.failed": "Sign-in failed",
"nav.aria.main": "Main navigation",
"nav.aria.submenu": "Submenu",
"nav.collapse": "Collapse menu",
"nav.expand": "Expand menu",
"nav.dashboards": "Dashboards",
"nav.setup": "Setup",
"nav.monitoring": "Monitoring",
"nav.system": "System",
"nav.help": "Help",
"nav.logout": "Log out",
"nav.dashboard": "Dashboard",
"nav.missions": "Missions",
"nav.maps": "Maps & layout",
"nav.monitoring-log": "System log",
"nav.integrations": "Integrations",
"nav.help-api": "API documentation",
"topbar.robotTitle": "Robot",
"topbar.controlAria": "Start / Pause robot",
"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.joystickTitle": "Manual control (Joystick)",
"topbar.joystickSpeed": "Speed",
"topbar.joystickOff": "Disengage joystick",
"topbar.joystickAria": "Joystick",
"topbar.batteryTitle": "Battery",
"topbar.localeVi": "TIẾNG VIỆT",
"topbar.localeEn": "ENGLISH",
"topbar.localeOption.vi": "🇻🇳 Tiếng Việt",
"topbar.localeOption.en": "🇺🇸 English",
"topbar.userDefault": "USER",
"topbar.noControlPermission": "No control permission",
"topbar.queueCount": "{n} mission(s) in queue",
"topbar.code": "Code",
"topbar.module": "Module",
"topbar.joystickSpeed.slow": "Slow",
"topbar.joystickSpeed.medium": "Medium",
"topbar.joystickSpeed.fast": "Fast",
"topbar.startHint": "Click to START the robot",
"topbar.pauseHint": "Click to PAUSE the robot",
"auth.profile.displayNameRequired": "Display name cannot be empty",
"auth.profile.saveFailed": "Failed to save profile",
"auth.changePassword.title": "Change password",
"auth.changePassword.current": "Current password",
"auth.changePassword.new": "New password",
"auth.changePassword.confirm": "Confirm new password",
"auth.changePassword.mismatch": "New passwords do not match",
"auth.changePassword.failed": "Failed to change password",
"dashboard.title": "Dashboard",
"dashboard.subtitle": "Mission widgets — run, queue and pause like MiR Fleet.",
"dashboard.addWidget": "Add widget",
"dashboard.editLayout": "Edit layout",
"dashboard.editDone": "Done",
"dashboard.empty": "No widgets yet. Click «Add widget» to start.",
"dashboard.system.title": "System",
"dashboard.system.subtitle": "Backend status and active layout.",
"dashboard.system.backend": "Backend",
"dashboard.system.layout": "Layout",
"dashboard.system.model": "Robot model",
"dashboard.system.sensors": "LiDAR / IMU",
"dashboard.system.sensorCount": "{lidars} LiDAR • {imus} IMU",
"dashboard.dialog.add.title": "Add widget",
"dashboard.dialog.add.type": "Widget type",
"dashboard.dialog.edit.title": "Configure widget",
"dashboard.dialog.edit.type": "Type",
"dashboard.dialog.edit.delete": "Delete widget",
"dashboard.widget.mission_button": "Mission button",
"dashboard.widget.mission_group": "Mission group",
"dashboard.widget.mission_queue": "Mission queue",
"dashboard.widget.pause_continue": "Pause / Continue",
"dashboard.widget.field.mission": "Mission",
"dashboard.widget.field.group": "Mission group",
"dashboard.widget.field.title": "Widget title (optional)",
"dashboard.widget.titlePlaceholder": "e.g. Go to charging",
"dashboard.widget.pauseHint": "Pause, continue or cancel the running mission on the robot.",
"dashboard.widget.selectMission": "Select mission…",
"dashboard.widget.configHint": "Configure the widget and select a mission.",
"dashboard.widget.emptyGroup": "No missions in group «{group}».",
"dashboard.widget.queueEmpty": "Queue empty",
"dashboard.widget.clearQueue": "Clear pending queue",
"dashboard.widget.continue": "Continue",
"dashboard.widget.pause": "Pause",
"dashboard.widget.cancelMission": "Cancel mission",
"dashboard.widget.runner.paused": "Mission paused",
"dashboard.widget.runner.running": "Mission running",
"dashboard.widget.runner.idle": "No mission running",
"dashboard.widget.unsupported": "Unsupported widget.",
"dashboard.widget.deleteConfirm": "Delete this widget?",
"config.layout.title": "Layout manager",
"config.layout.subtitle": "Multiple robot configurations — each layout has its own LiDAR and model.",
"config.layout.save": "Save layout",
"config.layout.current": "Current layout",
"config.layout.newName": "New layout name",
"config.layout.newNamePlaceholder": "e.g. Warehouse AGV A",
"config.layout.cloneCurrent": "Clone from open layout",
"config.layout.create": "Create layout",
"config.layout.editingHint": "Editing: {name}{dirty}",
"config.layout.unsavedDirty": " • unsaved",
"config.layout.unsavedSwitchConfirm": "Current layout has unsaved changes. Continue?",
"config.layout.deleteConfirm": "Delete layout «{name}»? This cannot be undone.",
"config.lidar.title": "LiDARs",
"config.lidar.subtitle": "Register name, IP, port and adjust pose in robot frame.",
"config.lidar.field.name": "Name",
"config.lidar.field.ip": "IP",
"config.lidar.field.port": "Port",
"config.lidar.placeholder.name": "Front lidar",
"config.lidar.placeholder.ip": "192.168.0.10",
"config.lidar.empty": "No LiDAR yet",
"config.lidar.emptyHint": "Add a LiDAR using the form above.",
"config.lidar.deleteConfirm": "Delete this LiDAR?",
"config.imu.title": "IMU",
"config.imu.subtitle": "Inertial sensor — frame, topic and pose on robot.",
"config.imu.field.name": "Name",
"config.imu.field.frame": "Frame ID",
"config.imu.field.topic": "Topic",
"config.imu.field.source": "Source",
"config.imu.source.external": "External (ROS topic)",
"config.imu.source.lidarBuiltin": "LiDAR integrated",
"config.imu.source.onboard": "Onboard robot",
"config.imu.field.rate": "Rate (Hz)",
"config.imu.enabled": "Enable IMU",
"config.imu.add": "Add IMU",
"config.imu.placeholder.name": "Main IMU",
"config.imu.placeholder.frame": "imu_link",
"config.imu.placeholder.topic": "imu/data",
"config.imu.empty": "No IMU yet",
"config.imu.emptyHint": "Add an IMU using the form above.",
"config.imu.deleteConfirm": "Delete this IMU?",
"config.robot.title": "Robot model",
"config.robot.subtitle": "Differential kinematics — wheels, motors and velocity limits.",
"config.robot.model.diff": "Differential (2 wheels)",
"config.robot.model.bicycle": "Bicycle",
"config.canvas.title": "Layout on robot",
"config.canvas.viewHint": "Mouse wheel: zoom • Shift + drag: pan view",
"config.canvas.robotCenter": "Robot center:",
"config.canvas.selected": "Selected:",
"config.canvas.pose": "Pose:",
"config.pose.notSet": "pose not set",
"config.selected.lidar": "LiDAR: {name}",
"config.selected.imu": "IMU: {name}",
"config.motor.wheelRight": "Right wheel",
"config.motor.wheelLeft": "Left wheel",
"config.motor.wheelSteer": "Front wheel (steer)",
"config.motor.wheelDrive": "Rear wheel (drive)",
"config.motor.vendor": "Vendor",
"config.motor.model": "Model",
"config.motor.joint": "Joint (ROS)",
"config.motor.ratio": "Gear ratio",
"config.motor.invert": "Invert rotation",
"config.motor.invertSteer": "Invert",
"config.motor.custom": "Custom",
"config.motor.customMotor": "Custom motor",
"missions.title": "Missions",
"missions.subtitle": "Setup → Missions — robot task list.",
"missions.create": "Create mission",
"missions.empty": "No missions yet. Click Create mission to start.",
"missions.queue.title": "Mission queue",
"missions.queue.subtitle": "Add missions via the queue icon — robot runs top to bottom.",
"missions.queue.cancel": "Cancel run",
"missions.queue.clear": "Clear queue",
"missions.queue.empty": "Queue empty. Click ▤ on a mission to add.",
"missions.editor.kicker": "Mission editor",
"missions.editor.unsaved": "Unsaved",
"missions.editor.saveAs": "Save as",
"missions.editor.save": "Save",
"missions.editor.flowHint": "Execute top to bottom. Drag ↔ to reorder. For Loop: drag actions inside.",
"missions.editor.emptyActions": "Pick an action from the menu above to start.",
"missions.editor.backAria": "Back to list",
"missions.editor.settingsAria": "Mission settings",
"missions.editor.addActionAria": "Add action",
"missions.queue.status.pending": "Pending",
"missions.queue.status.running": "Running",
"missions.queue.status.done": "Done",
"missions.queue.status.error": "Error",
"missions.queue.status.cancelled": "Cancelled",
"missions.queue.ready": "Ready",
"missions.queue.idleMessage": "Robot ready — queue empty or waiting for new mission.",
"missions.queue.moveUp": "Up",
"missions.queue.moveDown": "Down",
"missions.queue.addAria": "Add to mission queue",
"missions.deleteConfirm": "Delete mission «{name}»?",
"missions.queue.clearConfirm": "Clear pending missions in queue?",
"missions.queue.cancelConfirm": "Cancel running mission? (exits loop if looping)",
"missions.dialog.create.title": "Create mission",
"missions.dialog.create.name": "Mission name",
"missions.dialog.create.group": "Mission group",
"missions.dialog.create.groupNew": "Or new group",
"missions.dialog.create.desc": "Description",
"missions.dialog.create.namePlaceholder": "e.g. Go to charging station",
"missions.dialog.settings.title": "Mission settings",
"missions.dialog.settings.name": "Name",
"missions.dialog.settings.group": "Group",
"missions.dialog.settings.desc": "Description",
"missions.dialog.saveAs.title": "Save mission as",
"missions.dialog.saveAs.name": "New mission name",
"missions.dialog.saveAs.submit": "Save copy",
"missions.dialog.actionConfig.title": "Configure action",
"missions.dialog.queue.title": "Add to mission queue",
"missions.group.Move": "Move",
"missions.group.Logic": "Logic",
"missions.group.IO": "I/O",
"missions.group.Cart": "Cart",
"missions.group.Misc": "Misc",
"missions.action.move_to_position": "Go to position",
"missions.action.move_to_marker": "Go to marker",
"missions.action.adjust_localization": "Adjust localization",
"missions.action.wait": "Wait",
"missions.action.set_speed": "Set speed",
"missions.action.if": "If",
"missions.action.loop": "Loop",
"missions.action.break": "Break",
"missions.action.continue": "Continue",
"missions.action.pause": "Pause",
"missions.action.set_digital_output": "Set digital output",
"missions.action.wait_digital_input": "Wait for digital input",
"missions.action.set_plc_register": "Set PLC register",
"missions.action.pick_cart": "Pick cart",
"missions.action.drop_cart": "Drop cart",
"missions.action.user_log": "User log",
"missions.action.play_sound": "Play sound",
"missions.error.nameRequired": "Mission name cannot be empty.",
"missions.error.nameDuplicate": "Mission name already exists.",
"missions.error.nameEmpty": "Name cannot be empty.",
"missions.saveSuccess": "Mission saved.",
"missions.editor.discardConfirm": "Discard unsaved changes?",
"missions.queue.status.executing": "Running",
"missions.action.waitOnLevel": "Wait for ON level",
"integrations.modbus.title": "Modbus trigger",
"integrations.modbus.subtitle": "System → Triggers — coils 10012000 map to mission_id. Remote device sets coil (Modbus TCP :5502) → mission queued.",
"integrations.modbus.add": "Add trigger",
"integrations.modbus.empty": "No Modbus triggers yet.",
"integrations.modbus.coilsLabel": "Assigned coils (click to simulate rising edge)",
"integrations.rest.title": "REST API — MiR v2.0.0",
"integrations.rest.subtitle": "External systems POST missions to the queue via REST.",
"integrations.rest.baseUrl": "Base URL",
"integrations.rest.quickTest": "Quick test",
"integrations.rest.postQueue": "POST queue",
"integrations.fleet.title": "MiRFleet — Schedule missions",
"integrations.fleet.subtitle": "Priority, robot assignment, run ASAP or scheduled.",
"integrations.fleet.addSchedule": "Add schedule",
"integrations.fleet.empty": "No fleet schedules yet.",
"integrations.noMissions": "— No missions —",
"integrations.defaultRobot": "Main robot",
"integrations.fireTrigger": "Fire",
"integrations.coilsEmpty": "No coils assigned. Add a trigger above (10012000).",
"integrations.coilState": "coil state: {state}",
"integrations.confirm.deleteTrigger": "Delete this Modbus trigger?",
"integrations.confirm.deleteSchedule": "Delete this fleet schedule?",
"integrations.dialog.trigger.title": "Modbus trigger",
"integrations.dialog.trigger.name": "Trigger name",
"integrations.dialog.trigger.coil": "Coil ID",
"integrations.dialog.trigger.mission": "Mission",
"integrations.dialog.schedule.title": "MiRFleet schedule",
"integrations.dialog.schedule.name": "Schedule name",
"integrations.dialog.schedule.robot": "Robot",
"integrations.dialog.schedule.priority": "Priority",
"integrations.dialog.schedule.mode": "Mode",
"integrations.dialog.schedule.asap": "ASAP",
"integrations.dialog.schedule.scheduled": "Scheduled",
"integrations.dialog.schedule.startTime": "Start time",
"integrations.schedule.runNow": "Run now",
"monitoring.log.title": "System log",
"monitoring.log.subtitle": "Monitoring → System log — system log (coming soon).",
"monitoring.log.placeholder": "Monitoring will show robot logs, alerts and mission history here.",
"help.api.title": "API documentation",
"help.api.subtitle": "Help → API — MiR v2.0.0 REST reference for external integration.",
"help.api.body1": "See endpoint details under System → Integrations or /api/v2.0.0/ docs.",
"help.api.body2": "Reference Guide MiR rev 1.9: docs/Reference guide.pdf",
},
};
const LOCALE_META = {
vi: { flag: "🇻🇳", labelKey: "topbar.localeVi" },
en: { flag: "🇺🇸", labelKey: "topbar.localeEn" },
};
let locale = "vi";
function interpolate(str, vars) {
if (!vars) return str;
return String(str).replace(/\{(\w+)\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : `{${k}}`));
}
function t(key, vars) {
const raw = MESSAGES[locale]?.[key] ?? MESSAGES.en[key] ?? key;
return interpolate(raw, vars);
}
function applyDOM() {
document.querySelectorAll("[data-i18n]").forEach((node) => {
const key = node.dataset.i18n;
if (key) node.textContent = t(key);
});
document.querySelectorAll("[data-i18n-placeholder]").forEach((node) => {
const key = node.dataset.i18nPlaceholder;
if (key) node.placeholder = t(key);
});
document.querySelectorAll("[data-i18n-title]").forEach((node) => {
const key = node.dataset.i18nTitle;
if (key) node.title = t(key);
});
document.querySelectorAll("[data-i18n-aria]").forEach((node) => {
const key = node.dataset.i18nAria;
if (key) node.setAttribute("aria-label", t(key));
});
document.querySelectorAll("option[data-i18n]").forEach((node) => {
const key = node.dataset.i18n;
if (key) node.textContent = t(key);
});
const titleKey = document.documentElement.dataset.i18nTitle;
if (titleKey) document.title = t(titleKey);
}
function syncTopbarLocaleUI() {
const meta = LOCALE_META[locale];
if (!meta) return;
const flagEl = document.getElementById("mirLocaleFlag");
const labelEl = document.getElementById("mirLocaleLabel");
if (flagEl) flagEl.textContent = meta.flag;
if (labelEl) labelEl.textContent = t(meta.labelKey);
}
function setLocale(next, opts = {}) {
locale = MESSAGES[next] ? next : "vi";
try {
localStorage.setItem("lm_locale", locale);
} catch {
/* ignore */
}
document.documentElement.lang = locale;
applyDOM();
syncTopbarLocaleUI();
if (!opts.silent) {
window.dispatchEvent(new CustomEvent("lm:locale-change", { detail: { locale } }));
}
}
function loadLocale() {
try {
const saved = localStorage.getItem("lm_locale");
if (saved && MESSAGES[saved]) locale = saved;
} catch {
/* ignore */
}
setLocale(locale, { silent: true });
window.dispatchEvent(new CustomEvent("lm:locale-change", { detail: { locale } }));
}
window.I18n = {
t,
getLocale: () => locale,
setLocale,
applyDOM,
loadLocale,
LOCALE_META,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", loadLocale);
} else {
loadLocale();
}
})();

View File

@@ -14,10 +14,10 @@
<div class="loginHeaderRight">
<span class="loginHeaderPrompt">Chọn cách đăng nhập:</span>
<div class="loginTabs" role="tablist">
<button id="loginTabPassword" type="button" class="loginTab active" role="tab" aria-selected="true">
<button id="loginTabPassword" type="button" class="loginTab active" role="tab" aria-selected="true" data-i18n="login.tab.password">
Tên đăng nhập và mật khẩu
</button>
<button id="loginTabPin" type="button" class="loginTab" role="tab" aria-selected="false">
<button id="loginTabPin" type="button" class="loginTab" role="tab" aria-selected="false" data-i18n="login.tab.pin">
Mã PIN
</button>
</div>
@@ -27,26 +27,26 @@
<div class="loginCard">
<div id="loginPanelPassword" class="loginPanel">
<div id="loginHelpPassword" class="loginHelp">
<h2 class="loginHelpTitle">Đăng nhập bằng tên và mật khẩu</h2>
<p>Nhập tên đăng nhập và mật khẩu để truy cập robot.</p>
<p>Tài khoản do quản trị viên cấp hoặc xem trong tài liệu hướng dẫn robot.</p>
<p>Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.</p>
<h2 class="loginHelpTitle" data-i18n="login.password.title">Đăng nhập bằng tên và mật khẩu</h2>
<p data-i18n="login.password.help1">Nhập tên đăng nhập và mật khẩu để truy cập robot.</p>
<p data-i18n="login.password.help2">Tài khoản do quản trị viên cấp hoặc xem trong tài liệu hướng dẫn robot.</p>
<p data-i18n="login.password.help3">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" 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 />
<span class="loginFieldLabel" data-i18n="login.field.username">Tên đăng nhập:</span>
<input id="loginUsername" name="username" type="text" autocomplete="username" placeholder="Admin" data-i18n-placeholder="login.placeholder.username" required />
</label>
<label class="loginField">
<span class="loginFieldLabel">Mật khẩu:</span>
<span class="loginFieldLabel" data-i18n="login.field.password">Mật khẩu:</span>
<input id="loginPasswordInput" name="password" type="password" autocomplete="current-password" placeholder="" required />
</label>
<button type="submit" class="loginSubmit" data-mode="password">
<svg class="loginSubmitIcon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M12.65 10A5.99 5.99 0 0 0 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6a5.99 5.99 0 0 0 5.65-4H17v2h3v-2h1v-3h-3V9h-1.35zM7 14a4 4 0 1 1 0-8 4 4 0 0 1 0 8z"/>
</svg>
<span class="loginSubmitLabel">Đăng nhập</span>
<span class="loginSubmitLabel" data-i18n="login.submit">Đăng nhập</span>
</button>
</form>
<p id="loginError" class="loginError" hidden></p>
@@ -56,12 +56,12 @@
<div id="loginPanelPin" class="loginPanel loginPanel--pin" hidden>
<div class="loginPinLeft">
<div class="loginHelp">
<h2 class="loginHelpTitle">Đăng nhập bằng mã PIN</h2>
<p>Người dùng được kích hoạt PIN có thể đăng nhập tại đây.</p>
<p>Nếu chưa có mã PIN 4 chữ số, vui lòng liên hệ quản trị viên robot.</p>
<p class="loginHelpNote">Không có mã PIN cấu hình sẵn — quản trị viên phải gán PIN trước.</p>
<h2 class="loginHelpTitle" data-i18n="login.pin.title">Đăng nhập bằng mã PIN</h2>
<p data-i18n="login.pin.help1">Người dùng được kích hoạt PIN có thể đăng nhập tại đây.</p>
<p data-i18n="login.pin.help2">Nếu chưa có mã PIN 4 chữ số, vui lòng liên hệ quản trị viên robot.</p>
<p class="loginHelpNote" data-i18n="login.pin.helpNote">Không có mã PIN cấu hình sẵn — quản trị viên phải gán PIN trước.</p>
</div>
<div class="loginPinBoxes" id="loginPinBoxes" role="group" aria-label="Mã PIN 4 chữ số">
<div class="loginPinBoxes" id="loginPinBoxes" role="group" aria-label="Mã PIN 4 chữ số" data-i18n-aria="login.pin.aria.group">
<div class="loginPinCell" data-idx="0"></div>
<div class="loginPinCell" data-idx="1"></div>
<div class="loginPinCell" data-idx="2"></div>
@@ -70,7 +70,7 @@
<input id="loginPin" type="hidden" value="" autocomplete="off" />
<p id="loginPinError" class="loginError loginPinError" hidden></p>
</div>
<div class="loginKeypad" id="loginKeypad" aria-label="Bàn phím số">
<div class="loginKeypad" id="loginKeypad" aria-label="Bàn phím số" data-i18n-aria="login.pin.aria.keypad">
<button type="button" class="loginKey" data-key="1">1</button>
<button type="button" class="loginKey" data-key="2">2</button>
<button type="button" class="loginKey" data-key="3">3</button>
@@ -81,7 +81,7 @@
<button type="button" class="loginKey" data-key="8">8</button>
<button type="button" class="loginKey" data-key="9">9</button>
<button type="button" class="loginKey loginKey--wide" data-key="0">0</button>
<button type="button" class="loginKey loginKey--back" data-key="back" aria-label="Xóa"></button>
<button type="button" class="loginKey loginKey--back" data-key="back" aria-label="Xóa" data-i18n-aria="login.pin.aria.backspace"></button>
</div>
</div>
</div>
@@ -89,54 +89,77 @@
</div>
<div class="shell auth-locked">
<aside class="sidebar">
<div class="brand">
<div class="brandIcon">R</div>
<div class="brandText">
<div class="brandTitle">PhenikaaX</div>
<div class="brandSub">RobotApp</div>
</div>
</div>
<aside class="mirNavShell" id="mirNavShell">
<nav class="mirNavRail" id="mirNavRail" aria-label="Main navigation" data-i18n-aria="nav.aria.main">
<button type="button" class="mirNavBackBtn" id="mirNavBackBtn" aria-label="Collapse menu" title="Collapse menu" data-i18n-aria="nav.collapse" data-i18n-title="nav.collapse">
<span aria-hidden="true">«</span>
</button>
<div class="navTitle">WORKSPACE</div>
<nav class="nav">
<a class="navItem active" href="#" data-page="dashboard" aria-current="page">
<span class="navDot"></span>
Dashboard
</a>
<a class="navItem" href="#" data-page="config">
<span class="navDot"></span>
Cấu hình
</a>
<div class="mirNavRailItems">
<button type="button" class="mirNavRailItem" data-module="dashboards">
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M4 4h7v9H4V4zm9 0h7v5h-7V4zM4 15h7v5H4v-5zm9 4h7v-5h-7v5z" fill="currentColor"/>
</svg>
<span class="mirNavRailLabel" data-i18n="nav.dashboards">Dashboards</span>
</button>
<button type="button" class="mirNavRailItem is-active" data-module="setup" aria-current="true">
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="mirNavRailLabel" data-i18n="nav.setup">Setup</span>
</button>
<button type="button" class="mirNavRailItem" data-module="monitoring">
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M3 3v18h18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M7 14l4-4 3 3 5-6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="mirNavRailLabel" data-i18n="nav.monitoring">Monitoring</span>
</button>
<button type="button" class="mirNavRailItem" data-module="system">
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.8"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
<span class="mirNavRailLabel" data-i18n="nav.system">System</span>
</button>
<button type="button" class="mirNavRailItem" data-module="help">
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8"/>
<path d="M9.5 9a2.5 2.5 0 1 1 4.2 1.8c-.8.6-1.2 1.2-1.2 2.2M12 17h.01" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
<span class="mirNavRailLabel" data-i18n="nav.help">Help</span>
</button>
</div>
<div class="mirNavRailFooter">
<button type="button" class="mirNavRailItem mirNavRailItem--logout" id="mirNavLogout">
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="mirNavRailLabel" data-i18n="nav.logout">Log out</span>
</button>
</div>
</nav>
<div class="navTitle">CÀI ĐẶT</div>
<nav class="nav">
<a class="navItem" href="#" data-page="missions">
<span class="navDot"></span>
Missions
</a>
<a class="navItem" href="#" data-page="integrations">
<span class="navDot"></span>
Tích hợp
</a>
</nav>
<div class="sidebarFooter">
<div class="statusBadge">
<span class="statusLed"></span>
<span id="status" class="statusText"></span>
<aside class="mirNavFlyout" id="mirNavFlyout">
<div class="mirNavFlyoutHeader">
<h2 class="mirNavFlyoutTitle" id="mirNavFlyoutTitle">Setup</h2>
</div>
</div>
<nav class="mirNavFlyoutList" id="mirNavFlyoutList" aria-label="Submenu" data-i18n-aria="nav.aria.submenu"></nav>
<div class="mirNavFlyoutFooter">
<span class="mirNavStatusLed" aria-hidden="true"></span>
<span id="status" class="mirNavStatusText"></span>
</div>
</aside>
</aside>
<div class="body">
<header class="mirTopbar" id="mirTopbar">
<div class="mirTopbarInner">
<div class="mirTopbarLeft">
<div class="mirRobotId" id="mirRobotId" title="Robot">RobotApp</div>
<div class="mirRobotId" id="mirRobotId" title="Robot" data-i18n-title="topbar.robotTitle">RobotApp</div>
<button type="button" class="mirPauseBtn" id="mirSegControl" aria-label="Start / Pause robot" title="Start / Pause robot">
<button type="button" class="mirPauseBtn" id="mirSegControl" aria-label="Start / Pause robot" title="Start / Pause robot" data-i18n-aria="topbar.controlAria" data-i18n-title="topbar.controlAria">
<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"/>
@@ -174,8 +197,8 @@
<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>
<button type="button" class="mirLocaleOption" data-locale="vi" data-i18n="topbar.localeOption.vi">🇻🇳 Tiếng Việt</button>
<button type="button" class="mirLocaleOption" data-locale="en" data-i18n="topbar.localeOption.en">🇺🇸 English</button>
</div>
<button type="button" class="mirSegment mirSegment--user" id="mirUserBtn" aria-haspopup="true" aria-expanded="false">
@@ -205,7 +228,7 @@
<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">
<button type="button" class="mirSegment mirSegment--joystick" id="mirSegJoystick" title="Engage joystick" data-i18n-title="topbar.joystickAria" 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"/>
@@ -213,7 +236,7 @@
</svg>
</button>
<div class="mirSegment mirSegment--battery" id="mirSegBattery" title="Battery">
<div class="mirSegment mirSegment--battery" id="mirSegBattery" title="Battery" data-i18n-title="topbar.batteryTitle">
<span class="mirBatteryIcon" id="mirBatteryIcon" aria-hidden="true">
<span class="mirBatteryLevel" id="mirBatteryLevel"></span>
</span>
@@ -229,42 +252,42 @@
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">Dashboard</div>
<div class="cardSub">Widget mission — chạy, xếp hàng và tạm dừng giống MiR Fleet.</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>
<div class="dashboardToolbar">
<button id="dashboardAddWidgetBtn" type="button" class="btn subtle">Thêm widget</button>
<button id="dashboardEditBtn" type="button" class="btn subtle">Sửa layout</button>
<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>
</div>
<div class="cardBody">
<div id="dashboardGrid" class="dashboardGrid"></div>
<p id="dashboardEmpty" class="mutedNote dashboardEmpty" hidden>Chưa có widget. Bấm «Thêm widget» để bắt đầu.</p>
<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">Hệ thống</div>
<div class="cardSub">Trạng thái backend và layout đang active.</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>
</div>
<div class="cardBody dashboardInfoGrid">
<div class="row rowWide">
<label>Backend</label>
<label data-i18n="dashboard.system.backend">Backend</label>
<div id="overviewBackend" class="mutedNote"></div>
</div>
<div class="row rowWide">
<label>Layout</label>
<label data-i18n="dashboard.system.layout">Layout</label>
<div id="overviewActiveLayout" class="mutedNote"></div>
</div>
<div class="row rowWide">
<label>Model robot</label>
<label data-i18n="dashboard.system.model">Model robot</label>
<div id="overviewActiveModel" class="mutedNote"></div>
</div>
<div class="row rowWide">
<label>LiDAR / IMU</label>
<label data-i18n="dashboard.system.sensors">LiDAR / IMU</label>
<div id="overviewActiveSensors" class="mutedNote"></div>
</div>
</div>
@@ -277,32 +300,32 @@
<section class="card" id="layoutManagerCard">
<div class="cardHeader">
<div>
<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 class="cardTitle" data-i18n="config.layout.title">Quản lý layout</div>
<div class="cardSub" data-i18n="config.layout.subtitle">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>
<button id="refreshBtn" type="button" class="btn subtle" data-i18n="common.reload">Tải lại</button>
<button id="saveLayoutBtn" type="button" class="btn primary" data-i18n="config.layout.save">Lưu layout</button>
</div>
</div>
<div class="cardBody">
<div class="row rowWide">
<label>Layout hiện tại</label>
<label data-i18n="config.layout.current">Layout hiện tại</label>
<select id="layoutSelect"></select>
</div>
<div class="row rowWide">
<label>Tên layout mới</label>
<input id="layoutNewName" type="text" placeholder="VD: AGV kho A" />
<label data-i18n="config.layout.newName">Tên layout mới</label>
<input id="layoutNewName" type="text" placeholder="VD: AGV kho A" data-i18n-placeholder="config.layout.newNamePlaceholder" />
</div>
<div class="checkRow">
<label>
<input id="layoutCloneCurrent" type="checkbox" />
Sao chép từ layout đang mở
<span data-i18n="config.layout.cloneCurrent">Sao chép từ layout đang mở</span>
</label>
</div>
<div class="layoutManagerActions">
<button id="layoutCreateBtn" type="button" class="btn subtle">Tạo layout</button>
<button id="layoutDeleteBtn" type="button" class="btn subtle danger">Xóa</button>
<button id="layoutCreateBtn" type="button" class="btn subtle" data-i18n="config.layout.create">Tạo layout</button>
<button id="layoutDeleteBtn" type="button" class="btn subtle danger" data-i18n="common.delete">Xóa</button>
</div>
<p id="layoutActiveHint" class="mutedNote"></p>
</div>
@@ -318,8 +341,8 @@
aria-controls="lidarListCardBody"
>
<div>
<div class="cardTitle">LiDARs</div>
<div class="cardSub">Đăng ký tên, IP, port và chỉnh pose theo robot frame.</div>
<div class="cardTitle" data-i18n="config.lidar.title">LiDARs</div>
<div class="cardSub" data-i18n="config.lidar.subtitle">Đăng ký tên, IP, port và chỉnh pose theo robot frame.</div>
</div>
<span class="cardChevron" aria-hidden="true"></span>
</div>
@@ -327,19 +350,19 @@
<div class="cardBody" id="lidarListCardBody">
<form id="lidarForm" class="form">
<div class="row">
<label>Tên</label>
<input id="name" placeholder="Lidar trước" required />
<label data-i18n="config.lidar.field.name">Tên</label>
<input id="name" placeholder="Lidar trước" data-i18n-placeholder="config.lidar.placeholder.name" required />
</div>
<div class="row">
<label>IP</label>
<input id="ip" placeholder="192.168.0.10" required />
<label data-i18n="config.lidar.field.ip">IP</label>
<input id="ip" placeholder="192.168.0.10" data-i18n-placeholder="config.lidar.placeholder.ip" required />
</div>
<div class="row">
<label>Port</label>
<label data-i18n="config.lidar.field.port">Port</label>
<input id="port" type="number" min="1" max="65535" value="2112" required />
</div>
<div class="actions">
<button id="addLidarBtn" class="btn primary" type="button">Thêm</button>
<button id="addLidarBtn" class="btn primary" type="button" data-i18n="common.add">Thêm</button>
</div>
<p id="lidarFormHint" class="formHint" hidden></p>
</form>
@@ -358,8 +381,8 @@
aria-controls="imuListCardBody"
>
<div>
<div class="cardTitle">IMU</div>
<div class="cardSub">Cảm biến quán tính — frame, topic và pose trên robot.</div>
<div class="cardTitle" data-i18n="config.imu.title">IMU</div>
<div class="cardSub" data-i18n="config.imu.subtitle">Cảm biến quán tính — frame, topic và pose trên robot.</div>
</div>
<span class="cardChevron" aria-hidden="true"></span>
</div>
@@ -367,37 +390,37 @@
<div class="cardBody" id="imuListCardBody">
<form id="imuForm" class="form">
<div class="row">
<label>Tên</label>
<input id="imuName" placeholder="IMU chính" required />
<label data-i18n="config.imu.field.name">Tên</label>
<input id="imuName" placeholder="IMU chính" data-i18n-placeholder="config.imu.placeholder.name" required />
</div>
<div class="row">
<label>Frame ID</label>
<input id="imuFrameId" placeholder="imu_link" required />
<label data-i18n="config.imu.field.frame">Frame ID</label>
<input id="imuFrameId" placeholder="imu_link" data-i18n-placeholder="config.imu.placeholder.frame" required />
</div>
<div class="row">
<label>Topic</label>
<input id="imuTopic" placeholder="imu/data" value="imu/data" required />
<label data-i18n="config.imu.field.topic">Topic</label>
<input id="imuTopic" placeholder="imu/data" data-i18n-placeholder="config.imu.placeholder.topic" value="imu/data" required />
</div>
<div class="row rowWide">
<label>Nguồn</label>
<label data-i18n="config.imu.field.source">Nguồn</label>
<select id="imuSource">
<option value="external">Ngoài (ROS topic)</option>
<option value="lidar_builtin">Tích hợp LiDAR</option>
<option value="onboard">Onboard robot</option>
<option value="external" data-i18n="config.imu.source.external">Ngoài (ROS topic)</option>
<option value="lidar_builtin" data-i18n="config.imu.source.lidarBuiltin">Tích hợp LiDAR</option>
<option value="onboard" data-i18n="config.imu.source.onboard">Onboard robot</option>
</select>
</div>
<div class="row">
<label>Tần số (Hz)</label>
<label data-i18n="config.imu.field.rate">Tần số (Hz)</label>
<input id="imuRateHz" type="number" min="1" max="1000" step="1" value="100" />
</div>
<div class="checkRow">
<label>
<input id="imuEnabled" type="checkbox" checked />
Bật IMU
<span data-i18n="config.imu.enabled">Bật IMU</span>
</label>
</div>
<div class="actions">
<button id="addImuBtn" class="btn primary" type="button">Thêm IMU</button>
<button id="addImuBtn" class="btn primary" type="button" data-i18n="config.imu.add">Thêm IMU</button>
</div>
<p id="imuFormHint" class="formHint" hidden></p>
</form>
@@ -416,7 +439,7 @@
aria-controls="robotModelCardBody"
>
<div>
<div class="cardTitle">Model robot</div>
<div class="cardTitle" data-i18n="config.robot.title">Model robot</div>
<div class="cardSub">Kinematic differential — bánh, động cơ và giới hạn vận tốc.</div>
</div>
<span class="cardChevron" aria-hidden="true"></span>
@@ -696,13 +719,13 @@
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">Missions</div>
<div class="cardSub">Setup → Missions — danh sách nhiệm vụ robot.</div>
<div class="cardTitle" data-i18n="missions.title">Missions</div>
<div class="cardSub" data-i18n="missions.subtitle">Setup → Missions — danh sách nhiệm vụ robot.</div>
</div>
<button id="missionCreateOpenBtn" type="button" class="btn primary">Create mission</button>
<button id="missionCreateOpenBtn" type="button" class="btn primary" data-i18n="missions.create">Create mission</button>
</div>
<div class="cardBody">
<div id="missionListEmpty" class="mutedNote" hidden>Chưa có mission. Bấm Create mission để bắt đầu.</div>
<div id="missionListEmpty" class="mutedNote" hidden data-i18n="missions.empty">Chưa có mission. Bấm Create mission để bắt đầu.</div>
<div id="missionList" class="missionList"></div>
</div>
</section>
@@ -710,17 +733,17 @@
<section class="card" id="missionQueueCard">
<div class="cardHeader">
<div>
<div class="cardTitle">Mission queue</div>
<div class="cardSub">Thêm mission bằng biểu tượng queue — robot chạy theo thứ tự từ trên xuống.</div>
<div class="cardTitle" data-i18n="missions.queue.title">Mission queue</div>
<div class="cardSub" data-i18n="missions.queue.subtitle">Thêm mission bằng biểu tượng queue — robot chạy theo thứ tự từ trên xuống.</div>
</div>
<div class="missionQueueCardActions">
<button id="missionQueueCancelBtn" type="button" class="btn subtle danger" title="Hủy mission đang chạy">Hủy chạy</button>
<button id="missionQueueClearBtn" type="button" class="btn subtle danger">Xóa queue</button>
<button id="missionQueueCancelBtn" type="button" class="btn subtle danger" data-i18n="missions.queue.cancel">Hủy chạy</button>
<button id="missionQueueClearBtn" type="button" class="btn subtle danger" data-i18n="missions.queue.clear">Xóa queue</button>
</div>
</div>
<div class="cardBody">
<div id="missionQueueRunner" class="missionQueueRunner mutedNote"></div>
<div id="missionQueueEmpty" class="mutedNote">Queue trống. Bấm <span class="mono"></span> trên mission để thêm.</div>
<div id="missionQueueEmpty" class="mutedNote" data-i18n="missions.queue.empty">Queue trống. Bấm ▤ trên mission để thêm.</div>
<div id="missionQueueList" class="missionQueueList"></div>
</div>
</section>
@@ -730,31 +753,31 @@
<section class="card missionEditorCard">
<div class="missionEditorTop">
<div class="missionEditorTitleWrap">
<button id="missionEditorBackBtn" type="button" class="btn subtle missionBackBtn" aria-label="Quay lại danh sách"></button>
<button id="missionEditorBackBtn" type="button" class="btn subtle missionBackBtn" data-i18n-aria="missions.editor.backAria"></button>
<div>
<div class="missionEditorKicker">Mission editor</div>
<div class="missionEditorKicker" data-i18n="missions.editor.kicker">Mission editor</div>
<div class="missionEditorTitleRow">
<h2 id="missionEditorTitle" class="missionEditorTitle"></h2>
<button id="missionSettingsBtn" type="button" class="iconBtn" title="Cài đặt mission" aria-label="Cài đặt mission"></button>
<button id="missionSettingsBtn" type="button" class="iconBtn" data-i18n-aria="missions.editor.settingsAria" data-i18n-title="missions.editor.settingsAria"></button>
</div>
<div id="missionEditorMeta" class="missionEditorMeta"></div>
</div>
</div>
<div class="missionEditorTopActions">
<span id="missionEditorDirty" class="missionDirtyBadge" hidden>Chưa lưu</span>
<button id="missionSaveAsBtn" type="button" class="btn subtle">Save as</button>
<button id="missionSaveBtn" type="button" class="btn primary">Save</button>
<span id="missionEditorDirty" class="missionDirtyBadge" hidden data-i18n="missions.editor.unsaved">Chưa lưu</span>
<button id="missionSaveAsBtn" type="button" class="btn subtle" data-i18n="missions.editor.saveAs">Save as</button>
<button id="missionSaveBtn" type="button" class="btn primary" data-i18n="missions.editor.save">Save</button>
</div>
</div>
<div class="missionActionBar" id="missionActionBar" role="toolbar" aria-label="Thêm action">
<div class="missionActionBar" id="missionActionBar" role="toolbar" data-i18n-aria="missions.editor.addActionAria">
<div class="missionGroupTabs" id="missionGroupTabs"></div>
</div>
<div class="missionEditorBody">
<p class="missionFlowHint">Thực thi từ trên xuống dưới. Kéo biểu tượng ↔ để đổi thứ tự. Với Loop: kéo action vào vùng bên trong.</p>
<p class="missionFlowHint" data-i18n="missions.editor.flowHint">Thực thi từ trên xuống dưới. Kéo biểu tượng ↔ để đổi thứ tự. Với Loop: kéo action vào vùng bên trong.</p>
<div id="missionActionList" class="missionActionList"></div>
<div id="missionActionListEmpty" class="missionActionListEmpty mutedNote">Chọn action từ menu phía trên để bắt đầu.</div>
<div id="missionActionListEmpty" class="missionActionListEmpty mutedNote" data-i18n="missions.editor.emptyActions">Chọn action từ menu phía trên để bắt đầu.</div>
</div>
</section>
</div>
@@ -765,16 +788,16 @@
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">Modbus trigger</div>
<div class="cardSub">System → Triggers — coil 10012000 gắn mission_id. Thiết bị remote bật coil (Modbus TCP :5502) → mission vào queue.</div>
<div class="cardTitle" data-i18n="integrations.modbus.title">Modbus trigger</div>
<div class="cardSub" data-i18n="integrations.modbus.subtitle">System → Triggers — coil 10012000 gắn mission_id. Thiết bị remote bật coil (Modbus TCP :5502) → mission vào queue.</div>
</div>
<button id="integrationAddTriggerBtn" type="button" class="btn primary">Thêm trigger</button>
<button id="integrationAddTriggerBtn" type="button" class="btn primary" data-i18n="integrations.modbus.add">Thêm trigger</button>
</div>
<div class="cardBody">
<div id="integrationTriggerEmpty" class="mutedNote">Chưa có trigger Modbus.</div>
<div id="integrationTriggerEmpty" class="mutedNote" data-i18n="integrations.modbus.empty">Chưa có trigger Modbus.</div>
<div id="integrationTriggerList" class="missionList"></div>
<div class="integrationCoilSection">
<div class="integrationSectionLabel">Coil đã gán (bấm để mô phỏng rising edge)</div>
<div class="integrationSectionLabel" data-i18n="integrations.modbus.coilsLabel">Coil đã gán (bấm để mô phỏng rising edge)</div>
<div id="integrationCoilGrid" class="integrationCoilGrid"></div>
</div>
</div>
@@ -783,13 +806,13 @@
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">REST API — MiR v2.0.0</div>
<div class="cardSub">Hệ thống bên ngoài POST mission vào queue qua REST.</div>
<div class="cardTitle" data-i18n="integrations.rest.title">REST API — MiR v2.0.0</div>
<div class="cardSub" data-i18n="integrations.rest.subtitle">Hệ thống bên ngoài POST mission vào queue qua REST.</div>
</div>
</div>
<div class="cardBody integrationApiBody">
<div class="row rowWide">
<label>Base URL</label>
<label data-i18n="integrations.rest.baseUrl">Base URL</label>
<div id="integrationApiBaseUrl" class="mono integrationCode"></div>
</div>
<div class="integrationApiBlock">
@@ -805,10 +828,10 @@ GET /api/v2.0.0/missions
GET /api/v2.0.0/status</pre>
</div>
<div class="row rowWide integrationTestRow">
<label for="integrationRestMission">Thử nhanh</label>
<label for="integrationRestMission" data-i18n="integrations.rest.quickTest">Thử nhanh</label>
<div class="integrationTestActions">
<select id="integrationRestMission"></select>
<button id="integrationRestTestBtn" type="button" class="btn subtle">POST queue</button>
<button id="integrationRestTestBtn" type="button" class="btn subtle" data-i18n="integrations.rest.postQueue">POST queue</button>
</div>
</div>
</div>
@@ -817,29 +840,58 @@ GET /api/v2.0.0/status</pre>
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">MiRFleet — Lên lịch mission</div>
<div class="cardSub">Ưu tiên, gán robot, chạy ASAP hoặc theo thời gian.</div>
<div class="cardTitle" data-i18n="integrations.fleet.title">MiRFleet — Lên lịch mission</div>
<div class="cardSub" data-i18n="integrations.fleet.subtitle">Ưu tiên, gán robot, chạy ASAP hoặc theo thời gian.</div>
</div>
<div class="integrationHeaderActions">
<button id="integrationRefreshBtn" type="button" class="btn subtle">Tải lại</button>
<button id="integrationAddScheduleBtn" type="button" class="btn primary">Thêm lịch</button>
<button id="integrationRefreshBtn" type="button" class="btn subtle" data-i18n="common.reload">Tải lại</button>
<button id="integrationAddScheduleBtn" type="button" class="btn primary" data-i18n="integrations.fleet.addSchedule">Thêm lịch</button>
</div>
</div>
<div class="cardBody">
<div id="integrationScheduleEmpty" class="mutedNote">Chưa có lịch fleet.</div>
<div id="integrationScheduleEmpty" class="mutedNote" data-i18n="integrations.fleet.empty">Chưa có lịch fleet.</div>
<div id="integrationScheduleList" class="missionList"></div>
</div>
</section>
</div>
</div>
<div class="page" id="pageMonitoring" data-page-content="monitoring" hidden>
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle" data-i18n="monitoring.log.title">System log</div>
<div class="cardSub" data-i18n="monitoring.log.subtitle">Monitoring → System log — nhật ký hệ thống (đang phát triển).</div>
</div>
</div>
<div class="cardBody">
<p class="mutedNote" data-i18n="monitoring.log.placeholder">Tính năng monitoring sẽ hiển thị log robot, cảnh báo và lịch sử mission tại đây.</p>
</div>
</section>
</div>
<div class="page" id="pageHelp" data-page-content="help" hidden>
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle" data-i18n="help.api.title">API documentation</div>
<div class="cardSub" data-i18n="help.api.subtitle">Help → API — tham chiếu REST MiR v2.0.0 cho tích hợp bên ngoài.</div>
</div>
</div>
<div class="cardBody">
<p class="mutedNote" data-i18n="help.api.body1">Xem chi tiết endpoint tại System → Tích hợp hoặc tài liệu /api/v2.0.0/.</p>
<p class="mutedNote" data-i18n="help.api.body2">Reference Guide MiR rev 1.9: docs/Reference guide.pdf</p>
</div>
</section>
</div>
<div id="configSplitter" class="splitter" role="separator" aria-orientation="vertical" tabindex="0"></div>
<div class="contentRight" id="contentRight">
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle">Bố trí trên robot</div>
<div class="cardTitle" data-i18n="config.canvas.title">Bố trí trên robot</div>
</div>
</div>
@@ -848,11 +900,11 @@ GET /api/v2.0.0/status</pre>
<canvas id="canvas"></canvas>
</div>
<div class="metaBar">
<div class="viewHint">Cuộn chuột: zoom • Shift + kéo: di chuyển vùng nhìn</div>
<div class="viewHint" data-i18n="config.canvas.viewHint">Cuộn chuột: zoom • Shift + kéo: di chuyển vùng nhìn</div>
<div id="robotDiffSummary" class="robotDiffSummary"></div>
<div>Robot center: <span id="robotCenterText"></span></div>
<div>Selected: <span id="selectedText">none</span></div>
<div>Pose: <span id="selectedRelText"></span></div>
<div><span data-i18n="config.canvas.robotCenter">Robot center:</span> <span id="robotCenterText"></span></div>
<div><span data-i18n="config.canvas.selected">Selected:</span> <span id="selectedText" data-i18n="common.none">none</span></div>
<div><span data-i18n="config.canvas.pose">Pose:</span> <span id="selectedRelText"></span></div>
</div>
</div>
</section>
@@ -864,30 +916,30 @@ GET /api/v2.0.0/status</pre>
<dialog id="missionCreateDialog" class="missionDialog">
<form id="missionCreateForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Create mission</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionCreateDialog" aria-label="Đóng">×</button>
<h3 data-i18n="missions.dialog.create.title">Create mission</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionCreateDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
<label for="missionCreateName">Tên mission</label>
<input id="missionCreateName" type="text" required placeholder="VD: Go to charging station" />
<label for="missionCreateName" data-i18n="missions.dialog.create.name">Tên mission</label>
<input id="missionCreateName" type="text" required placeholder="VD: Go to charging station" data-i18n-placeholder="missions.dialog.create.namePlaceholder" />
</div>
<div class="row rowWide">
<label for="missionCreateGroup">Nhóm mission</label>
<label for="missionCreateGroup" data-i18n="missions.dialog.create.group">Nhóm mission</label>
<select id="missionCreateGroup"></select>
</div>
<div class="row rowWide">
<label for="missionCreateGroupNew">Hoặc nhóm mới</label>
<input id="missionCreateGroupNew" type="text" placeholder="Tùy chọn" />
<label for="missionCreateGroupNew" data-i18n="missions.dialog.create.groupNew">Hoặc nhóm mới</label>
<input id="missionCreateGroupNew" type="text" placeholder="Tùy chọn" data-i18n-placeholder="common.optional" />
</div>
<div class="row rowWide">
<label for="missionCreateDesc">Mô tả</label>
<textarea id="missionCreateDesc" rows="2" placeholder="Tùy chọn"></textarea>
<label for="missionCreateDesc" data-i18n="missions.dialog.create.desc">Mô tả</label>
<textarea id="missionCreateDesc" rows="2" placeholder="Tùy chọn" data-i18n-placeholder="common.optional"></textarea>
</div>
</div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle" data-close-dialog="missionCreateDialog">Hủy</button>
<button type="submit" class="btn primary">Create mission</button>
<button type="button" class="btn subtle" data-close-dialog="missionCreateDialog" data-i18n="common.cancel">Hủy</button>
<button type="submit" class="btn primary" data-i18n="missions.create">Create mission</button>
</div>
</form>
</dialog>
@@ -895,26 +947,26 @@ GET /api/v2.0.0/status</pre>
<dialog id="missionSettingsDialog" class="missionDialog">
<form id="missionSettingsForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Cài đặt mission</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionSettingsDialog" aria-label="Đóng">×</button>
<h3 data-i18n="missions.dialog.settings.title">Cài đặt mission</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionSettingsDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
<label for="missionSettingsName">Tên</label>
<label for="missionSettingsName" data-i18n="missions.dialog.settings.name">Tên</label>
<input id="missionSettingsName" type="text" required />
</div>
<div class="row rowWide">
<label for="missionSettingsGroup">Nhóm</label>
<label for="missionSettingsGroup" data-i18n="missions.dialog.settings.group">Nhóm</label>
<select id="missionSettingsGroup"></select>
</div>
<div class="row rowWide">
<label for="missionSettingsDesc">Mô tả</label>
<label for="missionSettingsDesc" data-i18n="missions.dialog.settings.desc">Mô tả</label>
<textarea id="missionSettingsDesc" rows="2"></textarea>
</div>
</div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle" data-close-dialog="missionSettingsDialog">Hủy</button>
<button type="submit" class="btn primary">Áp dụng</button>
<button type="submit" class="btn primary" data-i18n="common.apply">Áp dụng</button>
</div>
</form>
</dialog>
@@ -922,18 +974,18 @@ GET /api/v2.0.0/status</pre>
<dialog id="missionSaveAsDialog" class="missionDialog">
<form id="missionSaveAsForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Save mission as</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionSaveAsDialog" aria-label="Đóng">×</button>
<h3 data-i18n="missions.dialog.saveAs.title">Save mission as</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionSaveAsDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
<label for="missionSaveAsName">Tên mission mới</label>
<label for="missionSaveAsName" data-i18n="missions.dialog.saveAs.name">Tên mission mới</label>
<input id="missionSaveAsName" type="text" required />
</div>
</div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle" data-close-dialog="missionSaveAsDialog">Hủy</button>
<button type="submit" class="btn primary">Lưu bản sao</button>
<button type="submit" class="btn primary" data-i18n="missions.dialog.saveAs.submit">Lưu bản sao</button>
</div>
</form>
</dialog>
@@ -941,13 +993,13 @@ GET /api/v2.0.0/status</pre>
<dialog id="missionActionConfigDialog" class="missionDialog missionDialogWide">
<form id="missionActionConfigForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3 id="missionActionConfigTitle">Cấu hình action</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionActionConfigDialog" aria-label="Đóng">×</button>
<h3 id="missionActionConfigTitle" data-i18n="missions.dialog.actionConfig.title">Cấu hình action</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionActionConfigDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody" id="missionActionConfigBody"></div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle" data-close-dialog="missionActionConfigDialog">Hủy</button>
<button type="submit" class="btn primary">Áp dụng</button>
<button type="submit" class="btn primary" data-i18n="common.apply">Áp dụng</button>
</div>
</form>
</dialog>
@@ -956,7 +1008,7 @@ GET /api/v2.0.0/status</pre>
<form id="missionQueueForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Thêm vào mission queue</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionQueueDialog" aria-label="Đóng">×</button>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionQueueDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<p id="missionQueueDialogMission" class="mutedNote"></p>
@@ -976,7 +1028,7 @@ GET /api/v2.0.0/status</pre>
<form id="dashboardAddWidgetForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Thêm widget</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardAddWidgetDialog" aria-label="Đóng">×</button>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardAddWidgetDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
@@ -1001,7 +1053,7 @@ GET /api/v2.0.0/status</pre>
<form id="dashboardEditWidgetForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Cấu hình widget</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardEditWidgetDialog" aria-label="Đóng">×</button>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardEditWidgetDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<input type="hidden" id="dashboardEditWidgetId" />
@@ -1023,7 +1075,7 @@ GET /api/v2.0.0/status</pre>
<form id="integrationAddTriggerForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Modbus trigger</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddTriggerDialog" aria-label="Đóng">×</button>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddTriggerDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
@@ -1050,7 +1102,7 @@ GET /api/v2.0.0/status</pre>
<form id="integrationAddScheduleForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Lịch MiRFleet</h3>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddScheduleDialog" aria-label="Đóng">×</button>
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddScheduleDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
@@ -1091,26 +1143,26 @@ GET /api/v2.0.0/status</pre>
<dialog id="changePasswordDialog" class="missionDialog">
<form id="changePasswordForm" method="dialog" class="missionDialogForm">
<div class="missionDialogHeader">
<h3>Đổi mật khẩu</h3>
<button type="button" class="iconBtn missionDialogClose" onclick="document.getElementById('changePasswordDialog').close()" aria-label="Đóng">×</button>
<h3 data-i18n="auth.changePassword.title">Đổi mật khẩu</h3>
<button type="button" class="iconBtn missionDialogClose" onclick="document.getElementById('changePasswordDialog').close()" aria-label="Đóng" data-i18n-aria="common.close">×</button>
</div>
<div class="missionDialogBody">
<div class="row rowWide">
<label for="changePasswordCurrent">Mật khẩu hiện tại</label>
<label for="changePasswordCurrent" data-i18n="auth.changePassword.current">Mật khẩu hiện tại</label>
<input id="changePasswordCurrent" type="password" autocomplete="current-password" required />
</div>
<div class="row rowWide">
<label for="changePasswordNew">Mật khẩu mới</label>
<label for="changePasswordNew" data-i18n="auth.changePassword.new">Mật khẩu mới</label>
<input id="changePasswordNew" type="password" autocomplete="new-password" required minlength="4" />
</div>
<div class="row rowWide">
<label for="changePasswordConfirm">Xác nhận mật khẩu mới</label>
<label for="changePasswordConfirm" data-i18n="auth.changePassword.confirm">Xác nhận mật khẩu mới</label>
<input id="changePasswordConfirm" type="password" autocomplete="new-password" required minlength="4" />
</div>
<p id="changePasswordError" class="loginError"></p>
</div>
<div class="missionDialogFooter">
<button type="button" class="btn subtle" onclick="document.getElementById('changePasswordDialog').close()">Hủy</button>
<button type="button" class="btn subtle" onclick="document.getElementById('changePasswordDialog').close()" data-i18n="common.cancel">Hủy</button>
<button type="submit" class="btn primary">Lưu</button>
</div>
</form>
@@ -1131,9 +1183,9 @@ GET /api/v2.0.0/status</pre>
<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>
<option value="slow" data-i18n="topbar.joystickSpeed.slow">Slow</option>
<option value="medium" data-i18n="topbar.joystickSpeed.medium">Medium</option>
<option value="fast" data-i18n="topbar.joystickSpeed.fast" selected>Fast</option>
</select>
</label>
<button type="button" class="mirBtn mirBtn--danger" id="joystickDisengageBtn" data-i18n="topbar.joystickOff">Tắt joystick</button>
@@ -1141,7 +1193,9 @@ GET /api/v2.0.0/status</pre>
</div>
</div>
<script src="/i18n.js"></script>
<script src="/auth.js"></script>
<script src="/nav.js"></script>
<script src="/missions.js"></script>
<script src="/topbar.js"></script>
<script src="/dashboard.js"></script>

View File

@@ -3,6 +3,7 @@
const COIL_MAX = 2000;
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const triggerListEl = el("integrationTriggerList");
const triggerEmptyEl = el("integrationTriggerEmpty");
const coilGridEl = el("integrationCoilGrid");
@@ -72,7 +73,7 @@
const data = await apiJson("/api/fleet/robots");
store.robots = Array.isArray(data) ? data : [];
} catch {
store.robots = [{ id: "default", name: "Robot chính" }];
store.robots = [{ id: "default", name: t("integrations.defaultRobot") }];
}
}
@@ -96,7 +97,7 @@
if (!store.missions.length) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "— Chưa có mission —";
opt.textContent = t("integrations.noMissions");
selectEl.appendChild(opt);
return;
}
@@ -122,7 +123,7 @@
if (!store.robots.length) {
const opt = document.createElement("option");
opt.value = "default";
opt.textContent = "Robot chính";
opt.textContent = t("integrations.defaultRobot");
opt.selected = selected === "default";
selectEl.appendChild(opt);
}
@@ -133,24 +134,24 @@
triggerListEl.innerHTML = "";
if (triggerEmptyEl) triggerEmptyEl.hidden = store.triggers.length > 0;
store.triggers.forEach((t) => {
store.triggers.forEach((trigger) => {
const row = document.createElement("div");
row.className = "missionListItem integrationRow";
const coil = t.coil_id;
const coil = trigger.coil_id;
const on = store.coils[String(coil)] === true;
row.innerHTML = `
<div>
<div class="missionListItemTitle">${escapeHtml(t.name)}</div>
<div class="missionListItemTitle">${escapeHtml(trigger.name)}</div>
<div class="missionListItemMeta">
Coil <span class="mono">${coil}</span>
${escapeHtml(missionName(t.mission_id))}
· ${t.enabled === false ? "Tắt" : "Bật"}
· coil hiện tại: <span class="mono">${on ? "ON" : "OFF"}</span>
${escapeHtml(missionName(trigger.mission_id))}
· ${trigger.enabled === false ? t("common.disabled") : t("common.enabled")}
· ${t("integrations.coilState", { state: on ? "ON" : "OFF" })}
</div>
</div>
<div class="missionListItemActions">
<button type="button" class="btn subtle" data-fire-coil="${coil}">Kích hoạt</button>
<button type="button" class="btn subtle danger" data-delete-trigger="${escapeHtml(t.id)}">Xóa</button>
<button type="button" class="btn subtle" data-fire-coil="${coil}">${t("integrations.fireTrigger")}</button>
<button type="button" class="btn subtle danger" data-delete-trigger="${escapeHtml(trigger.id)}">${t("common.delete")}</button>
</div>`;
triggerListEl.appendChild(row);
});
@@ -160,10 +161,10 @@
if (!coilGridEl) return;
const assigned = new Map(store.triggers.map((t) => [t.coil_id, t]));
const chips = [];
assigned.forEach((t, coilId) => {
assigned.forEach((trigger, coilId) => {
const on = store.coils[String(coilId)] === true;
chips.push(
`<button type="button" class="integrationCoilChip${on ? " on" : ""}" data-fire-coil="${coilId}" title="${escapeHtml(t.name)}">
`<button type="button" class="integrationCoilChip${on ? " on" : ""}" data-fire-coil="${coilId}" title="${escapeHtml(trigger.name)}">
${coilId}
</button>`
);
@@ -171,11 +172,11 @@
coilGridEl.innerHTML =
chips.length > 0
? chips.join("")
: `<span class="mutedNote">Chưa gán coil. Thêm trigger bên trên (10012000).</span>`;
: `<span class="mutedNote">${t("integrations.coilsEmpty")}</span>`;
}
function formatScheduleTime(s) {
if (!s.start_at) return s.start_mode === "scheduled" ? "—" : "Ngay (asap)";
if (!s.start_at) return s.start_mode === "scheduled" ? "—" : t("integrations.dialog.schedule.asap");
try {
return new Date(s.start_at).toLocaleString("vi-VN");
} catch {
@@ -204,7 +205,7 @@
</div>
</div>
<div class="missionListItemActions">
<button type="button" class="btn subtle" data-run-schedule="${escapeHtml(s.id)}">Chạy ngay</button>
<button type="button" class="btn subtle" data-run-schedule="${escapeHtml(s.id)}">${t("integrations.schedule.runNow")}</button>
<button type="button" class="btn subtle danger" data-delete-schedule="${escapeHtml(s.id)}">Xóa</button>
</div>`;
scheduleListEl.appendChild(row);
@@ -316,7 +317,7 @@
}
async function deleteTrigger(id) {
if (!confirm("Xóa trigger Modbus này?")) return;
if (!confirm(t("integrations.confirm.deleteTrigger"))) return;
try {
await apiJson(`/api/triggers/${id}`, { method: "DELETE" });
await refreshAll();
@@ -326,7 +327,7 @@
}
async function deleteSchedule(id) {
if (!confirm("Xóa lịch fleet này?")) return;
if (!confirm(t("integrations.confirm.deleteSchedule"))) return;
try {
await apiJson(`/api/fleet/schedules/${id}`, { method: "DELETE" });
await refreshAll();
@@ -446,6 +447,12 @@
function boot() {
init();
}
window.addEventListener("lm:locale-change", () => {
renderTriggers();
renderCoilGrid();
renderSchedules();
});
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
})();

View File

@@ -39,6 +39,7 @@
const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"];
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const missionListEl = el("missionList");
const missionListEmptyEl = el("missionListEmpty");
@@ -136,9 +137,9 @@
function actionMeta(type) {
for (const items of Object.values(ACTION_GROUPS)) {
const hit = items.find((a) => a.type === type);
if (hit) return hit;
if (hit) return { ...hit, label: t(`missions.action.${type}`) || hit.label };
}
return { type, label: type };
return { type, label: t(`missions.action.${type}`) || type };
}
function createAction(type, overrides = {}) {
@@ -336,7 +337,7 @@
if (action.id === actionId) return { action, list, index: i, path, parent };
if (Array.isArray(action.children)) {
const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action);
if (hit) return hit;
if (hit) return { ...hit, label: t(`missions.action.${type}`) || hit.label };
}
}
return null;
@@ -476,13 +477,14 @@
function queueStatusLabel(status) {
const map = {
pending: "Chờ",
executing: "Đang chạy",
completed: "Xong",
failed: "Lỗi",
cancelled: "Đã hủy",
pending: "missions.queue.status.pending",
executing: "missions.queue.status.executing",
completed: "missions.queue.status.done",
failed: "missions.queue.status.error",
cancelled: "missions.queue.status.cancelled",
};
return map[status] || status;
const key = map[status];
return key ? t(key) : status;
}
async function refreshQueue() {
@@ -495,7 +497,7 @@
notifyQueueUpdate();
} catch (e) {
if (String(e.message || "").includes("not authenticated")) return;
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `Không tải được queue: ${e.message}`;
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `${t("common.error", { msg: e.message })}`;
}
}
@@ -536,8 +538,8 @@
? `${store.runner.message}${action}`
: st === "idle"
? compact
? "Sẵn sàng"
: "Robot sẵn sàng — queue trống hoặc chờ mission mới."
? t("missions.queue.ready")
: t("missions.queue.idleMessage")
: "—";
}
@@ -554,12 +556,12 @@
${paramHtml ? `<div class="missionQueueItemParams">${paramHtml}</div>` : ""}
</div>
<div class="missionQueueWidgetActions">
${entry.status === "pending" ? `<button type="button" class="iconBtn danger" data-queue-remove="${entry.id}" title="Xóa">×</button>` : `<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>`}
${entry.status === "pending" ? `<button type="button" class="iconBtn danger" data-queue-remove="${entry.id}" title="" data-i18n-title="common.delete">×</button>` : `<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>`}
</div>`
: `
<div class="missionQueueOrder">
<button type="button" class="iconBtn" data-queue-up="${entry.id}" title="Lên" ${canReorder && index > 0 ? "" : "disabled"}>↑</button>
<button type="button" class="iconBtn" data-queue-down="${entry.id}" title="Xuống" ${canReorder && index < store.queue.length - 1 ? "" : "disabled"}>↓</button>
<button type="button" class="iconBtn" data-queue-up="${entry.id}" title="${t("missions.queue.moveUp")}" ${canReorder && index > 0 ? "" : "disabled"}>↑</button>
<button type="button" class="iconBtn" data-queue-down="${entry.id}" title="${t("missions.queue.moveDown")}" ${canReorder && index < store.queue.length - 1 ? "" : "disabled"}>↓</button>
</div>
<div>
<div class="missionQueueItemTitle">${escapeHtml(entry.mission_name || "Mission")}</div>
@@ -568,7 +570,7 @@
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px;">
<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>
${entry.status === "pending" ? `<button type="button" class="btn subtle danger" data-queue-remove="${entry.id}">Xóa</button>` : ""}
${entry.status === "pending" ? `<button type="button" class="btn subtle danger" data-queue-remove="${entry.id}">${t("common.delete")}</button>` : ""}
</div>`;
row.querySelector("[data-queue-up]")?.addEventListener("click", () => moveQueueItem(entry.id, -1));
@@ -619,7 +621,7 @@
}
async function clearQueue() {
if (!confirm("Xóa các mission đang chờ trong queue?")) return;
if (!confirm(t("missions.queue.clearConfirm"))) return;
try {
await missionApi("/api/mission_queue", { method: "DELETE" });
await refreshQueue();
@@ -650,7 +652,7 @@
}
async function cancelRunner() {
if (!confirm("Hủy mission đang chạy? (thoát loop và dừng ngay)")) return;
if (!confirm(t("missions.queue.cancelConfirm"))) return;
await missionApi("/api/mission_queue/cancel", { method: "POST", body: "{}" });
await refreshQueue();
}
@@ -756,8 +758,8 @@
</div>
<div class="missionListItemActions">
<button type="button" class="iconBtn missionQueueBtn" data-queue="${mission.id}" title="Thêm vào mission queue" aria-label="Thêm vào queue">▤</button>
<button type="button" class="btn subtle" data-edit="${mission.id}">Sửa</button>
<button type="button" class="btn subtle danger" data-delete="${mission.id}">Xóa</button>
<button type="button" class="btn subtle" data-edit="${mission.id}">${t("common.edit")}</button>
<button type="button" class="btn subtle danger" data-delete="${mission.id}">${t("common.delete")}</button>
</div>`;
row.addEventListener("click", (evt) => {
if (evt.target.closest("button")) return;
@@ -773,7 +775,7 @@
});
row.querySelector("[data-delete]").addEventListener("click", (evt) => {
evt.stopPropagation();
if (!confirm(`Xóa mission «${mission.name}»?`)) return;
if (!confirm(t("missions.deleteConfirm", { name: mission.name }))) return;
store.missions = store.missions.filter((m) => m.id !== mission.id);
persistStore();
renderMissionList();
@@ -782,6 +784,12 @@
});
}
function groupLabel(name) {
const key = `missions.group.${name}`;
const v = t(key);
return v !== key ? v : name;
}
function renderActionPalette() {
if (!missionGroupTabsEl) return;
missionGroupTabsEl.innerHTML = "";
@@ -889,7 +897,7 @@
</div>
<div class="missionActionBtns">
<button type="button" class="iconBtn" data-config="${action.id}" title="Cấu hình">⚙</button>
<button type="button" class="iconBtn danger" data-remove="${action.id}" title="Xóa">×</button>
<button type="button" class="iconBtn danger" data-remove="${action.id}" title="" data-i18n-title="common.delete">×</button>
</div>
</div>`;
@@ -1044,7 +1052,7 @@
}
function closeEditor() {
if (store.dirty && !confirm("Bỏ thay đổi chưa lưu?")) return;
if (store.dirty && !confirm(t("missions.editor.discardConfirm"))) return;
store.editingId = null;
store.draft = null;
setDirty(false);
@@ -1057,7 +1065,7 @@
const draft = getDraft();
if (!draft) return false;
if (!draft.name.trim()) {
alert("Tên mission không được trống.");
alert(t("missions.error.nameRequired"));
return false;
}
draft.updated_at = new Date().toISOString();
@@ -1076,7 +1084,7 @@
const name = newName.trim();
if (!name) return false;
if (store.missions.some((m) => m.name === name && m.id !== draft.id)) {
alert("Tên mission đã tồn tại.");
alert(t("missions.error.nameDuplicate"));
return false;
}
const copy = JSON.parse(JSON.stringify(draft));
@@ -1213,7 +1221,7 @@
addField("Timeout (s)", textInput("timeout_s", p.timeout_s, "number"));
{
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> Chờ mức ON`;
chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> ${t("missions.action.waitOnLevel")}`;
addField("Kỳ vọng", chk);
}
break;
@@ -1286,7 +1294,7 @@
if (!store.groups.includes(group)) store.groups.push(group);
}
if (store.missions.some((m) => m.name === name)) {
alert("Tên mission đã tồn tại.");
alert(t("missions.error.nameDuplicate"));
return;
}
const mission = createMission(name, group, el("missionCreateDesc").value);
@@ -1299,7 +1307,7 @@
el("missionEditorBackBtn")?.addEventListener("click", closeEditor);
el("missionSaveBtn")?.addEventListener("click", () => {
if (saveDraft()) alert("Đã lưu mission.");
if (saveDraft()) alert(t("missions.saveSuccess"));
});
el("missionSaveAsBtn")?.addEventListener("click", openSaveAsDialog);
el("missionSettingsBtn")?.addEventListener("click", openSettingsDialog);
@@ -1312,7 +1320,7 @@
draft.group = el("missionSettingsGroup").value;
draft.description = el("missionSettingsDesc").value.trim();
if (!draft.name) {
alert("Tên không được trống.");
alert(t("missions.error.nameEmpty"));
return;
}
setDirty(true);
@@ -1393,6 +1401,16 @@
function boot() {
init();
}
function onLocaleChange() {
if (!missionEditorViewEl?.hidden) renderMissionEditor();
else {
renderMissionList();
renderQueuePanel();
}
renderActionPalette();
}
window.addEventListener("lm:locale-change", onLocaleChange);
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
window.addEventListener("lm:auth-logout", stopQueuePollForce);

268
www/nav.js Normal file
View File

@@ -0,0 +1,268 @@
/**
* MiR-style 3-column navigation: primary rail + flyout submenu + content.
*/
(function () {
const STORAGE_MODULE = "mirNavModule";
const STORAGE_SECTION = "mirNavSection";
const STORAGE_FLYOUT = "mirNavFlyoutOpen";
const MODULES = {
dashboards: {
items: [{ section: "dashboard", page: "dashboard" }],
},
setup: {
items: [
{ section: "missions", page: "missions" },
{ section: "maps", page: "config" },
],
},
monitoring: {
items: [{ section: "monitoring-log", page: "monitoring" }],
},
system: {
items: [{ section: "integrations", page: "integrations" }],
},
help: {
items: [{ section: "help-api", page: "help" }],
},
};
const PAGE_NAV = {
dashboard: { module: "dashboards", section: "dashboard" },
config: { module: "setup", section: "maps" },
missions: { module: "setup", section: "missions" },
integrations: { module: "system", section: "integrations" },
monitoring: { module: "monitoring", section: "monitoring-log" },
help: { module: "help", section: "help-api" },
};
let activeModule = "setup";
let activeSection = "maps";
let flyoutOpen = true;
const shellEl = () => document.getElementById("mirNavShell");
const flyoutListEl = () => document.getElementById("mirNavFlyoutList");
const flyoutTitleEl = () => document.getElementById("mirNavFlyoutTitle");
const backBtnEl = () => document.getElementById("mirNavBackBtn");
function t(key) {
return window.I18n?.t(`nav.${key}`) ?? key;
}
function canAccessPage(page) {
if (window.AuthApp?.canAccessPage) return window.AuthApp.canAccessPage(page);
return true;
}
function visibleItems(moduleId) {
const mod = MODULES[moduleId];
if (!mod) return [];
return mod.items.filter((item) => canAccessPage(item.page));
}
function moduleHasAccess(moduleId) {
return visibleItems(moduleId).length > 0;
}
function saveState() {
try {
localStorage.setItem(STORAGE_MODULE, activeModule);
localStorage.setItem(STORAGE_SECTION, activeSection);
localStorage.setItem(STORAGE_FLYOUT, flyoutOpen ? "1" : "0");
} catch {
/* ignore */
}
}
function renderFlyout() {
const list = flyoutListEl();
const title = flyoutTitleEl();
if (!list || !title) return;
const items = visibleItems(activeModule);
title.textContent = t(activeModule);
list.replaceChildren();
items.forEach((item) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "mirNavFlyoutItem";
btn.dataset.section = item.section;
btn.dataset.page = item.page;
btn.textContent = t(item.section);
if (item.section === activeSection) {
btn.classList.add("is-active");
btn.setAttribute("aria-current", "page");
}
btn.addEventListener("click", () => selectSection(item.section, item.page));
list.appendChild(btn);
});
}
function updateRailUI() {
document.querySelectorAll(".mirNavRailItem[data-module]").forEach((btn) => {
const mod = btn.dataset.module || "";
const allowed = moduleHasAccess(mod);
btn.hidden = !allowed;
btn.style.display = allowed ? "" : "none";
const on = mod === activeModule && flyoutOpen;
btn.classList.toggle("is-active", on);
if (on) btn.setAttribute("aria-current", "true");
else btn.removeAttribute("aria-current");
});
const shell = shellEl();
if (shell) shell.classList.toggle("mirNavShell--flyout-collapsed", !flyoutOpen);
const back = backBtnEl();
if (back) {
const label = flyoutOpen ? t("collapse") : t("expand");
back.title = label;
back.setAttribute("aria-label", label);
}
renderFlyout();
}
function selectModule(moduleId, opts = {}) {
if (!MODULES[moduleId] || !moduleHasAccess(moduleId)) return;
if (moduleId === activeModule && flyoutOpen && !opts.forceSection) {
flyoutOpen = false;
saveState();
updateRailUI();
return;
}
activeModule = moduleId;
flyoutOpen = true;
const items = visibleItems(moduleId);
const keepSection = items.some((i) => i.section === activeSection);
if (!keepSection || opts.forceSection) {
const preferred = items.find((i) => i.section === opts.section) || items[0];
if (preferred) {
activeSection = preferred.section;
if (!opts.skipPage) navigateToPage(preferred.page);
}
} else if (!opts.skipPage) {
const current = items.find((i) => i.section === activeSection);
if (current) navigateToPage(current.page);
}
saveState();
updateRailUI();
}
function selectSection(section, page) {
activeSection = section;
saveState();
updateRailUI();
navigateToPage(page);
}
function navigateToPage(page) {
if (window.LmApp?.setActivePage) window.LmApp.setActivePage(page);
}
function syncFromPage(page) {
const nav = PAGE_NAV[page];
if (!nav) return;
activeModule = nav.module;
activeSection = nav.section;
saveState();
updateRailUI();
}
function toggleFlyout() {
flyoutOpen = !flyoutOpen;
saveState();
updateRailUI();
}
function applyPermissions() {
const modules = Object.keys(MODULES);
if (!moduleHasAccess(activeModule)) {
const fallback = modules.find((m) => moduleHasAccess(m));
if (fallback) selectModule(fallback, { forceSection: true, skipPage: false });
} else {
const items = visibleItems(activeModule);
if (!items.some((i) => i.section === activeSection)) {
activeSection = items[0]?.section || activeSection;
}
}
updateRailUI();
}
function restoreInitialPage() {
let page = "config";
try {
const saved = localStorage.getItem("activePage");
if (saved && PAGE_NAV[saved]) page = saved;
} catch {
/* ignore */
}
try {
const savedMod = localStorage.getItem(STORAGE_MODULE);
const savedSec = localStorage.getItem(STORAGE_SECTION);
const savedFlyout = localStorage.getItem(STORAGE_FLYOUT);
if (savedMod && MODULES[savedMod]) activeModule = savedMod;
if (savedSec) activeSection = savedSec;
if (savedFlyout === "0") flyoutOpen = false;
} catch {
/* ignore */
}
const nav = PAGE_NAV[page];
if (nav && moduleHasAccess(nav.module)) {
activeModule = nav.module;
activeSection = nav.section;
} else {
const modItems = visibleItems(activeModule);
const match = modItems.find((i) => i.page === page) || modItems[0];
if (match) {
activeSection = match.section;
page = match.page;
}
}
updateRailUI();
navigateToPage(page);
}
function refreshLabels() {
window.I18n?.applyDOM?.();
updateRailUI();
}
function bindEvents() {
document.querySelectorAll(".mirNavRailItem[data-module]").forEach((btn) => {
btn.addEventListener("click", () => selectModule(btn.dataset.module || "setup"));
});
backBtnEl()?.addEventListener("click", toggleFlyout);
document.getElementById("mirNavLogout")?.addEventListener("click", () => {
window.AuthApp?.logout?.();
});
window.addEventListener("lm:locale-change", () => refreshLabels());
}
function init() {
refreshLabels();
bindEvents();
applyPermissions();
restoreInitialPage();
}
window.NavApp = {
init,
syncFromPage,
applyPermissions,
selectModule,
selectSection,
toggleFlyout,
};
})();

View File

@@ -24,102 +24,184 @@ body {
.shell {
display: grid;
grid-template-columns: 260px 1fr;
grid-template-columns: auto 1fr;
min-height: 100vh;
}
.sidebar {
/* —— MiR 3-column navigation (rail + flyout + content) —— */
.mirNavShell {
display: flex;
height: 100vh;
position: sticky;
top: 0;
height: 100vh;
background: linear-gradient(180deg, #0b1220, #0b1220);
color: #e8eefc;
padding: 16px 14px;
border-right: 1px solid rgba(255, 255, 255, 0.08);
z-index: 35;
flex-shrink: 0;
}
.brand {
display: grid;
grid-template-columns: 40px 1fr;
gap: 10px;
align-items: center;
padding: 10px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
.mirNavShell--flyout-collapsed .mirNavFlyout {
width: 0;
min-width: 0;
padding: 0;
border: none;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.brandIcon {
width: 40px;
height: 40px;
border-radius: 12px;
display: grid;
place-items: center;
background: rgba(37, 99, 235, 0.22);
border: 1px solid rgba(37, 99, 235, 0.35);
font-weight: 800;
}
.brandTitle { font-weight: 800; font-size: 13px; letter-spacing: 0.2px; }
.brandSub { color: rgba(232,238,252,0.75); font-size: 12px; margin-top: 2px; }
.navTitle {
margin-top: 16px;
padding: 0 10px;
font-size: 11px;
color: rgba(232,238,252,0.65);
letter-spacing: 0.12em;
.mirNavRail {
width: 76px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: stretch;
background: linear-gradient(180deg, #1a3d66 0%, #122d4f 55%, #0f2744 100%);
color: rgba(255, 255, 255, 0.92);
border-right: 1px solid rgba(0, 0, 0, 0.28);
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.06);
}
.nav { margin-top: 8px; display: grid; gap: 6px; }
.navItem {
.mirNavBackBtn {
flex-shrink: 0;
margin: 8px 8px 4px;
height: 32px;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.mirNavBackBtn:hover { background: rgba(255, 255, 255, 0.14); }
.mirNavRailItems {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 6px;
overflow-y: auto;
}
.mirNavRailFooter {
padding: 8px 6px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.mirNavRailItem {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
min-height: 68px;
padding: 8px 4px;
border: none;
border-radius: 10px;
background: transparent;
color: rgba(255, 255, 255, 0.78);
cursor: pointer;
text-align: center;
}
.mirNavRailItem:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.mirNavRailItem.is-active {
background: rgba(255, 255, 255, 0.14);
color: #fff;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
.mirNavRailIcon { display: block; opacity: 0.95; }
.mirNavRailLabel {
font-size: 10px;
line-height: 1.15;
letter-spacing: 0.01em;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.mirNavRailItem--logout { color: rgba(255, 200, 200, 0.9); }
.mirNavRailItem--logout:hover { color: #fff; background: rgba(239, 68, 68, 0.22); }
.mirNavFlyout {
width: 248px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #5a94cf 0%, #4a7fbe 45%, #3d6fa8 100%);
color: #fff;
border-right: 1px solid rgba(0, 0, 0, 0.18);
box-shadow: 4px 0 18px rgba(15, 23, 42, 0.12);
transition: width 0.18s ease, opacity 0.18s ease;
overflow: hidden;
}
.mirNavFlyoutHeader {
padding: 14px 16px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.mirNavFlyoutTitle {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
}
.mirNavFlyoutList {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 8px;
overflow-y: auto;
}
.mirNavFlyoutItem {
display: block;
width: 100%;
text-align: left;
padding: 11px 14px;
border: none;
border-radius: 8px;
background: transparent;
color: rgba(255, 255, 255, 0.88);
font-size: 14px;
cursor: pointer;
}
.mirNavFlyoutItem:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.mirNavFlyoutItem.is-active {
background: rgba(255, 255, 255, 0.22);
color: #fff;
font-weight: 600;
box-shadow: inset 3px 0 0 #fff;
}
.mirNavFlyoutFooter {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 10px;
border-radius: 12px;
color: rgba(232,238,252,0.85);
text-decoration: none;
border: 1px solid transparent;
gap: 8px;
padding: 10px 14px 14px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
}
.navItem:hover { background: rgba(255,255,255,0.05); }
.navItem.active {
background: rgba(37, 99, 235, 0.22);
border-color: rgba(37, 99, 235, 0.30);
color: #ffffff;
}
.navDot {
width: 10px;
height: 10px;
.mirNavStatusLed {
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(255,255,255,0.25);
background: rgba(16, 185, 129, 0.9);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
flex-shrink: 0;
}
.navItem.active .navDot { background: rgba(37, 99, 235, 1); }
.sidebarFooter {
position: absolute;
left: 14px;
right: 14px;
bottom: 14px;
.mirNavStatusText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statusBadge {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.statusLed {
width: 10px;
height: 10px;
border-radius: 999px;
background: rgba(16, 185, 129, 0.85);
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12);
}
.statusText { color: rgba(232,238,252,0.85); font-size: 12px; }
.body {
display: grid;
grid-template-rows: auto 1fr;
min-width: 0;
background: var(--bg);
box-shadow: -2px 0 12px rgba(15, 23, 42, 0.06);
}
/* —— MiR-style top bar —— */
@@ -1370,8 +1452,10 @@ canvas {
}
@media (max-width: 980px) {
.shell { grid-template-columns: 1fr; }
.sidebar { position: relative; height: auto; }
.shell { grid-template-columns: auto 1fr; }
.mirNavShell { height: auto; position: relative; }
.mirNavShell--flyout-collapsed .mirNavFlyout { display: none; }
.mirNavFlyout { width: 200px; }
.body { grid-template-rows: auto 1fr; }
.content { grid-template-columns: 1fr; height: auto; }
.splitter { display: none; }

View File

@@ -1,63 +1,14 @@
(() => {
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";
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const getLocale = () => window.I18n?.getLocale?.() ?? "vi";
let robotStatus = null;
let pollTimer = null;
let eventsBound = false;
@@ -67,8 +18,13 @@
let joystickRaf = null;
let lastCmd = { linear: 0, angular: 0 };
function t(key) {
return I18N[locale]?.[key] ?? I18N.en[key] ?? key;
function applyLocale(next) {
if (window.I18n) window.I18n.setLocale(next);
if (robotStatus) renderAll(robotStatus);
}
function loadLocale() {
/* locale owned by I18n */
}
function canSeeMissions() {
@@ -96,35 +52,6 @@
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;
@@ -226,7 +153,7 @@
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>` : ""}`;
${status.queue_pending > 0 ? `<div class="mirStatusMeta">${t("topbar.queueCount", { n: status.queue_pending })}</div>` : ""}`;
if (footerEl) footerEl.hidden = true;
}
}
@@ -267,7 +194,10 @@
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";
if (el("joystickSpeedLabel")) {
const speed = status.joystick_speed || "fast";
el("joystickSpeedLabel").textContent = t(`topbar.joystickSpeed.${speed}`);
}
}
function renderAll(status) {
@@ -438,7 +368,7 @@
el("mirSegJoystick")?.addEventListener("click", async () => {
if (!canControl()) {
alert(locale === "vi" ? "Không có quyền điều khiển" : "No control permission");
alert(t("topbar.noControlPermission"));
return;
}
try {
@@ -472,6 +402,9 @@
});
bindJoystickPad();
window.addEventListener("lm:locale-change", () => {
if (robotStatus) renderAll(robotStatus);
});
}
function start() {
@@ -502,7 +435,7 @@
window.TopbarApp = {
t,
getLocale: () => locale,
getLocale,
applyLocale,
refresh: fetchStatus,
getRobotStatus: () => robotStatus,