diff --git a/data/mission_queue.json b/data/mission_queue.json
index 1068394..5ef4f8a 100644
--- a/data/mission_queue.json
+++ b/data/mission_queue.json
@@ -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"
}
}
\ No newline at end of file
diff --git a/www/app.js b/www/app.js
index ad9212d..8439708 100644
--- a/www/app.js
+++ b/www/app.js
@@ -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 = `
Chưa có LiDAR
Hãy thêm LiDAR ở form phía trên.
`;
+ listEl.innerHTML = `${t("config.lidar.empty")}
${t("config.lidar.emptyHint")}
`;
return;
}
@@ -2244,7 +2236,7 @@ function updateImuItemPoseUI(id) {
function renderImuList() {
if (!imuListEl) return;
if (!state.imus.length) {
- imuListEl.innerHTML = `Chưa có IMU
Thêm IMU ở form phía trên.
`;
+ imuListEl.innerHTML = `${t("config.imu.empty")}
${t("config.imu.emptyHint")}
`;
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?.();
+});
+
diff --git a/www/auth.js b/www/auth.js
index d043c8b..2e9e7a1 100644
--- a/www/auth.js
+++ b/www/auth.js
@@ -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) {
diff --git a/www/dashboard.js b/www/dashboard.js
index f3a8cfb..a91937c 100644
--- a/www/dashboard.js
+++ b/www/dashboard.js
@@ -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 = `
-
+
-
-
+
+
`;
} else if (type === "mission_group") {
container.innerHTML = `
-
+
-
+
`;
} else if (type === "mission_queue") {
container.innerHTML = `
-
+
`;
} else if (type === "pause_continue") {
container.innerHTML = `
-
+
Tạm dừng / tiếp tục / hủy mission đang chạy trên robot.
`;
@@ -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 = `
- ${!m ? `Cấu hình widget và chọn mission.
` : ""}`;
+ ${!m ? `${t("dashboard.widget.configHint")}
` : ""}`;
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 = `Không có mission trong nhóm «${escapeHtml(group)}».
`;
+ bodyEl.innerHTML = `${t("dashboard.widget.emptyGroup", { group })}
`;
return;
}
bodyEl.innerHTML = ``;
@@ -181,8 +179,8 @@
bodyEl.innerHTML = `
—
- Queue trống
- `;
+ ${t("dashboard.widget.queueEmpty")}
+ `;
bodyEl.querySelector(".dashboardQueueClear")?.addEventListener("click", () => missions()?.clearQueue?.());
refreshQueueWidget(bodyEl);
}
@@ -208,13 +206,13 @@
bodyEl.innerHTML = `
- ${running ? (paused ? "Mission đang tạm dừng" : "Mission đang chạy") : "Không có mission đang chạy"}
`;
+ ${running ? (paused ? t("dashboard.widget.runner.paused") : t("dashboard.widget.runner.running")) : t("dashboard.widget.runner.idle")}
`;
bodyEl.querySelector("[data-pause-action]")?.addEventListener("click", async (evt) => {
const action = evt.currentTarget.dataset.pauseAction;
try {
@@ -242,8 +240,8 @@
`;
@@ -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);
diff --git a/www/i18n.js b/www/i18n.js
new file mode 100644
index 0000000..c5e94d9
--- /dev/null
+++ b/www/i18n.js
@@ -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 1001–2000 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 (1001–2000).",
+ "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 1001–2000 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 (1001–2000).",
+ "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();
+ }
+})();
diff --git a/www/index.html b/www/index.html
index 459e94c..e41284c 100644
--- a/www/index.html
+++ b/www/index.html
@@ -14,10 +14,10 @@