From c441fed1b33ffb0e601d4a85f78700a5e74af635 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 4 Jun 2026 09:20:26 +0800 Subject: [PATCH] =?UTF-8?q?=E8=8A=82=E7=82=B9=E8=AF=A6=E6=83=85=E9=A1=B5?= =?UTF-8?q?=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- db_test.go | 29 ++ meshmap_frontend/src/App.vue | 22 +- meshmap_frontend/src/api.ts | 33 +- meshmap_frontend/src/components/ChatPanel.vue | 10 +- meshmap_frontend/src/components/MeshMap.vue | 44 +++ .../src/components/NodeDetailedPage.vue | 357 ++++++++++++++++++ .../src/components/NodeListPanel.vue | 10 +- .../src/components/NodeTrajectoryMap.vue | 75 ++++ meshmap_frontend/src/style.css | 106 +++++- meshmap_frontend/src/types.ts | 10 + 11 files changed, 679 insertions(+), 20 deletions(-) create mode 100644 meshmap_frontend/src/components/NodeDetailedPage.vue create mode 100644 meshmap_frontend/src/components/NodeTrajectoryMap.vue diff --git a/README.md b/README.md index fafc85f..5567919 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ go run . 构建后的文件位于项目根目录 `dist/`,Gin 会提供静态文件服务;`/api` 路径保留给后端接口。 -管理页面位于 `/admin`,默认管理员账号为 `admin` / `admin`。生产环境请修改 `web.admin.password` 或设置 `MESH_ADMIN_PASSWORD`,并配置固定的 `web.admin.session_secret` 或 `MESH_ADMIN_SESSION_SECRET`;如果 `session_secret` 为空,程序会在启动时生成临时签名密钥,重启后需要重新登录。后台页面包括 `/admin` 服务状态、`/admin/users` 用户管理、`/admin/log/login` 登录日志。后台支持新增管理员用户和修改用户密码;密码使用 bcrypt hash 保存,API 不会返回密码 hash。修改密码不会立即使已签发 Session 失效,当前 Session 到期或退出登录后才需要使用新密码。登录成功和失败都会记录到登录日志,包含用户名、结果、原因、来源地址、User-Agent 和时间。 +管理页面位于 `/admin`,默认管理员账号为 `admin` / `admin`。生产环境请修改 `web.admin.password` 或设置 `MESH_ADMIN_PASSWORD`,并配置固定的 `web.admin.session_secret` 或 `MESH_ADMIN_SESSION_SECRET`;如果 `session_secret` 为空,程序会在启动时生成临时签名密钥,重启后需要重新登录。后台页面包括 `/admin` 服务状态、`/admin/users` 用户管理、`/admin/log/login` 登录日志。后台支持新增管理员用户和修改用户密码;密码使用 bcrypt hash 保存,API 不会返回密码 hash。修改密码不会立即使已签发 Session 失效,当前 Session 到期或退出登录后才需要使用新密码。登录成功和失败都会记录到登录日志,包含用户名、结果、原因、来源地址、User-Agent 和时间。管理员可在主页右键删除聊天消息、地图节点或节点列表记录;删除节点会删除 `nodeinfo` 和 `map_report` 当前状态,不会删除历史消息、位置、遥测等 append 记录,后续收到新的节点上报时可能重新出现。 常用 API: @@ -132,6 +132,7 @@ GET /api/admin/users POST /api/admin/users PUT /api/admin/users/:id/password DELETE /api/admin/text-messages/:id +DELETE /api/admin/nodes/:id GET /api/nodeinfo GET /api/nodeinfo/:id GET /api/map-reports diff --git a/db_test.go b/db_test.go index 3e25fd3..773c329 100644 --- a/db_test.go +++ b/db_test.go @@ -134,6 +134,35 @@ func TestNodeInfoAndMapReportAreStoredSeparately(t *testing.T) { } } +func TestDeleteNodeDeletesNodeInfoAndMapReport(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + if err := st.UpsertNodeInfo(nodeInfoTestRecord("node name")); err != nil { + t.Fatalf("UpsertNodeInfo() error = %v", err) + } + if err := st.UpsertMapReport(mapReportTestRecord("map name")); err != nil { + t.Fatalf("UpsertMapReport() error = %v", err) + } + if err := st.DeleteNode("!12345678"); err != nil { + t.Fatalf("DeleteNode() error = %v", err) + } + + var nodeCount, reportCount int + if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM nodeinfo WHERE node_id = ?", "!12345678").Scan(&nodeCount); err != nil { + t.Fatal(err) + } + if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM map_report WHERE node_id = ?", "!12345678").Scan(&reportCount); err != nil { + t.Fatal(err) + } + if nodeCount != 0 || reportCount != 0 { + t.Fatalf("nodeinfo/map_report counts = %d/%d, want 0/0", nodeCount, reportCount) + } + if err := st.DeleteNode("!12345678"); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("DeleteNode(missing) error = %v, want record not found", err) + } +} + func TestUpsertNodeInfoRequiresNodeFields(t *testing.T) { st := openTestStore(t) defer st.Close() diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue index e429bde..70874f9 100644 --- a/meshmap_frontend/src/App.vue +++ b/meshmap_frontend/src/App.vue @@ -7,11 +7,16 @@ import AdminLoginLogs from './components/AdminLoginLogs.vue' import AdminUsers from './components/AdminUsers.vue' import ChatPanel from './components/ChatPanel.vue' import MeshMap from './components/MeshMap.vue' +import NodeDetailedPage from './components/NodeDetailedPage.vue' import NodeListPanel from './components/NodeListPanel.vue' import type { AdminUser, HealthStatus, MapNode, MapReport, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types' -const adminPath = window.location.pathname +const currentPath = window.location.pathname +const adminPath = currentPath const isAdminPage = adminPath.startsWith('/admin') +const detailMatch = currentPath.match(/^\/detailed\/(.+)$/) +const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : '' +const isDetailedPage = !!detailedNodeId const adminUser = ref(null) const adminChecking = ref(false) @@ -212,6 +217,9 @@ onMounted(() => { return } checkAdminSession() + if (isDetailedPage) { + return + } refresh() refreshTimer = window.setInterval(() => refresh(false), 5000) }) @@ -228,7 +236,8 @@ onBeforeUnmount(() => {

Meshtastic MQTT Server

-

{{ isAdminPage ? 'Admin' : 'MeshMap' }}

+

节点详情

+

{{ isAdminPage ? 'Admin' : 'MeshMap' }}

+ + + diff --git a/meshmap_frontend/src/components/MeshMap.vue b/meshmap_frontend/src/components/MeshMap.vue index 5aebcfb..280412f 100644 --- a/meshmap_frontend/src/components/MeshMap.vue +++ b/meshmap_frontend/src/components/MeshMap.vue @@ -25,6 +25,8 @@ let markerLayer: L.LayerGroup | null = null let hasFitBounds = false onMounted(async () => { + window.addEventListener('click', closeNodeMenu) + window.addEventListener('keydown', handleKeydown) await nextTick() if (!mapEl.value) { return @@ -51,6 +53,8 @@ onMounted(async () => { }) onBeforeUnmount(() => { + window.removeEventListener('click', closeNodeMenu) + window.removeEventListener('keydown', handleKeydown) map?.remove() map = null markerLayer = null @@ -62,6 +66,35 @@ watch( { deep: true }, ) +function closeNodeMenu() { + menuNodeId.value = null +} + +function nodeDetailHref(nodeId: string): string { + return `/detailed/${encodeURIComponent(nodeId)}` +} + +function openNodeMenu(node: MapNode, event: L.LeafletMouseEvent) { + L.DomEvent.stopPropagation(event) + emit('select-node', node.node_id) + menuNodeId.value = node.node_id + menuX.value = event.originalEvent.clientX + menuY.value = event.originalEvent.clientY +} + +function deleteSelectedNode() { + if (menuNodeId.value) { + emit('delete-node', menuNodeId.value) + } + closeNodeMenu() +} + +function handleKeydown(event: KeyboardEvent) { + if (event.key === 'Escape') { + closeNodeMenu() + } +} + function renderMarkers(forceFit: boolean) { if (!map || !markerLayer) { return @@ -83,8 +116,10 @@ function renderMarkers(forceFit: boolean) { marker.bindPopup(buildNodePopupHTML(node), { maxWidth: 320, className: 'node-detail-popup' }) marker.on('click', (event) => { L.DomEvent.stopPropagation(event) + closeNodeMenu() emit('select-node', node.node_id) }) + marker.on('contextmenu', (event) => openNodeMenu(node, event)) marker.addTo(markerLayer) if (selected) { marker.openPopup() @@ -159,5 +194,14 @@ function escapeHTML(value: string): string {
暂无可显示坐标的节点
+
+ 节点详细 + +
diff --git a/meshmap_frontend/src/components/NodeDetailedPage.vue b/meshmap_frontend/src/components/NodeDetailedPage.vue new file mode 100644 index 0000000..8cb6ddf --- /dev/null +++ b/meshmap_frontend/src/components/NodeDetailedPage.vue @@ -0,0 +1,357 @@ + + + diff --git a/meshmap_frontend/src/components/NodeListPanel.vue b/meshmap_frontend/src/components/NodeListPanel.vue index 0f990b2..bf57cbc 100644 --- a/meshmap_frontend/src/components/NodeListPanel.vue +++ b/meshmap_frontend/src/components/NodeListPanel.vue @@ -33,10 +33,11 @@ function closeNodeMenu() { menuNode.value = null } +function nodeDetailHref(nodeId: string): string { + return `/detailed/${encodeURIComponent(nodeId)}` +} + function openNodeMenu(node: NodeInfo, event: MouseEvent) { - if (!props.isAdmin) { - return - } emit('select-node', node.node_id) menuNode.value = node menuX.value = event.clientX @@ -118,7 +119,8 @@ onBeforeUnmount(() => { :style="{ left: `${menuX}px`, top: `${menuY}px` }" @click.stop > - + 节点详细 +