更新地图
This commit is contained in:
@@ -1,5 +1,46 @@
|
|||||||
# Vue 3 + TypeScript + Vite
|
# MeshMap Frontend
|
||||||
|
|
||||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
Vue 3 + TypeScript + Vite frontend for the Meshtastic MQTT server.
|
||||||
|
|
||||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
## Features
|
||||||
|
|
||||||
|
- Left panel: recent chat messages
|
||||||
|
- Right panel: Leaflet/OpenStreetMap node map
|
||||||
|
- Bottom panel: selected node details, recent messages, and recent positions
|
||||||
|
|
||||||
|
The app uses relative `/api` URLs. In development, Vite proxies `/api` to the Go backend.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Start the Go backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . --web-host 127.0.0.1 --web-port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the frontend dev server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd meshmap_frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd meshmap_frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The build output is written to the repository root `dist/` directory, which is served by the Gin backend.
|
||||||
|
|
||||||
|
## Map tiles
|
||||||
|
|
||||||
|
The map uses Leaflet with OpenStreetMap tiles:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Network access to the tile server is required unless this is changed to a local tile source later.
|
||||||
|
|||||||
Generated
+25
-25
@@ -8,9 +8,11 @@
|
|||||||
"name": "meshmap_frontend",
|
"name": "meshmap_frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"vue": "^3.5.34"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
@@ -65,31 +67,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
@@ -411,6 +388,23 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.12.4",
|
"version": "24.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
|
||||||
@@ -677,6 +671,12 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
|
|||||||
@@ -9,9 +9,11 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"vue": "^3.5.34"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
|||||||
@@ -1,29 +1,81 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { getHealth, getNodes, getPositions, getTextMessages } from './api'
|
import { getHealth, getNodes, getPositions, getTextMessages } from './api'
|
||||||
import type { HealthStatus, NodeInfoMap, PositionRecord, TextMessage } from './types'
|
import ChatPanel from './components/ChatPanel.vue'
|
||||||
|
import MeshMap from './components/MeshMap.vue'
|
||||||
|
import NodeListPanel from './components/NodeListPanel.vue'
|
||||||
|
import type { HealthStatus, MapNode, NodeInfoById, NodeInfoMap, PositionRecord, TextMessage } from './types'
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const nodePageLoading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const selectedNodeId = ref<string | null>(null)
|
||||||
const health = ref<HealthStatus | null>(null)
|
const health = ref<HealthStatus | null>(null)
|
||||||
const nodes = ref<NodeInfoMap[]>([])
|
const mapNodeSource = ref<NodeInfoMap[]>([])
|
||||||
|
const pagedNodes = ref<NodeInfoMap[]>([])
|
||||||
|
const nodePage = ref(1)
|
||||||
|
const nodePageSize = 25
|
||||||
|
const nodeTotal = ref(0)
|
||||||
const messages = ref<TextMessage[]>([])
|
const messages = ref<TextMessage[]>([])
|
||||||
const positions = ref<PositionRecord[]>([])
|
const positions = ref<PositionRecord[]>([])
|
||||||
|
|
||||||
|
const nodesById = computed<NodeInfoById>(() => {
|
||||||
|
const map = new Map<string, NodeInfoMap>()
|
||||||
|
for (const node of mapNodeSource.value) {
|
||||||
|
map.set(node.node_id, node)
|
||||||
|
}
|
||||||
|
for (const node of pagedNodes.value) {
|
||||||
|
map.set(node.node_id, node)
|
||||||
|
}
|
||||||
|
return Object.fromEntries(map)
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapNodes = computed<MapNode[]>(() => {
|
||||||
|
return mapNodeSource.value
|
||||||
|
.filter((node) => node.latitude != null && node.longitude != null)
|
||||||
|
.map((node) => ({
|
||||||
|
node_id: node.node_id,
|
||||||
|
label: node.short_name || node.node_id,
|
||||||
|
latitude: node.latitude as number,
|
||||||
|
longitude: node.longitude as number,
|
||||||
|
altitude: node.altitude,
|
||||||
|
source: 'node',
|
||||||
|
updated_at: node.updated_at,
|
||||||
|
node,
|
||||||
|
latest_position: null,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadNodePage(page: number) {
|
||||||
|
nodePageLoading.value = true
|
||||||
|
try {
|
||||||
|
const safePage = Math.max(1, page)
|
||||||
|
const response = await getNodes(nodePageSize, (safePage - 1) * nodePageSize)
|
||||||
|
pagedNodes.value = response.items
|
||||||
|
nodeTotal.value = response.total ?? response.offset + response.items.length
|
||||||
|
nodePage.value = safePage
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
nodePageLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const [healthData, nodeData, messageData, positionData] = await Promise.all([
|
const [healthData, mapNodeData, messageData, positionData] = await Promise.all([
|
||||||
getHealth(),
|
getHealth(),
|
||||||
getNodes(),
|
getNodes(500, 0),
|
||||||
getTextMessages(),
|
getTextMessages(100),
|
||||||
getPositions(),
|
getPositions(500),
|
||||||
])
|
])
|
||||||
health.value = healthData
|
health.value = healthData
|
||||||
nodes.value = nodeData.items
|
mapNodeSource.value = mapNodeData.items
|
||||||
messages.value = messageData.items
|
messages.value = messageData.items
|
||||||
positions.value = positionData.items
|
positions.value = positionData.items
|
||||||
|
await loadNodePage(nodePage.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : String(err)
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -35,71 +87,47 @@ onMounted(refresh)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="page">
|
<main class="app-shell">
|
||||||
<header class="header">
|
<header class="topbar">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Meshtastic MQTT Server</p>
|
<p class="eyebrow">Meshtastic MQTT Server</p>
|
||||||
<h1>MeshMap Dashboard</h1>
|
<h1>MeshMap</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<span class="status-pill" :class="{ ok: health?.status === 'ok' }">
|
||||||
|
{{ health?.status ?? 'unknown' }} / db {{ health?.database ?? 'unknown' }}
|
||||||
|
</span>
|
||||||
|
<span class="counter">节点 {{ nodeTotal }} · 消息 {{ messages.length }} · 坐标 {{ mapNodes.length }}</span>
|
||||||
<button @click="refresh" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
<button @click="refresh" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="status" :class="{ ok: health?.status === 'ok' }">
|
|
||||||
<strong>服务状态</strong>
|
|
||||||
<span>{{ health?.status ?? 'unknown' }}</span>
|
|
||||||
<span>database: {{ health?.database ?? 'unknown' }}</span>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
|
||||||
<section class="grid">
|
<section class="workspace">
|
||||||
<article class="card wide">
|
<ChatPanel
|
||||||
<h2>节点</h2>
|
:messages="messages"
|
||||||
<table>
|
:nodes-by-id="nodesById"
|
||||||
<thead>
|
:selected-node-id="selectedNodeId"
|
||||||
<tr>
|
@select-node="selectedNodeId = $event"
|
||||||
<th>Node</th>
|
/>
|
||||||
<th>Name</th>
|
<MeshMap
|
||||||
<th>Role</th>
|
:nodes="mapNodes"
|
||||||
<th>HW</th>
|
:selected-node-id="selectedNodeId"
|
||||||
<th>Lat</th>
|
@select-node="selectedNodeId = $event"
|
||||||
<th>Lon</th>
|
@clear-node="selectedNodeId = null"
|
||||||
</tr>
|
/>
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="node in nodes" :key="node.node_id">
|
|
||||||
<td>{{ node.node_id }}</td>
|
|
||||||
<td>{{ node.long_name || node.short_name || '-' }}</td>
|
|
||||||
<td>{{ node.role || '-' }}</td>
|
|
||||||
<td>{{ node.hw_model || '-' }}</td>
|
|
||||||
<td>{{ node.latitude ?? '-' }}</td>
|
|
||||||
<td>{{ node.longitude ?? '-' }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="card">
|
|
||||||
<h2>最近聊天</h2>
|
|
||||||
<ul class="list">
|
|
||||||
<li v-for="msg in messages" :key="msg.id">
|
|
||||||
<strong>{{ msg.from_id }}</strong>
|
|
||||||
<span>{{ msg.text || '[binary]' }}</span>
|
|
||||||
<small>{{ msg.mqtt_remote_host || '-' }}</small>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="card">
|
|
||||||
<h2>最近位置</h2>
|
|
||||||
<ul class="list">
|
|
||||||
<li v-for="pos in positions" :key="pos.id">
|
|
||||||
<strong>{{ pos.from_id }}</strong>
|
|
||||||
<span>{{ pos.latitude ?? '-' }}, {{ pos.longitude ?? '-' }}</span>
|
|
||||||
<small>alt {{ pos.altitude ?? '-' }}</small>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<NodeListPanel
|
||||||
|
:nodes="pagedNodes"
|
||||||
|
:selected-node-id="selectedNodeId"
|
||||||
|
:page="nodePage"
|
||||||
|
:page-size="nodePageSize"
|
||||||
|
:total="nodeTotal"
|
||||||
|
:loading="nodePageLoading || loading"
|
||||||
|
@select-node="selectedNodeId = $event"
|
||||||
|
@page-change="loadNodePage"
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ export function getHealth(): Promise<HealthStatus> {
|
|||||||
return getJSON<HealthStatus>('/api/health')
|
return getJSON<HealthStatus>('/api/health')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNodes(): Promise<ListResponse<NodeInfoMap>> {
|
export function getNodes(limit = 500, offset = 0): Promise<ListResponse<NodeInfoMap>> {
|
||||||
return getJSON<ListResponse<NodeInfoMap>>('/api/nodes?limit=100')
|
return getJSON<ListResponse<NodeInfoMap>>(`/api/nodes?limit=${limit}&offset=${offset}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTextMessages(): Promise<ListResponse<TextMessage>> {
|
export function getTextMessages(limit = 100): Promise<ListResponse<TextMessage>> {
|
||||||
return getJSON<ListResponse<TextMessage>>('/api/text-messages?limit=20')
|
return getJSON<ListResponse<TextMessage>>(`/api/text-messages?limit=${limit}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPositions(): Promise<ListResponse<PositionRecord>> {
|
export function getPositions(limit = 500): Promise<ListResponse<PositionRecord>> {
|
||||||
return getJSON<ListResponse<PositionRecord>>('/api/positions?limit=200')
|
return getJSON<ListResponse<PositionRecord>>(`/api/positions?limit=${limit}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { NodeInfoById, TextMessage } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
messages: TextMessage[]
|
||||||
|
nodesById: NodeInfoById
|
||||||
|
selectedNodeId: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-node': [nodeId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function senderName(message: TextMessage): string {
|
||||||
|
const node = props.nodesById[message.from_id]
|
||||||
|
return node?.long_name || node?.short_name || message.from_id
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string): string {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="chat-panel panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Chat</p>
|
||||||
|
<h2>聊天信息</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge">{{ messages.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="messages.length === 0" class="empty">暂无聊天消息</div>
|
||||||
|
<button
|
||||||
|
v-for="message in messages"
|
||||||
|
:key="message.id"
|
||||||
|
class="chat-item"
|
||||||
|
:class="{ selected: selectedNodeId === message.from_id }"
|
||||||
|
type="button"
|
||||||
|
@click="emit('select-node', message.from_id)"
|
||||||
|
>
|
||||||
|
<span class="chat-meta">
|
||||||
|
<strong>{{ senderName(message) }}</strong>
|
||||||
|
<small>{{ formatTime(message.created_at) }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="chat-text">{{ message.text || '[binary]' }}</span>
|
||||||
|
<span class="chat-host">{{ message.mqtt_remote_host || message.topic }}</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
import type { MapNode } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
nodes: MapNode[]
|
||||||
|
selectedNodeId: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-node': [nodeId: string]
|
||||||
|
'clear-node': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mapEl = ref<HTMLElement | null>(null)
|
||||||
|
let map: L.Map | null = null
|
||||||
|
let markerLayer: L.LayerGroup | null = null
|
||||||
|
let hasFitBounds = false
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
if (!mapEl.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
map = L.map(mapEl.value, {
|
||||||
|
zoomControl: true,
|
||||||
|
maxBounds: [
|
||||||
|
[-85, -180],
|
||||||
|
[85, 180],
|
||||||
|
],
|
||||||
|
maxBoundsViscosity: 1.0,
|
||||||
|
worldCopyJump: false,
|
||||||
|
}).setView([0, 0], 2)
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
}).addTo(map)
|
||||||
|
map.on('click', () => emit('clear-node'))
|
||||||
|
markerLayer = L.layerGroup().addTo(map)
|
||||||
|
renderMarkers(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
map?.remove()
|
||||||
|
map = null
|
||||||
|
markerLayer = null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.nodes, props.selectedNodeId] as const,
|
||||||
|
() => renderMarkers(false),
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function renderMarkers(forceFit: boolean) {
|
||||||
|
if (!map || !markerLayer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
markerLayer.clearLayers()
|
||||||
|
const bounds = L.latLngBounds([])
|
||||||
|
|
||||||
|
for (const node of props.nodes) {
|
||||||
|
const selected = node.node_id === props.selectedNodeId
|
||||||
|
const marker = L.marker([node.latitude, node.longitude], {
|
||||||
|
icon: L.divIcon({
|
||||||
|
className: `node-marker${selected ? ' selected' : ''}`,
|
||||||
|
html: `<span style="--node-color: ${nodeColor(node.node_id)}">${escapeHTML(node.label || 'N')}</span>`,
|
||||||
|
iconSize: [34, 22],
|
||||||
|
iconAnchor: [17, 11],
|
||||||
|
}),
|
||||||
|
title: node.label,
|
||||||
|
})
|
||||||
|
marker.bindPopup(buildNodePopupHTML(node), { maxWidth: 320, className: 'node-detail-popup' })
|
||||||
|
marker.on('click', (event) => {
|
||||||
|
L.DomEvent.stopPropagation(event)
|
||||||
|
emit('select-node', node.node_id)
|
||||||
|
})
|
||||||
|
marker.addTo(markerLayer)
|
||||||
|
if (selected) {
|
||||||
|
marker.openPopup()
|
||||||
|
}
|
||||||
|
bounds.extend([node.latitude, node.longitude])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.nodes.length > 0 && (forceFit || !hasFitBounds)) {
|
||||||
|
map.fitBounds(bounds, { padding: [24, 24], maxZoom: 13 })
|
||||||
|
hasFitBounds = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNodePopupHTML(node: MapNode): string {
|
||||||
|
const info = node.node
|
||||||
|
return `
|
||||||
|
<div class="node-popup">
|
||||||
|
<strong>${escapeHTML(node.node_id)}</strong>
|
||||||
|
<dl>
|
||||||
|
<div><dt>长名称</dt><dd>${escapeHTML(info?.long_name || '-')}</dd></div>
|
||||||
|
<div><dt>短名称</dt><dd>${escapeHTML(info?.short_name || '-')}</dd></div>
|
||||||
|
<div><dt>硬件型号</dt><dd>${escapeHTML(info?.hw_model || '-')}</dd></div>
|
||||||
|
<div><dt>角色</dt><dd>${escapeHTML(info?.role || '-')}</dd></div>
|
||||||
|
<div><dt>固件版本</dt><dd>${escapeHTML(info?.firmware_version || '-')}</dd></div>
|
||||||
|
<div><dt>海拔</dt><dd>${node.altitude ?? '-'}</dd></div>
|
||||||
|
<div><dt>经度</dt><dd>${node.longitude.toFixed(5)}</dd></div>
|
||||||
|
<div><dt>纬度</dt><dd>${node.latitude.toFixed(5)}</dd></div>
|
||||||
|
<div><dt>位置精度</dt><dd>${info?.position_precision ?? '-'}</dd></div>
|
||||||
|
<div><dt>在线节点</dt><dd>${info?.num_online_local_nodes ?? '-'}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeColor(nodeId: string): string {
|
||||||
|
let hash = 0
|
||||||
|
for (let index = 0; index < nodeId.length; index += 1) {
|
||||||
|
hash = (hash * 31 + nodeId.charCodeAt(index)) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const hueRanges = [
|
||||||
|
[35, 75],
|
||||||
|
[95, 165],
|
||||||
|
[185, 250],
|
||||||
|
[265, 315],
|
||||||
|
]
|
||||||
|
const range = hueRanges[hash % hueRanges.length]
|
||||||
|
const hue = range[0] + (hash % (range[1] - range[0]))
|
||||||
|
const saturation = 68 + (hash % 18)
|
||||||
|
const lightness = 32 + (hash % 10)
|
||||||
|
return `hsl(${hue} ${saturation}% ${lightness}%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHTML(value: string): string {
|
||||||
|
return value.replace(/[&<>'"]/g, (char) => {
|
||||||
|
const entities: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
"'": ''',
|
||||||
|
'"': '"',
|
||||||
|
}
|
||||||
|
return entities[char]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="map-panel panel">
|
||||||
|
<div ref="mapEl" class="map-container"></div>
|
||||||
|
<div v-if="nodes.length === 0" class="map-empty">暂无可显示坐标的节点</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MapNode, NodeInfoMap, PositionRecord, TextMessage } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
node: NodeInfoMap | null
|
||||||
|
mapNode: MapNode | null
|
||||||
|
messages: TextMessage[]
|
||||||
|
positions: PositionRecord[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function nodeLabel(): string {
|
||||||
|
return props.node?.long_name || props.node?.short_name || props.mapNode?.label || props.mapNode?.node_id || '未选择节点'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="node-detail-panel panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Node detail</p>
|
||||||
|
<h2>{{ nodeLabel() }}</h2>
|
||||||
|
</div>
|
||||||
|
<span v-if="mapNode" class="badge">{{ mapNode.source }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!node && !mapNode" class="empty">点击聊天消息或地图节点查看详情</div>
|
||||||
|
<div v-else class="detail-grid">
|
||||||
|
<div class="detail-main">
|
||||||
|
<dl>
|
||||||
|
<div><dt>Node ID</dt><dd>{{ node?.node_id || mapNode?.node_id }}</dd></div>
|
||||||
|
<div><dt>Role</dt><dd>{{ node?.role || '-' }}</dd></div>
|
||||||
|
<div><dt>Hardware</dt><dd>{{ node?.hw_model || '-' }}</dd></div>
|
||||||
|
<div><dt>Latitude</dt><dd>{{ mapNode?.latitude ?? node?.latitude ?? '-' }}</dd></div>
|
||||||
|
<div><dt>Longitude</dt><dd>{{ mapNode?.longitude ?? node?.longitude ?? '-' }}</dd></div>
|
||||||
|
<div><dt>Altitude</dt><dd>{{ mapNode?.altitude ?? node?.altitude ?? '-' }}</dd></div>
|
||||||
|
<div><dt>Updated</dt><dd>{{ formatTime(node?.updated_at || mapNode?.updated_at) }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-side">
|
||||||
|
<h3>最近消息</h3>
|
||||||
|
<p v-if="messages.length === 0" class="muted">暂无消息</p>
|
||||||
|
<ul v-else>
|
||||||
|
<li v-for="message in messages.slice(0, 5)" :key="message.id">
|
||||||
|
{{ message.text || '[binary]' }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-side">
|
||||||
|
<h3>最近位置</h3>
|
||||||
|
<p v-if="positions.length === 0" class="muted">暂无位置</p>
|
||||||
|
<ul v-else>
|
||||||
|
<li v-for="position in positions.slice(0, 5)" :key="position.id">
|
||||||
|
{{ position.latitude ?? '-' }}, {{ position.longitude ?? '-' }} · {{ formatTime(position.created_at) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { NodeInfoMap } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
nodes: NodeInfoMap[]
|
||||||
|
selectedNodeId: string | null
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-node': [nodeId: string]
|
||||||
|
'page-change': [page: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
|
||||||
|
const canPrev = computed(() => props.page > 1)
|
||||||
|
const canNext = computed(() => props.page < totalPages.value)
|
||||||
|
|
||||||
|
function nodeName(node: NodeInfoMap): string {
|
||||||
|
return node.long_name || node.short_name || node.node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string): string {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="node-list-panel panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">NodeInfo Map</p>
|
||||||
|
<h2>节点列表</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge">共 {{ total }} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="node-table-wrap">
|
||||||
|
<table class="node-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>节点</th>
|
||||||
|
<th>Node ID</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>角色</th>
|
||||||
|
<th>硬件</th>
|
||||||
|
<th>坐标</th>
|
||||||
|
<th>更新时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="node in nodes"
|
||||||
|
:key="node.node_id"
|
||||||
|
class="node-row"
|
||||||
|
:class="{ selected: selectedNodeId === node.node_id }"
|
||||||
|
@click="emit('select-node', node.node_id)"
|
||||||
|
>
|
||||||
|
<td>{{ nodeName(node) }}</td>
|
||||||
|
<td>{{ node.node_id }}</td>
|
||||||
|
<td>{{ node.latest_type }}</td>
|
||||||
|
<td>{{ node.role || '-' }}</td>
|
||||||
|
<td>{{ node.hw_model || '-' }}</td>
|
||||||
|
<td>{{ node.latitude ?? '-' }}, {{ node.longitude ?? '-' }}</td>
|
||||||
|
<td>{{ formatTime(node.updated_at) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-if="nodes.length === 0" class="empty">暂无节点数据</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<button :disabled="loading || !canPrev" @click="emit('page-change', page - 1)">上一页</button>
|
||||||
|
<span>第 {{ page }} / {{ totalPages }} 页</span>
|
||||||
|
<span>每页 {{ pageSize }} 条</span>
|
||||||
|
<button :disabled="loading || !canNext" @click="emit('page-change', page + 1)">下一页</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
+369
-69
@@ -1,7 +1,7 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
color: #172033;
|
color: #172033;
|
||||||
background: #f3f6fb;
|
background: #edf2f7;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -13,36 +13,44 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
border: 0;
|
font: inherit;
|
||||||
border-radius: 10px;
|
}
|
||||||
padding: 10px 18px;
|
|
||||||
background: #2563eb;
|
button:not(:disabled) {
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
.app-shell {
|
||||||
opacity: 0.6;
|
min-height: 100vh;
|
||||||
cursor: wait;
|
box-sizing: border-box;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.topbar {
|
||||||
width: min(1200px, calc(100vw - 32px));
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 32px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24px;
|
justify-content: space-between;
|
||||||
margin-bottom: 24px;
|
gap: 16px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border: 1px solid #dbe4ef;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 5px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
@@ -51,113 +59,405 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2 {
|
h2,
|
||||||
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 34px;
|
font-size: 32px;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status,
|
h3 {
|
||||||
.error,
|
font-size: 14px;
|
||||||
.card {
|
color: #475569;
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
background: white;
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.topbar button {
|
||||||
display: flex;
|
border: 0;
|
||||||
gap: 16px;
|
border-radius: 10px;
|
||||||
|
padding: 9px 16px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill,
|
||||||
|
.counter,
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 14px 18px;
|
border-radius: 999px;
|
||||||
margin-bottom: 16px;
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
color: #92400e;
|
color: #92400e;
|
||||||
background: #fffbeb;
|
background: #fffbeb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.ok {
|
.status-pill.ok {
|
||||||
color: #166534;
|
color: #166534;
|
||||||
background: #f0fdf4;
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter,
|
||||||
|
.badge {
|
||||||
|
color: #334155;
|
||||||
|
background: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
padding: 14px 18px;
|
padding: 12px 16px;
|
||||||
margin-bottom: 16px;
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 14px;
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
background: #fef2f2;
|
background: #fef2f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.workspace {
|
||||||
|
min-height: 560px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.panel {
|
||||||
padding: 18px;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border: 1px solid #dbe4ef;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card.wide {
|
.panel-header {
|
||||||
grid-column: 1 / -1;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
.chat-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 560px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .panel-header {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 10;
|
||||||
|
top: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
padding: 13px 16px;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-item:hover,
|
||||||
|
.chat-item.selected {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-meta small,
|
||||||
|
.chat-host,
|
||||||
|
.muted {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text {
|
||||||
|
color: #0f172a;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-header {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 500;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-empty {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 450;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #475569;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-marker {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 34px !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: 22px !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: white;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.25);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 20px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-marker span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 34px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 1px solid white;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--node-color, #2563eb);
|
||||||
|
line-height: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-marker.selected {
|
||||||
|
box-shadow: 0 6px 18px rgba(220, 38, 38, 0.45);
|
||||||
|
transform: scale(1.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-marker.selected span {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-detail-panel {
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-list-panel {
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
th,
|
.node-table th,
|
||||||
td {
|
.node-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
padding: 10px 8px;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
.node-table th {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-row:hover,
|
||||||
|
.node-row.selected {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button {
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-detail-popup .leaflet-popup-content {
|
||||||
|
width: 300px !important;
|
||||||
|
max-width: 300px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-popup {
|
||||||
|
width: 100%;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-popup strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-popup dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-popup dl div {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-popup dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-popup dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
dd {
|
||||||
display: flex;
|
margin: 3px 0 0;
|
||||||
flex-direction: column;
|
color: #0f172a;
|
||||||
gap: 12px;
|
font-weight: 700;
|
||||||
list-style: none;
|
overflow-wrap: anywhere;
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.list li {
|
.detail-side ul {
|
||||||
display: grid;
|
margin: 10px 0 0;
|
||||||
gap: 4px;
|
padding-left: 18px;
|
||||||
padding-bottom: 12px;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.list small {
|
.detail-side li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 22px 16px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
.leaflet-container {
|
||||||
.header,
|
font: inherit;
|
||||||
.status {
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.app-shell {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.topbar-actions {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.workspace,
|
||||||
|
.detail-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export interface ListResponse<T> {
|
|||||||
items: T[]
|
items: T[]
|
||||||
limit: number
|
limit: number
|
||||||
offset: number
|
offset: number
|
||||||
|
total?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealthStatus {
|
export interface HealthStatus {
|
||||||
@@ -17,9 +18,12 @@ export interface NodeInfoMap {
|
|||||||
short_name: string | null
|
short_name: string | null
|
||||||
hw_model: string | null
|
hw_model: string | null
|
||||||
role: string | null
|
role: string | null
|
||||||
|
firmware_version: string | null
|
||||||
latitude: number | null
|
latitude: number | null
|
||||||
longitude: number | null
|
longitude: number | null
|
||||||
altitude: number | null
|
altitude: number | null
|
||||||
|
position_precision: number | null
|
||||||
|
num_online_local_nodes: number | null
|
||||||
updated_at: string
|
updated_at: string
|
||||||
content_json: string
|
content_json: string
|
||||||
}
|
}
|
||||||
@@ -45,3 +49,17 @@ export interface PositionRecord {
|
|||||||
created_at: string
|
created_at: string
|
||||||
content_json: string
|
content_json: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MapNode {
|
||||||
|
node_id: string
|
||||||
|
label: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
altitude: number | null
|
||||||
|
source: 'node' | 'position'
|
||||||
|
updated_at: string
|
||||||
|
node: NodeInfoMap | null
|
||||||
|
latest_position: PositionRecord | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeInfoById = Record<string, NodeInfoMap>
|
||||||
|
|||||||
+15
-2
@@ -38,7 +38,20 @@ func normalizeListOptions(opts listOptions) listOptions {
|
|||||||
func (s *store) ListNodes(opts listOptions) ([]nodeInfoMapRecord, error) {
|
func (s *store) ListNodes(opts listOptions) ([]nodeInfoMapRecord, error) {
|
||||||
opts = normalizeListOptions(opts)
|
opts = normalizeListOptions(opts)
|
||||||
var rows []nodeInfoMapRecord
|
var rows []nodeInfoMapRecord
|
||||||
q := s.db.Order("updated_at DESC").Limit(opts.Limit).Offset(opts.Offset)
|
q := applyNodeFilters(s.db.Model(&nodeInfoMapRecord{}), opts).
|
||||||
|
Order("updated_at DESC").
|
||||||
|
Limit(opts.Limit).
|
||||||
|
Offset(opts.Offset)
|
||||||
|
return rows, q.Find(&rows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) CountNodes(opts listOptions) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
q := applyNodeFilters(s.db.Model(&nodeInfoMapRecord{}), opts)
|
||||||
|
return total, q.Count(&total).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyNodeFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
||||||
if opts.NodeID != "" {
|
if opts.NodeID != "" {
|
||||||
q = q.Where("node_id = ?", opts.NodeID)
|
q = q.Where("node_id = ?", opts.NodeID)
|
||||||
}
|
}
|
||||||
@@ -48,7 +61,7 @@ func (s *store) ListNodes(opts listOptions) ([]nodeInfoMapRecord, error) {
|
|||||||
if opts.Until != nil {
|
if opts.Until != nil {
|
||||||
q = q.Where("updated_at <= ?", *opts.Until)
|
q = q.Where("updated_at <= ?", *opts.Until)
|
||||||
}
|
}
|
||||||
return rows, q.Find(&rows).Error
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) GetNode(nodeID string) (*nodeInfoMapRecord, error) {
|
func (s *store) GetNode(nodeID string) (*nodeInfoMapRecord, error) {
|
||||||
|
|||||||
@@ -47,7 +47,12 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := store.ListNodes(opts)
|
rows, err := store.ListNodes(opts)
|
||||||
|
if err != nil {
|
||||||
writeListResponse(c, rows, opts, err, nodeDTO)
|
writeListResponse(c, rows, opts, err, nodeDTO)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := store.CountNodes(opts)
|
||||||
|
writeListResponseWithTotal(c, rows, opts, total, err, nodeDTO)
|
||||||
})
|
})
|
||||||
r.GET("/nodes/:id", func(c *gin.Context) {
|
r.GET("/nodes/:id", func(c *gin.Context) {
|
||||||
row, err := store.GetNode(c.Param("id"))
|
row, err := store.GetNode(c.Param("id"))
|
||||||
@@ -191,8 +196,20 @@ func writeListResponse[T any](c *gin.Context, rows []T, opts listOptions, err er
|
|||||||
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset})
|
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeListResponseWithTotal[T any](c *gin.Context, rows []T, opts listOptions, total int64, err error, convert func(T) gin.H) {
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]gin.H, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, convert(row))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
func nodeDTO(row nodeInfoMapRecord) gin.H {
|
func nodeDTO(row nodeInfoMapRecord) gin.H {
|
||||||
return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "latest_type": row.LatestType, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON}
|
return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "latest_type": row.LatestType, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "firmware_version": ptrString(row.FirmwareVersion), "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "position_precision": ptrInt64(row.PositionPrecision), "num_online_local_nodes": ptrInt64(row.NumOnlineLocalNodes), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON}
|
||||||
}
|
}
|
||||||
|
|
||||||
func textMessageDTO(row textMessageRecord) gin.H {
|
func textMessageDTO(row textMessageRecord) gin.H {
|
||||||
|
|||||||
Reference in New Issue
Block a user