添加前端
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getHealth, getNodes, getPositions, getTextMessages } from './api'
|
||||
import type { HealthStatus, NodeInfoMap, PositionRecord, TextMessage } from './types'
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const health = ref<HealthStatus | null>(null)
|
||||
const nodes = ref<NodeInfoMap[]>([])
|
||||
const messages = ref<TextMessage[]>([])
|
||||
const positions = ref<PositionRecord[]>([])
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [healthData, nodeData, messageData, positionData] = await Promise.all([
|
||||
getHealth(),
|
||||
getNodes(),
|
||||
getTextMessages(),
|
||||
getPositions(),
|
||||
])
|
||||
health.value = healthData
|
||||
nodes.value = nodeData.items
|
||||
messages.value = messageData.items
|
||||
positions.value = positionData.items
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page">
|
||||
<header class="header">
|
||||
<div>
|
||||
<p class="eyebrow">Meshtastic MQTT Server</p>
|
||||
<h1>MeshMap Dashboard</h1>
|
||||
</div>
|
||||
<button @click="refresh" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||
</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>
|
||||
|
||||
<section class="grid">
|
||||
<article class="card wide">
|
||||
<h2>节点</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>HW</th>
|
||||
<th>Lat</th>
|
||||
<th>Lon</th>
|
||||
</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>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { HealthStatus, ListResponse, NodeInfoMap, PositionRecord, TextMessage } from './types'
|
||||
|
||||
async function getJSON<T>(path: string): Promise<T> {
|
||||
const response = await fetch(path)
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export function getHealth(): Promise<HealthStatus> {
|
||||
return getJSON<HealthStatus>('/api/health')
|
||||
}
|
||||
|
||||
export function getNodes(): Promise<ListResponse<NodeInfoMap>> {
|
||||
return getJSON<ListResponse<NodeInfoMap>>('/api/nodes?limit=100')
|
||||
}
|
||||
|
||||
export function getTextMessages(): Promise<ListResponse<TextMessage>> {
|
||||
return getJSON<ListResponse<TextMessage>>('/api/text-messages?limit=20')
|
||||
}
|
||||
|
||||
export function getPositions(): Promise<ListResponse<PositionRecord>> {
|
||||
return getJSON<ListResponse<PositionRecord>>('/api/positions?limit=200')
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button type="button" class="counter" @click="count++">
|
||||
Count is {{ count }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,163 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
color: #172033;
|
||||
background: #f3f6fb;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 10px 18px;
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: min(1200px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status,
|
||||
.error,
|
||||
.card {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.status.ok {
|
||||
color: #166534;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 10px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.list li {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.list small {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.header,
|
||||
.status {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface ListResponse<T> {
|
||||
items: T[]
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface HealthStatus {
|
||||
status: string
|
||||
database: string
|
||||
}
|
||||
|
||||
export interface NodeInfoMap {
|
||||
node_id: string
|
||||
node_num: number
|
||||
latest_type: string
|
||||
long_name: string | null
|
||||
short_name: string | null
|
||||
hw_model: string | null
|
||||
role: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
altitude: number | null
|
||||
updated_at: string
|
||||
content_json: string
|
||||
}
|
||||
|
||||
export interface TextMessage {
|
||||
id: number
|
||||
from_id: string
|
||||
from_num: number
|
||||
text: string | null
|
||||
topic: string
|
||||
created_at: string
|
||||
mqtt_remote_host: string | null
|
||||
content_json: string
|
||||
}
|
||||
|
||||
export interface PositionRecord {
|
||||
id: number
|
||||
from_id: string
|
||||
from_num: number
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
altitude: number | null
|
||||
created_at: string
|
||||
content_json: string
|
||||
}
|
||||
Reference in New Issue
Block a user