This commit is contained in:
@@ -15,6 +15,7 @@ const pageTransitionsEl = el("pageTransitions");
|
||||
const pageUsersEl = el("pageUsers");
|
||||
const pageUserGroupsEl = el("pageUserGroups");
|
||||
const pageIoModulesEl = el("pageIoModules");
|
||||
const pagePathsEl = el("pagePaths");
|
||||
const pageMonitoringEl = el("pageMonitoring");
|
||||
const pageHelpEl = el("pageHelp");
|
||||
const contentEl = document.querySelector(".content");
|
||||
@@ -129,7 +130,7 @@ const state = {
|
||||
};
|
||||
|
||||
function setActivePage(page) {
|
||||
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "users", "integrations", "monitoring", "help"];
|
||||
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "paths", "users", "integrations", "monitoring", "help"];
|
||||
let p = valid.includes(page) ? page : "missions";
|
||||
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
|
||||
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
|
||||
@@ -144,6 +145,7 @@ function setActivePage(page) {
|
||||
if (pageTransitionsEl) pageTransitionsEl.hidden = p !== "transitions";
|
||||
if (pageUserGroupsEl) pageUserGroupsEl.hidden = p !== "user-groups";
|
||||
if (pageIoModulesEl) pageIoModulesEl.hidden = p !== "io-modules";
|
||||
if (pagePathsEl) pagePathsEl.hidden = p !== "paths";
|
||||
if (pageUsersEl) pageUsersEl.hidden = p !== "users";
|
||||
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
||||
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
|
||||
@@ -159,6 +161,7 @@ function setActivePage(page) {
|
||||
contentEl.classList.toggle("content--transitions", p === "transitions");
|
||||
contentEl.classList.toggle("content--user-groups", p === "user-groups");
|
||||
contentEl.classList.toggle("content--io-modules", p === "io-modules");
|
||||
contentEl.classList.toggle("content--paths", p === "paths");
|
||||
contentEl.classList.toggle("content--users", p === "users");
|
||||
contentEl.classList.toggle("content--integrations", p === "integrations");
|
||||
contentEl.classList.toggle("content--monitoring", p === "monitoring");
|
||||
@@ -175,6 +178,8 @@ function setActivePage(page) {
|
||||
else if (window.UserGroupsApp?.onPageHide) window.UserGroupsApp.onPageHide();
|
||||
if (p === "io-modules" && window.IoModulesApp) window.IoModulesApp.onPageShow();
|
||||
else if (window.IoModulesApp?.onPageHide) window.IoModulesApp.onPageHide();
|
||||
if (p === "paths" && window.PathsApp) window.PathsApp.onPageShow();
|
||||
else if (window.PathsApp?.onPageHide) window.PathsApp.onPageHide();
|
||||
if (p === "users" && window.UsersApp) window.UsersApp.onPageShow();
|
||||
else if (window.UsersApp?.onPageHide) window.UsersApp.onPageHide();
|
||||
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
transitions: "maps",
|
||||
"user-groups": "users",
|
||||
"io-modules": "integrations",
|
||||
paths: "maps",
|
||||
users: "users",
|
||||
integrations: "integrations",
|
||||
};
|
||||
@@ -177,6 +178,7 @@
|
||||
document.body.classList.toggle("auth-readonly-users", !canWrite("users"));
|
||||
document.body.classList.toggle("auth-readonly-user-groups", !canWrite("users"));
|
||||
document.body.classList.toggle("auth-readonly-io-modules", !canWrite("integrations"));
|
||||
document.body.classList.toggle("auth-readonly-paths", !canWrite("maps"));
|
||||
}
|
||||
|
||||
function updateUserMenu() {
|
||||
|
||||
48
www/i18n.js
48
www/i18n.js
@@ -76,6 +76,7 @@
|
||||
"nav.transitions": "Transitions",
|
||||
"nav.user-groups": "User groups",
|
||||
"nav.io-modules": "I/O modules",
|
||||
"nav.paths": "Paths",
|
||||
"nav.users": "Users",
|
||||
"nav.build-robot": "Build Robot",
|
||||
"nav.monitoring-log": "System log",
|
||||
@@ -317,7 +318,7 @@
|
||||
"maps.view": "Xem",
|
||||
"maps.importComingSoon": "Import site sẽ có trong phiên bản sau.",
|
||||
"maps.importNoSite": "Chọn site trước khi import.",
|
||||
"maps.importSuccess": "Import xong: {maps} map(s), {io} I/O module(s), {transitions} transition(s).",
|
||||
"maps.importSuccess": "Import xong: {maps} map(s), {io} I/O module(s), {transitions} transition(s), {paths} path(s).",
|
||||
"maps.helpTitle": "Trợ giúp Maps",
|
||||
"maps.helpText": "Tạo map mới, upload ảnh PNG qua menu ⋮, sau đó kích hoạt map cho robot.",
|
||||
"maps.createDialog.title": "Tạo map",
|
||||
@@ -401,6 +402,7 @@
|
||||
"maps.editor.canvasTip": "Kéo map để di chuyển vùng nhìn hoặc dùng nút zoom in/out để phóng to/thu nhỏ.",
|
||||
"maps.editor.unsaved": "Chưa lưu",
|
||||
"maps.editor.unsavedLeave": "Có thay đổi chưa lưu. Rời editor?",
|
||||
"maps.editor.preferredPathWarning": "Map này có path đã lưu. Thay đổi preferred zone có thể không áp dụng cho đến khi bạn xóa các path liên quan trong Setup → Paths. Tiếp tục lưu?",
|
||||
"maps.editor.menu": "Menu",
|
||||
"maps.editor.undo": "Hoàn tác",
|
||||
"maps.editor.save": "Lưu",
|
||||
@@ -692,6 +694,26 @@
|
||||
"ioModules.testConnection": "Kiểm tra kết nối",
|
||||
"ioModules.testOk": "Kết nối TCP thành công.",
|
||||
|
||||
"paths.title": "Paths",
|
||||
"paths.subtitle": "Xem và xóa tuyến đường đã lưu giữa các position.",
|
||||
"paths.helpTitle": "Trợ giúp Paths",
|
||||
"paths.helpBody": "Path là tuyến đường giữa hai position trên cùng một map. Path được tạo tự động lần đầu robot chạy từ position A đến B và được tái sử dụng cho các lần sau. Xóa path để buộc tính lại (ví dụ sau khi thêm preferred zone). Không thể tạo path thủ công.",
|
||||
"paths.clearFilters": "Xóa bộ lọc",
|
||||
"paths.filterLabel": "Lọc:",
|
||||
"paths.filterPlaceholder": "Lọc theo position hoặc map...",
|
||||
"paths.itemsFound": "{n} mục",
|
||||
"paths.pageOf": "Trang {page} / {total}",
|
||||
"paths.colFrom": "From position",
|
||||
"paths.colTo": "To position",
|
||||
"paths.colMap": "Map",
|
||||
"paths.colFunctions": "Functions",
|
||||
"paths.empty": "Chưa có path. Path được tạo tự động khi robot di chuyển giữa hai position.",
|
||||
"paths.emptyFilter": "Không có path khớp bộ lọc.",
|
||||
"paths.view": "Xem trên map",
|
||||
"paths.viewUnavailable": "Không mở được map editor.",
|
||||
"paths.deleteTitle": "Xóa path?",
|
||||
"paths.deleteConfirmText": "Xóa path từ \"{from}\" đến \"{to}\"? Path sẽ được tạo lại khi robot chạy lại giữa hai position này.",
|
||||
|
||||
"missions.title": "Missions",
|
||||
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
|
||||
"missions.create": "Tạo mission",
|
||||
@@ -887,6 +909,7 @@
|
||||
"nav.transitions": "Transitions",
|
||||
"nav.user-groups": "User groups",
|
||||
"nav.io-modules": "I/O modules",
|
||||
"nav.paths": "Paths",
|
||||
"nav.users": "Users",
|
||||
"nav.build-robot": "Build Robot",
|
||||
"nav.monitoring-log": "System log",
|
||||
@@ -1128,7 +1151,7 @@
|
||||
"maps.view": "View",
|
||||
"maps.importComingSoon": "Import site will be available in a future release.",
|
||||
"maps.importNoSite": "Select a site before importing.",
|
||||
"maps.importSuccess": "Import complete: {maps} map(s), {io} I/O module(s), {transitions} transition(s).",
|
||||
"maps.importSuccess": "Import complete: {maps} map(s), {io} I/O module(s), {transitions} transition(s), {paths} path(s).",
|
||||
"maps.helpTitle": "Maps help",
|
||||
"maps.helpText": "Create a new map, upload a PNG via the ⋮ menu, then activate the map for the robot.",
|
||||
"maps.createDialog.title": "Create map",
|
||||
@@ -1212,6 +1235,7 @@
|
||||
"maps.editor.canvasTip": "Drag the map to move your view or use the zoom-in and -out buttons to zoom.",
|
||||
"maps.editor.unsaved": "Unsaved",
|
||||
"maps.editor.unsavedLeave": "You have unsaved changes. Leave the editor?",
|
||||
"maps.editor.preferredPathWarning": "This map has cached paths. Preferred zone changes may not apply until you delete the related paths under Setup → Paths. Save anyway?",
|
||||
"maps.editor.menu": "Menu",
|
||||
"maps.editor.undo": "Undo",
|
||||
"maps.editor.save": "Save",
|
||||
@@ -1503,6 +1527,26 @@
|
||||
"ioModules.testConnection": "Test connection",
|
||||
"ioModules.testOk": "TCP connection successful.",
|
||||
|
||||
"paths.title": "Paths",
|
||||
"paths.subtitle": "View and delete cached routes between positions.",
|
||||
"paths.helpTitle": "Paths help",
|
||||
"paths.helpBody": "A path is a saved route between two positions on the same map. It is created automatically the first time the robot drives from position A to B and reused afterward. Delete a path to force recalculation (for example after adding preferred zones). Paths cannot be created manually.",
|
||||
"paths.clearFilters": "Clear filters",
|
||||
"paths.filterLabel": "Filter:",
|
||||
"paths.filterPlaceholder": "Filter by position or map...",
|
||||
"paths.itemsFound": "{n} item(s) found",
|
||||
"paths.pageOf": "Page {page} of {total}",
|
||||
"paths.colFrom": "From position",
|
||||
"paths.colTo": "To position",
|
||||
"paths.colMap": "Map",
|
||||
"paths.colFunctions": "Functions",
|
||||
"paths.empty": "No paths yet. Paths are created automatically when the robot drives between two positions.",
|
||||
"paths.emptyFilter": "No paths match the filter.",
|
||||
"paths.view": "View on map",
|
||||
"paths.viewUnavailable": "Could not open the map editor.",
|
||||
"paths.deleteTitle": "Delete path?",
|
||||
"paths.deleteConfirmText": "Delete path from \"{from}\" to \"{to}\"? It will be recreated when the robot drives between these positions again.",
|
||||
|
||||
"missions.title": "Missions",
|
||||
"missions.subtitle": "Setup → Missions — robot task list.",
|
||||
"missions.create": "Create mission",
|
||||
|
||||
@@ -1272,6 +1272,68 @@
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<div class="page" id="pagePaths" data-page-content="paths" hidden>
|
||||
<div id="pathsListView" class="mapsMirPage">
|
||||
<header class="mapsMirHeader">
|
||||
<div class="mapsMirHeaderText">
|
||||
<h1 class="mapsMirTitle" data-i18n="paths.title">Paths</h1>
|
||||
<p class="mapsMirSubtitle">
|
||||
<span data-i18n="paths.subtitle">View and delete cached routes between positions.</span>
|
||||
<button type="button" class="mapsMirHelpBtn" id="pathsHelpBtn" data-i18n-title="paths.helpTitle" aria-label="Help">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<div class="mapsMirHeaderActions">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="pathsClearFiltersBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
<span data-i18n="paths.clearFilters">Clear filters</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mapsMirFilterBar">
|
||||
<label class="mapsMirFilterLabel" for="pathsFilterInput" data-i18n="paths.filterLabel">Filter:</label>
|
||||
<input type="search" id="pathsFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="paths.filterPlaceholder" placeholder="Filter by position or map..." autocomplete="off" />
|
||||
<span id="pathsFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
|
||||
<div class="mapsMirPager">
|
||||
<button type="button" class="mapsMirPageBtn" id="pathsPageFirst" aria-label="First page">«</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="pathsPagePrev" aria-label="Previous page">‹</button>
|
||||
<span id="pathsPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||
<button type="button" class="mapsMirPageBtn" id="pathsPageNext" aria-label="Next page">›</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="pathsPageLast" aria-label="Last page">»</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mapsMirTableWrap">
|
||||
<table class="mapsMirTable mapsMirTable--paths" id="pathsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="pathsMirThIcon" aria-hidden="true"></th>
|
||||
<th data-i18n="paths.colFrom">From position</th>
|
||||
<th data-i18n="paths.colTo">To position</th>
|
||||
<th data-i18n="paths.colMap">Map</th>
|
||||
<th class="mapsMirThFunctions" data-i18n="paths.colFunctions">Functions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pathList"></tbody>
|
||||
</table>
|
||||
<div id="pathListEmpty" class="mapsMirEmpty" hidden data-i18n="paths.empty">No paths yet. Paths are created automatically when the robot drives between two positions.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="pathDeleteConfirmDialog" class="mapsMirDialog">
|
||||
<div class="mapsMirDialogBody">
|
||||
<h2 class="mapsMirDialogTitle" data-i18n="paths.deleteTitle">Delete path?</h2>
|
||||
<p id="pathDeleteConfirmText" class="mapsMirDialogText"></p>
|
||||
<div class="mapsMirDialogFooter">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="pathDeleteCancelBtn" data-i18n="common.no">No</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="pathDeleteYesBtn" data-i18n="common.yes">Yes</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<div class="page" id="pageUsers" data-page-content="users" hidden>
|
||||
<div id="usersListView" class="mapsMirPage">
|
||||
<header class="mapsMirHeader">
|
||||
@@ -2557,6 +2619,7 @@ GET /api/v2.0.0/status</pre>
|
||||
<script src="/users.js"></script>
|
||||
<script src="/user-groups.js"></script>
|
||||
<script src="/io-modules.js"></script>
|
||||
<script src="/paths.js"></script>
|
||||
<script src="/map-editor.js"></script>
|
||||
<script src="/topbar.js"></script>
|
||||
<script src="/dashboard.js"></script>
|
||||
|
||||
@@ -61,6 +61,10 @@
|
||||
yamlMeta: null,
|
||||
/** Pending ROS metadata from upload dialog (set before PNG picker). */
|
||||
uploadMeta: null,
|
||||
/** Dotted path overlay when opened from Setup → Paths (view). */
|
||||
pathPreview: null,
|
||||
/** Fingerprint of preferred zones when map was loaded (for save warning). */
|
||||
initialPreferredFp: "",
|
||||
};
|
||||
|
||||
const titleEl = el("mapEditorTitle");
|
||||
@@ -500,6 +504,46 @@
|
||||
return mapMetaForOriginDisplay() || state.map || {};
|
||||
}
|
||||
|
||||
function preferredZonesFingerprint(zones) {
|
||||
return zones
|
||||
.filter((z) => z && z.type === "preferred")
|
||||
.map((z) =>
|
||||
JSON.stringify({
|
||||
id: z.id,
|
||||
points: z.points,
|
||||
geometry: z.geometry,
|
||||
line_width_cm: z.line_width_cm,
|
||||
}),
|
||||
)
|
||||
.sort()
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function renderPathPreview() {
|
||||
if (!objectsSvgEl || !state.pathPreview?.points?.length) return;
|
||||
const geo = Geo();
|
||||
const meta = mapMetaForEditor();
|
||||
const { width, height } = floorPlanSize();
|
||||
if (!geo || !width) return;
|
||||
const pts = state.pathPreview.points
|
||||
.map((p) => {
|
||||
const px = geo.worldToPixel(meta, width, height, p.x, p.y);
|
||||
return `${px.x},${px.y}`;
|
||||
})
|
||||
.join(" ");
|
||||
const poly = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
|
||||
poly.setAttribute("points", pts);
|
||||
poly.setAttribute("fill", "none");
|
||||
poly.setAttribute("stroke", "#2563eb");
|
||||
poly.setAttribute("stroke-width", "3");
|
||||
poly.setAttribute("stroke-dasharray", "8 6");
|
||||
poly.setAttribute("stroke-linecap", "round");
|
||||
poly.setAttribute("stroke-linejoin", "round");
|
||||
poly.classList.add("mapEditorPathPreview");
|
||||
poly.setAttribute("pointer-events", "none");
|
||||
objectsSvgEl.appendChild(poly);
|
||||
}
|
||||
|
||||
function renderObjects() {
|
||||
const obj = Objects();
|
||||
if (!objectsSvgEl || !obj) return;
|
||||
@@ -530,6 +574,7 @@
|
||||
draft: state.draft,
|
||||
selectionRect: state.selectionRect,
|
||||
});
|
||||
renderPathPreview();
|
||||
}
|
||||
|
||||
function imagePointFromEvent(evt) {
|
||||
@@ -1769,6 +1814,7 @@
|
||||
state.map = await api(`/api/maps/${encodeURIComponent(state.mapId)}`);
|
||||
await loadYamlMeta();
|
||||
syncZonesFromMap();
|
||||
state.initialPreferredFp = preferredZonesFingerprint(state.zones);
|
||||
updateHeader();
|
||||
renderMapImage();
|
||||
fillSettingsForm();
|
||||
@@ -1796,6 +1842,8 @@
|
||||
state.selectionRect = null;
|
||||
state.positionDrag = null;
|
||||
state.pendingPosition = null;
|
||||
state.pathPreview = callbacks.pathPreview || null;
|
||||
state.initialPreferredFp = "";
|
||||
updateObjectTypePickerUi();
|
||||
state.view = Geo()?.createView(1, 0, 0) || { scale: 1, panX: 0, panY: 0 };
|
||||
if (tipEl) {
|
||||
@@ -1829,6 +1877,8 @@
|
||||
state.selectionRect = null;
|
||||
state.positionDrag = null;
|
||||
state.pendingPosition = null;
|
||||
state.pathPreview = null;
|
||||
state.initialPreferredFp = "";
|
||||
drawLineDialogEl?.close();
|
||||
menuDialogEl?.close();
|
||||
settingsDialogEl?.close();
|
||||
@@ -2062,6 +2112,19 @@
|
||||
if (imageUpdated) state.map = { ...state.map, ...imageUpdated };
|
||||
state.rasterDirty = false;
|
||||
}
|
||||
const preferredChanged =
|
||||
preferredZonesFingerprint(state.zones) !== state.initialPreferredFp;
|
||||
if (preferredChanged && state.map?.id) {
|
||||
try {
|
||||
const pathsData = await api(
|
||||
`/api/paths?site_id=${encodeURIComponent(state.map.site_id || "")}`,
|
||||
);
|
||||
const onMap = (pathsData.paths || []).filter((p) => p.map_id === state.map.id);
|
||||
if (onMap.length && !confirm(t("maps.editor.preferredPathWarning"))) return;
|
||||
} catch {
|
||||
/* continue save */
|
||||
}
|
||||
}
|
||||
const updated = await api(`/api/maps/${encodeURIComponent(state.map.id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -2069,6 +2132,7 @@
|
||||
});
|
||||
state.map = updated;
|
||||
syncZonesFromMap();
|
||||
state.initialPreferredFp = preferredZonesFingerprint(state.zones);
|
||||
state.callbacks.onMapUpdated?.(updated);
|
||||
setDirty(false);
|
||||
updateHeader();
|
||||
|
||||
24
www/maps.js
24
www/maps.js
@@ -301,6 +301,7 @@
|
||||
}
|
||||
window.MapEditorApp?.open?.(mapId, {
|
||||
readOnly: opts.readOnly,
|
||||
pathPreview: opts.pathPreview || null,
|
||||
onMapUpdated: (updated) => {
|
||||
const idx = store.maps.findIndex((m) => m.id === updated.id);
|
||||
if (idx >= 0) store.maps[idx] = updated;
|
||||
@@ -326,6 +327,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
function openEditorWithPath(path) {
|
||||
if (!path?.map_id) return;
|
||||
openEditor(path.map_id, {
|
||||
readOnly: !canWrite(),
|
||||
pathPreview: {
|
||||
points: Array.isArray(path.points) ? path.points : [],
|
||||
fromPositionId: path.from_position_id,
|
||||
toPositionId: path.to_position_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDeleteMap(map) {
|
||||
return new Promise((resolve) => {
|
||||
deleteDialogResolve = resolve;
|
||||
@@ -554,8 +567,16 @@
|
||||
Array.isArray(result.maps) ? result.maps.length : 0,
|
||||
Array.isArray(result.io_modules) ? result.io_modules.length : 0,
|
||||
Array.isArray(result.transitions) ? result.transitions.length : 0,
|
||||
Array.isArray(result.paths) ? result.paths.length : 0,
|
||||
];
|
||||
alert(t("maps.importSuccess", { maps: counts[0], io: counts[1], transitions: counts[2] }));
|
||||
alert(
|
||||
t("maps.importSuccess", {
|
||||
maps: counts[0],
|
||||
io: counts[1],
|
||||
transitions: counts[2],
|
||||
paths: counts[3],
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
@@ -659,6 +680,7 @@
|
||||
getMaps: () => [...store.maps],
|
||||
getMapById: findMap,
|
||||
activateMap,
|
||||
openEditorWithPath,
|
||||
};
|
||||
|
||||
function boot() {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
{ section: "transitions", page: "transitions" },
|
||||
{ section: "user-groups", page: "user-groups" },
|
||||
{ section: "io-modules", page: "io-modules" },
|
||||
{ section: "paths", page: "paths" },
|
||||
{ section: "users", page: "users" },
|
||||
{ section: "build-robot", page: "config" },
|
||||
],
|
||||
@@ -43,6 +44,7 @@
|
||||
transitions: { module: "setup", section: "transitions" },
|
||||
"user-groups": { module: "setup", section: "user-groups" },
|
||||
"io-modules": { module: "setup", section: "io-modules" },
|
||||
paths: { module: "setup", section: "paths" },
|
||||
users: { module: "setup", section: "users" },
|
||||
integrations: { module: "system", section: "integrations" },
|
||||
monitoring: { module: "monitoring", section: "monitoring-log" },
|
||||
|
||||
257
www/paths.js
Normal file
257
www/paths.js
Normal file
@@ -0,0 +1,257 @@
|
||||
(() => {
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const ICONS = {
|
||||
path: `<svg class="pathsMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><circle cx="5" cy="11" r="2.5" fill="currentColor"/><circle cx="17" cy="11" r="2.5" fill="currentColor"/><path d="M7.5 11h7" stroke="currentColor" stroke-width="1.5" stroke-dasharray="2 2" stroke-linecap="round"/></svg>`,
|
||||
view: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M1 7s2.5-4 6-4 6 4 6 4-2.5 4-6 4-6-4-6-4z" fill="none" stroke="currentColor" stroke-width="1.2"/><circle cx="7" cy="7" r="1.8" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>`,
|
||||
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
|
||||
};
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
|
||||
const listEl = el("pathList");
|
||||
const emptyEl = el("pathListEmpty");
|
||||
const tableEl = el("pathsTable");
|
||||
const filterInputEl = el("pathsFilterInput");
|
||||
const filterCountEl = el("pathsFilterCount");
|
||||
const pageLabelEl = el("pathsPageLabel");
|
||||
const deleteConfirmDialogEl = el("pathDeleteConfirmDialog");
|
||||
const deleteConfirmTextEl = el("pathDeleteConfirmText");
|
||||
|
||||
const store = {
|
||||
paths: [],
|
||||
maps: [],
|
||||
sites: [],
|
||||
pendingDeleteId: null,
|
||||
filter: "",
|
||||
page: 1,
|
||||
};
|
||||
|
||||
function canWrite() {
|
||||
if (!window.AuthApp?.canWrite) return true;
|
||||
return window.AuthApp.canWrite("maps");
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function apiJson(url, opts = {}) {
|
||||
const res = await fetch(url, { credentials: "include", ...opts });
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
const [sitesData, mapsData, pathsData] = await Promise.all([
|
||||
apiJson("/api/sites"),
|
||||
apiJson("/api/maps"),
|
||||
apiJson("/api/paths"),
|
||||
]);
|
||||
store.sites = Array.isArray(sitesData.sites) ? sitesData.sites : [];
|
||||
store.maps = Array.isArray(mapsData.maps) ? mapsData.maps : [];
|
||||
store.paths = Array.isArray(pathsData.paths) ? pathsData.paths : [];
|
||||
return store.paths;
|
||||
}
|
||||
|
||||
function mapName(id) {
|
||||
return store.maps.find((m) => m.id === id)?.name || id || "—";
|
||||
}
|
||||
|
||||
function positionLabel(mapId, positionId) {
|
||||
const map = store.maps.find((m) => m.id === mapId);
|
||||
const zones = Array.isArray(map?.zones) ? map.zones : [];
|
||||
const hit = zones.find((z) => z && z.type === "position" && z.id === positionId);
|
||||
const pname = hit?.name || positionId || "—";
|
||||
return `${mapName(mapId)} / ${pname}`;
|
||||
}
|
||||
|
||||
function filteredPaths() {
|
||||
const q = store.filter.trim().toLowerCase();
|
||||
let items = [...store.paths];
|
||||
if (q) {
|
||||
items = items.filter((p) => {
|
||||
const from = positionLabel(p.map_id, p.from_position_id).toLowerCase();
|
||||
const to = positionLabel(p.map_id, p.to_position_id).toLowerCase();
|
||||
const map = mapName(p.map_id).toLowerCase();
|
||||
return from.includes(q) || to.includes(q) || map.includes(q);
|
||||
});
|
||||
}
|
||||
return items.sort((a, b) => {
|
||||
const ma = mapName(a.map_id).localeCompare(mapName(b.map_id));
|
||||
if (ma !== 0) return ma;
|
||||
return positionLabel(a.map_id, a.from_position_id).localeCompare(
|
||||
positionLabel(b.map_id, b.from_position_id),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function pageCount(total) {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
if (!listEl) return;
|
||||
const items = filteredPaths();
|
||||
const total = items.length;
|
||||
const pages = pageCount(total);
|
||||
if (store.page > pages) store.page = pages;
|
||||
const start = (store.page - 1) * PAGE_SIZE;
|
||||
const pageItems = items.slice(start, start + PAGE_SIZE);
|
||||
|
||||
if (filterCountEl) filterCountEl.textContent = t("paths.itemsFound", { n: total });
|
||||
if (pageLabelEl) pageLabelEl.textContent = t("paths.pageOf", { page: store.page, total: pages });
|
||||
|
||||
listEl.innerHTML = "";
|
||||
if (tableEl) tableEl.hidden = total === 0;
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = total > 0;
|
||||
emptyEl.textContent = store.filter ? t("paths.emptyFilter") : t("paths.empty");
|
||||
}
|
||||
|
||||
pageItems.forEach((path) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pathsMirRow";
|
||||
tr.innerHTML = `
|
||||
<td class="pathsMirTdIcon">${ICONS.path}</td>
|
||||
<td>${escapeHtml(positionLabel(path.map_id, path.from_position_id))}</td>
|
||||
<td>${escapeHtml(positionLabel(path.map_id, path.to_position_id))}</td>
|
||||
<td>${escapeHtml(mapName(path.map_id))}</td>
|
||||
<td class="mapsMirTdFunctions">
|
||||
<button type="button" class="mapsMirIconBtn" data-view="${escapeHtml(path.id)}" title="${escapeHtml(t("paths.view"))}">${ICONS.view}</button>
|
||||
${canWrite() ? `<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${escapeHtml(path.id)}" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>` : ""}
|
||||
</td>`;
|
||||
listEl.appendChild(tr);
|
||||
});
|
||||
|
||||
document.body.classList.toggle("auth-readonly-paths", !canWrite());
|
||||
}
|
||||
|
||||
async function viewPath(id) {
|
||||
const path = store.paths.find((p) => p.id === id);
|
||||
if (!path) return;
|
||||
let full = path;
|
||||
try {
|
||||
full = await apiJson(`/api/paths/${encodeURIComponent(id)}`);
|
||||
} catch {
|
||||
/* use list row */
|
||||
}
|
||||
if (window.MapsApp?.openEditorWithPath) {
|
||||
window.NavApp?.selectSection?.("maps", "maps");
|
||||
window.MapsApp.openEditorWithPath(full);
|
||||
} else {
|
||||
alert(t("paths.viewUnavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteConfirm(id) {
|
||||
const path = store.paths.find((p) => p.id === id);
|
||||
if (!path) return;
|
||||
store.pendingDeleteId = id;
|
||||
if (deleteConfirmTextEl) {
|
||||
deleteConfirmTextEl.textContent = t("paths.deleteConfirmText", {
|
||||
from: positionLabel(path.map_id, path.from_position_id),
|
||||
to: positionLabel(path.map_id, path.to_position_id),
|
||||
});
|
||||
}
|
||||
deleteConfirmDialogEl?.showModal();
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const id = store.pendingDeleteId;
|
||||
if (!id) return;
|
||||
try {
|
||||
await apiJson(`/api/paths/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
store.pendingDeleteId = null;
|
||||
deleteConfirmDialogEl?.close();
|
||||
await refreshAll();
|
||||
renderList();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
listEl?.addEventListener("click", (evt) => {
|
||||
const viewBtn = evt.target.closest("[data-view]");
|
||||
const deleteBtn = evt.target.closest("[data-delete]");
|
||||
if (viewBtn?.dataset.view) void viewPath(viewBtn.dataset.view);
|
||||
else if (deleteBtn?.dataset.delete && canWrite()) openDeleteConfirm(deleteBtn.dataset.delete);
|
||||
});
|
||||
|
||||
filterInputEl?.addEventListener("input", () => {
|
||||
store.filter = filterInputEl.value;
|
||||
store.page = 1;
|
||||
renderList();
|
||||
});
|
||||
el("pathsClearFiltersBtn")?.addEventListener("click", () => {
|
||||
store.filter = "";
|
||||
store.page = 1;
|
||||
if (filterInputEl) filterInputEl.value = "";
|
||||
renderList();
|
||||
});
|
||||
el("pathsPageFirst")?.addEventListener("click", () => {
|
||||
store.page = 1;
|
||||
renderList();
|
||||
});
|
||||
el("pathsPagePrev")?.addEventListener("click", () => {
|
||||
store.page = Math.max(1, store.page - 1);
|
||||
renderList();
|
||||
});
|
||||
el("pathsPageNext")?.addEventListener("click", () => {
|
||||
store.page += 1;
|
||||
renderList();
|
||||
});
|
||||
el("pathsPageLast")?.addEventListener("click", () => {
|
||||
store.page = pageCount(filteredPaths().length);
|
||||
renderList();
|
||||
});
|
||||
el("pathsHelpBtn")?.addEventListener("click", () => alert(t("paths.helpBody")));
|
||||
|
||||
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
|
||||
evt.preventDefault();
|
||||
store.pendingDeleteId = null;
|
||||
deleteConfirmDialogEl?.close();
|
||||
});
|
||||
el("pathDeleteCancelBtn")?.addEventListener("click", () => {
|
||||
store.pendingDeleteId = null;
|
||||
deleteConfirmDialogEl?.close();
|
||||
});
|
||||
el("pathDeleteYesBtn")?.addEventListener("click", () => confirmDelete().catch((e) => alert(e.message)));
|
||||
|
||||
window.addEventListener("lm:locale-change", () => renderList());
|
||||
}
|
||||
|
||||
async function onPageShow() {
|
||||
if (!window.AuthApp?.canAccessPage?.("paths")) return;
|
||||
await refreshAll();
|
||||
renderList();
|
||||
}
|
||||
|
||||
function onPageHide() {}
|
||||
|
||||
function init() {
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
window.PathsApp = { init, onPageShow, onPageHide, refresh: refreshAll };
|
||||
|
||||
function boot() {
|
||||
init();
|
||||
}
|
||||
|
||||
if (window.AuthApp?.isReady()) boot();
|
||||
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||
})();
|
||||
@@ -3935,6 +3935,11 @@ body.auth-readonly-io-modules .ioModulesMirRow .ioModuleDeleteBtn { pointer-even
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mapsMirTable--paths thead th.pathsMirThIcon { width: 52px; }
|
||||
.pathsMirIcon { color: var(--mir-accent, #0d9488); }
|
||||
body.auth-readonly-paths .pathsMirRow .mapsMirIconBtn--danger { pointer-events: none; opacity: 0.55; }
|
||||
.mapEditorPathPreview { filter: drop-shadow(0 0 2px rgba(37, 99, 235, 0.45)); }
|
||||
|
||||
.transMirIcon {
|
||||
color: var(--mir-green, #5cb85c);
|
||||
display: block;
|
||||
|
||||
Reference in New Issue
Block a user