添加前端

This commit is contained in:
2026-06-03 18:31:15 +08:00
parent a3ad72c140
commit 9748a1e681
29 changed files with 2506 additions and 20 deletions
+105
View File
@@ -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>
+25
View File
@@ -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

+1
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
+163
View File
@@ -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;
}
}
+47
View File
@@ -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
}