first commit
This commit is contained in:
8
frontend/.editorconfig
Normal file
8
frontend/.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
1
frontend/.gitattributes
vendored
Normal file
1
frontend/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
39
frontend/.gitignore
vendored
Normal file
39
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
10
frontend/.oxlintrc.json
Normal file
10
frontend/.oxlintrc.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
6
frontend/.prettierrc.json
Normal file
6
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
48
frontend/README.md
Normal file
48
frontend/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# frontend
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
1
frontend/env.d.ts
vendored
Normal file
1
frontend/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
26
frontend/eslint.config.ts
Normal file
26
frontend/eslint.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from 'eslint-config-prettier/flat'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
...pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
|
||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||
|
||||
skipFormatting,
|
||||
)
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
5032
frontend/package-lock.json
generated
Normal file
5032
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
frontend/package.json
Normal file
44
frontend/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "run-s lint:*",
|
||||
"lint:oxlint": "oxlint . --fix",
|
||||
"lint:eslint": "eslint . --fix --cache",
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31",
|
||||
"vue-router": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.57.0",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"jiti": "^2.6.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.57.0",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.3",
|
||||
"vite-plugin-vue-devtools": "^8.1.1",
|
||||
"vue-tsc": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
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>
|
||||
18
frontend/tsconfig.app.json
Normal file
18
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Path mapping for cleaner imports.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
11
frontend/tsconfig.json
Normal file
11
frontend/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
frontend/tsconfig.node.json
Normal file
27
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,27 @@
|
||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||
// Bundler mode provides a smoother developer experience.
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||
"types": ["node"],
|
||||
|
||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||
"noEmit": true,
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
18
frontend/vite.config.ts
Normal file
18
frontend/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user