thêm phần paths
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-23 17:51:54 +07:00
parent 50a2587cef
commit 2ce8a23ce9
25 changed files with 1411 additions and 31 deletions

View File

@@ -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();

View File

@@ -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() {

View File

@@ -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",

View File

@@ -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">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="pathsPagePrev" aria-label="Previous page">&lsaquo;</button>
<span id="pathsPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="pathsPageNext" aria-label="Next page">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="pathsPageLast" aria-label="Last page">&raquo;</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>

View File

@@ -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();

View File

@@ -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() {

View File

@@ -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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 });
})();

View File

@@ -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;