first commit
This commit is contained in:
187
frontend/src/App.vue
Normal file
187
frontend/src/App.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: '总览' },
|
||||
{ to: '/video', label: '视频流' },
|
||||
{ to: '/map', label: '地图定位' },
|
||||
{ to: '/network', label: '网络状态' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<p class="brand-mark">RCC</p>
|
||||
<div>
|
||||
<strong>Robot Command Center</strong>
|
||||
<span>机器人竞赛指挥台</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="nav-link"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="page-body">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(91, 122, 255, 0.18), transparent 24%),
|
||||
radial-gradient(circle at top right, rgba(77, 212, 172, 0.13), transparent 22%),
|
||||
linear-gradient(180deg, #08101d 0%, #050914 58%, #02040a 100%);
|
||||
color: #f5f7fb;
|
||||
font-family:
|
||||
'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1440px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 22px 0 40px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 24px;
|
||||
border-radius: 24px;
|
||||
background: rgba(8, 14, 26, 0.82);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
:global(.panel) {
|
||||
padding: 22px;
|
||||
border-radius: 28px;
|
||||
background: rgba(12, 20, 36, 0.84);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.24);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #5b7aff, #4dd4ac);
|
||||
color: #06101d;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.brand strong,
|
||||
.brand span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
margin-top: 4px;
|
||||
color: #a9b6cf;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||
background: rgba(13, 22, 40, 0.78);
|
||||
color: #dfe6f8;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
background 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
transform: translateY(-1px);
|
||||
background: rgba(25, 38, 66, 0.9);
|
||||
}
|
||||
|
||||
.nav-link.router-link-exact-active {
|
||||
background: linear-gradient(135deg, #5b7aff, #7bc4ff);
|
||||
color: #08101d;
|
||||
border-color: transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.topbar {
|
||||
position: static;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.nav {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-shell {
|
||||
width: min(100%, calc(100% - 20px));
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
433
frontend/src/components/GpsMapPanel.vue
Normal file
433
frontend/src/components/GpsMapPanel.vue
Normal file
@@ -0,0 +1,433 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { GpsTelemetry } from '@/types'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AMap?: any
|
||||
_AMapSecurityConfig?: {
|
||||
securityJsCode: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
gps: GpsTelemetry | null
|
||||
}>()
|
||||
|
||||
const STORAGE_KEY = 'robot_command_center_amap'
|
||||
|
||||
const keyInput = ref('')
|
||||
const securityCodeInput = ref('')
|
||||
const statusText = ref('等待加载高德地图。')
|
||||
const amapCoordinateText = ref('暂无')
|
||||
const mapElement = ref<HTMLDivElement | null>(null)
|
||||
const mapRunning = ref(false)
|
||||
|
||||
let loadPromise: Promise<any> | null = null
|
||||
let mapInstance: any = null
|
||||
let marker: any = null
|
||||
let infoWindow: any = null
|
||||
|
||||
function readSavedCredentials() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveCredentials() {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
key: keyInput.value.trim(),
|
||||
securityJsCode: securityCodeInput.value.trim(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return value.toFixed(6)
|
||||
}
|
||||
|
||||
async function loadAmapScript(key: string, securityJsCode: string) {
|
||||
if (window.AMap) {
|
||||
return window.AMap
|
||||
}
|
||||
|
||||
if (loadPromise) {
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
window._AMapSecurityConfig = { securityJsCode }
|
||||
|
||||
loadPromise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = `https://webapi.amap.com/maps?v=2.0&key=${encodeURIComponent(key)}`
|
||||
script.async = true
|
||||
script.onload = () => resolve(window.AMap)
|
||||
script.onerror = () => reject(new Error('高德地图脚本加载失败,请检查 Key / jscode 和网络。'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
function ensureMap() {
|
||||
if (mapInstance || !mapElement.value || !window.AMap) {
|
||||
return
|
||||
}
|
||||
|
||||
mapInstance = new window.AMap.Map(mapElement.value, {
|
||||
viewMode: '3D',
|
||||
zoom: 15,
|
||||
center: [121.4737, 31.2304],
|
||||
mapStyle: 'amap://styles/normal',
|
||||
})
|
||||
|
||||
marker = new window.AMap.Marker({
|
||||
anchor: 'bottom-center',
|
||||
title: 'Robot GPS',
|
||||
})
|
||||
|
||||
infoWindow = new window.AMap.InfoWindow({
|
||||
offset: new window.AMap.Pixel(0, -28),
|
||||
})
|
||||
}
|
||||
|
||||
function stopMap() {
|
||||
mapRunning.value = false
|
||||
amapCoordinateText.value = '已停止'
|
||||
|
||||
if (infoWindow) {
|
||||
infoWindow.close()
|
||||
}
|
||||
|
||||
if (marker) {
|
||||
marker.setMap(null)
|
||||
marker = null
|
||||
}
|
||||
|
||||
if (mapInstance) {
|
||||
if (typeof mapInstance.destroy === 'function') {
|
||||
mapInstance.destroy()
|
||||
}
|
||||
mapInstance = null
|
||||
}
|
||||
|
||||
infoWindow = null
|
||||
|
||||
if (mapElement.value) {
|
||||
mapElement.value.innerHTML = ''
|
||||
}
|
||||
|
||||
statusText.value = '已停止高德地图加载与坐标转换。需要时再点击“加载地图”即可。'
|
||||
}
|
||||
|
||||
function updateMap(gps: GpsTelemetry | null) {
|
||||
if (!mapRunning.value || !mapInstance || !window.AMap) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!gps?.has_fix || gps.latitude == null || gps.longitude == null) {
|
||||
amapCoordinateText.value = '暂无'
|
||||
marker?.setMap(null)
|
||||
infoWindow?.close()
|
||||
statusText.value = gps ? 'GPS 在线,但当前还没有有效定位。' : '等待 GPS 数据。'
|
||||
return
|
||||
}
|
||||
|
||||
const rawLatitude = gps.latitude
|
||||
const rawLongitude = gps.longitude
|
||||
|
||||
window.AMap.convertFrom([rawLongitude, rawLatitude], 'gps', (status: string, result: any) => {
|
||||
if (status !== 'complete' || !result?.locations?.length) {
|
||||
statusText.value = 'GPS 坐标转换失败。'
|
||||
return
|
||||
}
|
||||
|
||||
const point = result.locations[0]
|
||||
const lng = typeof point.getLng === 'function' ? point.getLng() : point.lng
|
||||
const lat = typeof point.getLat === 'function' ? point.getLat() : point.lat
|
||||
|
||||
amapCoordinateText.value = `${formatNumber(lat)}, ${formatNumber(lng)}`
|
||||
marker.setPosition([lng, lat])
|
||||
marker.setMap(mapInstance)
|
||||
|
||||
infoWindow.setContent(
|
||||
[
|
||||
'<div style="min-width: 240px; padding: 6px 2px; line-height: 1.75; font-size: 13px; color: #152033;">',
|
||||
'<div style="margin-bottom: 8px; font-size: 14px; font-weight: 700; color: #0f172a;">Robot GPS 定位</div>',
|
||||
`<div><span style="color: #667085;">原始 WGS84:</span> <strong style="color: #0f172a;">${formatNumber(rawLatitude)}, ${formatNumber(rawLongitude)}</strong></div>`,
|
||||
`<div><span style="color: #667085;">高德 GCJ-02:</span> <strong style="color: #0f172a;">${formatNumber(lat)}, ${formatNumber(lng)}</strong></div>`,
|
||||
`<div><span style="color: #667085;">UTC 时间:</span> <strong style="color: #0f172a;">${gps.utc_time || '--:--:--'}</strong></div>`,
|
||||
`<div><span style="color: #667085;">卫星数:</span> <strong style="color: #0f172a;">${gps.satellites ?? '未知'}</strong></div>`,
|
||||
`<div><span style="color: #667085;">海拔:</span> <strong style="color: #0f172a;">${gps.altitude_m ?? '未知'} m</strong></div>`,
|
||||
'</div>',
|
||||
].join(''),
|
||||
)
|
||||
infoWindow.open(mapInstance, [lng, lat])
|
||||
mapInstance.setZoomAndCenter(17, [lng, lat])
|
||||
statusText.value = `地图已刷新,数据源:${gps.source_mode}`
|
||||
})
|
||||
}
|
||||
|
||||
async function startMap() {
|
||||
const key = keyInput.value.trim()
|
||||
const securityJsCode = securityCodeInput.value.trim()
|
||||
|
||||
if (!key || !securityJsCode) {
|
||||
statusText.value = '请先填写高德 Key 和安全密钥 jscode。'
|
||||
return
|
||||
}
|
||||
|
||||
statusText.value = '正在加载高德地图...'
|
||||
|
||||
try {
|
||||
await loadAmapScript(key, securityJsCode)
|
||||
ensureMap()
|
||||
saveCredentials()
|
||||
mapRunning.value = true
|
||||
statusText.value = '地图已加载。'
|
||||
updateMap(props.gps)
|
||||
} catch (error) {
|
||||
statusText.value = error instanceof Error ? error.message : '地图加载失败。'
|
||||
}
|
||||
}
|
||||
|
||||
const rawCoordinateText = computed(() => {
|
||||
if (!props.gps?.has_fix || props.gps.latitude == null || props.gps.longitude == null) {
|
||||
return '暂无有效定位'
|
||||
}
|
||||
|
||||
return `${formatNumber(props.gps.latitude)}, ${formatNumber(props.gps.longitude)}`
|
||||
})
|
||||
|
||||
const metaText = computed(() => {
|
||||
if (!props.gps) {
|
||||
return '暂无'
|
||||
}
|
||||
const satellites = props.gps.satellites ?? '未知'
|
||||
const altitude = props.gps.altitude_m == null ? '未知' : `${props.gps.altitude_m} m`
|
||||
return `${satellites} 颗 / ${altitude}`
|
||||
})
|
||||
|
||||
const updatedAtText = computed(() => {
|
||||
if (!props.gps?.updated_at) {
|
||||
return '暂无'
|
||||
}
|
||||
return new Date(props.gps.updated_at).toLocaleString('zh-CN', { hour12: false })
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const saved = readSavedCredentials()
|
||||
if (saved) {
|
||||
keyInput.value = saved.key ?? ''
|
||||
securityCodeInput.value = saved.securityJsCode ?? ''
|
||||
if (keyInput.value && securityCodeInput.value) {
|
||||
statusText.value = '已恢复高德配置。高德地图不会自动加载,请按需点击“加载地图”。'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.gps,
|
||||
(gps) => {
|
||||
updateMap(gps)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel map-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">GPS</p>
|
||||
<h2>地图定位</h2>
|
||||
</div>
|
||||
<span class="badge">{{ gps?.source_mode ?? 'loading' }}</span>
|
||||
</div>
|
||||
|
||||
<p class="intro">
|
||||
这里复用了你原来 `GeoStream/gps_map.html` 的高德地图思路。后端优先读取
|
||||
`GeoStream/gps_latest.json`,所以你运行 `parse_gps.c` 生成数据后,这里会直接接上。
|
||||
</p>
|
||||
|
||||
<div class="credentials">
|
||||
<input v-model="keyInput" type="text" placeholder="高德 Web 端 Key" />
|
||||
<input v-model="securityCodeInput" type="text" placeholder="安全密钥 jscode" />
|
||||
<button type="button" @click="startMap">加载地图</button>
|
||||
<button type="button" class="secondary" @click="stopMap">停止加载</button>
|
||||
</div>
|
||||
|
||||
<div class="status">{{ statusText }}</div>
|
||||
|
||||
<div class="details">
|
||||
<div class="detail-card">
|
||||
<span>原始 WGS84</span>
|
||||
<strong>{{ rawCoordinateText }}</strong>
|
||||
</div>
|
||||
<div class="detail-card">
|
||||
<span>高德 GCJ-02</span>
|
||||
<strong>{{ amapCoordinateText }}</strong>
|
||||
</div>
|
||||
<div class="detail-card">
|
||||
<span>UTC 时间</span>
|
||||
<strong>{{ gps?.utc_time ?? '--:--:--' }}</strong>
|
||||
</div>
|
||||
<div class="detail-card">
|
||||
<span>卫星 / 海拔</span>
|
||||
<strong>{{ metaText }}</strong>
|
||||
</div>
|
||||
<div class="detail-card">
|
||||
<span>坐标系</span>
|
||||
<strong>{{ gps?.coordinate_system ?? 'WGS84' }}</strong>
|
||||
</div>
|
||||
<div class="detail-card">
|
||||
<span>最近刷新</span>
|
||||
<strong>{{ updatedAtText }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="mapElement" class="map-canvas" :class="{ stopped: !mapRunning }">
|
||||
<div v-if="!mapRunning" class="map-placeholder">
|
||||
高德地图当前未加载。点击上方“加载地图”后才会开始请求地图与坐标转换服务。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.map-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: #f5a524;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(245, 165, 36, 0.15);
|
||||
color: #ffd48a;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.intro,
|
||||
.status {
|
||||
margin: 0;
|
||||
color: #d5dbee;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.credentials {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 140px 140px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.credentials input,
|
||||
.credentials button {
|
||||
border: 1px solid rgba(133, 147, 169, 0.28);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(7, 14, 26, 0.78);
|
||||
color: #f5f7fb;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.credentials button {
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, #ffb347, #ff8f5a);
|
||||
color: #10151f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.credentials button.secondary {
|
||||
background: rgba(7, 14, 26, 0.78);
|
||||
color: #f5f7fb;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(7, 14, 26, 0.78);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
}
|
||||
|
||||
.detail-card span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #8d99b3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-card strong {
|
||||
font-size: 17px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.map-canvas {
|
||||
position: relative;
|
||||
min-height: 420px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(133, 147, 169, 0.28);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 179, 71, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, #0b1220 0%, #070b14 100%);
|
||||
}
|
||||
|
||||
.map-canvas.stopped {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.map-placeholder {
|
||||
width: min(560px, calc(100% - 40px));
|
||||
padding: 20px 22px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
background: rgba(7, 14, 26, 0.84);
|
||||
color: #d5dbee;
|
||||
text-align: center;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.credentials,
|
||||
.details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
151
frontend/src/components/NetworkPanel.vue
Normal file
151
frontend/src/components/NetworkPanel.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { NetworkTelemetry } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
network: NetworkTelemetry | null
|
||||
}>()
|
||||
|
||||
const updatedAt = computed(() => {
|
||||
if (!props.network?.updated_at) {
|
||||
return '暂无'
|
||||
}
|
||||
return new Date(props.network.updated_at).toLocaleString('zh-CN', { hour12: false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel network-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Network</p>
|
||||
<h2>链路状态</h2>
|
||||
</div>
|
||||
<span class="badge">{{ network?.peer_status ?? 'loading' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<span>延迟</span>
|
||||
<strong>{{ network?.latency_ms ?? '--' }} ms</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>抖动</span>
|
||||
<strong>{{ network?.jitter_ms ?? '--' }} ms</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>丢包率</span>
|
||||
<strong>{{ network?.packet_loss_pct ?? '--' }} %</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>信号强度</span>
|
||||
<strong>{{ network?.signal_dbm ?? '--' }} dBm</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>发送速率</span>
|
||||
<strong>{{ network?.tx_kbps ?? '--' }} kbps</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>接收速率</span>
|
||||
<strong>{{ network?.rx_kbps ?? '--' }} kbps</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<p><strong>来源:</strong>{{ network?.transport ?? '暂无' }} / {{ network?.source_mode ?? '暂无' }}</p>
|
||||
<p><strong>刷新:</strong>{{ updatedAt }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.network-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: #4dd4ac;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(40, 199, 111, 0.16);
|
||||
color: #63e6a9;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(7, 14, 26, 0.78);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
}
|
||||
|
||||
.stat-card span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #8d99b3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(7, 14, 26, 0.78);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
color: #d5dbee;
|
||||
}
|
||||
|
||||
.summary p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary p + p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
228
frontend/src/components/VideoPanel.vue
Normal file
228
frontend/src/components/VideoPanel.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { buildVideoFrameUrl } from '@/lib/api'
|
||||
import type { VideoStatus } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
video: VideoStatus | null
|
||||
}>()
|
||||
|
||||
const frameUrl = ref(buildVideoFrameUrl(0))
|
||||
const currentFps = computed(() => props.video?.fps ?? 30)
|
||||
const canRequestFrames = computed(() => props.video == null || props.video.available)
|
||||
const modeLabel = computed(() => {
|
||||
if (!props.video) {
|
||||
return '--'
|
||||
}
|
||||
if (props.video.source_mode === 'omnisocket-jpeg-live') {
|
||||
return `${props.video.fps} FPS 实时接收`
|
||||
}
|
||||
if (props.video.source_mode === 'omnisocket-waiting') {
|
||||
return '等待 OmniSocket 实时帧'
|
||||
}
|
||||
if (props.video.source_mode === 'sample-jpeg-frame-loop') {
|
||||
return `${props.video.fps} FPS 本地演示`
|
||||
}
|
||||
return `${props.video.fps} FPS`
|
||||
})
|
||||
|
||||
let frameTimer: number | null = null
|
||||
let frameKey = 0
|
||||
|
||||
function refreshFrame() {
|
||||
if (!canRequestFrames.value) {
|
||||
return
|
||||
}
|
||||
frameKey += 1
|
||||
frameUrl.value = buildVideoFrameUrl(frameKey)
|
||||
}
|
||||
|
||||
function startFrameLoop() {
|
||||
if (frameTimer != null) {
|
||||
window.clearInterval(frameTimer)
|
||||
frameTimer = null
|
||||
}
|
||||
|
||||
if (!canRequestFrames.value) {
|
||||
return
|
||||
}
|
||||
|
||||
refreshFrame()
|
||||
const intervalMs = Math.max(33, Math.round(1000 / currentFps.value))
|
||||
frameTimer = window.setInterval(() => {
|
||||
refreshFrame()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startFrameLoop()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (frameTimer != null) {
|
||||
window.clearInterval(frameTimer)
|
||||
}
|
||||
})
|
||||
|
||||
watch([currentFps, canRequestFrames], () => {
|
||||
startFrameLoop()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel video-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Video</p>
|
||||
<h2>JPEG 视频流</h2>
|
||||
</div>
|
||||
<span class="badge" :class="{ bad: !video?.available }">
|
||||
{{ video?.source_mode ?? 'loading' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="video-shell">
|
||||
<img
|
||||
v-if="canRequestFrames"
|
||||
class="video-frame"
|
||||
:src="frameUrl"
|
||||
alt="Robot jpeg frame stream"
|
||||
/>
|
||||
<div v-else class="video-placeholder">
|
||||
正在等待 OmniSocket 实时 JPEG 帧接入...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<span>帧源</span>
|
||||
<strong>{{ video?.frame_count ?? '--' }} 张 JPEG</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>当前模式</span>
|
||||
<strong>{{ modeLabel }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
这里始终按固定频率逐张请求 Django 返回的单帧 JPEG,不依赖 MJPEG。只要后端已经收到
|
||||
OmniSocket 里的真实 JPEG 帧,这个组件就会直接显示实时画面。
|
||||
</p>
|
||||
|
||||
<p class="hint subtle">
|
||||
当前帧源状态:{{ video?.source_detail ?? '暂无' }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.video-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: #5b7aff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(40, 199, 111, 0.16);
|
||||
color: #63e6a9;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge.bad {
|
||||
background: rgba(255, 107, 107, 0.18);
|
||||
color: #ffb4b4;
|
||||
}
|
||||
|
||||
.video-shell {
|
||||
overflow: hidden;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(133, 147, 169, 0.28);
|
||||
background: linear-gradient(180deg, #09111f 0%, #050812 100%);
|
||||
}
|
||||
|
||||
.video-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
background: #02050d;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 24px;
|
||||
color: #a8b4ce;
|
||||
text-align: center;
|
||||
line-height: 1.7;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(91, 122, 255, 0.14), transparent 42%),
|
||||
#02050d;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(7, 14, 26, 0.78);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
}
|
||||
|
||||
.stat-card span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #8d99b3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #8d99b3;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.hint.subtle {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
66
frontend/src/composables/useMonitoringData.ts
Normal file
66
frontend/src/composables/useMonitoringData.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { fetchDashboardSnapshot } from '@/lib/api'
|
||||
import type { GpsTelemetry, NetworkTelemetry, VideoStatus } from '@/types'
|
||||
|
||||
type UseMonitoringDataOptions = {
|
||||
refreshIntervalMs?: number
|
||||
}
|
||||
|
||||
export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
|
||||
const gps = ref<GpsTelemetry | null>(null)
|
||||
const network = ref<NetworkTelemetry | null>(null)
|
||||
const video = ref<VideoStatus | null>(null)
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const refreshIntervalMs = Math.max(200, options.refreshIntervalMs ?? 2000)
|
||||
|
||||
let refreshTimer: number | null = null
|
||||
|
||||
async function refreshDashboard() {
|
||||
try {
|
||||
const snapshot = await fetchDashboardSnapshot()
|
||||
gps.value = snapshot.gps
|
||||
network.value = snapshot.network
|
||||
video.value = snapshot.video
|
||||
errorMessage.value = ''
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '数据加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const headerStatus = computed(() => {
|
||||
if (errorMessage.value) {
|
||||
return errorMessage.value
|
||||
}
|
||||
if (loading.value) {
|
||||
return '正在连接 Django 后端并加载监控数据...'
|
||||
}
|
||||
return '页面已连接 Django 后端。GPS 与网络状态按当前页面策略轮询更新,视频区域单独按目标 30FPS 请求单帧 JPEG。'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
refreshDashboard().catch(() => undefined)
|
||||
refreshTimer = window.setInterval(() => {
|
||||
refreshDashboard().catch(() => undefined)
|
||||
}, refreshIntervalMs)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer != null) {
|
||||
window.clearInterval(refreshTimer)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
gps,
|
||||
network,
|
||||
video,
|
||||
loading,
|
||||
errorMessage,
|
||||
headerStatus,
|
||||
refreshDashboard,
|
||||
}
|
||||
}
|
||||
21
frontend/src/lib/api.ts
Normal file
21
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { DashboardSnapshot } from '@/types'
|
||||
|
||||
const envBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined
|
||||
|
||||
export const API_BASE = (envBaseUrl?.trim() || 'http://127.0.0.1:8001').replace(/\/$/, '')
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求失败: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export function fetchDashboardSnapshot() {
|
||||
return fetchJson<DashboardSnapshot>('/api/dashboard/')
|
||||
}
|
||||
|
||||
export function buildVideoFrameUrl(frameKey: number) {
|
||||
return `${API_BASE}/api/video/frame/?frame=${frameKey}&t=${Date.now()}`
|
||||
}
|
||||
12
frontend/src/main.ts
Normal file
12
frontend/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
34
frontend/src/router/index.ts
Normal file
34
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import DashboardView from '@/views/DashboardView.vue'
|
||||
import MapView from '@/views/MapView.vue'
|
||||
import NetworkView from '@/views/NetworkView.vue'
|
||||
import VideoView from '@/views/VideoView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: DashboardView,
|
||||
},
|
||||
{
|
||||
path: '/video',
|
||||
name: 'video',
|
||||
component: VideoView,
|
||||
},
|
||||
{
|
||||
path: '/map',
|
||||
name: 'map',
|
||||
component: MapView,
|
||||
},
|
||||
{
|
||||
path: '/network',
|
||||
name: 'network',
|
||||
component: NetworkView,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
12
frontend/src/stores/counter.ts
Normal file
12
frontend/src/stores/counter.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
55
frontend/src/types.ts
Normal file
55
frontend/src/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export interface GpsTelemetry {
|
||||
has_fix: boolean
|
||||
utc_time: string
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
satellites: number | null
|
||||
altitude_m: number | null
|
||||
coordinate_system: string
|
||||
source_sentence: string
|
||||
raw_coordinate_format: string
|
||||
source_mode: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface NetworkTelemetry {
|
||||
peer_status: string
|
||||
latency_ms: number
|
||||
jitter_ms: number
|
||||
packet_loss_pct: number
|
||||
tx_kbps: number
|
||||
rx_kbps: number
|
||||
signal_dbm: number
|
||||
transport: string
|
||||
source_mode: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface VideoStatus {
|
||||
available: boolean
|
||||
source_mode: string
|
||||
frame_count: number
|
||||
fps: number
|
||||
frame_dir: string
|
||||
source_detail?: string
|
||||
receiver?: {
|
||||
backend_ready: boolean
|
||||
mode: string
|
||||
connected: boolean
|
||||
has_recent_frame: boolean
|
||||
frames_received: number
|
||||
latest_sequence: number | null
|
||||
last_error: string
|
||||
config_path: string
|
||||
server_addr?: string
|
||||
relay_via?: string
|
||||
peer_id?: string
|
||||
buffer_bytes?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface DashboardSnapshot {
|
||||
gps: GpsTelemetry
|
||||
network: NetworkTelemetry
|
||||
video: VideoStatus
|
||||
}
|
||||
93
frontend/src/views/DashboardView.vue
Normal file
93
frontend/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import GpsMapPanel from '@/components/GpsMapPanel.vue'
|
||||
import NetworkPanel from '@/components/NetworkPanel.vue'
|
||||
import VideoPanel from '@/components/VideoPanel.vue'
|
||||
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||
|
||||
const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Overview</p>
|
||||
<h1>机器人竞赛指挥台</h1>
|
||||
</div>
|
||||
<p class="hero-text">
|
||||
当前版本已经接通三块核心能力:JPEG 视频流、GPS 地图定位、网络状态展示。后面接真实
|
||||
C 数据源时,前端页面不需要大改。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="banner" :class="{ error: !!errorMessage }">
|
||||
{{ headerStatus }}
|
||||
</section>
|
||||
|
||||
<main class="layout">
|
||||
<VideoPanel :video="video" />
|
||||
<GpsMapPanel :gps="gps" />
|
||||
<NetworkPanel :network="network" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 520px);
|
||||
gap: 20px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: #8da2fb;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(34px, 5vw, 64px);
|
||||
line-height: 1.04;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
margin: 0;
|
||||
color: #c8d2e8;
|
||||
font-size: 16px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.banner {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(11, 19, 35, 0.84);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
color: #d5dbee;
|
||||
}
|
||||
|
||||
.banner.error {
|
||||
color: #ffd0d0;
|
||||
border-color: rgba(255, 107, 107, 0.28);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
74
frontend/src/views/MapView.vue
Normal file
74
frontend/src/views/MapView.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import GpsMapPanel from '@/components/GpsMapPanel.vue'
|
||||
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||
|
||||
const { gps, errorMessage, headerStatus } = useMonitoringData({
|
||||
refreshIntervalMs: 500,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Map</p>
|
||||
<h1>地图定位页面</h1>
|
||||
</div>
|
||||
<p class="description">
|
||||
这里整合了 `GeoStream` 的 GPS 展示逻辑。只要原来的 GPS 模块继续写
|
||||
`gps_latest.json`,这个页面就能直接显示实时定位。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="banner" :class="{ error: !!errorMessage }">
|
||||
{{ headerStatus }}
|
||||
</section>
|
||||
|
||||
<GpsMapPanel :gps="gps" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: #f5a524;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 4vw, 48px);
|
||||
}
|
||||
|
||||
.description,
|
||||
.banner {
|
||||
margin: 0;
|
||||
color: #d5dbee;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.banner {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(11, 19, 35, 0.84);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
}
|
||||
|
||||
.banner.error {
|
||||
color: #ffd0d0;
|
||||
border-color: rgba(255, 107, 107, 0.28);
|
||||
}
|
||||
</style>
|
||||
72
frontend/src/views/NetworkView.vue
Normal file
72
frontend/src/views/NetworkView.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import NetworkPanel from '@/components/NetworkPanel.vue'
|
||||
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||
|
||||
const { network, errorMessage, headerStatus } = useMonitoringData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Network</p>
|
||||
<h1>网络状态页面</h1>
|
||||
</div>
|
||||
<p class="description">
|
||||
当前先展示模拟网络遥测数据,后续只需要把后端采集函数替换成真实 C 输出,就能保留同样的渲染界面。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="banner" :class="{ error: !!errorMessage }">
|
||||
{{ headerStatus }}
|
||||
</section>
|
||||
|
||||
<NetworkPanel :network="network" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: #4dd4ac;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 4vw, 48px);
|
||||
}
|
||||
|
||||
.description,
|
||||
.banner {
|
||||
margin: 0;
|
||||
color: #d5dbee;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.banner {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(11, 19, 35, 0.84);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
}
|
||||
|
||||
.banner.error {
|
||||
color: #ffd0d0;
|
||||
border-color: rgba(255, 107, 107, 0.28);
|
||||
}
|
||||
</style>
|
||||
|
||||
69
frontend/src/views/VideoView.vue
Normal file
69
frontend/src/views/VideoView.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import VideoPanel from '@/components/VideoPanel.vue'
|
||||
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||
|
||||
const { video, errorMessage, headerStatus } = useMonitoringData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Video</p>
|
||||
<h1>视频流页面</h1>
|
||||
</div>
|
||||
<p class="description">这个页面专门用于看逐帧 JPEG 画面。前端会按固定频率请求单张 JPEG,后端每次返回一帧。</p>
|
||||
</header>
|
||||
|
||||
<section class="banner" :class="{ error: !!errorMessage }">
|
||||
{{ headerStatus }}
|
||||
</section>
|
||||
|
||||
<VideoPanel :video="video" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: #8da2fb;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 4vw, 48px);
|
||||
}
|
||||
|
||||
.description,
|
||||
.banner {
|
||||
margin: 0;
|
||||
color: #d5dbee;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.banner {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(11, 19, 35, 0.84);
|
||||
border: 1px solid rgba(133, 147, 169, 0.2);
|
||||
}
|
||||
|
||||
.banner.error {
|
||||
color: #ffd0d0;
|
||||
border-color: rgba(255, 107, 107, 0.28);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user