From a2e87aeb29454f676f45ce3c8a2e1d025d12231c Mon Sep 17 00:00:00 2001 From: HiepLM Date: Tue, 16 Jun 2026 16:44:04 +0700 Subject: [PATCH] Add function Language --- data/mission_queue.json | 169 ++++++++- www/app.js | 81 +++-- www/auth.js | 37 +- www/dashboard.js | 60 ++-- www/i18n.js | 771 ++++++++++++++++++++++++++++++++++++++++ www/index.html | 412 +++++++++++---------- www/integrations.js | 43 ++- www/missions.js | 76 ++-- www/nav.js | 268 ++++++++++++++ www/style.css | 240 +++++++++---- www/topbar.js | 107 ++---- 11 files changed, 1790 insertions(+), 474 deletions(-) create mode 100644 www/i18n.js create mode 100644 www/nav.js 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 @@
${escapeHtml(widgetTitle(widget))}
`; @@ -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 @@
Chọn cách đăng nhập:
- -
@@ -27,26 +27,26 @@
-

Đăng nhập bằng tên và mật khẩu

-

Nhập tên đăng nhập và mật khẩu để truy cập robot.

-

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.

-

Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.

+

Đăng nhập bằng tên và mật khẩu

+

Nhập tên đăng nhập và mật khẩu để truy cập robot.

+

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.

+

Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.

@@ -56,12 +56,12 @@