节点详情页完成
This commit is contained in:
@@ -117,7 +117,7 @@ go run .
|
|||||||
|
|
||||||
构建后的文件位于项目根目录 `dist/`,Gin 会提供静态文件服务;`/api` 路径保留给后端接口。
|
构建后的文件位于项目根目录 `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:
|
常用 API:
|
||||||
|
|
||||||
@@ -132,6 +132,7 @@ GET /api/admin/users
|
|||||||
POST /api/admin/users
|
POST /api/admin/users
|
||||||
PUT /api/admin/users/:id/password
|
PUT /api/admin/users/:id/password
|
||||||
DELETE /api/admin/text-messages/:id
|
DELETE /api/admin/text-messages/:id
|
||||||
|
DELETE /api/admin/nodes/:id
|
||||||
GET /api/nodeinfo
|
GET /api/nodeinfo
|
||||||
GET /api/nodeinfo/:id
|
GET /api/nodeinfo/:id
|
||||||
GET /api/map-reports
|
GET /api/map-reports
|
||||||
|
|||||||
+29
@@ -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) {
|
func TestUpsertNodeInfoRequiresNodeFields(t *testing.T) {
|
||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
|||||||
import AdminUsers from './components/AdminUsers.vue'
|
import AdminUsers from './components/AdminUsers.vue'
|
||||||
import ChatPanel from './components/ChatPanel.vue'
|
import ChatPanel from './components/ChatPanel.vue'
|
||||||
import MeshMap from './components/MeshMap.vue'
|
import MeshMap from './components/MeshMap.vue'
|
||||||
|
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
||||||
import NodeListPanel from './components/NodeListPanel.vue'
|
import NodeListPanel from './components/NodeListPanel.vue'
|
||||||
import type { AdminUser, HealthStatus, MapNode, MapReport, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types'
|
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 isAdminPage = adminPath.startsWith('/admin')
|
||||||
|
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
||||||
|
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
||||||
|
const isDetailedPage = !!detailedNodeId
|
||||||
const adminUser = ref<AdminUser | null>(null)
|
const adminUser = ref<AdminUser | null>(null)
|
||||||
const adminChecking = ref(false)
|
const adminChecking = ref(false)
|
||||||
|
|
||||||
@@ -212,6 +217,9 @@ onMounted(() => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
checkAdminSession()
|
checkAdminSession()
|
||||||
|
if (isDetailedPage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
refresh()
|
refresh()
|
||||||
refreshTimer = window.setInterval(() => refresh(false), 5000)
|
refreshTimer = window.setInterval(() => refresh(false), 5000)
|
||||||
})
|
})
|
||||||
@@ -228,7 +236,8 @@ onBeforeUnmount(() => {
|
|||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Meshtastic MQTT Server</p>
|
<p class="eyebrow">Meshtastic MQTT Server</p>
|
||||||
<h1>{{ isAdminPage ? 'Admin' : 'MeshMap' }}</h1>
|
<h1 v-if="isDetailedPage">节点详情</h1>
|
||||||
|
<h1 v-else>{{ isAdminPage ? 'Admin' : 'MeshMap' }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<template v-if="isAdminPage">
|
<template v-if="isAdminPage">
|
||||||
@@ -239,6 +248,11 @@ onBeforeUnmount(() => {
|
|||||||
</nav>
|
</nav>
|
||||||
<a class="topbar-link" href="/">返回地图</a>
|
<a class="topbar-link" href="/">返回地图</a>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="isDetailedPage">
|
||||||
|
<span class="counter">{{ detailedNodeId }}</span>
|
||||||
|
<a class="topbar-link" href="/">返回地图</a>
|
||||||
|
<a class="topbar-link" href="/admin">管理</a>
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="status-pill" :class="{ ok: health?.status === 'ok' }">
|
<span class="status-pill" :class="{ ok: health?.status === 'ok' }">
|
||||||
{{ health?.status ?? 'unknown' }} / db {{ health?.database ?? 'unknown' }}
|
{{ health?.status ?? 'unknown' }} / db {{ health?.database ?? 'unknown' }}
|
||||||
@@ -267,6 +281,10 @@ onBeforeUnmount(() => {
|
|||||||
<AdminLogin v-else @login="adminUser = $event" />
|
<AdminLogin v-else @login="adminUser = $event" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="isDetailedPage">
|
||||||
|
<NodeDetailedPage :node-id="detailedNodeId" :is-admin="!!adminUser" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
MapReport,
|
MapReport,
|
||||||
NodeInfo,
|
NodeInfo,
|
||||||
PositionRecord,
|
PositionRecord,
|
||||||
|
TelemetryRecord,
|
||||||
TextMessage,
|
TextMessage,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
@@ -29,6 +30,14 @@ async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
return response.json() as Promise<T>
|
return response.json() as Promise<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listPath(path: string, limit: number, offset: number, nodeId = ''): string {
|
||||||
|
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
|
||||||
|
if (nodeId) {
|
||||||
|
params.set('node_id', nodeId)
|
||||||
|
}
|
||||||
|
return `${path}?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
function getJSON<T>(path: string): Promise<T> {
|
function getJSON<T>(path: string): Promise<T> {
|
||||||
return requestJSON<T>(path)
|
return requestJSON<T>(path)
|
||||||
}
|
}
|
||||||
@@ -58,15 +67,23 @@ export function getHealth(): Promise<HealthStatus> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getNodeInfo(limit = 500, offset = 0): Promise<ListResponse<NodeInfo>> {
|
export function getNodeInfo(limit = 500, offset = 0): Promise<ListResponse<NodeInfo>> {
|
||||||
return getJSON<ListResponse<NodeInfo>>(`/api/nodeinfo?limit=${limit}&offset=${offset}`)
|
return getJSON<ListResponse<NodeInfo>>(listPath('/api/nodeinfo', limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeInfoById(nodeId: string): Promise<NodeInfo> {
|
||||||
|
return getJSON<NodeInfo>(`/api/nodeinfo/${encodeURIComponent(nodeId)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMapReports(limit = 500, offset = 0): Promise<ListResponse<MapReport>> {
|
export function getMapReports(limit = 500, offset = 0): Promise<ListResponse<MapReport>> {
|
||||||
return getJSON<ListResponse<MapReport>>(`/api/map-reports?limit=${limit}&offset=${offset}`)
|
return getJSON<ListResponse<MapReport>>(listPath('/api/map-reports', limit, offset))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTextMessages(limit = 100, offset = 0): Promise<ListResponse<TextMessage>> {
|
export function getMapReportById(nodeId: string): Promise<MapReport> {
|
||||||
return getJSON<ListResponse<TextMessage>>(`/api/text-messages?limit=${limit}&offset=${offset}`)
|
return getJSON<MapReport>(`/api/map-reports/${encodeURIComponent(nodeId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise<ListResponse<TextMessage>> {
|
||||||
|
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeId))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteTextMessage(id: number): Promise<{ status: string }> {
|
export function deleteTextMessage(id: number): Promise<{ status: string }> {
|
||||||
@@ -77,8 +94,12 @@ export function deleteNode(nodeId: string): Promise<{ status: string }> {
|
|||||||
return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}`)
|
return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPositions(limit = 500): Promise<ListResponse<PositionRecord>> {
|
export function getPositions(limit = 500, offset = 0, nodeId = ''): Promise<ListResponse<PositionRecord>> {
|
||||||
return getJSON<ListResponse<PositionRecord>>(`/api/positions?limit=${limit}`)
|
return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTelemetry(limit = 500, offset = 0, nodeId = ''): Promise<ListResponse<TelemetryRecord>> {
|
||||||
|
return getJSON<ListResponse<TelemetryRecord>>(listPath('/api/telemetry', limit, offset, nodeId))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function adminLogin(username: string, password: string): Promise<AdminLoginResponse> {
|
export function adminLogin(username: string, password: string): Promise<AdminLoginResponse> {
|
||||||
|
|||||||
@@ -53,10 +53,11 @@ function closeMessageMenu() {
|
|||||||
menuMessage.value = null
|
menuMessage.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMessageMenu(message: TextMessage, event: MouseEvent) {
|
function nodeDetailHref(nodeId: string): string {
|
||||||
if (!props.isAdmin) {
|
return `/detailed/${encodeURIComponent(nodeId)}`
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMessageMenu(message: TextMessage, event: MouseEvent) {
|
||||||
emit('select-node', message.from_id)
|
emit('select-node', message.from_id)
|
||||||
menuMessage.value = message
|
menuMessage.value = message
|
||||||
menuX.value = event.clientX
|
menuX.value = event.clientX
|
||||||
@@ -180,7 +181,8 @@ onUpdated(() => {
|
|||||||
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
|
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<button class="danger" type="button" @click="deleteSelectedMessage">删除</button>
|
<a :href="nodeDetailHref(menuMessage.from_id)">节点详细</a>
|
||||||
|
<button v-if="isAdmin" class="danger" type="button" @click="deleteSelectedMessage">删除</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ let markerLayer: L.LayerGroup | null = null
|
|||||||
let hasFitBounds = false
|
let hasFitBounds = false
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
window.addEventListener('click', closeNodeMenu)
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (!mapEl.value) {
|
if (!mapEl.value) {
|
||||||
return
|
return
|
||||||
@@ -51,6 +53,8 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('click', closeNodeMenu)
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
map?.remove()
|
map?.remove()
|
||||||
map = null
|
map = null
|
||||||
markerLayer = null
|
markerLayer = null
|
||||||
@@ -62,6 +66,35 @@ watch(
|
|||||||
{ deep: true },
|
{ 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) {
|
function renderMarkers(forceFit: boolean) {
|
||||||
if (!map || !markerLayer) {
|
if (!map || !markerLayer) {
|
||||||
return
|
return
|
||||||
@@ -83,8 +116,10 @@ function renderMarkers(forceFit: boolean) {
|
|||||||
marker.bindPopup(buildNodePopupHTML(node), { maxWidth: 320, className: 'node-detail-popup' })
|
marker.bindPopup(buildNodePopupHTML(node), { maxWidth: 320, className: 'node-detail-popup' })
|
||||||
marker.on('click', (event) => {
|
marker.on('click', (event) => {
|
||||||
L.DomEvent.stopPropagation(event)
|
L.DomEvent.stopPropagation(event)
|
||||||
|
closeNodeMenu()
|
||||||
emit('select-node', node.node_id)
|
emit('select-node', node.node_id)
|
||||||
})
|
})
|
||||||
|
marker.on('contextmenu', (event) => openNodeMenu(node, event))
|
||||||
marker.addTo(markerLayer)
|
marker.addTo(markerLayer)
|
||||||
if (selected) {
|
if (selected) {
|
||||||
marker.openPopup()
|
marker.openPopup()
|
||||||
@@ -159,5 +194,14 @@ function escapeHTML(value: string): string {
|
|||||||
<section class="map-panel panel">
|
<section class="map-panel panel">
|
||||||
<div ref="mapEl" class="map-container"></div>
|
<div ref="mapEl" class="map-container"></div>
|
||||||
<div v-if="nodes.length === 0" class="map-empty">暂无可显示坐标的节点</div>
|
<div v-if="nodes.length === 0" class="map-empty">暂无可显示坐标的节点</div>
|
||||||
|
<div
|
||||||
|
v-if="menuNodeId"
|
||||||
|
class="context-menu"
|
||||||
|
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<a :href="nodeDetailHref(menuNodeId)">节点详细</a>
|
||||||
|
<button v-if="isAdmin" class="danger" type="button" @click="deleteSelectedNode">删除</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { deleteTextMessage, getMapReportById, getNodeInfoById, getPositions, getTelemetry, getTextMessages } from '../api'
|
||||||
|
import type { MapReport, NodeInfo, PositionRecord, TelemetryRecord, TextMessage } from '../types'
|
||||||
|
import NodeTrajectoryMap from './NodeTrajectoryMap.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
nodeId: string
|
||||||
|
isAdmin: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const nodeInfo = ref<NodeInfo | null>(null)
|
||||||
|
const mapReport = ref<MapReport | null>(null)
|
||||||
|
const messages = ref<TextMessage[]>([])
|
||||||
|
const positions = ref<PositionRecord[]>([])
|
||||||
|
const telemetry = ref<TelemetryRecord[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const chatLoadingOlder = ref(false)
|
||||||
|
const chatHasMore = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const chatPageSize = 20
|
||||||
|
const chatHistoryRef = ref<HTMLElement | null>(null)
|
||||||
|
const menuMessage = ref<TextMessage | null>(null)
|
||||||
|
const menuX = ref(0)
|
||||||
|
const menuY = ref(0)
|
||||||
|
|
||||||
|
const nodeTitle = computed(() => {
|
||||||
|
return nodeInfo.value?.long_name || nodeInfo.value?.short_name || mapReport.value?.long_name || mapReport.value?.short_name || props.nodeId
|
||||||
|
})
|
||||||
|
|
||||||
|
const mergedNode = computed(() => {
|
||||||
|
return {
|
||||||
|
node_num: nodeInfo.value?.node_num ?? mapReport.value?.node_num ?? null,
|
||||||
|
long_name: nodeInfo.value?.long_name || mapReport.value?.long_name || null,
|
||||||
|
short_name: nodeInfo.value?.short_name || mapReport.value?.short_name || null,
|
||||||
|
hw_model: nodeInfo.value?.hw_model || mapReport.value?.hw_model || null,
|
||||||
|
role: nodeInfo.value?.role || mapReport.value?.role || null,
|
||||||
|
updated_at: nodeInfo.value?.updated_at || mapReport.value?.updated_at || null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(value: string): string {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricEntries(value: string | null): Array<[string, unknown]> {
|
||||||
|
if (!value) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as Record<string, unknown>
|
||||||
|
return Object.entries(parsed)
|
||||||
|
} catch {
|
||||||
|
return [['raw', value]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricLabel(key: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
air_util_tx: '空口发送占用',
|
||||||
|
battery_level: '电量',
|
||||||
|
channel_utilization: '信道占用',
|
||||||
|
uptime_seconds: '运行时长',
|
||||||
|
voltage: '电压',
|
||||||
|
}
|
||||||
|
return labels[key] || key
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricValue(key: string, value: unknown): string {
|
||||||
|
if (typeof value !== 'number') {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
if (key === 'battery_level') {
|
||||||
|
return `${value}%`
|
||||||
|
}
|
||||||
|
if (key === 'voltage') {
|
||||||
|
return `${value.toFixed(2)} V`
|
||||||
|
}
|
||||||
|
if (key === 'air_util_tx' || key === 'channel_utilization') {
|
||||||
|
return `${value.toFixed(2)}%`
|
||||||
|
}
|
||||||
|
if (key === 'uptime_seconds') {
|
||||||
|
const hours = Math.floor(value / 3600)
|
||||||
|
const minutes = Math.floor((value % 3600) / 60)
|
||||||
|
const seconds = Math.floor(value % 60)
|
||||||
|
return `${hours}h ${minutes}m ${seconds}s`
|
||||||
|
}
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toChronological(items: TextMessage[]): TextMessage[] {
|
||||||
|
return [...items].reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareMessages(a: TextMessage, b: TextMessage): number {
|
||||||
|
const timeDiff = Date.parse(a.created_at) - Date.parse(b.created_at)
|
||||||
|
return timeDiff !== 0 ? timeDiff : a.id - b.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMessages(existing: TextMessage[], incoming: TextMessage[]): TextMessage[] {
|
||||||
|
const byId = new Map<number, TextMessage>()
|
||||||
|
for (const message of existing) {
|
||||||
|
byId.set(message.id, message)
|
||||||
|
}
|
||||||
|
for (const message of incoming) {
|
||||||
|
byId.set(message.id, message)
|
||||||
|
}
|
||||||
|
return Array.from(byId.values()).sort(compareMessages)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optional<T>(request: Promise<T>): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
return await request
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInitialMessages() {
|
||||||
|
const response = await getTextMessages(chatPageSize, 0, props.nodeId)
|
||||||
|
messages.value = toChronological(response.items)
|
||||||
|
chatHasMore.value = response.items.length === chatPageSize
|
||||||
|
await nextTick()
|
||||||
|
const el = chatHistoryRef.value
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = el.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOlderMessages() {
|
||||||
|
if (chatLoadingOlder.value || !chatHasMore.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = chatHistoryRef.value
|
||||||
|
const previousScrollHeight = el?.scrollHeight ?? 0
|
||||||
|
const previousScrollTop = el?.scrollTop ?? 0
|
||||||
|
chatLoadingOlder.value = true
|
||||||
|
try {
|
||||||
|
const response = await getTextMessages(chatPageSize, messages.value.length, props.nodeId)
|
||||||
|
messages.value = mergeMessages(messages.value, toChronological(response.items))
|
||||||
|
chatHasMore.value = response.items.length === chatPageSize
|
||||||
|
await nextTick()
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = el.scrollHeight - previousScrollHeight + previousScrollTop
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
chatLoadingOlder.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMessageMenu() {
|
||||||
|
menuMessage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMessageMenu(message: TextMessage, event: MouseEvent) {
|
||||||
|
if (!props.isAdmin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
menuMessage.value = message
|
||||||
|
menuX.value = event.clientX
|
||||||
|
menuY.value = event.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelectedMessage() {
|
||||||
|
if (!menuMessage.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const message = menuMessage.value
|
||||||
|
closeMessageMenu()
|
||||||
|
try {
|
||||||
|
await deleteTextMessage(message.id)
|
||||||
|
messages.value = messages.value.filter((item) => item.id !== message.id)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeMessageMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChatScroll() {
|
||||||
|
closeMessageMenu()
|
||||||
|
const el = chatHistoryRef.value
|
||||||
|
if (!el || el.scrollTop > 8) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadOlderMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetails() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const [nodeData, reportData, positionData, telemetryData] = await Promise.all([
|
||||||
|
optional(getNodeInfoById(props.nodeId)),
|
||||||
|
optional(getMapReportById(props.nodeId)),
|
||||||
|
getPositions(500, 0, props.nodeId),
|
||||||
|
getTelemetry(200, 0, props.nodeId),
|
||||||
|
])
|
||||||
|
nodeInfo.value = nodeData
|
||||||
|
mapReport.value = reportData
|
||||||
|
positions.value = positionData.items
|
||||||
|
telemetry.value = telemetryData.items
|
||||||
|
await loadInitialMessages()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('click', closeMessageMenu)
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
loadDetails()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('click', closeMessageMenu)
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="detail-page">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Node detail</p>
|
||||||
|
<h2>{{ nodeTitle }}</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge">{{ nodeId }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<div v-if="loading" class="empty">正在加载节点详情...</div>
|
||||||
|
<div v-else class="detail-summary-grid">
|
||||||
|
<div><span>Node ID</span><strong>{{ nodeId }}</strong></div>
|
||||||
|
<div><span>Node Num</span><strong>{{ mergedNode.node_num ?? '-' }}</strong></div>
|
||||||
|
<div><span>Long Name</span><strong>{{ mergedNode.long_name || '-' }}</strong></div>
|
||||||
|
<div><span>Short Name</span><strong>{{ mergedNode.short_name || '-' }}</strong></div>
|
||||||
|
<div><span>硬件</span><strong>{{ mergedNode.hw_model || '-' }}</strong></div>
|
||||||
|
<div><span>角色</span><strong>{{ mergedNode.role || '-' }}</strong></div>
|
||||||
|
<div><span>User ID</span><strong>{{ nodeInfo?.user_id || '-' }}</strong></div>
|
||||||
|
<div><span>授权</span><strong>{{ nodeInfo?.is_licensed ?? '-' }}</strong></div>
|
||||||
|
<div><span>固件版本</span><strong>{{ mapReport?.firmware_version || '-' }}</strong></div>
|
||||||
|
<div><span>区域</span><strong>{{ mapReport?.region || '-' }}</strong></div>
|
||||||
|
<div><span>调制预设</span><strong>{{ mapReport?.modem_preset || '-' }}</strong></div>
|
||||||
|
<div><span>最新坐标</span><strong>{{ mapReport?.latitude ?? '-' }}, {{ mapReport?.longitude ?? '-' }}</strong></div>
|
||||||
|
<div><span>海拔</span><strong>{{ mapReport?.altitude ?? '-' }}</strong></div>
|
||||||
|
<div><span>位置精度</span><strong>{{ mapReport?.position_precision ?? '-' }}</strong></div>
|
||||||
|
<div><span>在线节点</span><strong>{{ mapReport?.num_online_local_nodes ?? '-' }}</strong></div>
|
||||||
|
<div><span>上报位置</span><strong>{{ mapReport?.has_opted_report_location ?? '-' }}</strong></div>
|
||||||
|
<div><span>更新时间</span><strong>{{ mergedNode.updated_at ? formatTime(mergedNode.updated_at) : '-' }}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Public Key</p>
|
||||||
|
<h2>节点公钥</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="public-key-block">{{ nodeInfo?.public_key || '-' }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section-grid">
|
||||||
|
<div class="panel detail-chat-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Chat</p>
|
||||||
|
<h2>历史聊天记录</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge">{{ messages.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div ref="chatHistoryRef" class="detail-chat-history" @scroll.passive="handleChatScroll">
|
||||||
|
<div v-if="chatLoadingOlder" class="chat-loading">正在加载更早消息...</div>
|
||||||
|
<div v-else-if="!chatHasMore && messages.length > 0" class="chat-end">没有更多历史消息</div>
|
||||||
|
<div v-if="messages.length === 0" class="empty">暂无聊天记录</div>
|
||||||
|
<div
|
||||||
|
v-for="message in messages"
|
||||||
|
:key="message.id"
|
||||||
|
class="detail-chat-item"
|
||||||
|
@contextmenu.prevent.stop="openMessageMenu(message, $event)"
|
||||||
|
>
|
||||||
|
<span class="chat-meta">
|
||||||
|
<strong>{{ formatTime(message.created_at) }}</strong>
|
||||||
|
<small>{{ message.topic }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="chat-text">{{ message.text || '[binary]' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="menuMessage"
|
||||||
|
class="context-menu"
|
||||||
|
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<button class="danger" type="button" @click="deleteSelectedMessage">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Trajectory</p>
|
||||||
|
<h2>地图轨迹</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge">{{ positions.length }}</span>
|
||||||
|
</div>
|
||||||
|
<NodeTrajectoryMap :positions="positions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Telemetry</p>
|
||||||
|
<h2>遥测数据</h2>
|
||||||
|
</div>
|
||||||
|
<span class="badge">{{ telemetry.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="node-table-wrap">
|
||||||
|
<table class="node-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>指标</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in telemetry" :key="item.id">
|
||||||
|
<td>{{ formatTime(item.created_at) }}</td>
|
||||||
|
<td>{{ item.telemetry_type || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<div v-for="[key, value] in metricEntries(item.metrics_json)" :key="key" class="metric-chip">
|
||||||
|
<span>{{ metricLabel(key) }}</span>
|
||||||
|
<strong>{{ metricValue(key, value) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-if="telemetry.length === 0" class="empty">暂无遥测数据</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -33,10 +33,11 @@ function closeNodeMenu() {
|
|||||||
menuNode.value = null
|
menuNode.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNodeMenu(node: NodeInfo, event: MouseEvent) {
|
function nodeDetailHref(nodeId: string): string {
|
||||||
if (!props.isAdmin) {
|
return `/detailed/${encodeURIComponent(nodeId)}`
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openNodeMenu(node: NodeInfo, event: MouseEvent) {
|
||||||
emit('select-node', node.node_id)
|
emit('select-node', node.node_id)
|
||||||
menuNode.value = node
|
menuNode.value = node
|
||||||
menuX.value = event.clientX
|
menuX.value = event.clientX
|
||||||
@@ -118,7 +119,8 @@ onBeforeUnmount(() => {
|
|||||||
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
|
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<button class="danger" type="button" @click="deleteSelectedNode">删除</button>
|
<a :href="nodeDetailHref(menuNode.node_id)">节点详细</a>
|
||||||
|
<button v-if="isAdmin" class="danger" type="button" @click="deleteSelectedNode">删除</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
import type { PositionRecord } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
positions: PositionRecord[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mapEl = ref<HTMLElement | null>(null)
|
||||||
|
let map: L.Map | null = null
|
||||||
|
let layer: L.LayerGroup | null = null
|
||||||
|
|
||||||
|
function renderTrajectory() {
|
||||||
|
if (!map || !layer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
layer.clearLayers()
|
||||||
|
const points = [...props.positions]
|
||||||
|
.filter((position) => position.latitude != null && position.longitude != null)
|
||||||
|
.reverse()
|
||||||
|
.map((position) => [position.latitude as number, position.longitude as number] as L.LatLngTuple)
|
||||||
|
|
||||||
|
if (points.length === 0) {
|
||||||
|
map.setView([0, 0], 2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (points.length > 1) {
|
||||||
|
L.polyline(points, { color: '#2563eb', weight: 4, opacity: 0.8 }).addTo(layer)
|
||||||
|
}
|
||||||
|
L.circleMarker(points[0], { radius: 6, color: '#16a34a', fillColor: '#22c55e', fillOpacity: 0.9 }).bindPopup('起点').addTo(layer)
|
||||||
|
L.circleMarker(points[points.length - 1], { radius: 6, color: '#dc2626', fillColor: '#ef4444', fillOpacity: 0.9 }).bindPopup('终点').addTo(layer)
|
||||||
|
map.fitBounds(L.latLngBounds(points), { padding: [24, 24], maxZoom: 14 })
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
layer = L.layerGroup().addTo(map)
|
||||||
|
renderTrajectory()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
map?.remove()
|
||||||
|
map = null
|
||||||
|
layer = null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.positions,
|
||||||
|
() => renderTrajectory(),
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="mapEl" class="trajectory-map"></div>
|
||||||
|
</template>
|
||||||
@@ -231,17 +231,23 @@ h3 {
|
|||||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
|
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu button {
|
.context-menu button,
|
||||||
|
.context-menu a {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu button:hover {
|
.context-menu button:hover,
|
||||||
|
.context-menu a:hover {
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +343,99 @@ h3 {
|
|||||||
min-height: 240px;
|
min-height: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-summary-grid div {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-summary-grid span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-summary-grid strong {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-chat-panel {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-chat-history {
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-chat-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
padding: 13px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trajectory-map {
|
||||||
|
height: 420px;
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-key-block {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #0f172a;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-chip {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-chip span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-chip strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.node-table-wrap {
|
.node-table-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@@ -652,7 +751,8 @@ dd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workspace,
|
.workspace,
|
||||||
.detail-grid {
|
.detail-grid,
|
||||||
|
.detail-section-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ export interface PositionRecord {
|
|||||||
content_json: string
|
content_json: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TelemetryRecord {
|
||||||
|
id: number
|
||||||
|
from_id: string
|
||||||
|
from_num: number
|
||||||
|
telemetry_type: string | null
|
||||||
|
metrics_json: string | null
|
||||||
|
created_at: string
|
||||||
|
content_json: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface MapNode {
|
export interface MapNode {
|
||||||
node_id: string
|
node_id: string
|
||||||
label: string
|
label: string
|
||||||
|
|||||||
Reference in New Issue
Block a user