This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
81
www/app.js
81
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 = `<div class="item"><div class="itemName">Chưa có LiDAR</div><div class="itemMeta">Hãy thêm LiDAR ở form phía trên.</div></div>`;
|
||||
listEl.innerHTML = `<div class="item"><div class="itemName">${t("config.lidar.empty")}</div><div class="itemMeta">${t("config.lidar.emptyHint")}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2244,7 +2236,7 @@ function updateImuItemPoseUI(id) {
|
||||
function renderImuList() {
|
||||
if (!imuListEl) return;
|
||||
if (!state.imus.length) {
|
||||
imuListEl.innerHTML = `<div class="item"><div class="itemName">Chưa có IMU</div><div class="itemMeta">Thêm IMU ở form phía trên.</div></div>`;
|
||||
imuListEl.innerHTML = `<div class="item"><div class="itemName">${t("config.imu.empty")}</div><div class="itemMeta">${t("config.imu.emptyHint")}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3117,7 +3109,7 @@ async function loadAll() {
|
||||
state.selectedImuId = null;
|
||||
}
|
||||
if (!state.selectedId && !state.selectedImuId) {
|
||||
selectedText.textContent = "none";
|
||||
selectedText.textContent = t("common.none");
|
||||
}
|
||||
setSelectedRelText();
|
||||
renderList();
|
||||
@@ -3130,7 +3122,10 @@ async function loadAll() {
|
||||
overviewActiveModelEl.textContent = state.layout?.robot?.model || "diff";
|
||||
}
|
||||
if (overviewActiveSensorsEl) {
|
||||
overviewActiveSensorsEl.textContent = `${state.lidars.length} LiDAR • ${state.imus.length} IMU`;
|
||||
overviewActiveSensorsEl.textContent = t("dashboard.system.sensorCount", {
|
||||
lidars: state.lidars.length,
|
||||
imus: state.imus.length,
|
||||
});
|
||||
}
|
||||
if (!state.viewInitialized) {
|
||||
fitViewToWorld();
|
||||
@@ -3404,16 +3399,16 @@ saveLayoutBtn?.addEventListener("click", async () => {
|
||||
await api("/api/health");
|
||||
await loadMotorCatalog();
|
||||
await loadAll();
|
||||
selectedText.textContent = "none";
|
||||
selectedText.textContent = t("common.none");
|
||||
selectedRelText.textContent = "—";
|
||||
setStatus("Sẵn sàng");
|
||||
setStatus(t("app.status.ready"));
|
||||
} catch (e) {
|
||||
const msg = String(e.message || e);
|
||||
if (overviewBackendEl) overviewBackendEl.textContent = `Lỗi: ${msg}`;
|
||||
if (overviewBackendEl) overviewBackendEl.textContent = t("common.error", { msg });
|
||||
if (msg.includes("stack") || msg.includes("Maximum call")) {
|
||||
setStatus(`Lỗi JavaScript: ${msg}`);
|
||||
setStatus(`${t("app.status.jsError")}: ${msg}`);
|
||||
} else {
|
||||
setStatus(`Không kết nối được backend: ${msg}`);
|
||||
setStatus(`${t("app.status.backendError")}: ${msg}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -3421,3 +3416,15 @@ saveLayoutBtn?.addEventListener("click", async () => {
|
||||
else window.AuthApp?.whenReady(() => { boot(); });
|
||||
})();
|
||||
|
||||
window.addEventListener("lm:locale-change", () => {
|
||||
if (typeof renderList === "function") renderList();
|
||||
if (typeof renderImuList === "function") renderImuList();
|
||||
if (typeof renderLayoutSelect === "function") renderLayoutSelect();
|
||||
if (typeof renderLayoutSelect === "function") renderLayoutSelect();
|
||||
if (typeof updateLayoutActiveHint === "function") updateLayoutActiveHint();
|
||||
if (typeof renderMotorWheels === "function") renderMotorWheels();
|
||||
if (typeof renderBicycleMotorWheels === "function") renderBicycleMotorWheels();
|
||||
if (typeof updateOverview === "function") updateOverview();
|
||||
window.I18n?.applyDOM?.();
|
||||
});
|
||||
|
||||
|
||||
37
www/auth.js
37
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) {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
(() => {
|
||||
const STORAGE_KEY = "phenikaax_dashboard_v1";
|
||||
|
||||
const WIDGET_LABELS = {
|
||||
mission_button: "Mission button",
|
||||
mission_group: "Mission group",
|
||||
mission_queue: "Mission queue",
|
||||
pause_continue: "Pause / Continue",
|
||||
};
|
||||
function widgetTypeLabel(type) {
|
||||
return t(`dashboard.widget.${type}`) || type;
|
||||
}
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
const gridEl = el("dashboardGrid");
|
||||
const emptyEl = el("dashboardEmpty");
|
||||
const addDialogEl = el("dashboardAddWidgetDialog");
|
||||
@@ -60,7 +58,7 @@
|
||||
store.widgets = [
|
||||
{ id: newId(), type: "mission_button", mission_id: firstId, title: "" },
|
||||
{ id: newId(), type: "mission_group", group: "Missions", title: "" },
|
||||
{ id: newId(), type: "mission_queue", title: "Mission queue" },
|
||||
{ id: newId(), type: "mission_queue", title: "" },
|
||||
{ id: newId(), type: "pause_continue", title: "" },
|
||||
];
|
||||
persistStore();
|
||||
@@ -76,7 +74,7 @@
|
||||
|
||||
function widgetTitle(widget) {
|
||||
if (widget.title) return widget.title;
|
||||
return WIDGET_LABELS[widget.type] || widget.type;
|
||||
return widgetTypeLabel(widget.type);
|
||||
}
|
||||
|
||||
function missionOptions(selected) {
|
||||
@@ -102,33 +100,33 @@
|
||||
if (type === "mission_button") {
|
||||
container.innerHTML = `
|
||||
<div class="row rowWide">
|
||||
<label>Mission</label>
|
||||
<label>${t("dashboard.widget.field.mission")}</label>
|
||||
<select data-field="mission_id">${missionOptions(widget.mission_id || "")}</select>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label>Tiêu đề widget (tùy chọn)</label>
|
||||
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="VD: Go to charging" />
|
||||
<label>${t("dashboard.widget.field.title")}</label>
|
||||
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="${t(\"dashboard.widget.titlePlaceholder\")}" />
|
||||
</div>`;
|
||||
} else if (type === "mission_group") {
|
||||
container.innerHTML = `
|
||||
<div class="row rowWide">
|
||||
<label>Nhóm mission</label>
|
||||
<label>${t("dashboard.widget.field.group")}</label>
|
||||
<select data-field="group">${groupOptions(widget.group || "Missions")}</select>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label>Tiêu đề widget (tùy chọn)</label>
|
||||
<label>${t("dashboard.widget.field.title")}</label>
|
||||
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" />
|
||||
</div>`;
|
||||
} else if (type === "mission_queue") {
|
||||
container.innerHTML = `
|
||||
<div class="row rowWide">
|
||||
<label>Tiêu đề widget (tùy chọn)</label>
|
||||
<label>${t("dashboard.widget.field.title")}</label>
|
||||
<input data-field="title" type="text" value="${escapeHtml(widget.title || "Mission queue")}" />
|
||||
</div>`;
|
||||
} else if (type === "pause_continue") {
|
||||
container.innerHTML = `
|
||||
<div class="row rowWide">
|
||||
<label>Tiêu đề widget (tùy chọn)</label>
|
||||
<label>${t("dashboard.widget.field.title")}</label>
|
||||
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" />
|
||||
</div>
|
||||
<p class="mutedNote">Tạm dừng / tiếp tục / hủy mission đang chạy trên robot.</p>`;
|
||||
@@ -145,13 +143,13 @@
|
||||
|
||||
function renderMissionButtonWidget(widget, bodyEl) {
|
||||
const m = missions()?.getMissionById?.(widget.mission_id);
|
||||
const label = m?.name || "Chọn mission…";
|
||||
const label = m?.name || t("dashboard.widget.selectMission");
|
||||
bodyEl.innerHTML = `
|
||||
<button type="button" class="dashboardMissionBtn" data-run-mission="${escapeHtml(widget.mission_id || "")}">
|
||||
<span class="dashboardMissionBtnIcon">▶</span>
|
||||
<span>${escapeHtml(label)}</span>
|
||||
</button>
|
||||
${!m ? `<p class="mutedNote dashboardWidgetHint">Cấu hình widget và chọn mission.</p>` : ""}`;
|
||||
${!m ? `<p class="mutedNote dashboardWidgetHint">${t("dashboard.widget.configHint")}</p>` : ""}`;
|
||||
bodyEl.querySelector("[data-run-mission]")?.addEventListener("click", () => {
|
||||
if (!widget.mission_id) return;
|
||||
missions()?.queueMission?.(widget.mission_id);
|
||||
@@ -162,7 +160,7 @@
|
||||
const group = widget.group || "Missions";
|
||||
const list = (missions()?.getMissions?.() || []).filter((m) => m.group === group);
|
||||
if (!list.length) {
|
||||
bodyEl.innerHTML = `<p class="mutedNote">Không có mission trong nhóm «${escapeHtml(group)}».</p>`;
|
||||
bodyEl.innerHTML = `<p class="mutedNote">${t("dashboard.widget.emptyGroup", { group })}</p>`;
|
||||
return;
|
||||
}
|
||||
bodyEl.innerHTML = `<div class="dashboardMissionGroupList"></div>`;
|
||||
@@ -181,8 +179,8 @@
|
||||
bodyEl.innerHTML = `
|
||||
<div class="dashboardQueueRunner mutedNote" data-role="runner">—</div>
|
||||
<div class="dashboardQueueList" data-role="list"></div>
|
||||
<p class="mutedNote dashboardQueueEmpty" data-role="empty">Queue trống</p>
|
||||
<button type="button" class="btn subtle btnBlock dashboardQueueClear">Xóa queue chờ</button>`;
|
||||
<p class="mutedNote dashboardQueueEmpty" data-role="empty">${t("dashboard.widget.queueEmpty")}</p>
|
||||
<button type="button" class="btn subtle btnBlock dashboardQueueClear">${t("dashboard.widget.clearQueue")}</button>`;
|
||||
bodyEl.querySelector(".dashboardQueueClear")?.addEventListener("click", () => missions()?.clearQueue?.());
|
||||
refreshQueueWidget(bodyEl);
|
||||
}
|
||||
@@ -208,13 +206,13 @@
|
||||
bodyEl.innerHTML = `
|
||||
<div class="dashboardRunnerControls">
|
||||
<button type="button" class="dashboardPauseBtn ${paused ? "is-paused" : ""}" data-pause-action="${paused ? "continue" : "pause"}" ${running ? "" : "disabled"}>
|
||||
${paused ? "Continue" : "Pause"}
|
||||
${paused ? t("dashboard.widget.continue") : t("dashboard.widget.pause")}
|
||||
</button>
|
||||
<button type="button" class="dashboardCancelBtn" data-cancel-mission ${running ? "" : "disabled"}>
|
||||
Hủy mission
|
||||
${t("dashboard.widget.cancelMission")}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mutedNote dashboardWidgetHint">${running ? (paused ? "Mission đang tạm dừng" : "Mission đang chạy") : "Không có mission đang chạy"}</p>`;
|
||||
<p class="mutedNote dashboardWidgetHint">${running ? (paused ? t("dashboard.widget.runner.paused") : t("dashboard.widget.runner.running")) : t("dashboard.widget.runner.idle")}</p>`;
|
||||
bodyEl.querySelector("[data-pause-action]")?.addEventListener("click", async (evt) => {
|
||||
const action = evt.currentTarget.dataset.pauseAction;
|
||||
try {
|
||||
@@ -242,8 +240,8 @@
|
||||
<div class="dashboardWidgetHeader">
|
||||
<div class="dashboardWidgetTitle">${escapeHtml(widgetTitle(widget))}</div>
|
||||
<div class="dashboardWidgetChrome" hidden>
|
||||
<button type="button" class="iconBtn" data-widget-config title="Cấu hình">⚙</button>
|
||||
<button type="button" class="iconBtn danger" data-widget-delete title="Xóa">×</button>
|
||||
<button type="button" class="iconBtn" data-widget-config title="${t(\"common.configure\")}">⚙</button>
|
||||
<button type="button" class="iconBtn danger" data-widget-delete title="${t(\"common.delete\")}">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboardWidgetBody"></div>`;
|
||||
@@ -301,13 +299,13 @@
|
||||
const widget = store.widgets.find((w) => w.id === widgetId);
|
||||
if (!widget) return;
|
||||
editWidgetIdEl.value = widget.id;
|
||||
editWidgetTypeEl.value = WIDGET_LABELS[widget.type] || widget.type;
|
||||
editWidgetTypeEl.value = widgetTypeLabel(widget.type);
|
||||
fillTypeFields(editFieldsEl, widget.type, widget);
|
||||
editDialogEl.showModal();
|
||||
}
|
||||
|
||||
function deleteWidget(widgetId) {
|
||||
if (!confirm("Xóa widget này?")) return;
|
||||
if (!confirm(t("dashboard.widget.deleteConfirm"))) return;
|
||||
store.widgets = store.widgets.filter((w) => w.id !== widgetId);
|
||||
persistStore();
|
||||
renderDashboard();
|
||||
@@ -318,7 +316,7 @@
|
||||
el("dashboardAddWidgetBtn")?.addEventListener("click", openAddDialog);
|
||||
el("dashboardEditBtn")?.addEventListener("click", () => {
|
||||
store.editMode = !store.editMode;
|
||||
el("dashboardEditBtn").textContent = store.editMode ? "Xong" : "Sửa layout";
|
||||
el("dashboardEditBtn").textContent = store.editMode ? t("dashboard.editDone") : t("dashboard.editLayout");
|
||||
renderDashboard();
|
||||
});
|
||||
|
||||
@@ -391,6 +389,12 @@
|
||||
function boot() {
|
||||
init();
|
||||
}
|
||||
window.addEventListener("lm:locale-change", () => {
|
||||
renderDashboard();
|
||||
const editBtn = el("dashboardEditBtn");
|
||||
if (editBtn) editBtn.textContent = store.editMode ? t("dashboard.editDone") : t("dashboard.editLayout");
|
||||
});
|
||||
|
||||
if (window.AuthApp?.isReady()) boot();
|
||||
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||
window.addEventListener("lm:auth-logout", stopDashboardPoll);
|
||||
|
||||
771
www/i18n.js
Normal file
771
www/i18n.js
Normal file
@@ -0,0 +1,771 @@
|
||||
/**
|
||||
* Central i18n for LiDAR Manager — vi / en.
|
||||
* Static DOM: data-i18n, data-i18n-placeholder, data-i18n-title, data-i18n-aria
|
||||
* Dynamic JS: I18n.t("key") or I18n.t("key", { name: "..." })
|
||||
*/
|
||||
(() => {
|
||||
const MESSAGES = {
|
||||
vi: {
|
||||
"app.title": "LiDAR Manager",
|
||||
"app.robotName": "RobotApp",
|
||||
"app.status.ready": "Sẵn sàng",
|
||||
"app.status.reloaded": "Đã tải lại",
|
||||
"app.status.backendError": "Không kết nối được backend",
|
||||
"app.status.jsError": "Lỗi JavaScript",
|
||||
|
||||
"common.cancel": "Hủy",
|
||||
"common.close": "Đóng",
|
||||
"common.save": "Lưu",
|
||||
"common.add": "Thêm",
|
||||
"common.delete": "Xóa",
|
||||
"common.apply": "Áp dụng",
|
||||
"common.reload": "Tải lại",
|
||||
"common.select": "Chọn",
|
||||
"common.edit": "Sửa",
|
||||
"common.enabled": "Bật",
|
||||
"common.disabled": "Tắt",
|
||||
"common.configure": "Cấu hình",
|
||||
"common.error": "Lỗi: {msg}",
|
||||
"common.none": "none",
|
||||
"common.optional": "Tùy chọn",
|
||||
|
||||
"login.prompt": "Chọn cách đăng nhập:",
|
||||
"login.tab.password": "Tên đăng nhập và mật khẩu",
|
||||
"login.tab.pin": "Mã PIN",
|
||||
"login.password.title": "Đăng nhập bằng tên và mật khẩu",
|
||||
"login.password.help1": "Nhập tên đăng nhập và mật khẩu để truy cập robot.",
|
||||
"login.password.help2": "Tài khoản do quản trị viên cấp hoặc xem trong tài liệu hướng dẫn robot.",
|
||||
"login.password.help3": "Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.",
|
||||
"login.field.username": "Tên đăng nhập:",
|
||||
"login.field.password": "Mật khẩu:",
|
||||
"login.placeholder.username": "Admin",
|
||||
"login.submit": "Đăng nhập",
|
||||
"login.submitting": "Đang đăng nhập…",
|
||||
"login.pin.title": "Đăng nhập bằng mã PIN",
|
||||
"login.pin.help1": "Người dùng được kích hoạt PIN có thể đăng nhập tại đây.",
|
||||
"login.pin.help2": "Nếu chưa có mã PIN 4 chữ số, vui lòng liên hệ quản trị viên robot.",
|
||||
"login.pin.helpNote": "Không có mã PIN cấu hình sẵn — quản trị viên phải gán PIN trước.",
|
||||
"login.pin.aria.group": "Mã PIN 4 chữ số",
|
||||
"login.pin.aria.keypad": "Bàn phím số",
|
||||
"login.pin.aria.backspace": "Xóa",
|
||||
"login.error.invalidPin": "Mã PIN không hợp lệ. Liên hệ quản trị viên.",
|
||||
"login.error.invalidPinShort": "Mã PIN không hợp lệ",
|
||||
"login.error.missingCredentials": "Nhập tên đăng nhập và mật khẩu",
|
||||
"login.error.badCredentials": "Sai tên đăng nhập hoặc mật khẩu. Thử Admin / admin",
|
||||
"login.error.serverUnreachable": "Không kết nối được server. Kiểm tra http://localhost:8080",
|
||||
"login.error.failed": "Đăng nhập thất bại",
|
||||
|
||||
"nav.aria.main": "Điều hướng chính",
|
||||
"nav.aria.submenu": "Menu phụ",
|
||||
"nav.collapse": "Thu gọn menu",
|
||||
"nav.expand": "Mở menu",
|
||||
"nav.dashboards": "Dashboards",
|
||||
"nav.setup": "Setup",
|
||||
"nav.monitoring": "Monitoring",
|
||||
"nav.system": "System",
|
||||
"nav.help": "Help",
|
||||
"nav.logout": "Log out",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.missions": "Missions",
|
||||
"nav.maps": "Maps & layout",
|
||||
"nav.monitoring-log": "System log",
|
||||
"nav.integrations": "Tích hợp",
|
||||
"nav.help-api": "API documentation",
|
||||
|
||||
"topbar.robotTitle": "Robot",
|
||||
"topbar.controlAria": "Start / Pause robot",
|
||||
"topbar.allOk": "ỔN ĐỊNH",
|
||||
"topbar.error": "LỖI",
|
||||
"topbar.paused": "TẠM DỪNG",
|
||||
"topbar.running": "ĐANG CHẠY",
|
||||
"topbar.waiting": "Đang chờ mission mới…",
|
||||
"topbar.noMissionsQueue": "Không có mission trong queue…",
|
||||
"topbar.reset": "RESET",
|
||||
"topbar.changeUserData": "Lưu thông tin",
|
||||
"topbar.changePassword": "Đổi mật khẩu",
|
||||
"topbar.logout": "Đăng xuất",
|
||||
"topbar.displayName": "Tên hiển thị",
|
||||
"topbar.joystickTitle": "Điều khiển tay (Joystick)",
|
||||
"topbar.joystickSpeed": "Tốc độ",
|
||||
"topbar.joystickOff": "Tắt joystick",
|
||||
"topbar.joystickAria": "Joystick",
|
||||
"topbar.batteryTitle": "Pin",
|
||||
"topbar.localeVi": "TIẾNG VIỆT",
|
||||
"topbar.localeEn": "ENGLISH",
|
||||
"topbar.localeOption.vi": "🇻🇳 Tiếng Việt",
|
||||
"topbar.localeOption.en": "🇺🇸 English",
|
||||
"topbar.userDefault": "USER",
|
||||
"topbar.noControlPermission": "Không có quyền điều khiển",
|
||||
"topbar.queueCount": "{n} mission trong queue",
|
||||
"topbar.code": "Mã",
|
||||
"topbar.module": "Module",
|
||||
"topbar.joystickSpeed.slow": "Chậm",
|
||||
"topbar.joystickSpeed.medium": "Trung bình",
|
||||
"topbar.joystickSpeed.fast": "Nhanh",
|
||||
"topbar.startHint": "Bấm để START robot",
|
||||
"topbar.pauseHint": "Bấm để PAUSE robot",
|
||||
|
||||
"auth.profile.displayNameRequired": "Tên hiển thị không được trống",
|
||||
"auth.profile.saveFailed": "Lưu thông tin thất bại",
|
||||
"auth.changePassword.title": "Đổi mật khẩu",
|
||||
"auth.changePassword.current": "Mật khẩu hiện tại",
|
||||
"auth.changePassword.new": "Mật khẩu mới",
|
||||
"auth.changePassword.confirm": "Xác nhận mật khẩu mới",
|
||||
"auth.changePassword.mismatch": "Mật khẩu mới không khớp",
|
||||
"auth.changePassword.failed": "Đổi mật khẩu thất bại",
|
||||
|
||||
"dashboard.title": "Dashboard",
|
||||
"dashboard.subtitle": "Widget mission — chạy, xếp hàng và tạm dừng giống MiR Fleet.",
|
||||
"dashboard.addWidget": "Thêm widget",
|
||||
"dashboard.editLayout": "Sửa layout",
|
||||
"dashboard.editDone": "Xong",
|
||||
"dashboard.empty": "Chưa có widget. Bấm «Thêm widget» để bắt đầu.",
|
||||
"dashboard.system.title": "Hệ thống",
|
||||
"dashboard.system.subtitle": "Trạng thái backend và layout đang active.",
|
||||
"dashboard.system.backend": "Backend",
|
||||
"dashboard.system.layout": "Layout",
|
||||
"dashboard.system.model": "Model robot",
|
||||
"dashboard.system.sensors": "LiDAR / IMU",
|
||||
"dashboard.system.sensorCount": "{lidars} LiDAR • {imus} IMU",
|
||||
"dashboard.dialog.add.title": "Thêm widget",
|
||||
"dashboard.dialog.add.type": "Loại widget",
|
||||
"dashboard.dialog.edit.title": "Cấu hình widget",
|
||||
"dashboard.dialog.edit.type": "Loại",
|
||||
"dashboard.dialog.edit.delete": "Xóa widget",
|
||||
"dashboard.widget.mission_button": "Nút mission",
|
||||
"dashboard.widget.mission_group": "Nhóm mission",
|
||||
"dashboard.widget.mission_queue": "Mission queue",
|
||||
"dashboard.widget.pause_continue": "Tạm dừng / Tiếp tục",
|
||||
"dashboard.widget.field.mission": "Mission",
|
||||
"dashboard.widget.field.group": "Nhóm mission",
|
||||
"dashboard.widget.field.title": "Tiêu đề widget (tùy chọn)",
|
||||
"dashboard.widget.titlePlaceholder": "VD: Go to charging",
|
||||
"dashboard.widget.pauseHint": "Tạm dừng / tiếp tục / hủy mission đang chạy trên robot.",
|
||||
"dashboard.widget.selectMission": "Chọn mission…",
|
||||
"dashboard.widget.configHint": "Cấu hình widget và chọn mission.",
|
||||
"dashboard.widget.emptyGroup": "Không có mission trong nhóm «{group}».",
|
||||
"dashboard.widget.queueEmpty": "Queue trống",
|
||||
"dashboard.widget.clearQueue": "Xóa queue chờ",
|
||||
"dashboard.widget.continue": "Tiếp tục",
|
||||
"dashboard.widget.pause": "Tạm dừng",
|
||||
"dashboard.widget.cancelMission": "Hủy mission",
|
||||
"dashboard.widget.runner.paused": "Mission đang tạm dừng",
|
||||
"dashboard.widget.runner.running": "Mission đang chạy",
|
||||
"dashboard.widget.runner.idle": "Không có mission đang chạy",
|
||||
"dashboard.widget.unsupported": "Widget không hỗ trợ.",
|
||||
"dashboard.widget.deleteConfirm": "Xóa widget này?",
|
||||
|
||||
"config.layout.title": "Quản lý layout",
|
||||
"config.layout.subtitle": "Nhiều cấu hình robot — mỗi layout có LiDAR và model riêng.",
|
||||
"config.layout.save": "Lưu layout",
|
||||
"config.layout.current": "Layout hiện tại",
|
||||
"config.layout.newName": "Tên layout mới",
|
||||
"config.layout.newNamePlaceholder": "VD: AGV kho A",
|
||||
"config.layout.cloneCurrent": "Sao chép từ layout đang mở",
|
||||
"config.layout.create": "Tạo layout",
|
||||
"config.layout.editingHint": "Đang chỉnh: {name}{dirty}",
|
||||
"config.layout.unsavedDirty": " • chưa lưu",
|
||||
"config.layout.unsavedSwitchConfirm": "Layout hiện tại có thay đổi chưa lưu. Tiếp tục?",
|
||||
"config.layout.deleteConfirm": "Xóa layout «{name}»? Hành động không hoàn tác.",
|
||||
"config.lidar.title": "LiDARs",
|
||||
"config.lidar.subtitle": "Đăng ký tên, IP, port và chỉnh pose theo robot frame.",
|
||||
"config.lidar.field.name": "Tên",
|
||||
"config.lidar.field.ip": "IP",
|
||||
"config.lidar.field.port": "Port",
|
||||
"config.lidar.placeholder.name": "Lidar trước",
|
||||
"config.lidar.placeholder.ip": "192.168.0.10",
|
||||
"config.lidar.empty": "Chưa có LiDAR",
|
||||
"config.lidar.emptyHint": "Hãy thêm LiDAR ở form phía trên.",
|
||||
"config.lidar.deleteConfirm": "Xóa LiDAR này?",
|
||||
"config.imu.title": "IMU",
|
||||
"config.imu.subtitle": "Cảm biến quán tính — frame, topic và pose trên robot.",
|
||||
"config.imu.field.name": "Tên",
|
||||
"config.imu.field.frame": "Frame ID",
|
||||
"config.imu.field.topic": "Topic",
|
||||
"config.imu.field.source": "Nguồn",
|
||||
"config.imu.source.external": "Ngoài (ROS topic)",
|
||||
"config.imu.source.lidarBuiltin": "Tích hợp LiDAR",
|
||||
"config.imu.source.onboard": "Onboard robot",
|
||||
"config.imu.field.rate": "Tần số (Hz)",
|
||||
"config.imu.enabled": "Bật IMU",
|
||||
"config.imu.add": "Thêm IMU",
|
||||
"config.imu.placeholder.name": "IMU chính",
|
||||
"config.imu.placeholder.frame": "imu_link",
|
||||
"config.imu.placeholder.topic": "imu/data",
|
||||
"config.imu.empty": "Chưa có IMU",
|
||||
"config.imu.emptyHint": "Thêm IMU ở form phía trên.",
|
||||
"config.imu.deleteConfirm": "Xóa IMU này?",
|
||||
"config.robot.title": "Model robot",
|
||||
"config.robot.subtitle": "Kinematic differential — bánh, động cơ và giới hạn vận tốc.",
|
||||
"config.robot.model.diff": "Differential (2 bánh)",
|
||||
"config.robot.model.bicycle": "Bicycle",
|
||||
"config.canvas.title": "Bố trí trên robot",
|
||||
"config.canvas.viewHint": "Cuộn chuột: zoom • Shift + kéo: di chuyển vùng nhìn",
|
||||
"config.canvas.robotCenter": "Robot center:",
|
||||
"config.canvas.selected": "Selected:",
|
||||
"config.canvas.pose": "Pose:",
|
||||
"config.pose.notSet": "chưa đặt pose",
|
||||
"config.selected.lidar": "LiDAR: {name}",
|
||||
"config.selected.imu": "IMU: {name}",
|
||||
"config.motor.wheelRight": "Bánh phải",
|
||||
"config.motor.wheelLeft": "Bánh trái",
|
||||
"config.motor.wheelSteer": "Bánh trước (steer)",
|
||||
"config.motor.wheelDrive": "Bánh sau (drive)",
|
||||
"config.motor.vendor": "Hãng",
|
||||
"config.motor.model": "Model",
|
||||
"config.motor.joint": "Joint (ROS)",
|
||||
"config.motor.ratio": "Tỷ số hộp số",
|
||||
"config.motor.invert": "Đảo chiều quay",
|
||||
"config.motor.invertSteer": "Đảo chiều",
|
||||
"config.motor.custom": "Tùy chỉnh",
|
||||
"config.motor.customMotor": "Motor tùy chỉnh",
|
||||
|
||||
"missions.title": "Missions",
|
||||
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
|
||||
"missions.create": "Tạo mission",
|
||||
"missions.empty": "Chưa có mission. Bấm Tạo mission để bắt đầu.",
|
||||
"missions.queue.title": "Mission queue",
|
||||
"missions.queue.subtitle": "Thêm mission bằng biểu tượng queue — robot chạy theo thứ tự từ trên xuống.",
|
||||
"missions.queue.cancel": "Hủy chạy",
|
||||
"missions.queue.clear": "Xóa queue",
|
||||
"missions.queue.empty": "Queue trống. Bấm ▤ trên mission để thêm.",
|
||||
"missions.editor.kicker": "Mission editor",
|
||||
"missions.editor.unsaved": "Chưa lưu",
|
||||
"missions.editor.saveAs": "Save as",
|
||||
"missions.editor.save": "Save",
|
||||
"missions.editor.flowHint": "Thực thi từ trên xuống dưới. Kéo biểu tượng ↔ để đổi thứ tự. Với Loop: kéo action vào vùng bên trong.",
|
||||
"missions.editor.emptyActions": "Chọn action từ menu phía trên để bắt đầu.",
|
||||
"missions.editor.backAria": "Quay lại danh sách",
|
||||
"missions.editor.settingsAria": "Cài đặt mission",
|
||||
"missions.editor.addActionAria": "Thêm action",
|
||||
"missions.queue.status.pending": "Chờ",
|
||||
"missions.queue.status.running": "Đang chạy",
|
||||
"missions.queue.status.done": "Xong",
|
||||
"missions.queue.status.error": "Lỗi",
|
||||
"missions.queue.status.cancelled": "Đã hủy",
|
||||
"missions.queue.ready": "Sẵn sàng",
|
||||
"missions.queue.idleMessage": "Robot sẵn sàng — queue trống hoặc chờ mission mới.",
|
||||
"missions.queue.moveUp": "Lên",
|
||||
"missions.queue.moveDown": "Xuống",
|
||||
"missions.queue.addAria": "Thêm vào mission queue",
|
||||
"missions.deleteConfirm": "Xóa mission «{name}»?",
|
||||
"missions.queue.clearConfirm": "Xóa các mission đang chờ trong queue?",
|
||||
"missions.queue.cancelConfirm": "Hủy mission đang chạy? (thoát loop nếu đang lặp)",
|
||||
"missions.dialog.create.title": "Tạo mission",
|
||||
"missions.dialog.create.name": "Tên mission",
|
||||
"missions.dialog.create.group": "Nhóm mission",
|
||||
"missions.dialog.create.groupNew": "Hoặc nhóm mới",
|
||||
"missions.dialog.create.desc": "Mô tả",
|
||||
"missions.dialog.create.namePlaceholder": "VD: Go to charging station",
|
||||
"missions.dialog.settings.title": "Cài đặt mission",
|
||||
"missions.dialog.settings.name": "Tên",
|
||||
"missions.dialog.settings.group": "Nhóm",
|
||||
"missions.dialog.settings.desc": "Mô tả",
|
||||
"missions.dialog.saveAs.title": "Save mission as",
|
||||
"missions.dialog.saveAs.name": "Tên mission mới",
|
||||
"missions.dialog.saveAs.submit": "Lưu bản sao",
|
||||
"missions.dialog.actionConfig.title": "Cấu hình action",
|
||||
"missions.dialog.queue.title": "Thêm vào mission queue",
|
||||
"missions.group.Move": "Move",
|
||||
"missions.group.Logic": "Logic",
|
||||
"missions.group.IO": "I/O",
|
||||
"missions.group.Cart": "Cart",
|
||||
"missions.group.Misc": "Misc",
|
||||
"missions.action.move_to_position": "Go to position",
|
||||
"missions.action.move_to_marker": "Go to marker",
|
||||
"missions.action.adjust_localization": "Adjust localization",
|
||||
"missions.action.wait": "Wait",
|
||||
"missions.action.set_speed": "Set speed",
|
||||
"missions.action.if": "If",
|
||||
"missions.action.loop": "Loop",
|
||||
"missions.action.break": "Break",
|
||||
"missions.action.continue": "Continue",
|
||||
"missions.action.pause": "Pause",
|
||||
"missions.action.set_digital_output": "Set digital output",
|
||||
"missions.action.wait_digital_input": "Wait for digital input",
|
||||
"missions.action.set_plc_register": "Set PLC register",
|
||||
"missions.action.pick_cart": "Pick cart",
|
||||
"missions.action.drop_cart": "Drop cart",
|
||||
"missions.action.user_log": "User log",
|
||||
"missions.action.play_sound": "Play sound",
|
||||
"missions.error.nameRequired": "Tên mission không được trống.",
|
||||
"missions.error.nameDuplicate": "Tên mission đã tồn tại.",
|
||||
"missions.error.nameEmpty": "Tên không được trống.",
|
||||
"missions.saveSuccess": "Đã lưu mission.",
|
||||
"missions.editor.discardConfirm": "Bỏ thay đổi chưa lưu?",
|
||||
"missions.queue.status.executing": "Đang chạy",
|
||||
"missions.action.waitOnLevel": "Chờ mức ON",
|
||||
|
||||
"integrations.modbus.title": "Modbus trigger",
|
||||
"integrations.modbus.subtitle": "System → Triggers — coil 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();
|
||||
}
|
||||
})();
|
||||
412
www/index.html
412
www/index.html
@@ -14,10 +14,10 @@
|
||||
<div class="loginHeaderRight">
|
||||
<span class="loginHeaderPrompt">Chọn cách đăng nhập:</span>
|
||||
<div class="loginTabs" role="tablist">
|
||||
<button id="loginTabPassword" type="button" class="loginTab active" role="tab" aria-selected="true">
|
||||
<button id="loginTabPassword" type="button" class="loginTab active" role="tab" aria-selected="true" data-i18n="login.tab.password">
|
||||
Tên đăng nhập và mật khẩu
|
||||
</button>
|
||||
<button id="loginTabPin" type="button" class="loginTab" role="tab" aria-selected="false">
|
||||
<button id="loginTabPin" type="button" class="loginTab" role="tab" aria-selected="false" data-i18n="login.tab.pin">
|
||||
Mã PIN
|
||||
</button>
|
||||
</div>
|
||||
@@ -27,26 +27,26 @@
|
||||
<div class="loginCard">
|
||||
<div id="loginPanelPassword" class="loginPanel">
|
||||
<div id="loginHelpPassword" class="loginHelp">
|
||||
<h2 class="loginHelpTitle">Đăng nhập bằng tên và mật khẩu</h2>
|
||||
<p>Nhập tên đăng nhập và mật khẩu để truy cập robot.</p>
|
||||
<p>Tài khoản do quản trị viên cấp hoặc xem trong tài liệu hướng dẫn robot.</p>
|
||||
<p>Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.</p>
|
||||
<h2 class="loginHelpTitle" data-i18n="login.password.title">Đăng nhập bằng tên và mật khẩu</h2>
|
||||
<p data-i18n="login.password.help1">Nhập tên đăng nhập và mật khẩu để truy cập robot.</p>
|
||||
<p data-i18n="login.password.help2">Tài khoản do quản trị viên cấp hoặc xem trong tài liệu hướng dẫn robot.</p>
|
||||
<p data-i18n="login.password.help3">Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.</p>
|
||||
</div>
|
||||
<div class="loginForms">
|
||||
<form id="loginForm" class="loginForm" action="#" method="post" novalidate>
|
||||
<label class="loginField">
|
||||
<span class="loginFieldLabel">Tên đăng nhập:</span>
|
||||
<input id="loginUsername" name="username" type="text" autocomplete="username" placeholder="Admin" required />
|
||||
<span class="loginFieldLabel" data-i18n="login.field.username">Tên đăng nhập:</span>
|
||||
<input id="loginUsername" name="username" type="text" autocomplete="username" placeholder="Admin" data-i18n-placeholder="login.placeholder.username" required />
|
||||
</label>
|
||||
<label class="loginField">
|
||||
<span class="loginFieldLabel">Mật khẩu:</span>
|
||||
<span class="loginFieldLabel" data-i18n="login.field.password">Mật khẩu:</span>
|
||||
<input id="loginPasswordInput" name="password" type="password" autocomplete="current-password" placeholder="" required />
|
||||
</label>
|
||||
<button type="submit" class="loginSubmit" data-mode="password">
|
||||
<svg class="loginSubmitIcon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12.65 10A5.99 5.99 0 0 0 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6a5.99 5.99 0 0 0 5.65-4H17v2h3v-2h1v-3h-3V9h-1.35zM7 14a4 4 0 1 1 0-8 4 4 0 0 1 0 8z"/>
|
||||
</svg>
|
||||
<span class="loginSubmitLabel">Đăng nhập</span>
|
||||
<span class="loginSubmitLabel" data-i18n="login.submit">Đăng nhập</span>
|
||||
</button>
|
||||
</form>
|
||||
<p id="loginError" class="loginError" hidden></p>
|
||||
@@ -56,12 +56,12 @@
|
||||
<div id="loginPanelPin" class="loginPanel loginPanel--pin" hidden>
|
||||
<div class="loginPinLeft">
|
||||
<div class="loginHelp">
|
||||
<h2 class="loginHelpTitle">Đăng nhập bằng mã PIN</h2>
|
||||
<p>Người dùng được kích hoạt PIN có thể đăng nhập tại đây.</p>
|
||||
<p>Nếu chưa có mã PIN 4 chữ số, vui lòng liên hệ quản trị viên robot.</p>
|
||||
<p class="loginHelpNote">Không có mã PIN cấu hình sẵn — quản trị viên phải gán PIN trước.</p>
|
||||
<h2 class="loginHelpTitle" data-i18n="login.pin.title">Đăng nhập bằng mã PIN</h2>
|
||||
<p data-i18n="login.pin.help1">Người dùng được kích hoạt PIN có thể đăng nhập tại đây.</p>
|
||||
<p data-i18n="login.pin.help2">Nếu chưa có mã PIN 4 chữ số, vui lòng liên hệ quản trị viên robot.</p>
|
||||
<p class="loginHelpNote" data-i18n="login.pin.helpNote">Không có mã PIN cấu hình sẵn — quản trị viên phải gán PIN trước.</p>
|
||||
</div>
|
||||
<div class="loginPinBoxes" id="loginPinBoxes" role="group" aria-label="Mã PIN 4 chữ số">
|
||||
<div class="loginPinBoxes" id="loginPinBoxes" role="group" aria-label="Mã PIN 4 chữ số" data-i18n-aria="login.pin.aria.group">
|
||||
<div class="loginPinCell" data-idx="0"></div>
|
||||
<div class="loginPinCell" data-idx="1"></div>
|
||||
<div class="loginPinCell" data-idx="2"></div>
|
||||
@@ -70,7 +70,7 @@
|
||||
<input id="loginPin" type="hidden" value="" autocomplete="off" />
|
||||
<p id="loginPinError" class="loginError loginPinError" hidden></p>
|
||||
</div>
|
||||
<div class="loginKeypad" id="loginKeypad" aria-label="Bàn phím số">
|
||||
<div class="loginKeypad" id="loginKeypad" aria-label="Bàn phím số" data-i18n-aria="login.pin.aria.keypad">
|
||||
<button type="button" class="loginKey" data-key="1">1</button>
|
||||
<button type="button" class="loginKey" data-key="2">2</button>
|
||||
<button type="button" class="loginKey" data-key="3">3</button>
|
||||
@@ -81,7 +81,7 @@
|
||||
<button type="button" class="loginKey" data-key="8">8</button>
|
||||
<button type="button" class="loginKey" data-key="9">9</button>
|
||||
<button type="button" class="loginKey loginKey--wide" data-key="0">0</button>
|
||||
<button type="button" class="loginKey loginKey--back" data-key="back" aria-label="Xóa">✕</button>
|
||||
<button type="button" class="loginKey loginKey--back" data-key="back" aria-label="Xóa" data-i18n-aria="login.pin.aria.backspace">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,54 +89,77 @@
|
||||
</div>
|
||||
|
||||
<div class="shell auth-locked">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brandIcon">R</div>
|
||||
<div class="brandText">
|
||||
<div class="brandTitle">PhenikaaX</div>
|
||||
<div class="brandSub">RobotApp</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="mirNavShell" id="mirNavShell">
|
||||
<nav class="mirNavRail" id="mirNavRail" aria-label="Main navigation" data-i18n-aria="nav.aria.main">
|
||||
<button type="button" class="mirNavBackBtn" id="mirNavBackBtn" aria-label="Collapse menu" title="Collapse menu" data-i18n-aria="nav.collapse" data-i18n-title="nav.collapse">
|
||||
<span aria-hidden="true">«</span>
|
||||
</button>
|
||||
|
||||
<div class="navTitle">WORKSPACE</div>
|
||||
<nav class="nav">
|
||||
<a class="navItem active" href="#" data-page="dashboard" aria-current="page">
|
||||
<span class="navDot"></span>
|
||||
Dashboard
|
||||
</a>
|
||||
<a class="navItem" href="#" data-page="config">
|
||||
<span class="navDot"></span>
|
||||
Cấu hình
|
||||
</a>
|
||||
<div class="mirNavRailItems">
|
||||
<button type="button" class="mirNavRailItem" data-module="dashboards">
|
||||
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
|
||||
<path d="M4 4h7v9H4V4zm9 0h7v5h-7V4zM4 15h7v5H4v-5zm9 4h7v-5h-7v5z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span class="mirNavRailLabel" data-i18n="nav.dashboards">Dashboards</span>
|
||||
</button>
|
||||
<button type="button" class="mirNavRailItem is-active" data-module="setup" aria-current="true">
|
||||
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span class="mirNavRailLabel" data-i18n="nav.setup">Setup</span>
|
||||
</button>
|
||||
<button type="button" class="mirNavRailItem" data-module="monitoring">
|
||||
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
|
||||
<path d="M3 3v18h18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<path d="M7 14l4-4 3 3 5-6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span class="mirNavRailLabel" data-i18n="nav.monitoring">Monitoring</span>
|
||||
</button>
|
||||
<button type="button" class="mirNavRailItem" data-module="system">
|
||||
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span class="mirNavRailLabel" data-i18n="nav.system">System</span>
|
||||
</button>
|
||||
<button type="button" class="mirNavRailItem" data-module="help">
|
||||
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M9.5 9a2.5 2.5 0 1 1 4.2 1.8c-.8.6-1.2 1.2-1.2 2.2M12 17h.01" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span class="mirNavRailLabel" data-i18n="nav.help">Help</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mirNavRailFooter">
|
||||
<button type="button" class="mirNavRailItem mirNavRailItem--logout" id="mirNavLogout">
|
||||
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span class="mirNavRailLabel" data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="navTitle">CÀI ĐẶT</div>
|
||||
<nav class="nav">
|
||||
<a class="navItem" href="#" data-page="missions">
|
||||
<span class="navDot"></span>
|
||||
Missions
|
||||
</a>
|
||||
<a class="navItem" href="#" data-page="integrations">
|
||||
<span class="navDot"></span>
|
||||
Tích hợp
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebarFooter">
|
||||
<div class="statusBadge">
|
||||
<span class="statusLed"></span>
|
||||
<span id="status" class="statusText">…</span>
|
||||
<aside class="mirNavFlyout" id="mirNavFlyout">
|
||||
<div class="mirNavFlyoutHeader">
|
||||
<h2 class="mirNavFlyoutTitle" id="mirNavFlyoutTitle">Setup</h2>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="mirNavFlyoutList" id="mirNavFlyoutList" aria-label="Submenu" data-i18n-aria="nav.aria.submenu"></nav>
|
||||
<div class="mirNavFlyoutFooter">
|
||||
<span class="mirNavStatusLed" aria-hidden="true"></span>
|
||||
<span id="status" class="mirNavStatusText">…</span>
|
||||
</div>
|
||||
</aside>
|
||||
</aside>
|
||||
|
||||
<div class="body">
|
||||
<header class="mirTopbar" id="mirTopbar">
|
||||
<div class="mirTopbarInner">
|
||||
<div class="mirTopbarLeft">
|
||||
<div class="mirRobotId" id="mirRobotId" title="Robot">RobotApp</div>
|
||||
<div class="mirRobotId" id="mirRobotId" title="Robot" data-i18n-title="topbar.robotTitle">RobotApp</div>
|
||||
|
||||
<button type="button" class="mirPauseBtn" id="mirSegControl" aria-label="Start / Pause robot" title="Start / Pause robot">
|
||||
<button type="button" class="mirPauseBtn" id="mirSegControl" aria-label="Start / Pause robot" title="Start / Pause robot" data-i18n-aria="topbar.controlAria" data-i18n-title="topbar.controlAria">
|
||||
<svg class="mirPauseBtnIcon mirPauseBtnIcon--pause" id="mirControlIconPause" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||
<rect x="6" y="5" width="4.5" height="14" rx="1" fill="#f39c12"/>
|
||||
<rect x="13.5" y="5" width="4.5" height="14" rx="1" fill="#f39c12"/>
|
||||
@@ -174,8 +197,8 @@
|
||||
<span class="mirCaret" aria-hidden="true">▴</span>
|
||||
</button>
|
||||
<div class="mirPanel mirPanel--locale" id="mirLocalePanel" hidden>
|
||||
<button type="button" class="mirLocaleOption" data-locale="vi">🇻🇳 Tiếng Việt</button>
|
||||
<button type="button" class="mirLocaleOption" data-locale="en">🇺🇸 English</button>
|
||||
<button type="button" class="mirLocaleOption" data-locale="vi" data-i18n="topbar.localeOption.vi">🇻🇳 Tiếng Việt</button>
|
||||
<button type="button" class="mirLocaleOption" data-locale="en" data-i18n="topbar.localeOption.en">🇺🇸 English</button>
|
||||
</div>
|
||||
|
||||
<button type="button" class="mirSegment mirSegment--user" id="mirUserBtn" aria-haspopup="true" aria-expanded="false">
|
||||
@@ -205,7 +228,7 @@
|
||||
<button type="button" class="mirBtn mirBtn--danger" id="mirUserSignOutBtn" data-i18n="topbar.logout">Đăng xuất</button>
|
||||
</div>
|
||||
|
||||
<button type="button" class="mirSegment mirSegment--joystick" id="mirSegJoystick" title="Engage joystick" aria-label="Joystick">
|
||||
<button type="button" class="mirSegment mirSegment--joystick" id="mirSegJoystick" title="Engage joystick" data-i18n-title="topbar.joystickAria" aria-label="Joystick">
|
||||
<svg class="mirSvgIcon mirJoystickSvg" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||
<rect x="7" y="10" width="10" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
|
||||
<line x1="12" y1="10" x2="12" y2="4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||
@@ -213,7 +236,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="mirSegment mirSegment--battery" id="mirSegBattery" title="Battery">
|
||||
<div class="mirSegment mirSegment--battery" id="mirSegBattery" title="Battery" data-i18n-title="topbar.batteryTitle">
|
||||
<span class="mirBatteryIcon" id="mirBatteryIcon" aria-hidden="true">
|
||||
<span class="mirBatteryLevel" id="mirBatteryLevel"></span>
|
||||
</span>
|
||||
@@ -229,42 +252,42 @@
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">Dashboard</div>
|
||||
<div class="cardSub">Widget mission — chạy, xếp hàng và tạm dừng giống MiR Fleet.</div>
|
||||
<div class="cardTitle" data-i18n="dashboard.title">Dashboard</div>
|
||||
<div class="cardSub" data-i18n="dashboard.subtitle">Widget mission — chạy, xếp hàng và tạm dừng giống MiR Fleet.</div>
|
||||
</div>
|
||||
<div class="dashboardToolbar">
|
||||
<button id="dashboardAddWidgetBtn" type="button" class="btn subtle">Thêm widget</button>
|
||||
<button id="dashboardEditBtn" type="button" class="btn subtle">Sửa layout</button>
|
||||
<button id="dashboardAddWidgetBtn" type="button" class="btn subtle" data-i18n="dashboard.addWidget">Thêm widget</button>
|
||||
<button id="dashboardEditBtn" type="button" class="btn subtle" data-i18n="dashboard.editLayout">Sửa layout</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<div id="dashboardGrid" class="dashboardGrid"></div>
|
||||
<p id="dashboardEmpty" class="mutedNote dashboardEmpty" hidden>Chưa có widget. Bấm «Thêm widget» để bắt đầu.</p>
|
||||
<p id="dashboardEmpty" class="mutedNote dashboardEmpty" hidden data-i18n="dashboard.empty">Chưa có widget. Bấm «Thêm widget» để bắt đầu.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card dashboardInfoCard">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">Hệ thống</div>
|
||||
<div class="cardSub">Trạng thái backend và layout đang active.</div>
|
||||
<div class="cardTitle" data-i18n="dashboard.system.title">Hệ thống</div>
|
||||
<div class="cardSub" data-i18n="dashboard.system.subtitle">Trạng thái backend và layout đang active.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody dashboardInfoGrid">
|
||||
<div class="row rowWide">
|
||||
<label>Backend</label>
|
||||
<label data-i18n="dashboard.system.backend">Backend</label>
|
||||
<div id="overviewBackend" class="mutedNote">—</div>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label>Layout</label>
|
||||
<label data-i18n="dashboard.system.layout">Layout</label>
|
||||
<div id="overviewActiveLayout" class="mutedNote">—</div>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label>Model robot</label>
|
||||
<label data-i18n="dashboard.system.model">Model robot</label>
|
||||
<div id="overviewActiveModel" class="mutedNote">—</div>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label>LiDAR / IMU</label>
|
||||
<label data-i18n="dashboard.system.sensors">LiDAR / IMU</label>
|
||||
<div id="overviewActiveSensors" class="mutedNote">—</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,32 +300,32 @@
|
||||
<section class="card" id="layoutManagerCard">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">Quản lý layout</div>
|
||||
<div class="cardSub">Nhiều cấu hình robot — mỗi layout có LiDAR và model riêng.</div>
|
||||
<div class="cardTitle" data-i18n="config.layout.title">Quản lý layout</div>
|
||||
<div class="cardSub" data-i18n="config.layout.subtitle">Nhiều cấu hình robot — mỗi layout có LiDAR và model riêng.</div>
|
||||
</div>
|
||||
<div class="configPageActions">
|
||||
<button id="refreshBtn" type="button" class="btn subtle">Tải lại</button>
|
||||
<button id="saveLayoutBtn" type="button" class="btn primary">Lưu layout</button>
|
||||
<button id="refreshBtn" type="button" class="btn subtle" data-i18n="common.reload">Tải lại</button>
|
||||
<button id="saveLayoutBtn" type="button" class="btn primary" data-i18n="config.layout.save">Lưu layout</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<div class="row rowWide">
|
||||
<label>Layout hiện tại</label>
|
||||
<label data-i18n="config.layout.current">Layout hiện tại</label>
|
||||
<select id="layoutSelect"></select>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label>Tên layout mới</label>
|
||||
<input id="layoutNewName" type="text" placeholder="VD: AGV kho A" />
|
||||
<label data-i18n="config.layout.newName">Tên layout mới</label>
|
||||
<input id="layoutNewName" type="text" placeholder="VD: AGV kho A" data-i18n-placeholder="config.layout.newNamePlaceholder" />
|
||||
</div>
|
||||
<div class="checkRow">
|
||||
<label>
|
||||
<input id="layoutCloneCurrent" type="checkbox" />
|
||||
Sao chép từ layout đang mở
|
||||
<span data-i18n="config.layout.cloneCurrent">Sao chép từ layout đang mở</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="layoutManagerActions">
|
||||
<button id="layoutCreateBtn" type="button" class="btn subtle">Tạo layout</button>
|
||||
<button id="layoutDeleteBtn" type="button" class="btn subtle danger">Xóa</button>
|
||||
<button id="layoutCreateBtn" type="button" class="btn subtle" data-i18n="config.layout.create">Tạo layout</button>
|
||||
<button id="layoutDeleteBtn" type="button" class="btn subtle danger" data-i18n="common.delete">Xóa</button>
|
||||
</div>
|
||||
<p id="layoutActiveHint" class="mutedNote">—</p>
|
||||
</div>
|
||||
@@ -318,8 +341,8 @@
|
||||
aria-controls="lidarListCardBody"
|
||||
>
|
||||
<div>
|
||||
<div class="cardTitle">LiDARs</div>
|
||||
<div class="cardSub">Đăng ký tên, IP, port và chỉnh pose theo robot frame.</div>
|
||||
<div class="cardTitle" data-i18n="config.lidar.title">LiDARs</div>
|
||||
<div class="cardSub" data-i18n="config.lidar.subtitle">Đăng ký tên, IP, port và chỉnh pose theo robot frame.</div>
|
||||
</div>
|
||||
<span class="cardChevron" aria-hidden="true"></span>
|
||||
</div>
|
||||
@@ -327,19 +350,19 @@
|
||||
<div class="cardBody" id="lidarListCardBody">
|
||||
<form id="lidarForm" class="form">
|
||||
<div class="row">
|
||||
<label>Tên</label>
|
||||
<input id="name" placeholder="Lidar trước" required />
|
||||
<label data-i18n="config.lidar.field.name">Tên</label>
|
||||
<input id="name" placeholder="Lidar trước" data-i18n-placeholder="config.lidar.placeholder.name" required />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>IP</label>
|
||||
<input id="ip" placeholder="192.168.0.10" required />
|
||||
<label data-i18n="config.lidar.field.ip">IP</label>
|
||||
<input id="ip" placeholder="192.168.0.10" data-i18n-placeholder="config.lidar.placeholder.ip" required />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Port</label>
|
||||
<label data-i18n="config.lidar.field.port">Port</label>
|
||||
<input id="port" type="number" min="1" max="65535" value="2112" required />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="addLidarBtn" class="btn primary" type="button">Thêm</button>
|
||||
<button id="addLidarBtn" class="btn primary" type="button" data-i18n="common.add">Thêm</button>
|
||||
</div>
|
||||
<p id="lidarFormHint" class="formHint" hidden></p>
|
||||
</form>
|
||||
@@ -358,8 +381,8 @@
|
||||
aria-controls="imuListCardBody"
|
||||
>
|
||||
<div>
|
||||
<div class="cardTitle">IMU</div>
|
||||
<div class="cardSub">Cảm biến quán tính — frame, topic và pose trên robot.</div>
|
||||
<div class="cardTitle" data-i18n="config.imu.title">IMU</div>
|
||||
<div class="cardSub" data-i18n="config.imu.subtitle">Cảm biến quán tính — frame, topic và pose trên robot.</div>
|
||||
</div>
|
||||
<span class="cardChevron" aria-hidden="true"></span>
|
||||
</div>
|
||||
@@ -367,37 +390,37 @@
|
||||
<div class="cardBody" id="imuListCardBody">
|
||||
<form id="imuForm" class="form">
|
||||
<div class="row">
|
||||
<label>Tên</label>
|
||||
<input id="imuName" placeholder="IMU chính" required />
|
||||
<label data-i18n="config.imu.field.name">Tên</label>
|
||||
<input id="imuName" placeholder="IMU chính" data-i18n-placeholder="config.imu.placeholder.name" required />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Frame ID</label>
|
||||
<input id="imuFrameId" placeholder="imu_link" required />
|
||||
<label data-i18n="config.imu.field.frame">Frame ID</label>
|
||||
<input id="imuFrameId" placeholder="imu_link" data-i18n-placeholder="config.imu.placeholder.frame" required />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Topic</label>
|
||||
<input id="imuTopic" placeholder="imu/data" value="imu/data" required />
|
||||
<label data-i18n="config.imu.field.topic">Topic</label>
|
||||
<input id="imuTopic" placeholder="imu/data" data-i18n-placeholder="config.imu.placeholder.topic" value="imu/data" required />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label>Nguồn</label>
|
||||
<label data-i18n="config.imu.field.source">Nguồn</label>
|
||||
<select id="imuSource">
|
||||
<option value="external">Ngoài (ROS topic)</option>
|
||||
<option value="lidar_builtin">Tích hợp LiDAR</option>
|
||||
<option value="onboard">Onboard robot</option>
|
||||
<option value="external" data-i18n="config.imu.source.external">Ngoài (ROS topic)</option>
|
||||
<option value="lidar_builtin" data-i18n="config.imu.source.lidarBuiltin">Tích hợp LiDAR</option>
|
||||
<option value="onboard" data-i18n="config.imu.source.onboard">Onboard robot</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Tần số (Hz)</label>
|
||||
<label data-i18n="config.imu.field.rate">Tần số (Hz)</label>
|
||||
<input id="imuRateHz" type="number" min="1" max="1000" step="1" value="100" />
|
||||
</div>
|
||||
<div class="checkRow">
|
||||
<label>
|
||||
<input id="imuEnabled" type="checkbox" checked />
|
||||
Bật IMU
|
||||
<span data-i18n="config.imu.enabled">Bật IMU</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="addImuBtn" class="btn primary" type="button">Thêm IMU</button>
|
||||
<button id="addImuBtn" class="btn primary" type="button" data-i18n="config.imu.add">Thêm IMU</button>
|
||||
</div>
|
||||
<p id="imuFormHint" class="formHint" hidden></p>
|
||||
</form>
|
||||
@@ -416,7 +439,7 @@
|
||||
aria-controls="robotModelCardBody"
|
||||
>
|
||||
<div>
|
||||
<div class="cardTitle">Model robot</div>
|
||||
<div class="cardTitle" data-i18n="config.robot.title">Model robot</div>
|
||||
<div class="cardSub">Kinematic differential — bánh, động cơ và giới hạn vận tốc.</div>
|
||||
</div>
|
||||
<span class="cardChevron" aria-hidden="true"></span>
|
||||
@@ -696,13 +719,13 @@
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">Missions</div>
|
||||
<div class="cardSub">Setup → Missions — danh sách nhiệm vụ robot.</div>
|
||||
<div class="cardTitle" data-i18n="missions.title">Missions</div>
|
||||
<div class="cardSub" data-i18n="missions.subtitle">Setup → Missions — danh sách nhiệm vụ robot.</div>
|
||||
</div>
|
||||
<button id="missionCreateOpenBtn" type="button" class="btn primary">Create mission</button>
|
||||
<button id="missionCreateOpenBtn" type="button" class="btn primary" data-i18n="missions.create">Create mission</button>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<div id="missionListEmpty" class="mutedNote" hidden>Chưa có mission. Bấm Create mission để bắt đầu.</div>
|
||||
<div id="missionListEmpty" class="mutedNote" hidden data-i18n="missions.empty">Chưa có mission. Bấm Create mission để bắt đầu.</div>
|
||||
<div id="missionList" class="missionList"></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -710,17 +733,17 @@
|
||||
<section class="card" id="missionQueueCard">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">Mission queue</div>
|
||||
<div class="cardSub">Thêm mission bằng biểu tượng queue — robot chạy theo thứ tự từ trên xuống.</div>
|
||||
<div class="cardTitle" data-i18n="missions.queue.title">Mission queue</div>
|
||||
<div class="cardSub" data-i18n="missions.queue.subtitle">Thêm mission bằng biểu tượng queue — robot chạy theo thứ tự từ trên xuống.</div>
|
||||
</div>
|
||||
<div class="missionQueueCardActions">
|
||||
<button id="missionQueueCancelBtn" type="button" class="btn subtle danger" title="Hủy mission đang chạy">Hủy chạy</button>
|
||||
<button id="missionQueueClearBtn" type="button" class="btn subtle danger">Xóa queue</button>
|
||||
<button id="missionQueueCancelBtn" type="button" class="btn subtle danger" data-i18n="missions.queue.cancel">Hủy chạy</button>
|
||||
<button id="missionQueueClearBtn" type="button" class="btn subtle danger" data-i18n="missions.queue.clear">Xóa queue</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<div id="missionQueueRunner" class="missionQueueRunner mutedNote">—</div>
|
||||
<div id="missionQueueEmpty" class="mutedNote">Queue trống. Bấm <span class="mono">▤</span> trên mission để thêm.</div>
|
||||
<div id="missionQueueEmpty" class="mutedNote" data-i18n="missions.queue.empty">Queue trống. Bấm ▤ trên mission để thêm.</div>
|
||||
<div id="missionQueueList" class="missionQueueList"></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -730,31 +753,31 @@
|
||||
<section class="card missionEditorCard">
|
||||
<div class="missionEditorTop">
|
||||
<div class="missionEditorTitleWrap">
|
||||
<button id="missionEditorBackBtn" type="button" class="btn subtle missionBackBtn" aria-label="Quay lại danh sách">←</button>
|
||||
<button id="missionEditorBackBtn" type="button" class="btn subtle missionBackBtn" data-i18n-aria="missions.editor.backAria">←</button>
|
||||
<div>
|
||||
<div class="missionEditorKicker">Mission editor</div>
|
||||
<div class="missionEditorKicker" data-i18n="missions.editor.kicker">Mission editor</div>
|
||||
<div class="missionEditorTitleRow">
|
||||
<h2 id="missionEditorTitle" class="missionEditorTitle">—</h2>
|
||||
<button id="missionSettingsBtn" type="button" class="iconBtn" title="Cài đặt mission" aria-label="Cài đặt mission">⚙</button>
|
||||
<button id="missionSettingsBtn" type="button" class="iconBtn" data-i18n-aria="missions.editor.settingsAria" data-i18n-title="missions.editor.settingsAria">⚙</button>
|
||||
</div>
|
||||
<div id="missionEditorMeta" class="missionEditorMeta">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="missionEditorTopActions">
|
||||
<span id="missionEditorDirty" class="missionDirtyBadge" hidden>Chưa lưu</span>
|
||||
<button id="missionSaveAsBtn" type="button" class="btn subtle">Save as</button>
|
||||
<button id="missionSaveBtn" type="button" class="btn primary">Save</button>
|
||||
<span id="missionEditorDirty" class="missionDirtyBadge" hidden data-i18n="missions.editor.unsaved">Chưa lưu</span>
|
||||
<button id="missionSaveAsBtn" type="button" class="btn subtle" data-i18n="missions.editor.saveAs">Save as</button>
|
||||
<button id="missionSaveBtn" type="button" class="btn primary" data-i18n="missions.editor.save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="missionActionBar" id="missionActionBar" role="toolbar" aria-label="Thêm action">
|
||||
<div class="missionActionBar" id="missionActionBar" role="toolbar" data-i18n-aria="missions.editor.addActionAria">
|
||||
<div class="missionGroupTabs" id="missionGroupTabs"></div>
|
||||
</div>
|
||||
|
||||
<div class="missionEditorBody">
|
||||
<p class="missionFlowHint">Thực thi từ trên xuống dưới. Kéo biểu tượng ↔ để đổi thứ tự. Với Loop: kéo action vào vùng bên trong.</p>
|
||||
<p class="missionFlowHint" data-i18n="missions.editor.flowHint">Thực thi từ trên xuống dưới. Kéo biểu tượng ↔ để đổi thứ tự. Với Loop: kéo action vào vùng bên trong.</p>
|
||||
<div id="missionActionList" class="missionActionList"></div>
|
||||
<div id="missionActionListEmpty" class="missionActionListEmpty mutedNote">Chọn action từ menu phía trên để bắt đầu.</div>
|
||||
<div id="missionActionListEmpty" class="missionActionListEmpty mutedNote" data-i18n="missions.editor.emptyActions">Chọn action từ menu phía trên để bắt đầu.</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -765,16 +788,16 @@
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">Modbus trigger</div>
|
||||
<div class="cardSub">System → Triggers — coil 1001–2000 gắn mission_id. Thiết bị remote bật coil (Modbus TCP :5502) → mission vào queue.</div>
|
||||
<div class="cardTitle" data-i18n="integrations.modbus.title">Modbus trigger</div>
|
||||
<div class="cardSub" data-i18n="integrations.modbus.subtitle">System → Triggers — coil 1001–2000 gắn mission_id. Thiết bị remote bật coil (Modbus TCP :5502) → mission vào queue.</div>
|
||||
</div>
|
||||
<button id="integrationAddTriggerBtn" type="button" class="btn primary">Thêm trigger</button>
|
||||
<button id="integrationAddTriggerBtn" type="button" class="btn primary" data-i18n="integrations.modbus.add">Thêm trigger</button>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<div id="integrationTriggerEmpty" class="mutedNote">Chưa có trigger Modbus.</div>
|
||||
<div id="integrationTriggerEmpty" class="mutedNote" data-i18n="integrations.modbus.empty">Chưa có trigger Modbus.</div>
|
||||
<div id="integrationTriggerList" class="missionList"></div>
|
||||
<div class="integrationCoilSection">
|
||||
<div class="integrationSectionLabel">Coil đã gán (bấm để mô phỏng rising edge)</div>
|
||||
<div class="integrationSectionLabel" data-i18n="integrations.modbus.coilsLabel">Coil đã gán (bấm để mô phỏng rising edge)</div>
|
||||
<div id="integrationCoilGrid" class="integrationCoilGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -783,13 +806,13 @@
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">REST API — MiR v2.0.0</div>
|
||||
<div class="cardSub">Hệ thống bên ngoài POST mission vào queue qua REST.</div>
|
||||
<div class="cardTitle" data-i18n="integrations.rest.title">REST API — MiR v2.0.0</div>
|
||||
<div class="cardSub" data-i18n="integrations.rest.subtitle">Hệ thống bên ngoài POST mission vào queue qua REST.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody integrationApiBody">
|
||||
<div class="row rowWide">
|
||||
<label>Base URL</label>
|
||||
<label data-i18n="integrations.rest.baseUrl">Base URL</label>
|
||||
<div id="integrationApiBaseUrl" class="mono integrationCode">—</div>
|
||||
</div>
|
||||
<div class="integrationApiBlock">
|
||||
@@ -805,10 +828,10 @@ GET /api/v2.0.0/missions
|
||||
GET /api/v2.0.0/status</pre>
|
||||
</div>
|
||||
<div class="row rowWide integrationTestRow">
|
||||
<label for="integrationRestMission">Thử nhanh</label>
|
||||
<label for="integrationRestMission" data-i18n="integrations.rest.quickTest">Thử nhanh</label>
|
||||
<div class="integrationTestActions">
|
||||
<select id="integrationRestMission"></select>
|
||||
<button id="integrationRestTestBtn" type="button" class="btn subtle">POST queue</button>
|
||||
<button id="integrationRestTestBtn" type="button" class="btn subtle" data-i18n="integrations.rest.postQueue">POST queue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -817,29 +840,58 @@ GET /api/v2.0.0/status</pre>
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">MiRFleet — Lên lịch mission</div>
|
||||
<div class="cardSub">Ưu tiên, gán robot, chạy ASAP hoặc theo thời gian.</div>
|
||||
<div class="cardTitle" data-i18n="integrations.fleet.title">MiRFleet — Lên lịch mission</div>
|
||||
<div class="cardSub" data-i18n="integrations.fleet.subtitle">Ưu tiên, gán robot, chạy ASAP hoặc theo thời gian.</div>
|
||||
</div>
|
||||
<div class="integrationHeaderActions">
|
||||
<button id="integrationRefreshBtn" type="button" class="btn subtle">Tải lại</button>
|
||||
<button id="integrationAddScheduleBtn" type="button" class="btn primary">Thêm lịch</button>
|
||||
<button id="integrationRefreshBtn" type="button" class="btn subtle" data-i18n="common.reload">Tải lại</button>
|
||||
<button id="integrationAddScheduleBtn" type="button" class="btn primary" data-i18n="integrations.fleet.addSchedule">Thêm lịch</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<div id="integrationScheduleEmpty" class="mutedNote">Chưa có lịch fleet.</div>
|
||||
<div id="integrationScheduleEmpty" class="mutedNote" data-i18n="integrations.fleet.empty">Chưa có lịch fleet.</div>
|
||||
<div id="integrationScheduleList" class="missionList"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page" id="pageMonitoring" data-page-content="monitoring" hidden>
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle" data-i18n="monitoring.log.title">System log</div>
|
||||
<div class="cardSub" data-i18n="monitoring.log.subtitle">Monitoring → System log — nhật ký hệ thống (đang phát triển).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<p class="mutedNote" data-i18n="monitoring.log.placeholder">Tính năng monitoring sẽ hiển thị log robot, cảnh báo và lịch sử mission tại đây.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page" id="pageHelp" data-page-content="help" hidden>
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle" data-i18n="help.api.title">API documentation</div>
|
||||
<div class="cardSub" data-i18n="help.api.subtitle">Help → API — tham chiếu REST MiR v2.0.0 cho tích hợp bên ngoài.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<p class="mutedNote" data-i18n="help.api.body1">Xem chi tiết endpoint tại System → Tích hợp hoặc tài liệu /api/v2.0.0/.</p>
|
||||
<p class="mutedNote" data-i18n="help.api.body2">Reference Guide MiR rev 1.9: docs/Reference guide.pdf</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="configSplitter" class="splitter" role="separator" aria-orientation="vertical" tabindex="0"></div>
|
||||
|
||||
<div class="contentRight" id="contentRight">
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">Bố trí trên robot</div>
|
||||
<div class="cardTitle" data-i18n="config.canvas.title">Bố trí trên robot</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -848,11 +900,11 @@ GET /api/v2.0.0/status</pre>
|
||||
<canvas id="canvas"></canvas>
|
||||
</div>
|
||||
<div class="metaBar">
|
||||
<div class="viewHint">Cuộn chuột: zoom • Shift + kéo: di chuyển vùng nhìn</div>
|
||||
<div class="viewHint" data-i18n="config.canvas.viewHint">Cuộn chuột: zoom • Shift + kéo: di chuyển vùng nhìn</div>
|
||||
<div id="robotDiffSummary" class="robotDiffSummary">—</div>
|
||||
<div>Robot center: <span id="robotCenterText"></span></div>
|
||||
<div>Selected: <span id="selectedText">none</span></div>
|
||||
<div>Pose: <span id="selectedRelText">—</span></div>
|
||||
<div><span data-i18n="config.canvas.robotCenter">Robot center:</span> <span id="robotCenterText"></span></div>
|
||||
<div><span data-i18n="config.canvas.selected">Selected:</span> <span id="selectedText" data-i18n="common.none">none</span></div>
|
||||
<div><span data-i18n="config.canvas.pose">Pose:</span> <span id="selectedRelText">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -864,30 +916,30 @@ GET /api/v2.0.0/status</pre>
|
||||
<dialog id="missionCreateDialog" class="missionDialog">
|
||||
<form id="missionCreateForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3>Create mission</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionCreateDialog" aria-label="Đóng">×</button>
|
||||
<h3 data-i18n="missions.dialog.create.title">Create mission</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionCreateDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<div class="row rowWide">
|
||||
<label for="missionCreateName">Tên mission</label>
|
||||
<input id="missionCreateName" type="text" required placeholder="VD: Go to charging station" />
|
||||
<label for="missionCreateName" data-i18n="missions.dialog.create.name">Tên mission</label>
|
||||
<input id="missionCreateName" type="text" required placeholder="VD: Go to charging station" data-i18n-placeholder="missions.dialog.create.namePlaceholder" />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="missionCreateGroup">Nhóm mission</label>
|
||||
<label for="missionCreateGroup" data-i18n="missions.dialog.create.group">Nhóm mission</label>
|
||||
<select id="missionCreateGroup"></select>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="missionCreateGroupNew">Hoặc nhóm mới</label>
|
||||
<input id="missionCreateGroupNew" type="text" placeholder="Tùy chọn" />
|
||||
<label for="missionCreateGroupNew" data-i18n="missions.dialog.create.groupNew">Hoặc nhóm mới</label>
|
||||
<input id="missionCreateGroupNew" type="text" placeholder="Tùy chọn" data-i18n-placeholder="common.optional" />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="missionCreateDesc">Mô tả</label>
|
||||
<textarea id="missionCreateDesc" rows="2" placeholder="Tùy chọn"></textarea>
|
||||
<label for="missionCreateDesc" data-i18n="missions.dialog.create.desc">Mô tả</label>
|
||||
<textarea id="missionCreateDesc" rows="2" placeholder="Tùy chọn" data-i18n-placeholder="common.optional"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="missionDialogFooter">
|
||||
<button type="button" class="btn subtle" data-close-dialog="missionCreateDialog">Hủy</button>
|
||||
<button type="submit" class="btn primary">Create mission</button>
|
||||
<button type="button" class="btn subtle" data-close-dialog="missionCreateDialog" data-i18n="common.cancel">Hủy</button>
|
||||
<button type="submit" class="btn primary" data-i18n="missions.create">Create mission</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
@@ -895,26 +947,26 @@ GET /api/v2.0.0/status</pre>
|
||||
<dialog id="missionSettingsDialog" class="missionDialog">
|
||||
<form id="missionSettingsForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3>Cài đặt mission</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionSettingsDialog" aria-label="Đóng">×</button>
|
||||
<h3 data-i18n="missions.dialog.settings.title">Cài đặt mission</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionSettingsDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<div class="row rowWide">
|
||||
<label for="missionSettingsName">Tên</label>
|
||||
<label for="missionSettingsName" data-i18n="missions.dialog.settings.name">Tên</label>
|
||||
<input id="missionSettingsName" type="text" required />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="missionSettingsGroup">Nhóm</label>
|
||||
<label for="missionSettingsGroup" data-i18n="missions.dialog.settings.group">Nhóm</label>
|
||||
<select id="missionSettingsGroup"></select>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="missionSettingsDesc">Mô tả</label>
|
||||
<label for="missionSettingsDesc" data-i18n="missions.dialog.settings.desc">Mô tả</label>
|
||||
<textarea id="missionSettingsDesc" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="missionDialogFooter">
|
||||
<button type="button" class="btn subtle" data-close-dialog="missionSettingsDialog">Hủy</button>
|
||||
<button type="submit" class="btn primary">Áp dụng</button>
|
||||
<button type="submit" class="btn primary" data-i18n="common.apply">Áp dụng</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
@@ -922,18 +974,18 @@ GET /api/v2.0.0/status</pre>
|
||||
<dialog id="missionSaveAsDialog" class="missionDialog">
|
||||
<form id="missionSaveAsForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3>Save mission as</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionSaveAsDialog" aria-label="Đóng">×</button>
|
||||
<h3 data-i18n="missions.dialog.saveAs.title">Save mission as</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionSaveAsDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<div class="row rowWide">
|
||||
<label for="missionSaveAsName">Tên mission mới</label>
|
||||
<label for="missionSaveAsName" data-i18n="missions.dialog.saveAs.name">Tên mission mới</label>
|
||||
<input id="missionSaveAsName" type="text" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="missionDialogFooter">
|
||||
<button type="button" class="btn subtle" data-close-dialog="missionSaveAsDialog">Hủy</button>
|
||||
<button type="submit" class="btn primary">Lưu bản sao</button>
|
||||
<button type="submit" class="btn primary" data-i18n="missions.dialog.saveAs.submit">Lưu bản sao</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
@@ -941,13 +993,13 @@ GET /api/v2.0.0/status</pre>
|
||||
<dialog id="missionActionConfigDialog" class="missionDialog missionDialogWide">
|
||||
<form id="missionActionConfigForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3 id="missionActionConfigTitle">Cấu hình action</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionActionConfigDialog" aria-label="Đóng">×</button>
|
||||
<h3 id="missionActionConfigTitle" data-i18n="missions.dialog.actionConfig.title">Cấu hình action</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionActionConfigDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody" id="missionActionConfigBody"></div>
|
||||
<div class="missionDialogFooter">
|
||||
<button type="button" class="btn subtle" data-close-dialog="missionActionConfigDialog">Hủy</button>
|
||||
<button type="submit" class="btn primary">Áp dụng</button>
|
||||
<button type="submit" class="btn primary" data-i18n="common.apply">Áp dụng</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
@@ -956,7 +1008,7 @@ GET /api/v2.0.0/status</pre>
|
||||
<form id="missionQueueForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3>Thêm vào mission queue</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionQueueDialog" aria-label="Đóng">×</button>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionQueueDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<p id="missionQueueDialogMission" class="mutedNote">—</p>
|
||||
@@ -976,7 +1028,7 @@ GET /api/v2.0.0/status</pre>
|
||||
<form id="dashboardAddWidgetForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3>Thêm widget</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardAddWidgetDialog" aria-label="Đóng">×</button>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardAddWidgetDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<div class="row rowWide">
|
||||
@@ -1001,7 +1053,7 @@ GET /api/v2.0.0/status</pre>
|
||||
<form id="dashboardEditWidgetForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3>Cấu hình widget</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardEditWidgetDialog" aria-label="Đóng">×</button>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="dashboardEditWidgetDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<input type="hidden" id="dashboardEditWidgetId" />
|
||||
@@ -1023,7 +1075,7 @@ GET /api/v2.0.0/status</pre>
|
||||
<form id="integrationAddTriggerForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3>Modbus trigger</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddTriggerDialog" aria-label="Đóng">×</button>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddTriggerDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<div class="row rowWide">
|
||||
@@ -1050,7 +1102,7 @@ GET /api/v2.0.0/status</pre>
|
||||
<form id="integrationAddScheduleForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3>Lịch MiRFleet</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddScheduleDialog" aria-label="Đóng">×</button>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddScheduleDialog" aria-label="Đóng" data-i18n-aria="common.close">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<div class="row rowWide">
|
||||
@@ -1091,26 +1143,26 @@ GET /api/v2.0.0/status</pre>
|
||||
<dialog id="changePasswordDialog" class="missionDialog">
|
||||
<form id="changePasswordForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3>Đổi mật khẩu</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" onclick="document.getElementById('changePasswordDialog').close()" aria-label="Đóng">×</button>
|
||||
<h3 data-i18n="auth.changePassword.title">Đổi mật khẩu</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" onclick="document.getElementById('changePasswordDialog').close()" aria-label="Đóng" data-i18n-aria="common.close">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<div class="row rowWide">
|
||||
<label for="changePasswordCurrent">Mật khẩu hiện tại</label>
|
||||
<label for="changePasswordCurrent" data-i18n="auth.changePassword.current">Mật khẩu hiện tại</label>
|
||||
<input id="changePasswordCurrent" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="changePasswordNew">Mật khẩu mới</label>
|
||||
<label for="changePasswordNew" data-i18n="auth.changePassword.new">Mật khẩu mới</label>
|
||||
<input id="changePasswordNew" type="password" autocomplete="new-password" required minlength="4" />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="changePasswordConfirm">Xác nhận mật khẩu mới</label>
|
||||
<label for="changePasswordConfirm" data-i18n="auth.changePassword.confirm">Xác nhận mật khẩu mới</label>
|
||||
<input id="changePasswordConfirm" type="password" autocomplete="new-password" required minlength="4" />
|
||||
</div>
|
||||
<p id="changePasswordError" class="loginError"></p>
|
||||
</div>
|
||||
<div class="missionDialogFooter">
|
||||
<button type="button" class="btn subtle" onclick="document.getElementById('changePasswordDialog').close()">Hủy</button>
|
||||
<button type="button" class="btn subtle" onclick="document.getElementById('changePasswordDialog').close()" data-i18n="common.cancel">Hủy</button>
|
||||
<button type="submit" class="btn primary">Lưu</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1131,9 +1183,9 @@ GET /api/v2.0.0/status</pre>
|
||||
<label class="joystickSpeedSelect">
|
||||
<span data-i18n="topbar.joystickSpeed">Tốc độ</span>
|
||||
<select id="joystickSpeedSelect">
|
||||
<option value="slow">Slow</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="fast" selected>Fast</option>
|
||||
<option value="slow" data-i18n="topbar.joystickSpeed.slow">Slow</option>
|
||||
<option value="medium" data-i18n="topbar.joystickSpeed.medium">Medium</option>
|
||||
<option value="fast" data-i18n="topbar.joystickSpeed.fast" selected>Fast</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="mirBtn mirBtn--danger" id="joystickDisengageBtn" data-i18n="topbar.joystickOff">Tắt joystick</button>
|
||||
@@ -1141,7 +1193,9 @@ GET /api/v2.0.0/status</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/i18n.js"></script>
|
||||
<script src="/auth.js"></script>
|
||||
<script src="/nav.js"></script>
|
||||
<script src="/missions.js"></script>
|
||||
<script src="/topbar.js"></script>
|
||||
<script src="/dashboard.js"></script>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const COIL_MAX = 2000;
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
const triggerListEl = el("integrationTriggerList");
|
||||
const triggerEmptyEl = el("integrationTriggerEmpty");
|
||||
const coilGridEl = el("integrationCoilGrid");
|
||||
@@ -72,7 +73,7 @@
|
||||
const data = await apiJson("/api/fleet/robots");
|
||||
store.robots = Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
store.robots = [{ id: "default", name: "Robot chính" }];
|
||||
store.robots = [{ id: "default", name: t("integrations.defaultRobot") }];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +97,7 @@
|
||||
if (!store.missions.length) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = "— Chưa có mission —";
|
||||
opt.textContent = t("integrations.noMissions");
|
||||
selectEl.appendChild(opt);
|
||||
return;
|
||||
}
|
||||
@@ -122,7 +123,7 @@
|
||||
if (!store.robots.length) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "default";
|
||||
opt.textContent = "Robot chính";
|
||||
opt.textContent = t("integrations.defaultRobot");
|
||||
opt.selected = selected === "default";
|
||||
selectEl.appendChild(opt);
|
||||
}
|
||||
@@ -133,24 +134,24 @@
|
||||
triggerListEl.innerHTML = "";
|
||||
if (triggerEmptyEl) triggerEmptyEl.hidden = store.triggers.length > 0;
|
||||
|
||||
store.triggers.forEach((t) => {
|
||||
store.triggers.forEach((trigger) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "missionListItem integrationRow";
|
||||
const coil = t.coil_id;
|
||||
const coil = trigger.coil_id;
|
||||
const on = store.coils[String(coil)] === true;
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<div class="missionListItemTitle">${escapeHtml(t.name)}</div>
|
||||
<div class="missionListItemTitle">${escapeHtml(trigger.name)}</div>
|
||||
<div class="missionListItemMeta">
|
||||
Coil <span class="mono">${coil}</span>
|
||||
→ ${escapeHtml(missionName(t.mission_id))}
|
||||
· ${t.enabled === false ? "Tắt" : "Bật"}
|
||||
· coil hiện tại: <span class="mono">${on ? "ON" : "OFF"}</span>
|
||||
→ ${escapeHtml(missionName(trigger.mission_id))}
|
||||
· ${trigger.enabled === false ? t("common.disabled") : t("common.enabled")}
|
||||
· ${t("integrations.coilState", { state: on ? "ON" : "OFF" })}
|
||||
</div>
|
||||
</div>
|
||||
<div class="missionListItemActions">
|
||||
<button type="button" class="btn subtle" data-fire-coil="${coil}">Kích hoạt</button>
|
||||
<button type="button" class="btn subtle danger" data-delete-trigger="${escapeHtml(t.id)}">Xóa</button>
|
||||
<button type="button" class="btn subtle" data-fire-coil="${coil}">${t("integrations.fireTrigger")}</button>
|
||||
<button type="button" class="btn subtle danger" data-delete-trigger="${escapeHtml(trigger.id)}">${t("common.delete")}</button>
|
||||
</div>`;
|
||||
triggerListEl.appendChild(row);
|
||||
});
|
||||
@@ -160,10 +161,10 @@
|
||||
if (!coilGridEl) return;
|
||||
const assigned = new Map(store.triggers.map((t) => [t.coil_id, t]));
|
||||
const chips = [];
|
||||
assigned.forEach((t, coilId) => {
|
||||
assigned.forEach((trigger, coilId) => {
|
||||
const on = store.coils[String(coilId)] === true;
|
||||
chips.push(
|
||||
`<button type="button" class="integrationCoilChip${on ? " on" : ""}" data-fire-coil="${coilId}" title="${escapeHtml(t.name)}">
|
||||
`<button type="button" class="integrationCoilChip${on ? " on" : ""}" data-fire-coil="${coilId}" title="${escapeHtml(trigger.name)}">
|
||||
${coilId}
|
||||
</button>`
|
||||
);
|
||||
@@ -171,11 +172,11 @@
|
||||
coilGridEl.innerHTML =
|
||||
chips.length > 0
|
||||
? chips.join("")
|
||||
: `<span class="mutedNote">Chưa gán coil. Thêm trigger bên trên (1001–2000).</span>`;
|
||||
: `<span class="mutedNote">${t("integrations.coilsEmpty")}</span>`;
|
||||
}
|
||||
|
||||
function formatScheduleTime(s) {
|
||||
if (!s.start_at) return s.start_mode === "scheduled" ? "—" : "Ngay (asap)";
|
||||
if (!s.start_at) return s.start_mode === "scheduled" ? "—" : t("integrations.dialog.schedule.asap");
|
||||
try {
|
||||
return new Date(s.start_at).toLocaleString("vi-VN");
|
||||
} catch {
|
||||
@@ -204,7 +205,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="missionListItemActions">
|
||||
<button type="button" class="btn subtle" data-run-schedule="${escapeHtml(s.id)}">Chạy ngay</button>
|
||||
<button type="button" class="btn subtle" data-run-schedule="${escapeHtml(s.id)}">${t("integrations.schedule.runNow")}</button>
|
||||
<button type="button" class="btn subtle danger" data-delete-schedule="${escapeHtml(s.id)}">Xóa</button>
|
||||
</div>`;
|
||||
scheduleListEl.appendChild(row);
|
||||
@@ -316,7 +317,7 @@
|
||||
}
|
||||
|
||||
async function deleteTrigger(id) {
|
||||
if (!confirm("Xóa trigger Modbus này?")) return;
|
||||
if (!confirm(t("integrations.confirm.deleteTrigger"))) return;
|
||||
try {
|
||||
await apiJson(`/api/triggers/${id}`, { method: "DELETE" });
|
||||
await refreshAll();
|
||||
@@ -326,7 +327,7 @@
|
||||
}
|
||||
|
||||
async function deleteSchedule(id) {
|
||||
if (!confirm("Xóa lịch fleet này?")) return;
|
||||
if (!confirm(t("integrations.confirm.deleteSchedule"))) return;
|
||||
try {
|
||||
await apiJson(`/api/fleet/schedules/${id}`, { method: "DELETE" });
|
||||
await refreshAll();
|
||||
@@ -446,6 +447,12 @@
|
||||
function boot() {
|
||||
init();
|
||||
}
|
||||
window.addEventListener("lm:locale-change", () => {
|
||||
renderTriggers();
|
||||
renderCoilGrid();
|
||||
renderSchedules();
|
||||
});
|
||||
|
||||
if (window.AuthApp?.isReady()) boot();
|
||||
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||
})();
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"];
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
|
||||
const missionListEl = el("missionList");
|
||||
const missionListEmptyEl = el("missionListEmpty");
|
||||
@@ -136,9 +137,9 @@
|
||||
function actionMeta(type) {
|
||||
for (const items of Object.values(ACTION_GROUPS)) {
|
||||
const hit = items.find((a) => a.type === type);
|
||||
if (hit) return hit;
|
||||
if (hit) return { ...hit, label: t(`missions.action.${type}`) || hit.label };
|
||||
}
|
||||
return { type, label: type };
|
||||
return { type, label: t(`missions.action.${type}`) || type };
|
||||
}
|
||||
|
||||
function createAction(type, overrides = {}) {
|
||||
@@ -336,7 +337,7 @@
|
||||
if (action.id === actionId) return { action, list, index: i, path, parent };
|
||||
if (Array.isArray(action.children)) {
|
||||
const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action);
|
||||
if (hit) return hit;
|
||||
if (hit) return { ...hit, label: t(`missions.action.${type}`) || hit.label };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -476,13 +477,14 @@
|
||||
|
||||
function queueStatusLabel(status) {
|
||||
const map = {
|
||||
pending: "Chờ",
|
||||
executing: "Đang chạy",
|
||||
completed: "Xong",
|
||||
failed: "Lỗi",
|
||||
cancelled: "Đã hủy",
|
||||
pending: "missions.queue.status.pending",
|
||||
executing: "missions.queue.status.executing",
|
||||
completed: "missions.queue.status.done",
|
||||
failed: "missions.queue.status.error",
|
||||
cancelled: "missions.queue.status.cancelled",
|
||||
};
|
||||
return map[status] || status;
|
||||
const key = map[status];
|
||||
return key ? t(key) : status;
|
||||
}
|
||||
|
||||
async function refreshQueue() {
|
||||
@@ -495,7 +497,7 @@
|
||||
notifyQueueUpdate();
|
||||
} catch (e) {
|
||||
if (String(e.message || "").includes("not authenticated")) return;
|
||||
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `Không tải được queue: ${e.message}`;
|
||||
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `${t("common.error", { msg: e.message })}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,8 +538,8 @@
|
||||
? `${store.runner.message}${action}`
|
||||
: st === "idle"
|
||||
? compact
|
||||
? "Sẵn sàng"
|
||||
: "Robot sẵn sàng — queue trống hoặc chờ mission mới."
|
||||
? t("missions.queue.ready")
|
||||
: t("missions.queue.idleMessage")
|
||||
: "—";
|
||||
}
|
||||
|
||||
@@ -554,12 +556,12 @@
|
||||
${paramHtml ? `<div class="missionQueueItemParams">${paramHtml}</div>` : ""}
|
||||
</div>
|
||||
<div class="missionQueueWidgetActions">
|
||||
${entry.status === "pending" ? `<button type="button" class="iconBtn danger" data-queue-remove="${entry.id}" title="Xóa">×</button>` : `<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>`}
|
||||
${entry.status === "pending" ? `<button type="button" class="iconBtn danger" data-queue-remove="${entry.id}" title="" data-i18n-title="common.delete">×</button>` : `<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>`}
|
||||
</div>`
|
||||
: `
|
||||
<div class="missionQueueOrder">
|
||||
<button type="button" class="iconBtn" data-queue-up="${entry.id}" title="Lên" ${canReorder && index > 0 ? "" : "disabled"}>↑</button>
|
||||
<button type="button" class="iconBtn" data-queue-down="${entry.id}" title="Xuống" ${canReorder && index < store.queue.length - 1 ? "" : "disabled"}>↓</button>
|
||||
<button type="button" class="iconBtn" data-queue-up="${entry.id}" title="${t("missions.queue.moveUp")}" ${canReorder && index > 0 ? "" : "disabled"}>↑</button>
|
||||
<button type="button" class="iconBtn" data-queue-down="${entry.id}" title="${t("missions.queue.moveDown")}" ${canReorder && index < store.queue.length - 1 ? "" : "disabled"}>↓</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="missionQueueItemTitle">${escapeHtml(entry.mission_name || "Mission")}</div>
|
||||
@@ -568,7 +570,7 @@
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px;">
|
||||
<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>
|
||||
${entry.status === "pending" ? `<button type="button" class="btn subtle danger" data-queue-remove="${entry.id}">Xóa</button>` : ""}
|
||||
${entry.status === "pending" ? `<button type="button" class="btn subtle danger" data-queue-remove="${entry.id}">${t("common.delete")}</button>` : ""}
|
||||
</div>`;
|
||||
|
||||
row.querySelector("[data-queue-up]")?.addEventListener("click", () => moveQueueItem(entry.id, -1));
|
||||
@@ -619,7 +621,7 @@
|
||||
}
|
||||
|
||||
async function clearQueue() {
|
||||
if (!confirm("Xóa các mission đang chờ trong queue?")) return;
|
||||
if (!confirm(t("missions.queue.clearConfirm"))) return;
|
||||
try {
|
||||
await missionApi("/api/mission_queue", { method: "DELETE" });
|
||||
await refreshQueue();
|
||||
@@ -650,7 +652,7 @@
|
||||
}
|
||||
|
||||
async function cancelRunner() {
|
||||
if (!confirm("Hủy mission đang chạy? (thoát loop và dừng ngay)")) return;
|
||||
if (!confirm(t("missions.queue.cancelConfirm"))) return;
|
||||
await missionApi("/api/mission_queue/cancel", { method: "POST", body: "{}" });
|
||||
await refreshQueue();
|
||||
}
|
||||
@@ -756,8 +758,8 @@
|
||||
</div>
|
||||
<div class="missionListItemActions">
|
||||
<button type="button" class="iconBtn missionQueueBtn" data-queue="${mission.id}" title="Thêm vào mission queue" aria-label="Thêm vào queue">▤</button>
|
||||
<button type="button" class="btn subtle" data-edit="${mission.id}">Sửa</button>
|
||||
<button type="button" class="btn subtle danger" data-delete="${mission.id}">Xóa</button>
|
||||
<button type="button" class="btn subtle" data-edit="${mission.id}">${t("common.edit")}</button>
|
||||
<button type="button" class="btn subtle danger" data-delete="${mission.id}">${t("common.delete")}</button>
|
||||
</div>`;
|
||||
row.addEventListener("click", (evt) => {
|
||||
if (evt.target.closest("button")) return;
|
||||
@@ -773,7 +775,7 @@
|
||||
});
|
||||
row.querySelector("[data-delete]").addEventListener("click", (evt) => {
|
||||
evt.stopPropagation();
|
||||
if (!confirm(`Xóa mission «${mission.name}»?`)) return;
|
||||
if (!confirm(t("missions.deleteConfirm", { name: mission.name }))) return;
|
||||
store.missions = store.missions.filter((m) => m.id !== mission.id);
|
||||
persistStore();
|
||||
renderMissionList();
|
||||
@@ -782,6 +784,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
function groupLabel(name) {
|
||||
const key = `missions.group.${name}`;
|
||||
const v = t(key);
|
||||
return v !== key ? v : name;
|
||||
}
|
||||
|
||||
function renderActionPalette() {
|
||||
if (!missionGroupTabsEl) return;
|
||||
missionGroupTabsEl.innerHTML = "";
|
||||
@@ -889,7 +897,7 @@
|
||||
</div>
|
||||
<div class="missionActionBtns">
|
||||
<button type="button" class="iconBtn" data-config="${action.id}" title="Cấu hình">⚙</button>
|
||||
<button type="button" class="iconBtn danger" data-remove="${action.id}" title="Xóa">×</button>
|
||||
<button type="button" class="iconBtn danger" data-remove="${action.id}" title="" data-i18n-title="common.delete">×</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
@@ -1044,7 +1052,7 @@
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
if (store.dirty && !confirm("Bỏ thay đổi chưa lưu?")) return;
|
||||
if (store.dirty && !confirm(t("missions.editor.discardConfirm"))) return;
|
||||
store.editingId = null;
|
||||
store.draft = null;
|
||||
setDirty(false);
|
||||
@@ -1057,7 +1065,7 @@
|
||||
const draft = getDraft();
|
||||
if (!draft) return false;
|
||||
if (!draft.name.trim()) {
|
||||
alert("Tên mission không được trống.");
|
||||
alert(t("missions.error.nameRequired"));
|
||||
return false;
|
||||
}
|
||||
draft.updated_at = new Date().toISOString();
|
||||
@@ -1076,7 +1084,7 @@
|
||||
const name = newName.trim();
|
||||
if (!name) return false;
|
||||
if (store.missions.some((m) => m.name === name && m.id !== draft.id)) {
|
||||
alert("Tên mission đã tồn tại.");
|
||||
alert(t("missions.error.nameDuplicate"));
|
||||
return false;
|
||||
}
|
||||
const copy = JSON.parse(JSON.stringify(draft));
|
||||
@@ -1213,7 +1221,7 @@
|
||||
addField("Timeout (s)", textInput("timeout_s", p.timeout_s, "number"));
|
||||
{
|
||||
const chk = document.createElement("label");
|
||||
chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> Chờ mức ON`;
|
||||
chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> ${t("missions.action.waitOnLevel")}`;
|
||||
addField("Kỳ vọng", chk);
|
||||
}
|
||||
break;
|
||||
@@ -1286,7 +1294,7 @@
|
||||
if (!store.groups.includes(group)) store.groups.push(group);
|
||||
}
|
||||
if (store.missions.some((m) => m.name === name)) {
|
||||
alert("Tên mission đã tồn tại.");
|
||||
alert(t("missions.error.nameDuplicate"));
|
||||
return;
|
||||
}
|
||||
const mission = createMission(name, group, el("missionCreateDesc").value);
|
||||
@@ -1299,7 +1307,7 @@
|
||||
|
||||
el("missionEditorBackBtn")?.addEventListener("click", closeEditor);
|
||||
el("missionSaveBtn")?.addEventListener("click", () => {
|
||||
if (saveDraft()) alert("Đã lưu mission.");
|
||||
if (saveDraft()) alert(t("missions.saveSuccess"));
|
||||
});
|
||||
el("missionSaveAsBtn")?.addEventListener("click", openSaveAsDialog);
|
||||
el("missionSettingsBtn")?.addEventListener("click", openSettingsDialog);
|
||||
@@ -1312,7 +1320,7 @@
|
||||
draft.group = el("missionSettingsGroup").value;
|
||||
draft.description = el("missionSettingsDesc").value.trim();
|
||||
if (!draft.name) {
|
||||
alert("Tên không được trống.");
|
||||
alert(t("missions.error.nameEmpty"));
|
||||
return;
|
||||
}
|
||||
setDirty(true);
|
||||
@@ -1393,6 +1401,16 @@
|
||||
function boot() {
|
||||
init();
|
||||
}
|
||||
function onLocaleChange() {
|
||||
if (!missionEditorViewEl?.hidden) renderMissionEditor();
|
||||
else {
|
||||
renderMissionList();
|
||||
renderQueuePanel();
|
||||
}
|
||||
renderActionPalette();
|
||||
}
|
||||
window.addEventListener("lm:locale-change", onLocaleChange);
|
||||
|
||||
if (window.AuthApp?.isReady()) boot();
|
||||
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||
window.addEventListener("lm:auth-logout", stopQueuePollForce);
|
||||
|
||||
268
www/nav.js
Normal file
268
www/nav.js
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* MiR-style 3-column navigation: primary rail + flyout submenu + content.
|
||||
*/
|
||||
(function () {
|
||||
const STORAGE_MODULE = "mirNavModule";
|
||||
const STORAGE_SECTION = "mirNavSection";
|
||||
const STORAGE_FLYOUT = "mirNavFlyoutOpen";
|
||||
|
||||
const MODULES = {
|
||||
dashboards: {
|
||||
items: [{ section: "dashboard", page: "dashboard" }],
|
||||
},
|
||||
setup: {
|
||||
items: [
|
||||
{ section: "missions", page: "missions" },
|
||||
{ section: "maps", page: "config" },
|
||||
],
|
||||
},
|
||||
monitoring: {
|
||||
items: [{ section: "monitoring-log", page: "monitoring" }],
|
||||
},
|
||||
system: {
|
||||
items: [{ section: "integrations", page: "integrations" }],
|
||||
},
|
||||
help: {
|
||||
items: [{ section: "help-api", page: "help" }],
|
||||
},
|
||||
};
|
||||
|
||||
const PAGE_NAV = {
|
||||
dashboard: { module: "dashboards", section: "dashboard" },
|
||||
config: { module: "setup", section: "maps" },
|
||||
missions: { module: "setup", section: "missions" },
|
||||
integrations: { module: "system", section: "integrations" },
|
||||
monitoring: { module: "monitoring", section: "monitoring-log" },
|
||||
help: { module: "help", section: "help-api" },
|
||||
};
|
||||
|
||||
let activeModule = "setup";
|
||||
let activeSection = "maps";
|
||||
let flyoutOpen = true;
|
||||
|
||||
const shellEl = () => document.getElementById("mirNavShell");
|
||||
const flyoutListEl = () => document.getElementById("mirNavFlyoutList");
|
||||
const flyoutTitleEl = () => document.getElementById("mirNavFlyoutTitle");
|
||||
const backBtnEl = () => document.getElementById("mirNavBackBtn");
|
||||
|
||||
function t(key) {
|
||||
return window.I18n?.t(`nav.${key}`) ?? key;
|
||||
}
|
||||
|
||||
function canAccessPage(page) {
|
||||
if (window.AuthApp?.canAccessPage) return window.AuthApp.canAccessPage(page);
|
||||
return true;
|
||||
}
|
||||
|
||||
function visibleItems(moduleId) {
|
||||
const mod = MODULES[moduleId];
|
||||
if (!mod) return [];
|
||||
return mod.items.filter((item) => canAccessPage(item.page));
|
||||
}
|
||||
|
||||
function moduleHasAccess(moduleId) {
|
||||
return visibleItems(moduleId).length > 0;
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_MODULE, activeModule);
|
||||
localStorage.setItem(STORAGE_SECTION, activeSection);
|
||||
localStorage.setItem(STORAGE_FLYOUT, flyoutOpen ? "1" : "0");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function renderFlyout() {
|
||||
const list = flyoutListEl();
|
||||
const title = flyoutTitleEl();
|
||||
if (!list || !title) return;
|
||||
|
||||
const items = visibleItems(activeModule);
|
||||
title.textContent = t(activeModule);
|
||||
list.replaceChildren();
|
||||
|
||||
items.forEach((item) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "mirNavFlyoutItem";
|
||||
btn.dataset.section = item.section;
|
||||
btn.dataset.page = item.page;
|
||||
btn.textContent = t(item.section);
|
||||
if (item.section === activeSection) {
|
||||
btn.classList.add("is-active");
|
||||
btn.setAttribute("aria-current", "page");
|
||||
}
|
||||
btn.addEventListener("click", () => selectSection(item.section, item.page));
|
||||
list.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function updateRailUI() {
|
||||
document.querySelectorAll(".mirNavRailItem[data-module]").forEach((btn) => {
|
||||
const mod = btn.dataset.module || "";
|
||||
const allowed = moduleHasAccess(mod);
|
||||
btn.hidden = !allowed;
|
||||
btn.style.display = allowed ? "" : "none";
|
||||
const on = mod === activeModule && flyoutOpen;
|
||||
btn.classList.toggle("is-active", on);
|
||||
if (on) btn.setAttribute("aria-current", "true");
|
||||
else btn.removeAttribute("aria-current");
|
||||
});
|
||||
|
||||
const shell = shellEl();
|
||||
if (shell) shell.classList.toggle("mirNavShell--flyout-collapsed", !flyoutOpen);
|
||||
|
||||
const back = backBtnEl();
|
||||
if (back) {
|
||||
const label = flyoutOpen ? t("collapse") : t("expand");
|
||||
back.title = label;
|
||||
back.setAttribute("aria-label", label);
|
||||
}
|
||||
|
||||
renderFlyout();
|
||||
}
|
||||
|
||||
function selectModule(moduleId, opts = {}) {
|
||||
if (!MODULES[moduleId] || !moduleHasAccess(moduleId)) return;
|
||||
|
||||
if (moduleId === activeModule && flyoutOpen && !opts.forceSection) {
|
||||
flyoutOpen = false;
|
||||
saveState();
|
||||
updateRailUI();
|
||||
return;
|
||||
}
|
||||
|
||||
activeModule = moduleId;
|
||||
flyoutOpen = true;
|
||||
|
||||
const items = visibleItems(moduleId);
|
||||
const keepSection = items.some((i) => i.section === activeSection);
|
||||
if (!keepSection || opts.forceSection) {
|
||||
const preferred = items.find((i) => i.section === opts.section) || items[0];
|
||||
if (preferred) {
|
||||
activeSection = preferred.section;
|
||||
if (!opts.skipPage) navigateToPage(preferred.page);
|
||||
}
|
||||
} else if (!opts.skipPage) {
|
||||
const current = items.find((i) => i.section === activeSection);
|
||||
if (current) navigateToPage(current.page);
|
||||
}
|
||||
|
||||
saveState();
|
||||
updateRailUI();
|
||||
}
|
||||
|
||||
function selectSection(section, page) {
|
||||
activeSection = section;
|
||||
saveState();
|
||||
updateRailUI();
|
||||
navigateToPage(page);
|
||||
}
|
||||
|
||||
function navigateToPage(page) {
|
||||
if (window.LmApp?.setActivePage) window.LmApp.setActivePage(page);
|
||||
}
|
||||
|
||||
function syncFromPage(page) {
|
||||
const nav = PAGE_NAV[page];
|
||||
if (!nav) return;
|
||||
activeModule = nav.module;
|
||||
activeSection = nav.section;
|
||||
saveState();
|
||||
updateRailUI();
|
||||
}
|
||||
|
||||
function toggleFlyout() {
|
||||
flyoutOpen = !flyoutOpen;
|
||||
saveState();
|
||||
updateRailUI();
|
||||
}
|
||||
|
||||
function applyPermissions() {
|
||||
const modules = Object.keys(MODULES);
|
||||
if (!moduleHasAccess(activeModule)) {
|
||||
const fallback = modules.find((m) => moduleHasAccess(m));
|
||||
if (fallback) selectModule(fallback, { forceSection: true, skipPage: false });
|
||||
} else {
|
||||
const items = visibleItems(activeModule);
|
||||
if (!items.some((i) => i.section === activeSection)) {
|
||||
activeSection = items[0]?.section || activeSection;
|
||||
}
|
||||
}
|
||||
updateRailUI();
|
||||
}
|
||||
|
||||
function restoreInitialPage() {
|
||||
let page = "config";
|
||||
try {
|
||||
const saved = localStorage.getItem("activePage");
|
||||
if (saved && PAGE_NAV[saved]) page = saved;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
const savedMod = localStorage.getItem(STORAGE_MODULE);
|
||||
const savedSec = localStorage.getItem(STORAGE_SECTION);
|
||||
const savedFlyout = localStorage.getItem(STORAGE_FLYOUT);
|
||||
if (savedMod && MODULES[savedMod]) activeModule = savedMod;
|
||||
if (savedSec) activeSection = savedSec;
|
||||
if (savedFlyout === "0") flyoutOpen = false;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const nav = PAGE_NAV[page];
|
||||
if (nav && moduleHasAccess(nav.module)) {
|
||||
activeModule = nav.module;
|
||||
activeSection = nav.section;
|
||||
} else {
|
||||
const modItems = visibleItems(activeModule);
|
||||
const match = modItems.find((i) => i.page === page) || modItems[0];
|
||||
if (match) {
|
||||
activeSection = match.section;
|
||||
page = match.page;
|
||||
}
|
||||
}
|
||||
|
||||
updateRailUI();
|
||||
navigateToPage(page);
|
||||
}
|
||||
|
||||
function refreshLabels() {
|
||||
window.I18n?.applyDOM?.();
|
||||
updateRailUI();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.querySelectorAll(".mirNavRailItem[data-module]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => selectModule(btn.dataset.module || "setup"));
|
||||
});
|
||||
|
||||
backBtnEl()?.addEventListener("click", toggleFlyout);
|
||||
|
||||
document.getElementById("mirNavLogout")?.addEventListener("click", () => {
|
||||
window.AuthApp?.logout?.();
|
||||
});
|
||||
|
||||
window.addEventListener("lm:locale-change", () => refreshLabels());
|
||||
}
|
||||
|
||||
function init() {
|
||||
refreshLabels();
|
||||
bindEvents();
|
||||
applyPermissions();
|
||||
restoreInitialPage();
|
||||
}
|
||||
|
||||
window.NavApp = {
|
||||
init,
|
||||
syncFromPage,
|
||||
applyPermissions,
|
||||
selectModule,
|
||||
selectSection,
|
||||
toggleFlyout,
|
||||
};
|
||||
})();
|
||||
240
www/style.css
240
www/style.css
@@ -24,102 +24,184 @@ body {
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
grid-template-columns: auto 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
/* —— MiR 3-column navigation (rail + flyout + content) —— */
|
||||
.mirNavShell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
background: linear-gradient(180deg, #0b1220, #0b1220);
|
||||
color: #e8eefc;
|
||||
padding: 16px 14px;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
z-index: 35;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
.mirNavShell--flyout-collapsed .mirNavFlyout {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.brandIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(37, 99, 235, 0.22);
|
||||
border: 1px solid rgba(37, 99, 235, 0.35);
|
||||
font-weight: 800;
|
||||
}
|
||||
.brandTitle { font-weight: 800; font-size: 13px; letter-spacing: 0.2px; }
|
||||
.brandSub { color: rgba(232,238,252,0.75); font-size: 12px; margin-top: 2px; }
|
||||
|
||||
.navTitle {
|
||||
margin-top: 16px;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
color: rgba(232,238,252,0.65);
|
||||
letter-spacing: 0.12em;
|
||||
.mirNavRail {
|
||||
width: 76px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
background: linear-gradient(180deg, #1a3d66 0%, #122d4f 55%, #0f2744 100%);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.28);
|
||||
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.nav { margin-top: 8px; display: grid; gap: 6px; }
|
||||
.navItem {
|
||||
.mirNavBackBtn {
|
||||
flex-shrink: 0;
|
||||
margin: 8px 8px 4px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mirNavBackBtn:hover { background: rgba(255, 255, 255, 0.14); }
|
||||
|
||||
.mirNavRailItems {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mirNavRailFooter {
|
||||
padding: 8px 6px 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.mirNavRailItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
min-height: 68px;
|
||||
padding: 8px 4px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
.mirNavRailItem:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
}
|
||||
.mirNavRailItem.is-active {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #fff;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.mirNavRailIcon { display: block; opacity: 0.95; }
|
||||
.mirNavRailLabel {
|
||||
font-size: 10px;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.01em;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mirNavRailItem--logout { color: rgba(255, 200, 200, 0.9); }
|
||||
.mirNavRailItem--logout:hover { color: #fff; background: rgba(239, 68, 68, 0.22); }
|
||||
|
||||
.mirNavFlyout {
|
||||
width: 248px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, #5a94cf 0%, #4a7fbe 45%, #3d6fa8 100%);
|
||||
color: #fff;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.18);
|
||||
box-shadow: 4px 0 18px rgba(15, 23, 42, 0.12);
|
||||
transition: width 0.18s ease, opacity 0.18s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mirNavFlyoutHeader {
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.mirNavFlyoutTitle {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.mirNavFlyoutList {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mirNavFlyoutItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 11px 14px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mirNavFlyoutItem:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
.mirNavFlyoutItem.is-active {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 3px 0 0 #fff;
|
||||
}
|
||||
.mirNavFlyoutFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 10px;
|
||||
border-radius: 12px;
|
||||
color: rgba(232,238,252,0.85);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
gap: 8px;
|
||||
padding: 10px 14px 14px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.navItem:hover { background: rgba(255,255,255,0.05); }
|
||||
.navItem.active {
|
||||
background: rgba(37, 99, 235, 0.22);
|
||||
border-color: rgba(37, 99, 235, 0.30);
|
||||
color: #ffffff;
|
||||
}
|
||||
.navDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
.mirNavStatusLed {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.25);
|
||||
background: rgba(16, 185, 129, 0.9);
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.navItem.active .navDot { background: rgba(37, 99, 235, 1); }
|
||||
|
||||
.sidebarFooter {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: 14px;
|
||||
.mirNavStatusText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.statusBadge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.statusLed {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(16, 185, 129, 0.85);
|
||||
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
.statusText { color: rgba(232,238,252,0.85); font-size: 12px; }
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
box-shadow: -2px 0 12px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
/* —— MiR-style top bar —— */
|
||||
@@ -1370,8 +1452,10 @@ canvas {
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: relative; height: auto; }
|
||||
.shell { grid-template-columns: auto 1fr; }
|
||||
.mirNavShell { height: auto; position: relative; }
|
||||
.mirNavShell--flyout-collapsed .mirNavFlyout { display: none; }
|
||||
.mirNavFlyout { width: 200px; }
|
||||
.body { grid-template-rows: auto 1fr; }
|
||||
.content { grid-template-columns: 1fr; height: auto; }
|
||||
.splitter { display: none; }
|
||||
|
||||
107
www/topbar.js
107
www/topbar.js
@@ -1,63 +1,14 @@
|
||||
(() => {
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
||||
const I18N = {
|
||||
vi: {
|
||||
"topbar.allOk": "ỔN ĐỊNH",
|
||||
"topbar.error": "LỖI",
|
||||
"topbar.paused": "TẠM DỪNG",
|
||||
"topbar.running": "ĐANG CHẠY",
|
||||
"topbar.waiting": "Đang chờ mission mới…",
|
||||
"topbar.noMissionsQueue": "Không có mission trong queue…",
|
||||
"topbar.reset": "RESET",
|
||||
"topbar.changeUserData": "Lưu thông tin",
|
||||
"topbar.changePassword": "Đổi mật khẩu",
|
||||
"topbar.logout": "Đăng xuất",
|
||||
"topbar.displayName": "Tên hiển thị",
|
||||
"topbar.reload": "Tải lại",
|
||||
"topbar.saveLayout": "Lưu layout",
|
||||
"topbar.joystickTitle": "Điều khiển tay (Joystick)",
|
||||
"topbar.joystickSpeed": "Tốc độ",
|
||||
"topbar.joystickOff": "Tắt joystick",
|
||||
"topbar.localeVi": "TIẾNG VIỆT",
|
||||
"topbar.localeEn": "ENGLISH",
|
||||
"topbar.startHint": "Bấm để START robot",
|
||||
"topbar.pauseHint": "Bấm để PAUSE robot",
|
||||
"topbar.code": "Mã",
|
||||
"topbar.module": "Module",
|
||||
},
|
||||
en: {
|
||||
"topbar.allOk": "ALL OK",
|
||||
"topbar.error": "ERROR",
|
||||
"topbar.paused": "PAUSED",
|
||||
"topbar.running": "RUNNING",
|
||||
"topbar.waiting": "Waiting for new missions…",
|
||||
"topbar.noMissionsQueue": "No missions in queue…",
|
||||
"topbar.reset": "RESET",
|
||||
"topbar.changeUserData": "Change user data",
|
||||
"topbar.changePassword": "Change password",
|
||||
"topbar.logout": "Log out",
|
||||
"topbar.displayName": "Display name",
|
||||
"topbar.reload": "Reload",
|
||||
"topbar.saveLayout": "Save layout",
|
||||
"topbar.joystickTitle": "Manual control (Joystick)",
|
||||
"topbar.joystickSpeed": "Speed",
|
||||
"topbar.joystickOff": "Disengage joystick",
|
||||
"topbar.localeVi": "TIẾNG VIỆT",
|
||||
"topbar.localeEn": "ENGLISH",
|
||||
"topbar.startHint": "Click to START the robot",
|
||||
"topbar.pauseHint": "Click to PAUSE the robot",
|
||||
"topbar.code": "Code",
|
||||
"topbar.module": "Module",
|
||||
},
|
||||
};
|
||||
|
||||
const LOCALE_META = {
|
||||
vi: { flag: "🇻🇳", labelKey: "topbar.localeVi" },
|
||||
en: { flag: "🇺🇸", labelKey: "topbar.localeEn" },
|
||||
};
|
||||
|
||||
let locale = "vi";
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
const getLocale = () => window.I18n?.getLocale?.() ?? "vi";
|
||||
|
||||
let robotStatus = null;
|
||||
let pollTimer = null;
|
||||
let eventsBound = false;
|
||||
@@ -67,8 +18,13 @@
|
||||
let joystickRaf = null;
|
||||
let lastCmd = { linear: 0, angular: 0 };
|
||||
|
||||
function t(key) {
|
||||
return I18N[locale]?.[key] ?? I18N.en[key] ?? key;
|
||||
function applyLocale(next) {
|
||||
if (window.I18n) window.I18n.setLocale(next);
|
||||
if (robotStatus) renderAll(robotStatus);
|
||||
}
|
||||
|
||||
function loadLocale() {
|
||||
/* locale owned by I18n */
|
||||
}
|
||||
|
||||
function canSeeMissions() {
|
||||
@@ -96,35 +52,6 @@
|
||||
return data;
|
||||
}
|
||||
|
||||
function applyLocale(next) {
|
||||
locale = LOCALE_META[next] ? next : "vi";
|
||||
try {
|
||||
localStorage.setItem("lm_locale", locale);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
document.documentElement.lang = locale;
|
||||
document.querySelectorAll("[data-i18n]").forEach((node) => {
|
||||
const key = node.dataset.i18n;
|
||||
if (key) node.textContent = t(key);
|
||||
});
|
||||
const meta = LOCALE_META[locale];
|
||||
if (el("mirLocaleFlag")) el("mirLocaleFlag").textContent = meta.flag;
|
||||
if (el("mirLocaleLabel")) el("mirLocaleLabel").textContent = t(meta.labelKey);
|
||||
window.dispatchEvent(new CustomEvent("lm:locale-change", { detail: { locale } }));
|
||||
if (robotStatus) renderAll(robotStatus);
|
||||
}
|
||||
|
||||
function loadLocale() {
|
||||
try {
|
||||
const saved = localStorage.getItem("lm_locale");
|
||||
if (saved && LOCALE_META[saved]) locale = saved;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
applyLocale(locale);
|
||||
}
|
||||
|
||||
function closePanels() {
|
||||
document.querySelectorAll(".mirPanel").forEach((p) => {
|
||||
p.hidden = true;
|
||||
@@ -226,7 +153,7 @@
|
||||
bodyEl.innerHTML = `
|
||||
<div class="mirStatusOkTitle">${t("topbar.allOk")}</div>
|
||||
<div class="mirStatusDesc">${message}</div>
|
||||
${status.queue_pending > 0 ? `<div class="mirStatusMeta">${status.queue_pending} mission(s) in queue</div>` : ""}`;
|
||||
${status.queue_pending > 0 ? `<div class="mirStatusMeta">${t("topbar.queueCount", { n: status.queue_pending })}</div>` : ""}`;
|
||||
if (footerEl) footerEl.hidden = true;
|
||||
}
|
||||
}
|
||||
@@ -267,7 +194,10 @@
|
||||
joystickActive = engaged;
|
||||
const speedSel = el("joystickSpeedSelect");
|
||||
if (speedSel && status.joystick_speed) speedSel.value = status.joystick_speed;
|
||||
if (el("joystickSpeedLabel")) el("joystickSpeedLabel").textContent = status.joystick_speed || "fast";
|
||||
if (el("joystickSpeedLabel")) {
|
||||
const speed = status.joystick_speed || "fast";
|
||||
el("joystickSpeedLabel").textContent = t(`topbar.joystickSpeed.${speed}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAll(status) {
|
||||
@@ -438,7 +368,7 @@
|
||||
|
||||
el("mirSegJoystick")?.addEventListener("click", async () => {
|
||||
if (!canControl()) {
|
||||
alert(locale === "vi" ? "Không có quyền điều khiển" : "No control permission");
|
||||
alert(t("topbar.noControlPermission"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -472,6 +402,9 @@
|
||||
});
|
||||
|
||||
bindJoystickPad();
|
||||
window.addEventListener("lm:locale-change", () => {
|
||||
if (robotStatus) renderAll(robotStatus);
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
@@ -502,7 +435,7 @@
|
||||
|
||||
window.TopbarApp = {
|
||||
t,
|
||||
getLocale: () => locale,
|
||||
getLocale,
|
||||
applyLocale,
|
||||
refresh: fetchStatus,
|
||||
getRobotStatus: () => robotStatus,
|
||||
|
||||
Reference in New Issue
Block a user