feat: 中英文切换
This commit is contained in:
@@ -1,12 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { RouterLink, RouterView } from 'vue-router'
|
||||||
|
|
||||||
const navItems = [
|
import { useLocale } from '@/lib/locale'
|
||||||
{ to: '/', label: '总览' },
|
|
||||||
{ to: '/video', label: '视频流' },
|
const { t, toggleLocale, nextLocaleLabel } = useLocale()
|
||||||
{ to: '/map', label: '地图定位' },
|
|
||||||
{ to: '/network', label: '网络状态' },
|
const navItems = computed(() => [
|
||||||
]
|
{ to: '/', label: t('app.nav.overview') },
|
||||||
|
{ to: '/video', label: t('app.nav.video') },
|
||||||
|
{ to: '/map', label: t('app.nav.map') },
|
||||||
|
{ to: '/network', label: t('app.nav.network') },
|
||||||
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -15,21 +20,27 @@ const navItems = [
|
|||||||
<div class="brand">
|
<div class="brand">
|
||||||
<p class="brand-mark">RCC</p>
|
<p class="brand-mark">RCC</p>
|
||||||
<div>
|
<div>
|
||||||
<strong>Robot Command Center</strong>
|
<strong>{{ t('app.brandTitle') }}</strong>
|
||||||
<span>机器人竞赛指挥台</span>
|
<span>{{ t('app.brandSubtitle') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="nav">
|
<div class="topbar-actions">
|
||||||
<RouterLink
|
<nav class="nav">
|
||||||
v-for="item in navItems"
|
<RouterLink
|
||||||
:key="item.to"
|
v-for="item in navItems"
|
||||||
:to="item.to"
|
:key="item.to"
|
||||||
class="nav-link"
|
:to="item.to"
|
||||||
>
|
class="nav-link"
|
||||||
{{ item.label }}
|
>
|
||||||
</RouterLink>
|
{{ item.label }}
|
||||||
</nav>
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<button type="button" class="locale-button" @click="toggleLocale">
|
||||||
|
{{ nextLocaleLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="page-body">
|
<main class="page-body">
|
||||||
@@ -128,6 +139,14 @@ const navItems = [
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -135,7 +154,8 @@ const navItems = [
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link,
|
||||||
|
.locale-button {
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid rgba(133, 147, 169, 0.18);
|
border: 1px solid rgba(133, 147, 169, 0.18);
|
||||||
@@ -148,7 +168,8 @@ const navItems = [
|
|||||||
border-color 0.2s ease;
|
border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
.nav-link:hover,
|
||||||
|
.locale-button:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
background: rgba(25, 38, 66, 0.9);
|
background: rgba(25, 38, 66, 0.9);
|
||||||
}
|
}
|
||||||
@@ -160,6 +181,13 @@ const navItems = [
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.locale-button {
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.page-body {
|
.page-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
@@ -170,6 +198,7 @@ const navItems = [
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-actions,
|
||||||
.nav {
|
.nav {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
@@ -180,6 +209,7 @@ const navItems = [
|
|||||||
width: min(100%, calc(100% - 20px));
|
width: min(100%, calc(100% - 20px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-actions,
|
||||||
.nav {
|
.nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import { useControlInterface } from '@/composables/useControlInterface'
|
import { useControlInterface } from '@/composables/useControlInterface'
|
||||||
|
import { t } from '@/lib/locale'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
@@ -51,22 +52,43 @@ const keyClusters = computed(() => {
|
|||||||
|
|
||||||
const commandBars = computed(() => [
|
const commandBars = computed(() => [
|
||||||
{
|
{
|
||||||
label: 'Forward',
|
label: t('controlFeedback.forward'),
|
||||||
value: commandValues.value.lx,
|
value: commandValues.value.lx,
|
||||||
max: controlLimits.value.forward,
|
max: controlLimits.value.forward,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Strafe',
|
label: t('controlFeedback.strafe'),
|
||||||
value: commandValues.value.ly,
|
value: commandValues.value.ly,
|
||||||
max: controlLimits.value.strafe,
|
max: controlLimits.value.strafe,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Turn',
|
label: t('controlFeedback.turn'),
|
||||||
value: commandValues.value.az,
|
value: commandValues.value.az,
|
||||||
max: controlLimits.value.turn,
|
max: controlLimits.value.turn,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const tuningSummary = computed(() =>
|
||||||
|
t('controlFeedback.tuningSummary', {
|
||||||
|
forward: controlTuning.value.forward.toFixed(2),
|
||||||
|
strafe: controlTuning.value.strafe.toFixed(2),
|
||||||
|
turn: controlTuning.value.turn.toFixed(2),
|
||||||
|
turbo: controlTuning.value.turbo.toFixed(2),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const gamepadMeta = computed(() => {
|
||||||
|
if (!gamepadConnected.value) {
|
||||||
|
return t('controlFeedback.gamepadHint')
|
||||||
|
}
|
||||||
|
return t('controlFeedback.gamepadMeta', {
|
||||||
|
index: gamepadIndex.value ?? '--',
|
||||||
|
mapping: gamepadMapping.value || t('control.gamepad.unknownMapping'),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const outgoingCommandText = computed(() => t('controlFeedback.outgoingCommand', { command: commandLabel.value }))
|
||||||
|
|
||||||
function meterPosition(value: number, max: number) {
|
function meterPosition(value: number, max: number) {
|
||||||
const normalized = Math.max(-1, Math.min(1, value / max))
|
const normalized = Math.max(-1, Math.min(1, value / max))
|
||||||
return `${50 + normalized * 45}%`
|
return `${50 + normalized * 45}%`
|
||||||
@@ -85,7 +107,7 @@ function stickOffset(value: number) {
|
|||||||
{{ activeSourceLabel }}
|
{{ activeSourceLabel }}
|
||||||
</div>
|
</div>
|
||||||
<div class="input-chip">
|
<div class="input-chip">
|
||||||
{{ controlInputModeLabel }} mode
|
{{ t('controlFeedback.modeChip', { mode: controlInputModeLabel }) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-stack">
|
<div class="status-stack">
|
||||||
@@ -112,19 +134,18 @@ function stickOffset(value: number) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="summary">
|
<p class="summary">
|
||||||
Tuning: fwd {{ controlTuning.forward.toFixed(2) }} m/s, strafe {{ controlTuning.strafe.toFixed(2) }} m/s,
|
{{ tuningSummary }}
|
||||||
turn {{ controlTuning.turn.toFixed(2) }} rad/s, turbo x{{ controlTuning.turbo.toFixed(2) }}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="feedback-grid" :class="{ compact }">
|
<div class="feedback-grid" :class="{ compact }">
|
||||||
<section class="feedback-card">
|
<section class="feedback-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="label">Keyboard</p>
|
<p class="label">{{ t('controlFeedback.keyboard') }}</p>
|
||||||
<strong>{{ pressedKeysLabel }}</strong>
|
<strong>{{ pressedKeysLabel }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<span class="mode-chip" :class="{ hot: controlInputMode === 'keyboard' && keyboardActive }">
|
<span class="mode-chip" :class="{ hot: controlInputMode === 'keyboard' && keyboardActive }">
|
||||||
{{ controlInputMode === 'keyboard' ? (keyboardTurbo ? 'Turbo' : 'Selected') : 'Standby' }}
|
{{ controlInputMode === 'keyboard' ? (keyboardTurbo ? t('common.turbo') : t('common.selected')) : t('common.standby') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -143,31 +164,27 @@ function stickOffset(value: number) {
|
|||||||
<section class="feedback-card">
|
<section class="feedback-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="label">Gamepad</p>
|
<p class="label">{{ t('controlFeedback.gamepad') }}</p>
|
||||||
<strong>{{ gamepadConnected ? gamepadName : 'Waiting for controller' }}</strong>
|
<strong>{{ gamepadConnected ? gamepadName : t('controlFeedback.waitingForController') }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<span class="mode-chip" :class="{ hot: controlInputMode === 'gamepad' && gamepadActive }">
|
<span class="mode-chip" :class="{ hot: controlInputMode === 'gamepad' && gamepadActive }">
|
||||||
{{
|
{{
|
||||||
gamepadConnected
|
gamepadConnected
|
||||||
? controlInputMode === 'gamepad'
|
? controlInputMode === 'gamepad'
|
||||||
? 'Selected'
|
? t('common.selected')
|
||||||
: 'Standby'
|
: t('common.standby')
|
||||||
: 'Offline'
|
: t('common.offline')
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="subtle">
|
<p class="subtle">
|
||||||
{{
|
{{ gamepadMeta }}
|
||||||
gamepadConnected
|
|
||||||
? `#${gamepadIndex} / mapping=${gamepadMapping || 'unknown'}`
|
|
||||||
: 'Left stick drives, right stick turns, RB boosts, A stops.'
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="sticks">
|
<div class="sticks">
|
||||||
<div class="stick-card">
|
<div class="stick-card">
|
||||||
<span>Left stick</span>
|
<span>{{ t('controlFeedback.leftStick') }}</span>
|
||||||
<div class="stick-pad">
|
<div class="stick-pad">
|
||||||
<span class="crosshair crosshair-x" />
|
<span class="crosshair crosshair-x" />
|
||||||
<span class="crosshair crosshair-y" />
|
<span class="crosshair crosshair-y" />
|
||||||
@@ -181,7 +198,7 @@ function stickOffset(value: number) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stick-card">
|
<div class="stick-card">
|
||||||
<span>Right stick</span>
|
<span>{{ t('controlFeedback.rightStick') }}</span>
|
||||||
<div class="stick-pad">
|
<div class="stick-pad">
|
||||||
<span class="crosshair crosshair-x" />
|
<span class="crosshair crosshair-x" />
|
||||||
<span class="crosshair crosshair-y" />
|
<span class="crosshair crosshair-y" />
|
||||||
@@ -209,7 +226,7 @@ function stickOffset(value: number) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!compact" class="summary accent">
|
<p v-if="!compact" class="summary accent">
|
||||||
Outgoing command: {{ commandLabel }}
|
{{ outgoingCommandText }}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ import { computed } from 'vue'
|
|||||||
|
|
||||||
import ControlFeedback from '@/components/ControlFeedback.vue'
|
import ControlFeedback from '@/components/ControlFeedback.vue'
|
||||||
import { useControlInterface } from '@/composables/useControlInterface'
|
import { useControlInterface } from '@/composables/useControlInterface'
|
||||||
|
import { useLocale } from '@/lib/locale'
|
||||||
|
|
||||||
const { controlInputMode, controlInputModeLabel, controlTuning, resetControlTuning, setControlInputMode, setControlTuning } =
|
const { controlInputMode, controlInputModeLabel, controlTuning, resetControlTuning, setControlInputMode, setControlTuning } =
|
||||||
useControlInterface()
|
useControlInterface()
|
||||||
|
const { t } = useLocale()
|
||||||
|
|
||||||
const inputModes = [
|
const inputModes = computed(() => [
|
||||||
{ id: 'keyboard', label: 'Keyboard', detail: 'Use W/S, A/D, Q/E, Shift, and Space.' },
|
{ id: 'keyboard', label: t('common.keyboard'), detail: t('controlPanel.keyboardDetail') },
|
||||||
{ id: 'gamepad', label: 'Gamepad', detail: 'Use the browser-detected controller only.' },
|
{ id: 'gamepad', label: t('common.gamepad'), detail: t('controlPanel.gamepadDetail') },
|
||||||
] as const
|
] as const)
|
||||||
|
|
||||||
const forwardSpeed = computed({
|
const forwardSpeed = computed({
|
||||||
get: () => controlTuning.value.forward,
|
get: () => controlTuning.value.forward,
|
||||||
@@ -37,24 +39,24 @@ const turboMultiplier = computed({
|
|||||||
<section class="panel control-panel">
|
<section class="panel control-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Control</p>
|
<p class="eyebrow">{{ t('controlPanel.eyebrow') }}</p>
|
||||||
<h2>Control Feedback</h2>
|
<h2>{{ t('controlPanel.title') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="reset-button" @click="resetControlTuning">
|
<button type="button" class="reset-button" @click="resetControlTuning">
|
||||||
Reset Defaults
|
{{ t('controlPanel.resetDefaults') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="mode-panel">
|
<section class="mode-panel">
|
||||||
<div class="mode-panel-head">
|
<div class="mode-panel-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="mode-eyebrow">Input Mode</p>
|
<p class="mode-eyebrow">{{ t('controlPanel.inputModeEyebrow') }}</p>
|
||||||
<p class="mode-copy">Only one local input mode can control the page at a time.</p>
|
<p class="mode-copy">{{ t('controlPanel.inputModeCopy') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<strong class="mode-current">{{ controlInputModeLabel }}</strong>
|
<strong class="mode-current">{{ controlInputModeLabel }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mode-toggle" role="radiogroup" aria-label="Control input mode">
|
<div class="mode-toggle" role="radiogroup" :aria-label="t('controlPanel.inputModeEyebrow')">
|
||||||
<button
|
<button
|
||||||
v-for="mode in inputModes"
|
v-for="mode in inputModes"
|
||||||
:key="mode.id"
|
:key="mode.id"
|
||||||
@@ -72,25 +74,25 @@ const turboMultiplier = computed({
|
|||||||
|
|
||||||
<div class="tuning-grid">
|
<div class="tuning-grid">
|
||||||
<label class="tuning-field">
|
<label class="tuning-field">
|
||||||
<span>Forward</span>
|
<span>{{ t('controlPanel.forward') }}</span>
|
||||||
<input v-model.number="forwardSpeed" type="number" min="0.05" max="3" step="0.05" />
|
<input v-model.number="forwardSpeed" type="number" min="0.05" max="3" step="0.05" />
|
||||||
<small>m/s</small>
|
<small>m/s</small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="tuning-field">
|
<label class="tuning-field">
|
||||||
<span>Strafe</span>
|
<span>{{ t('controlPanel.strafe') }}</span>
|
||||||
<input v-model.number="strafeSpeed" type="number" min="0.05" max="3" step="0.05" />
|
<input v-model.number="strafeSpeed" type="number" min="0.05" max="3" step="0.05" />
|
||||||
<small>m/s</small>
|
<small>m/s</small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="tuning-field">
|
<label class="tuning-field">
|
||||||
<span>Turn</span>
|
<span>{{ t('controlPanel.turn') }}</span>
|
||||||
<input v-model.number="turnSpeed" type="number" min="0.05" max="3" step="0.05" />
|
<input v-model.number="turnSpeed" type="number" min="0.05" max="3" step="0.05" />
|
||||||
<small>rad/s</small>
|
<small>rad/s</small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="tuning-field">
|
<label class="tuning-field">
|
||||||
<span>Turbo</span>
|
<span>{{ t('controlPanel.turbo') }}</span>
|
||||||
<input v-model.number="turboMultiplier" type="number" min="1" max="3" step="0.1" />
|
<input v-model.number="turboMultiplier" type="number" min="1" max="3" step="0.1" />
|
||||||
<small>x</small>
|
<small>x</small>
|
||||||
</label>
|
</label>
|
||||||
@@ -99,15 +101,13 @@ const turboMultiplier = computed({
|
|||||||
<ControlFeedback />
|
<ControlFeedback />
|
||||||
|
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Keyboard mapping: <code>W/S</code> forward-back, <code>A/D</code> strafe, <code>Q/E</code> turn,
|
{{ t('controlPanel.keyboardHint') }}
|
||||||
<code>Shift</code> turbo, <code>Space</code> stop.
|
|
||||||
</p>
|
</p>
|
||||||
<p class="hint subtle">
|
<p class="hint subtle">
|
||||||
Speed tuning is shared by both local input modes and is saved in this browser.
|
{{ t('controlPanel.tuningHint') }}
|
||||||
</p>
|
</p>
|
||||||
<p class="hint subtle">
|
<p class="hint subtle">
|
||||||
Browser gamepad mode uses the left stick to drive, the right stick to turn,
|
{{ t('controlPanel.gamepadHint') }}
|
||||||
<code>RB</code> to boost, and <code>A</code> to send stop.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { formatDateTime, useLocale, type MessageKey } from '@/lib/locale'
|
||||||
import type { GpsTelemetry } from '@/types'
|
import type { GpsTelemetry } from '@/types'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -16,12 +17,19 @@ const props = defineProps<{
|
|||||||
gps: GpsTelemetry | null
|
gps: GpsTelemetry | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { locale, t } = useLocale()
|
||||||
|
|
||||||
const STORAGE_KEY = 'robot_command_center_amap'
|
const STORAGE_KEY = 'robot_command_center_amap'
|
||||||
|
|
||||||
|
type StatusState = {
|
||||||
|
key: MessageKey
|
||||||
|
params?: Record<string, string | number | null | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
const keyInput = ref('')
|
const keyInput = ref('')
|
||||||
const securityCodeInput = ref('')
|
const securityCodeInput = ref('')
|
||||||
const statusText = ref('等待加载高德地图。')
|
const statusState = ref<StatusState>({ key: 'gpsMap.status.waitingInit' })
|
||||||
const amapCoordinateText = ref('暂无')
|
const amapCoordinateRaw = ref('')
|
||||||
const mapElement = ref<HTMLDivElement | null>(null)
|
const mapElement = ref<HTMLDivElement | null>(null)
|
||||||
const mapRunning = ref(false)
|
const mapRunning = ref(false)
|
||||||
|
|
||||||
@@ -30,6 +38,12 @@ let mapInstance: any = null
|
|||||||
let marker: any = null
|
let marker: any = null
|
||||||
let infoWindow: any = null
|
let infoWindow: any = null
|
||||||
|
|
||||||
|
function setStatus(key: MessageKey, params?: Record<string, string | number | null | undefined>) {
|
||||||
|
statusState.value = { key, params }
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusText = computed(() => t(statusState.value.key, statusState.value.params))
|
||||||
|
|
||||||
function readSavedCredentials() {
|
function readSavedCredentials() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
@@ -54,7 +68,7 @@ function formatNumber(value: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatHexText(value: string | null | undefined) {
|
function formatHexText(value: string | null | undefined) {
|
||||||
return value || '暂无'
|
return value || t('gpsMap.noValue')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAmapScript(key: string, securityJsCode: string) {
|
async function loadAmapScript(key: string, securityJsCode: string) {
|
||||||
@@ -73,7 +87,7 @@ async function loadAmapScript(key: string, securityJsCode: string) {
|
|||||||
script.src = `https://webapi.amap.com/maps?v=2.0&key=${encodeURIComponent(key)}`
|
script.src = `https://webapi.amap.com/maps?v=2.0&key=${encodeURIComponent(key)}`
|
||||||
script.async = true
|
script.async = true
|
||||||
script.onload = () => resolve(window.AMap)
|
script.onload = () => resolve(window.AMap)
|
||||||
script.onerror = () => reject(new Error('高德地图脚本加载失败,请检查 Key / jscode 和网络。'))
|
script.onerror = () => reject(new Error(t('gpsMap.status.loadFailed')))
|
||||||
document.head.appendChild(script)
|
document.head.appendChild(script)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,7 +108,7 @@ function ensureMap() {
|
|||||||
|
|
||||||
marker = new window.AMap.Marker({
|
marker = new window.AMap.Marker({
|
||||||
anchor: 'bottom-center',
|
anchor: 'bottom-center',
|
||||||
title: 'Robot GPS',
|
title: t('gpsMap.infoTitle'),
|
||||||
})
|
})
|
||||||
|
|
||||||
infoWindow = new window.AMap.InfoWindow({
|
infoWindow = new window.AMap.InfoWindow({
|
||||||
@@ -104,7 +118,7 @@ function ensureMap() {
|
|||||||
|
|
||||||
function stopMap() {
|
function stopMap() {
|
||||||
mapRunning.value = false
|
mapRunning.value = false
|
||||||
amapCoordinateText.value = '已停止'
|
amapCoordinateRaw.value = ''
|
||||||
|
|
||||||
if (infoWindow) {
|
if (infoWindow) {
|
||||||
infoWindow.close()
|
infoWindow.close()
|
||||||
@@ -128,7 +142,23 @@ function stopMap() {
|
|||||||
mapElement.value.innerHTML = ''
|
mapElement.value.innerHTML = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
statusText.value = '已停止高德地图加载与坐标转换。需要时再点击“加载地图”即可。'
|
setStatus('gpsMap.status.stopped')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInfoWindowContent(gps: GpsTelemetry, lat: number, lng: number) {
|
||||||
|
const altitudeText = gps.altitude_m == null ? t('common.unknown') : `${gps.altitude_m} m`
|
||||||
|
return [
|
||||||
|
'<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;">${t('gpsMap.infoTitle')}</div>`,
|
||||||
|
`<div><span style="color: #667085;">${t('gpsMap.wgs84')}:</span> <strong style="color: #0f172a;">${formatNumber(gps.latitude!)}, ${formatNumber(gps.longitude!)}</strong></div>`,
|
||||||
|
`<div><span style="color: #667085;">${t('gpsMap.gcj02')}:</span> <strong style="color: #0f172a;">${formatNumber(lat)}, ${formatNumber(lng)}</strong></div>`,
|
||||||
|
`<div><span style="color: #667085;">${t('gpsMap.rawLatHex')}:</span> <strong style="color: #0f172a;">${formatHexText(gps.raw_latitude_hex)}</strong></div>`,
|
||||||
|
`<div><span style="color: #667085;">${t('gpsMap.rawLonHex')}:</span> <strong style="color: #0f172a;">${formatHexText(gps.raw_longitude_hex)}</strong></div>`,
|
||||||
|
`<div><span style="color: #667085;">${t('gpsMap.utcTime')}:</span> <strong style="color: #0f172a;">${gps.utc_time || '--:--:--'}</strong></div>`,
|
||||||
|
`<div><span style="color: #667085;">${t('gpsMap.infoSatellites')}:</span> <strong style="color: #0f172a;">${gps.satellites ?? t('common.unknown')}</strong></div>`,
|
||||||
|
`<div><span style="color: #667085;">${t('gpsMap.infoAltitude')}:</span> <strong style="color: #0f172a;">${altitudeText}</strong></div>`,
|
||||||
|
'</div>',
|
||||||
|
].join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMap(gps: GpsTelemetry | null) {
|
function updateMap(gps: GpsTelemetry | null) {
|
||||||
@@ -137,10 +167,10 @@ function updateMap(gps: GpsTelemetry | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!gps?.has_fix || gps.latitude == null || gps.longitude == null) {
|
if (!gps?.has_fix || gps.latitude == null || gps.longitude == null) {
|
||||||
amapCoordinateText.value = '暂无'
|
amapCoordinateRaw.value = ''
|
||||||
marker?.setMap(null)
|
marker?.setMap(null)
|
||||||
infoWindow?.close()
|
infoWindow?.close()
|
||||||
statusText.value = gps ? 'GPS 在线,但当前还没有有效定位。' : '等待 GPS 数据。'
|
setStatus(gps ? 'gpsMap.status.noFix' : 'gpsMap.status.waitingGps')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +179,7 @@ function updateMap(gps: GpsTelemetry | null) {
|
|||||||
|
|
||||||
window.AMap.convertFrom([rawLongitude, rawLatitude], 'gps', (status: string, result: any) => {
|
window.AMap.convertFrom([rawLongitude, rawLatitude], 'gps', (status: string, result: any) => {
|
||||||
if (status !== 'complete' || !result?.locations?.length) {
|
if (status !== 'complete' || !result?.locations?.length) {
|
||||||
statusText.value = 'GPS 坐标转换失败。'
|
setStatus('gpsMap.status.convertFailed')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,27 +187,14 @@ function updateMap(gps: GpsTelemetry | null) {
|
|||||||
const lng = typeof point.getLng === 'function' ? point.getLng() : point.lng
|
const lng = typeof point.getLng === 'function' ? point.getLng() : point.lng
|
||||||
const lat = typeof point.getLat === 'function' ? point.getLat() : point.lat
|
const lat = typeof point.getLat === 'function' ? point.getLat() : point.lat
|
||||||
|
|
||||||
amapCoordinateText.value = `${formatNumber(lat)}, ${formatNumber(lng)}`
|
amapCoordinateRaw.value = `${formatNumber(lat)}, ${formatNumber(lng)}`
|
||||||
marker.setPosition([lng, lat])
|
marker.setPosition([lng, lat])
|
||||||
marker.setMap(mapInstance)
|
marker.setMap(mapInstance)
|
||||||
|
|
||||||
infoWindow.setContent(
|
infoWindow.setContent(buildInfoWindowContent(gps, lat, lng))
|
||||||
[
|
|
||||||
'<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;">纬度原始 8B:</span> <strong style="color: #0f172a;">${formatHexText(gps.raw_latitude_hex)}</strong></div>`,
|
|
||||||
`<div><span style="color: #667085;">经度原始 8B:</span> <strong style="color: #0f172a;">${formatHexText(gps.raw_longitude_hex)}</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])
|
infoWindow.open(mapInstance, [lng, lat])
|
||||||
mapInstance.setZoomAndCenter(17, [lng, lat])
|
mapInstance.setZoomAndCenter(17, [lng, lat])
|
||||||
statusText.value = `地图已刷新,数据源:${gps.source_mode}`
|
setStatus('gpsMap.status.refreshedSource', { source: gps.source_mode })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,58 +203,55 @@ async function startMap() {
|
|||||||
const securityJsCode = securityCodeInput.value.trim()
|
const securityJsCode = securityCodeInput.value.trim()
|
||||||
|
|
||||||
if (!key || !securityJsCode) {
|
if (!key || !securityJsCode) {
|
||||||
statusText.value = '请先填写高德 Key 和安全密钥 jscode。'
|
setStatus('gpsMap.status.fillCredentials')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
statusText.value = '正在加载高德地图...'
|
setStatus('gpsMap.status.loading')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadAmapScript(key, securityJsCode)
|
await loadAmapScript(key, securityJsCode)
|
||||||
ensureMap()
|
ensureMap()
|
||||||
saveCredentials()
|
saveCredentials()
|
||||||
mapRunning.value = true
|
mapRunning.value = true
|
||||||
statusText.value = '地图已加载。'
|
setStatus('gpsMap.status.loaded')
|
||||||
updateMap(props.gps)
|
updateMap(props.gps)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusText.value = error instanceof Error ? error.message : '地图加载失败。'
|
setStatus('gpsMap.status.loadFailed')
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
statusState.value = { key: 'gpsMap.status.loadFailed', params: { message: error.message } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawCoordinateText = computed(() => {
|
const rawCoordinateText = computed(() => {
|
||||||
if (!props.gps?.has_fix || props.gps.latitude == null || props.gps.longitude == null) {
|
if (!props.gps?.has_fix || props.gps.latitude == null || props.gps.longitude == null) {
|
||||||
return '暂无有效定位'
|
return t('gpsMap.noValidFix')
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${formatNumber(props.gps.latitude)}, ${formatNumber(props.gps.longitude)}`
|
return `${formatNumber(props.gps.latitude)}, ${formatNumber(props.gps.longitude)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const amapCoordinateText = computed(() => amapCoordinateRaw.value || t('gpsMap.noValue'))
|
||||||
const rawLatitudeHexText = computed(() => formatHexText(props.gps?.raw_latitude_hex))
|
const rawLatitudeHexText = computed(() => formatHexText(props.gps?.raw_latitude_hex))
|
||||||
|
|
||||||
const rawLongitudeHexText = computed(() => formatHexText(props.gps?.raw_longitude_hex))
|
const rawLongitudeHexText = computed(() => formatHexText(props.gps?.raw_longitude_hex))
|
||||||
|
|
||||||
const coordinateMetaText = computed(() => {
|
const coordinateMetaText = computed(() => {
|
||||||
if (!props.gps) {
|
if (!props.gps) {
|
||||||
return '暂无'
|
return t('gpsMap.noValue')
|
||||||
}
|
}
|
||||||
return `${props.gps.coordinate_system} / ${props.gps.raw_coordinate_format}`
|
return `${props.gps.coordinate_system} / ${props.gps.raw_coordinate_format}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const metaText = computed(() => {
|
const metaText = computed(() => {
|
||||||
if (!props.gps) {
|
if (!props.gps) {
|
||||||
return '暂无'
|
return t('gpsMap.noValue')
|
||||||
}
|
}
|
||||||
const satellites = props.gps.satellites ?? '未知'
|
const satellites = props.gps.satellites ?? t('common.unknown')
|
||||||
const altitude = props.gps.altitude_m == null ? '未知' : `${props.gps.altitude_m} m`
|
const altitude = props.gps.altitude_m == null ? t('common.unknown') : `${props.gps.altitude_m} m`
|
||||||
return `${satellites} 颗 / ${altitude}`
|
return `${satellites} / ${altitude}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const updatedAtText = computed(() => {
|
const updatedAtText = computed(() => formatDateTime(props.gps?.updated_at))
|
||||||
if (!props.gps?.updated_at) {
|
|
||||||
return '暂无'
|
|
||||||
}
|
|
||||||
return new Date(props.gps.updated_at).toLocaleString('zh-CN', { hour12: false })
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const saved = readSavedCredentials()
|
const saved = readSavedCredentials()
|
||||||
@@ -245,7 +259,7 @@ onMounted(() => {
|
|||||||
keyInput.value = saved.key ?? ''
|
keyInput.value = saved.key ?? ''
|
||||||
securityCodeInput.value = saved.securityJsCode ?? ''
|
securityCodeInput.value = saved.securityJsCode ?? ''
|
||||||
if (keyInput.value && securityCodeInput.value) {
|
if (keyInput.value && securityCodeInput.value) {
|
||||||
statusText.value = '已恢复高德配置。高德地图不会自动加载,请按需点击“加载地图”。'
|
setStatus('gpsMap.status.restoredConfig')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -257,70 +271,76 @@ watch(
|
|||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => locale.value,
|
||||||
|
() => {
|
||||||
|
if (mapRunning.value) {
|
||||||
|
updateMap(props.gps)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="panel map-panel">
|
<section class="panel map-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">GPS</p>
|
<p class="eyebrow">{{ t('gpsMap.eyebrow') }}</p>
|
||||||
<h2>地图定位</h2>
|
<h2>{{ t('gpsMap.title') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge">{{ gps?.source_mode ?? 'loading' }}</span>
|
<span class="badge">{{ gps?.source_mode ?? t('common.loading') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="intro">
|
<p class="intro">{{ t('gpsMap.intro') }}</p>
|
||||||
这里复用了你原来 `GeoStream/gps_map.html` 的高德地图思路。后端优先读取
|
|
||||||
`GeoStream/gps_latest.json`,所以你运行 `parse_gps.c` 生成数据后,这里会直接接上。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="credentials">
|
<div class="credentials">
|
||||||
<input v-model="keyInput" type="text" placeholder="高德 Web 端 Key" />
|
<input v-model="keyInput" type="text" :placeholder="t('gpsMap.keyPlaceholder')" />
|
||||||
<input v-model="securityCodeInput" type="text" placeholder="安全密钥 jscode" />
|
<input v-model="securityCodeInput" type="text" :placeholder="t('gpsMap.jscodePlaceholder')" />
|
||||||
<button type="button" @click="startMap">加载地图</button>
|
<button type="button" @click="startMap">{{ t('gpsMap.loadMap') }}</button>
|
||||||
<button type="button" class="secondary" @click="stopMap">停止加载</button>
|
<button type="button" class="secondary" @click="stopMap">{{ t('gpsMap.stopMap') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status">{{ statusText }}</div>
|
<div class="status">{{ statusText }}</div>
|
||||||
|
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<span>WGS84 坐标</span>
|
<span>{{ t('gpsMap.wgs84') }}</span>
|
||||||
<strong>{{ rawCoordinateText }}</strong>
|
<strong>{{ rawCoordinateText }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<span>高德 GCJ-02</span>
|
<span>{{ t('gpsMap.gcj02') }}</span>
|
||||||
<strong>{{ amapCoordinateText }}</strong>
|
<strong>{{ amapCoordinateText }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<span>纬度原始 8 字节</span>
|
<span>{{ t('gpsMap.rawLatHex') }}</span>
|
||||||
<strong class="mono">{{ rawLatitudeHexText }}</strong>
|
<strong class="mono">{{ rawLatitudeHexText }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<span>经度原始 8 字节</span>
|
<span>{{ t('gpsMap.rawLonHex') }}</span>
|
||||||
<strong class="mono">{{ rawLongitudeHexText }}</strong>
|
<strong class="mono">{{ rawLongitudeHexText }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<span>UTC 时间</span>
|
<span>{{ t('gpsMap.utcTime') }}</span>
|
||||||
<strong>{{ gps?.utc_time ?? '--:--:--' }}</strong>
|
<strong>{{ gps?.utc_time ?? '--:--:--' }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<span>卫星 / 海拔</span>
|
<span>{{ t('gpsMap.satAltitude') }}</span>
|
||||||
<strong>{{ metaText }}</strong>
|
<strong>{{ metaText }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<span>坐标系 / 格式</span>
|
<span>{{ t('gpsMap.coordMeta') }}</span>
|
||||||
<strong>{{ coordinateMetaText }}</strong>
|
<strong>{{ coordinateMetaText }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-card">
|
<div class="detail-card">
|
||||||
<span>最近刷新</span>
|
<span>{{ t('gpsMap.lastUpdated') }}</span>
|
||||||
<strong>{{ updatedAtText }}</strong>
|
<strong>{{ updatedAtText }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="mapElement" class="map-canvas" :class="{ stopped: !mapRunning }">
|
<div ref="mapElement" class="map-canvas" :class="{ stopped: !mapRunning }">
|
||||||
<div v-if="!mapRunning" class="map-placeholder">
|
<div v-if="!mapRunning" class="map-placeholder">
|
||||||
高德地图当前未加载。点击上方“加载地图”后才会开始请求地图与坐标转换服务。
|
{{ t('gpsMap.mapPlaceholder') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -423,7 +443,7 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-card strong.mono {
|
.detail-card strong.mono {
|
||||||
font-family: "JetBrains Mono", "SFMono-Regular", monospace;
|
font-family: 'JetBrains Mono', 'SFMono-Regular', monospace;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { formatDateTime, t } from '@/lib/locale'
|
||||||
import type { LinkSessionTelemetry, LinkTelemetry, NetworkTelemetry } from '@/types'
|
import type { LinkSessionTelemetry, LinkTelemetry, NetworkTelemetry } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -20,13 +21,10 @@ const legCards = computed(() => [
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const activeSource = computed(() => props.network?.active_control_source ?? 'none')
|
const activeSource = computed(() => formatControlSource(props.network?.active_control_source))
|
||||||
|
|
||||||
function formatTime(value?: string | null) {
|
function formatTime(value?: string | null) {
|
||||||
if (!value) {
|
return formatDateTime(value)
|
||||||
return 'unavailable'
|
|
||||||
}
|
|
||||||
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatScalar(value?: number | string | null, suffix = '') {
|
function formatScalar(value?: number | string | null, suffix = '') {
|
||||||
@@ -45,64 +43,105 @@ function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: Li
|
|||||||
{ name: 'video', data: link?.sessions?.video ?? null },
|
{ name: 'video', data: link?.sessions?.video ?? null },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatControlSource(source?: string | null) {
|
||||||
|
if (source === 'keyboard') return t('common.keyboard')
|
||||||
|
if (source === 'gamepad') return t('common.gamepad')
|
||||||
|
return t('common.none')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBoolean(value?: boolean | number | null) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return t('common.na')
|
||||||
|
}
|
||||||
|
return value ? t('common.yes') : t('common.no')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAckMode(ackAvailable?: boolean) {
|
||||||
|
return ackAvailable ? t('common.ackLoop') : t('common.srttFallback')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStale(stale?: boolean | null) {
|
||||||
|
if (stale == null) {
|
||||||
|
return t('common.na')
|
||||||
|
}
|
||||||
|
return stale ? t('common.stale') : t('common.fresh')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOnline(online?: boolean | null) {
|
||||||
|
if (online == null) {
|
||||||
|
return t('common.na')
|
||||||
|
}
|
||||||
|
return online ? t('common.online') : t('common.idle')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSessionName(name: string) {
|
||||||
|
return name === 'control' ? t('common.control') : t('common.video')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTrend(value?: string | null) {
|
||||||
|
if (value === 'rising') return t('common.rising')
|
||||||
|
if (value === 'falling') return t('common.falling')
|
||||||
|
return t('common.stable')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="panel network-panel">
|
<section class="panel network-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Network</p>
|
<p class="eyebrow">{{ t('networkPanel.eyebrow') }}</p>
|
||||||
<h2>Dual-Leg Telemetry</h2>
|
<h2>{{ t('networkPanel.title') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge" :class="{ stale: network?.telemetry_receiver?.hub_stale }">
|
<span class="badge" :class="{ stale: network?.telemetry_receiver?.hub_stale }">
|
||||||
{{ network?.peer_status ?? 'loading' }}
|
{{ network?.peer_status ?? t('networkPanel.loadingPeer') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>Control Loop RTT</span>
|
<span>{{ t('networkPanel.controlLoopRtt') }}</span>
|
||||||
<strong>{{ formatScalar(network?.latency_estimate?.control_loop_rtt_ms, ' ms') }}</strong>
|
<strong>{{ formatScalar(network?.latency_estimate?.control_loop_rtt_ms, ' ms') }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>Control to Persist</span>
|
<span>{{ t('networkPanel.controlToPersist') }}</span>
|
||||||
<strong>{{ formatScalar(network?.latency_estimate?.control_to_persist_est_ms, ' ms') }}</strong>
|
<strong>{{ formatScalar(network?.latency_estimate?.control_to_persist_est_ms, ' ms') }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>Control SRTT One-way</span>
|
<span>{{ t('networkPanel.controlSrttOneWay') }}</span>
|
||||||
<strong>{{ formatScalar(network?.latency_estimate?.control_oneway_srtt_est_ms, ' ms') }}</strong>
|
<strong>{{ formatScalar(network?.latency_estimate?.control_oneway_srtt_est_ms, ' ms') }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>Video One-way Est.</span>
|
<span>{{ t('networkPanel.videoOneWayEst') }}</span>
|
||||||
<strong>{{ formatScalar(network?.latency_estimate?.video_network_oneway_est_ms, ' ms') }}</strong>
|
<strong>{{ formatScalar(network?.latency_estimate?.video_network_oneway_est_ms, ' ms') }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>TX Rate</span>
|
<span>{{ t('networkPanel.txRate') }}</span>
|
||||||
<strong>{{ formatScalar(network?.tx_kbps, ' kbps') }}</strong>
|
<strong>{{ formatScalar(network?.tx_kbps, ' kbps') }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>RX Rate</span>
|
<span>{{ t('networkPanel.rxRate') }}</span>
|
||||||
<strong>{{ formatScalar(network?.rx_kbps, ' kbps') }}</strong>
|
<strong>{{ formatScalar(network?.rx_kbps, ' kbps') }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="summary telemetry-strip">
|
<div class="summary telemetry-strip">
|
||||||
<p><strong>Robot Fault:</strong> {{ network?.robot_health?.fault_reason ?? 'n/a' }}</p>
|
<p><strong>{{ t('networkPanel.robotFault') }}:</strong> {{ network?.robot_health?.fault_reason ?? t('common.na') }}</p>
|
||||||
<p><strong>Recovery State:</strong> {{ network?.robot_health?.recovery_state ?? 'n/a' }}</p>
|
<p><strong>{{ t('networkPanel.recoveryState') }}:</strong> {{ network?.robot_health?.recovery_state ?? t('common.na') }}</p>
|
||||||
<p><strong>Health Confidence:</strong> {{ network?.robot_health?.confidence ?? 'n/a' }}</p>
|
<p><strong>{{ t('networkPanel.healthConfidence') }}:</strong> {{ network?.robot_health?.confidence ?? t('common.na') }}</p>
|
||||||
<p><strong>Health Updated:</strong> {{ formatTime(network?.robot_health?.updated_at) }}</p>
|
<p><strong>{{ t('networkPanel.healthUpdated') }}:</strong> {{ formatTime(network?.robot_health?.updated_at) }}</p>
|
||||||
<p><strong>Transport:</strong> {{ network?.transport ?? 'n/a' }} / {{ network?.source_mode ?? 'n/a' }}</p>
|
<p><strong>{{ t('networkPanel.transport') }}:</strong> {{ network?.transport ?? t('common.na') }} / {{ network?.source_mode ?? t('common.na') }}</p>
|
||||||
<p><strong>Active Control:</strong> {{ activeSource }}</p>
|
<p><strong>{{ t('networkPanel.activeControl') }}:</strong> {{ activeSource }}</p>
|
||||||
<p><strong>Lease:</strong> {{ formatScalar(network?.control_lease_remaining_ms, ' ms') }}</p>
|
<p><strong>{{ t('networkPanel.lease') }}:</strong> {{ formatScalar(network?.control_lease_remaining_ms, ' ms') }}</p>
|
||||||
<p><strong>ACK Mode:</strong> {{ network?.control_ack_status?.ack_available ? 'ack-loop' : 'srtt-fallback' }}</p>
|
<p><strong>{{ t('networkPanel.ackMode') }}:</strong> {{ formatAckMode(network?.control_ack_status?.ack_available) }}</p>
|
||||||
<p><strong>ACK Updated:</strong> {{ formatTime(network?.control_ack_status?.updated_at) }}</p>
|
<p><strong>{{ t('networkPanel.ackUpdated') }}:</strong> {{ formatTime(network?.control_ack_status?.updated_at) }}</p>
|
||||||
<p><strong>Telemetry Peer:</strong> {{ network?.telemetry_receiver?.peer_id ?? 'n/a' }}</p>
|
<p><strong>{{ t('networkPanel.telemetryPeer') }}:</strong> {{ network?.telemetry_receiver?.peer_id ?? t('common.na') }}</p>
|
||||||
<p><strong>Telemetry Registered:</strong> {{ network?.telemetry_receiver?.registered ? 'yes' : 'no' }}</p>
|
<p><strong>{{ t('networkPanel.telemetryRegistered') }}:</strong> {{ formatBoolean(network?.telemetry_receiver?.registered) }}</p>
|
||||||
<p><strong>Hub Freshness:</strong> {{ formatTime(network?.telemetry_receiver?.hub_updated_at) }}</p>
|
<p><strong>{{ t('networkPanel.hubFreshness') }}:</strong> {{ formatTime(network?.telemetry_receiver?.hub_updated_at) }}</p>
|
||||||
<p><strong>Hub State:</strong> {{ network?.telemetry_receiver?.hub_stale ? 'stale' : 'fresh' }}</p>
|
<p><strong>{{ t('networkPanel.hubState') }}:</strong> {{ formatStale(network?.telemetry_receiver?.hub_stale) }}</p>
|
||||||
<p><strong>Telemetry Reconnects:</strong> {{ network?.telemetry_receiver?.reconnect_count ?? 0 }}</p>
|
<p><strong>{{ t('networkPanel.telemetryReconnects') }}:</strong> {{ network?.telemetry_receiver?.reconnect_count ?? 0 }}</p>
|
||||||
<p v-if="network?.telemetry_receiver?.last_error"><strong>Hub Error:</strong> {{ network?.telemetry_receiver?.last_error }}</p>
|
<p v-if="network?.telemetry_receiver?.last_error"><strong>{{ t('networkPanel.hubError') }}:</strong> {{ network?.telemetry_receiver?.last_error }}</p>
|
||||||
<p v-if="network?.telemetry_receiver?.last_server_error"><strong>Telemetry Session Error:</strong> {{ network?.telemetry_receiver?.last_server_error }}</p>
|
<p v-if="network?.telemetry_receiver?.last_server_error"><strong>{{ t('networkPanel.telemetrySessionError') }}:</strong> {{ network?.telemetry_receiver?.last_server_error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="leg-grid">
|
<div class="leg-grid">
|
||||||
@@ -110,11 +149,11 @@ function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: Li
|
|||||||
<div class="leg-head">
|
<div class="leg-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="leg-label">{{ leg.label }}</p>
|
<p class="leg-label">{{ leg.label }}</p>
|
||||||
<h3>{{ leg.data?.source ?? 'waiting' }}</h3>
|
<h3>{{ leg.data?.source ?? t('common.waiting') }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="leg-meta">
|
<div class="leg-meta">
|
||||||
<span class="mini-badge" :class="{ stale: leg.data?.stale }">
|
<span class="mini-badge" :class="{ stale: leg.data?.stale }">
|
||||||
{{ leg.data?.stale ? 'stale' : 'fresh' }}
|
{{ formatStale(leg.data?.stale) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="mini-time">{{ formatTime(leg.data?.updated_at) }}</span>
|
<span class="mini-time">{{ formatTime(leg.data?.updated_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,27 +161,27 @@ function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: Li
|
|||||||
|
|
||||||
<div class="aggregate-grid">
|
<div class="aggregate-grid">
|
||||||
<div>
|
<div>
|
||||||
<span>Online</span>
|
<span>{{ t('networkPanel.online') }}</span>
|
||||||
<strong>{{ leg.data?.aggregate?.online_sessions ?? 0 }}</strong>
|
<strong>{{ leg.data?.aggregate?.online_sessions ?? 0 }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Max Pressure</span>
|
<span>{{ t('networkPanel.maxPressure') }}</span>
|
||||||
<strong>{{ formatScalar(leg.data?.aggregate?.max_window_pressure_pct, '%') }}</strong>
|
<strong>{{ formatScalar(leg.data?.aggregate?.max_window_pressure_pct, '%') }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Queued</span>
|
<span>{{ t('networkPanel.queued') }}</span>
|
||||||
<strong>{{ leg.data?.aggregate?.sum_snd_queue ?? 0 }}</strong>
|
<strong>{{ leg.data?.aggregate?.sum_snd_queue ?? 0 }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>In Flight Buffer</span>
|
<span>{{ t('networkPanel.inFlightBuffer') }}</span>
|
||||||
<strong>{{ leg.data?.aggregate?.sum_snd_buffer ?? 0 }}</strong>
|
<strong>{{ leg.data?.aggregate?.sum_snd_buffer ?? 0 }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Retrans Delta</span>
|
<span>{{ t('networkPanel.retransDelta') }}</span>
|
||||||
<strong>{{ leg.data?.aggregate?.sum_retrans_delta ?? 0 }}</strong>
|
<strong>{{ leg.data?.aggregate?.sum_retrans_delta ?? 0 }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Repair Rate</span>
|
<span>{{ t('networkPanel.repairRate') }}</span>
|
||||||
<strong>{{ formatScalar(leg.data?.aggregate?.repair_rate_pct, '%') }}</strong>
|
<strong>{{ formatScalar(leg.data?.aggregate?.repair_rate_pct, '%') }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,36 +190,36 @@ function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: Li
|
|||||||
<section v-for="session in legSessions(leg.data)" :key="session.name" class="session-card">
|
<section v-for="session in legSessions(leg.data)" :key="session.name" class="session-card">
|
||||||
<div class="session-head">
|
<div class="session-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="session-label">{{ session.name }}</p>
|
<p class="session-label">{{ formatSessionName(session.name) }}</p>
|
||||||
<h4>{{ session.data?.peer_id ?? 'unassigned' }}</h4>
|
<h4>{{ session.data?.peer_id ?? t('networkPanel.unassigned') }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<span class="mini-badge" :class="{ stale: session.data?.stale, active: session.data?.connected }">
|
<span class="mini-badge" :class="{ stale: session.data?.stale, active: session.data?.connected }">
|
||||||
{{ session.data?.connected ? 'online' : 'idle' }}
|
{{ formatOnline(session.data?.connected) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kv-grid">
|
<div class="kv-grid">
|
||||||
<p><strong>Updated:</strong> {{ formatTime(session.data?.updated_at) }}</p>
|
<p><strong>{{ t('networkPanel.updated') }}:</strong> {{ formatTime(session.data?.updated_at) }}</p>
|
||||||
<p><strong>SRTT:</strong> {{ formatScalar(session.data?.kcp?.srtt_ms, ' ms') }}</p>
|
<p><strong>{{ t('networkPanel.srtt') }}:</strong> {{ formatScalar(session.data?.kcp?.srtt_ms, ' ms') }}</p>
|
||||||
<p><strong>RTTVAR:</strong> {{ formatScalar(session.data?.kcp?.srttvar_ms, ' ms') }}</p>
|
<p><strong>{{ t('networkPanel.rttvar') }}:</strong> {{ formatScalar(session.data?.kcp?.srttvar_ms, ' ms') }}</p>
|
||||||
<p><strong>RTO:</strong> {{ formatScalar(session.data?.kcp?.rto_ms, ' ms') }}</p>
|
<p><strong>{{ t('networkPanel.rto') }}:</strong> {{ formatScalar(session.data?.kcp?.rto_ms, ' ms') }}</p>
|
||||||
<p><strong>SND WND:</strong> {{ formatScalar(session.data?.kcp?.snd_wnd) }}</p>
|
<p><strong>{{ t('networkPanel.sndWnd') }}:</strong> {{ formatScalar(session.data?.kcp?.snd_wnd) }}</p>
|
||||||
<p><strong>RMT WND:</strong> {{ formatScalar(session.data?.kcp?.rmt_wnd) }}</p>
|
<p><strong>{{ t('networkPanel.rmtWnd') }}:</strong> {{ formatScalar(session.data?.kcp?.rmt_wnd) }}</p>
|
||||||
<p><strong>Inflight:</strong> {{ formatScalar(session.data?.kcp?.inflight) }}</p>
|
<p><strong>{{ t('networkPanel.inflight') }}:</strong> {{ formatScalar(session.data?.kcp?.inflight) }}</p>
|
||||||
<p><strong>Window Limit:</strong> {{ formatScalar(session.data?.kcp?.window_limit) }}</p>
|
<p><strong>{{ t('networkPanel.windowLimit') }}:</strong> {{ formatScalar(session.data?.kcp?.window_limit) }}</p>
|
||||||
<p><strong>Pressure:</strong> {{ formatScalar(session.data?.kcp?.window_pressure_pct, '%') }}</p>
|
<p><strong>{{ t('networkPanel.pressure') }}:</strong> {{ formatScalar(session.data?.kcp?.window_pressure_pct, '%') }}</p>
|
||||||
<p><strong>SND Queue:</strong> {{ formatScalar(session.data?.kcp?.snd_queue) }} / {{ session.data?.trend?.snd_queue_trend ?? 'stable' }}</p>
|
<p><strong>{{ t('networkPanel.sndQueue') }}:</strong> {{ formatScalar(session.data?.kcp?.snd_queue) }} / {{ formatTrend(session.data?.trend?.snd_queue_trend) }}</p>
|
||||||
<p><strong>SND Buffer:</strong> {{ formatScalar(session.data?.kcp?.snd_buffer) }} / {{ session.data?.trend?.snd_buffer_trend ?? 'stable' }}</p>
|
<p><strong>{{ t('networkPanel.sndBuffer') }}:</strong> {{ formatScalar(session.data?.kcp?.snd_buffer) }} / {{ formatTrend(session.data?.trend?.snd_buffer_trend) }}</p>
|
||||||
<p><strong>Queue Delta:</strong> {{ formatScalar(session.data?.trend?.snd_queue_delta) }}</p>
|
<p><strong>{{ t('networkPanel.queueDelta') }}:</strong> {{ formatScalar(session.data?.trend?.snd_queue_delta) }}</p>
|
||||||
<p><strong>Buffer Delta:</strong> {{ formatScalar(session.data?.trend?.snd_buffer_delta) }}</p>
|
<p><strong>{{ t('networkPanel.bufferDelta') }}:</strong> {{ formatScalar(session.data?.trend?.snd_buffer_delta) }}</p>
|
||||||
<p><strong>Retrans:</strong> {{ formatScalar(session.data?.trend?.retrans_delta) }}</p>
|
<p><strong>{{ t('networkPanel.retrans') }}:</strong> {{ formatScalar(session.data?.trend?.retrans_delta) }}</p>
|
||||||
<p><strong>Fast Retrans:</strong> {{ formatScalar(session.data?.trend?.fast_retrans_delta) }}</p>
|
<p><strong>{{ t('networkPanel.fastRetrans') }}:</strong> {{ formatScalar(session.data?.trend?.fast_retrans_delta) }}</p>
|
||||||
<p><strong>Lost:</strong> {{ formatScalar(session.data?.trend?.lost_delta) }}</p>
|
<p><strong>{{ t('networkPanel.lost') }}:</strong> {{ formatScalar(session.data?.trend?.lost_delta) }}</p>
|
||||||
<p><strong>Repeat:</strong> {{ formatScalar(session.data?.trend?.repeat_delta) }}</p>
|
<p><strong>{{ t('networkPanel.repeat') }}:</strong> {{ formatScalar(session.data?.trend?.repeat_delta) }}</p>
|
||||||
<p><strong>Repair Rate:</strong> {{ formatScalar(session.data?.trend?.repair_rate_pct, '%') }}</p>
|
<p><strong>{{ t('networkPanel.repairRate') }}:</strong> {{ formatScalar(session.data?.trend?.repair_rate_pct, '%') }}</p>
|
||||||
<p v-if="session.data?.app"><strong>App Bytes:</strong> tx={{ session.data.app.send_bytes ?? 0 }} / rx={{ session.data.app.recv_bytes ?? 0 }}</p>
|
<p v-if="session.data?.app"><strong>{{ t('networkPanel.appBytes') }}:</strong> tx={{ session.data.app.send_bytes ?? 0 }} / rx={{ session.data.app.recv_bytes ?? 0 }}</p>
|
||||||
<p v-if="session.data?.app"><strong>Registered:</strong> {{ session.data.app.registered ? 'yes' : 'no' }}</p>
|
<p v-if="session.data?.app"><strong>{{ t('networkPanel.registered') }}:</strong> {{ formatBoolean(session.data.app.registered) }}</p>
|
||||||
<p v-if="session.data?.app?.last_server_error"><strong>Server Error:</strong> {{ session.data.app.last_server_error }}</p>
|
<p v-if="session.data?.app?.last_server_error"><strong>{{ t('networkPanel.serverError') }}:</strong> {{ session.data.app.last_server_error }}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,15 +227,15 @@ function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: Li
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<p><strong>Combined:</strong> sessions={{ network?.combined?.connected_sessions ?? 0 }} send={{ network?.combined?.send_bytes ?? 0 }}B recv={{ network?.combined?.recv_bytes ?? 0 }}B</p>
|
<p><strong>{{ t('networkPanel.combined') }}:</strong> sessions={{ network?.combined?.connected_sessions ?? 0 }} send={{ network?.combined?.send_bytes ?? 0 }}B recv={{ network?.combined?.recv_bytes ?? 0 }}B</p>
|
||||||
<p><strong>Video E2E Est.:</strong> {{ formatScalar(network?.latency_estimate?.video_e2e_est_ms, ' ms') }} / confidence={{ network?.latency_estimate?.confidence?.video ?? 'n/a' }}</p>
|
<p><strong>{{ t('networkPanel.videoE2E') }}:</strong> {{ formatScalar(network?.latency_estimate?.video_e2e_est_ms, ' ms') }} / confidence={{ network?.latency_estimate?.confidence?.video ?? t('common.na') }}</p>
|
||||||
<p><strong>Control Estimate Confidence:</strong> {{ network?.latency_estimate?.confidence?.control ?? 'n/a' }}</p>
|
<p><strong>{{ t('networkPanel.controlEstimateConfidence') }}:</strong> {{ network?.latency_estimate?.confidence?.control ?? t('common.na') }}</p>
|
||||||
<p><strong>Video Freshness:</strong> repeat={{ formatScalar((network?.video_freshness?.repeated_frame_ratio ?? 0) * 100, '%') }} skip={{ formatScalar((network?.video_freshness?.skip_ratio ?? 0) * 100, '%') }} freeze={{ formatScalar(network?.video_freshness?.longest_freeze_ms, ' ms') }}</p>
|
<p><strong>{{ t('networkPanel.videoFreshness') }}:</strong> {{ t('networkPanel.videoFreshnessRepeat') }}={{ formatScalar((network?.video_freshness?.repeated_frame_ratio ?? 0) * 100, '%') }} {{ t('networkPanel.videoFreshnessSkip') }}={{ formatScalar((network?.video_freshness?.skip_ratio ?? 0) * 100, '%') }} {{ t('networkPanel.videoFreshnessFreeze') }}={{ formatScalar(network?.video_freshness?.longest_freeze_ms, ' ms') }}</p>
|
||||||
<p><strong>Native UDP:</strong> {{ network?.ingress?.native_udp?.bind_addr ?? 'n/a' }} packets={{ network?.ingress?.native_udp?.packets_received ?? 0 }} invalid={{ network?.ingress?.native_udp?.invalid_packets ?? 0 }}</p>
|
<p><strong>{{ t('networkPanel.nativeUdp') }}:</strong> {{ network?.ingress?.native_udp?.bind_addr ?? t('common.na') }} packets={{ network?.ingress?.native_udp?.packets_received ?? 0 }} invalid={{ network?.ingress?.native_udp?.invalid_packets ?? 0 }}</p>
|
||||||
<p><strong>Control Sender:</strong> {{ network?.control?.sender?.peer_id ?? 'n/a' }} -> {{ network?.control?.sender?.target_peer ?? 'n/a' }} sends={{ network?.control?.sender?.send_count ?? 0 }} registered={{ network?.control?.sender?.registered ? 'yes' : 'no' }}</p>
|
<p><strong>{{ t('networkPanel.controlSender') }}:</strong> {{ network?.control?.sender?.peer_id ?? t('common.na') }} -> {{ network?.control?.sender?.target_peer ?? t('common.na') }} sends={{ network?.control?.sender?.send_count ?? 0 }} registered={{ formatBoolean(network?.control?.sender?.registered) }}</p>
|
||||||
<p><strong>ACK Receiver:</strong> {{ network?.control?.ack_receiver?.peer_id ?? 'n/a' }} reconnects={{ network?.control?.ack_receiver?.reconnect_count ?? 0 }}</p>
|
<p><strong>{{ t('networkPanel.ackReceiver') }}:</strong> {{ network?.control?.ack_receiver?.peer_id ?? t('common.na') }} reconnects={{ network?.control?.ack_receiver?.reconnect_count ?? 0 }}</p>
|
||||||
<p><strong>Control Reconnects:</strong> {{ network?.control?.sender?.reconnect_count ?? 0 }}</p>
|
<p><strong>{{ t('networkPanel.controlReconnects') }}:</strong> {{ network?.control?.sender?.reconnect_count ?? 0 }}</p>
|
||||||
<p v-if="network?.control?.sender?.last_server_error"><strong>Control Session Error:</strong> {{ network?.control?.sender?.last_server_error }}</p>
|
<p v-if="network?.control?.sender?.last_server_error"><strong>{{ t('networkPanel.controlSessionError') }}:</strong> {{ network?.control?.sender?.last_server_error }}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { buildVideoFrameUrl, fetchClockCalibrationSample, fetchVideoStatus, postVideoDisplayProbe } from '@/lib/api'
|
import { buildVideoFrameUrl, fetchClockCalibrationSample, fetchVideoStatus, postVideoDisplayProbe } from '@/lib/api'
|
||||||
|
import { t } from '@/lib/locale'
|
||||||
import { useOperatorInputTelemetry } from '@/composables/useControlInterface'
|
import { useOperatorInputTelemetry } from '@/composables/useControlInterface'
|
||||||
import type { NetworkTelemetry, VideoStatus } from '@/types'
|
import type { NetworkTelemetry, VideoStatus } from '@/types'
|
||||||
|
|
||||||
@@ -62,10 +63,10 @@ const clockCalibration = ref<ClockCalibrationSnapshot>({ ...EMPTY_CLOCK_CALIBRAT
|
|||||||
|
|
||||||
const modeLabel = computed(() => {
|
const modeLabel = computed(() => {
|
||||||
if (!displayVideo.value) {
|
if (!displayVideo.value) {
|
||||||
return 'loading'
|
return t('videoPanel.mode.loading')
|
||||||
}
|
}
|
||||||
if (displayVideo.value.source_mode === 'omnisocket-jpeg-live') {
|
if (displayVideo.value.source_mode === 'omnisocket-jpeg-live') {
|
||||||
return `${displayVideo.value.fps} FPS live`
|
return t('videoPanel.mode.live', { fps: displayVideo.value.fps })
|
||||||
}
|
}
|
||||||
return displayVideo.value.source_mode
|
return displayVideo.value.source_mode
|
||||||
})
|
})
|
||||||
@@ -73,7 +74,7 @@ const modeLabel = computed(() => {
|
|||||||
const timingHeadline = computed(() => {
|
const timingHeadline = computed(() => {
|
||||||
const latest = senderClockDebug.value?.sender_clock_delta_ms_raw
|
const latest = senderClockDebug.value?.sender_clock_delta_ms_raw
|
||||||
if (latest == null) {
|
if (latest == null) {
|
||||||
return 'waiting'
|
return t('videoPanel.timing.waiting')
|
||||||
}
|
}
|
||||||
return `${latest.toFixed(1)} ms`
|
return `${latest.toFixed(1)} ms`
|
||||||
})
|
})
|
||||||
@@ -81,9 +82,9 @@ const timingHeadline = computed(() => {
|
|||||||
const timingHint = computed(() => {
|
const timingHint = computed(() => {
|
||||||
const timing = senderClockDebug.value
|
const timing = senderClockDebug.value
|
||||||
if (!timing?.available) {
|
if (!timing?.available) {
|
||||||
return 'raw sender clock delta is waiting for the first valid video trailer'
|
return t('videoPanel.timing.noTrailer')
|
||||||
}
|
}
|
||||||
return 'raw sender clock delta only, unsynced clocks'
|
return t('videoPanel.timing.rawHint')
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatNumber(value: number | null | undefined, suffix = '') {
|
function formatNumber(value: number | null | undefined, suffix = '') {
|
||||||
@@ -437,8 +438,8 @@ watch(
|
|||||||
<section class="panel video-panel">
|
<section class="panel video-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Video</p>
|
<p class="eyebrow">{{ t('videoPanel.eyebrow') }}</p>
|
||||||
<h2>Live Video</h2>
|
<h2>{{ t('videoPanel.title') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge" :class="{ bad: !displayVideo?.available }">
|
<span class="badge" :class="{ bad: !displayVideo?.available }">
|
||||||
{{ modeLabel }}
|
{{ modeLabel }}
|
||||||
@@ -450,63 +451,63 @@ watch(
|
|||||||
v-if="canRequestFrames"
|
v-if="canRequestFrames"
|
||||||
class="video-frame"
|
class="video-frame"
|
||||||
:src="frameUrl"
|
:src="frameUrl"
|
||||||
alt="Robot live frame"
|
:alt="t('videoPanel.frameAlt')"
|
||||||
/>
|
/>
|
||||||
<div v-else class="video-placeholder">
|
<div v-else class="video-placeholder">
|
||||||
waiting for live video frames
|
{{ t('videoPanel.waitingFrames') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>Frames</span>
|
<span>{{ t('videoPanel.stats.frames') }}</span>
|
||||||
<strong>{{ displayVideo?.frame_count ?? 0 }}</strong>
|
<strong>{{ displayVideo?.frame_count ?? 0 }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>Latest Seq</span>
|
<span>{{ t('videoPanel.stats.latestSeq') }}</span>
|
||||||
<strong>{{ displayVideo?.receiver?.latest_sequence ?? '--' }}</strong>
|
<strong>{{ displayVideo?.receiver?.latest_sequence ?? '--' }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>Video E2E Est.</span>
|
<span>{{ t('videoPanel.stats.videoE2E') }}</span>
|
||||||
<strong>{{ formatNumber(networkEstimate?.video_e2e_est_ms, ' ms') }}</strong>
|
<strong>{{ formatNumber(networkEstimate?.video_e2e_est_ms, ' ms') }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<span>Paint Delay</span>
|
<span>{{ t('videoPanel.stats.paintDelay') }}</span>
|
||||||
<strong>{{ formatNumber(displayVideo?.display_probe?.request_to_paint_ms, ' ms') }}</strong>
|
<strong>{{ formatNumber(displayVideo?.display_probe?.request_to_paint_ms, ' ms') }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metric-grid">
|
<div class="metric-grid">
|
||||||
<div class="metric-group">
|
<div class="metric-group">
|
||||||
<h3>Pipeline Estimate</h3>
|
<h3>{{ t('videoPanel.section.pipeline') }}</h3>
|
||||||
<p><strong>Capture to send:</strong> {{ formatNumber(displayVideo?.receiver?.latest_capture_to_send_ms, ' ms') }}</p>
|
<p><strong>{{ t('videoPanel.captureToSend') }}:</strong> {{ formatNumber(displayVideo?.receiver?.latest_capture_to_send_ms, ' ms') }}</p>
|
||||||
<p><strong>Network one-way:</strong> {{ formatNumber(networkEstimate?.video_network_oneway_est_ms, ' ms') }}</p>
|
<p><strong>{{ t('videoPanel.networkOneWay') }}:</strong> {{ formatNumber(networkEstimate?.video_network_oneway_est_ms, ' ms') }}</p>
|
||||||
<p><strong>Partial estimate:</strong> {{ formatNumber(networkEstimate?.video_partial_est_ms, ' ms') }}</p>
|
<p><strong>{{ t('videoPanel.partialEstimate') }}:</strong> {{ formatNumber(networkEstimate?.video_partial_est_ms, ' ms') }}</p>
|
||||||
<p><strong>End-to-end estimate:</strong> {{ formatNumber(networkEstimate?.video_e2e_est_ms, ' ms') }}</p>
|
<p><strong>{{ t('videoPanel.endToEndEstimate') }}:</strong> {{ formatNumber(networkEstimate?.video_e2e_est_ms, ' ms') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metric-group">
|
<div class="metric-group">
|
||||||
<h3>Freshness</h3>
|
<h3>{{ t('videoPanel.section.freshness') }}</h3>
|
||||||
<p><strong>Inter-frame avg:</strong> {{ formatNumber(freshness?.inter_frame_avg_ms, ' ms') }}</p>
|
<p><strong>{{ t('videoPanel.interFrameAvg') }}:</strong> {{ formatNumber(freshness?.inter_frame_avg_ms, ' ms') }}</p>
|
||||||
<p><strong>Inter-frame p95:</strong> {{ formatNumber(freshness?.inter_frame_p95_ms, ' ms') }}</p>
|
<p><strong>{{ t('videoPanel.interFrameP95') }}:</strong> {{ formatNumber(freshness?.inter_frame_p95_ms, ' ms') }}</p>
|
||||||
<p><strong>Repeated ratio:</strong> {{ formatNumber((freshness?.repeated_frame_ratio ?? 0) * 100, ' %') }}</p>
|
<p><strong>{{ t('videoPanel.repeatedRatio') }}:</strong> {{ formatNumber((freshness?.repeated_frame_ratio ?? 0) * 100, ' %') }}</p>
|
||||||
<p><strong>Skip ratio:</strong> {{ formatNumber((freshness?.skip_ratio ?? 0) * 100, ' %') }}</p>
|
<p><strong>{{ t('videoPanel.skipRatio') }}:</strong> {{ formatNumber((freshness?.skip_ratio ?? 0) * 100, ' %') }}</p>
|
||||||
<p><strong>Longest freeze:</strong> {{ formatNumber(freshness?.longest_freeze_ms, ' ms') }}</p>
|
<p><strong>{{ t('videoPanel.longestFreeze') }}:</strong> {{ formatNumber(freshness?.longest_freeze_ms, ' ms') }}</p>
|
||||||
<p><strong>Lag frames:</strong> {{ freshness?.relative_freshness_lag_frames ?? 0 }}</p>
|
<p><strong>{{ t('videoPanel.lagFrames') }}:</strong> {{ freshness?.relative_freshness_lag_frames ?? 0 }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metric-group">
|
<div class="metric-group">
|
||||||
<h3>Operator Loop</h3>
|
<h3>{{ t('videoPanel.section.operator') }}</h3>
|
||||||
<p><strong>Input to next seq:</strong> {{ formatNumber(operatorMetrics.input_to_next_fresh_frame_ms, ' ms') }}</p>
|
<p><strong>{{ t('videoPanel.inputToNextSeq') }}:</strong> {{ formatNumber(operatorMetrics.input_to_next_fresh_frame_ms, ' ms') }}</p>
|
||||||
<p><strong>Input to changed frame:</strong> {{ formatNumber(operatorMetrics.input_to_next_changed_frame_ms, ' ms') }}</p>
|
<p><strong>{{ t('videoPanel.inputToChangedFrame') }}:</strong> {{ formatNumber(operatorMetrics.input_to_next_changed_frame_ms, ' ms') }}</p>
|
||||||
<p><strong>Input to paint:</strong> {{ formatNumber(operatorMetrics.input_to_next_paint_ms, ' ms') }}</p>
|
<p><strong>{{ t('videoPanel.inputToPaint') }}:</strong> {{ formatNumber(operatorMetrics.input_to_next_paint_ms, ' ms') }}</p>
|
||||||
<p><strong>Display probe request-to-paint:</strong> {{ formatNumber(displayVideo?.display_probe?.request_to_paint_ms, ' ms') }}</p>
|
<p><strong>{{ t('videoPanel.displayProbeRequestToPaint') }}:</strong> {{ formatNumber(displayVideo?.display_probe?.request_to_paint_ms, ' ms') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="timing-panel">
|
<div class="timing-panel">
|
||||||
<div class="timing-head">
|
<div class="timing-head">
|
||||||
<span>Sender Clock Delta</span>
|
<span>{{ t('videoPanel.senderClockDelta') }}</span>
|
||||||
<strong>{{ timingHeadline }}</strong>
|
<strong>{{ timingHeadline }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="timing-grid">
|
<div class="timing-grid">
|
||||||
@@ -524,7 +525,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
{{ displayVideo?.source_detail ?? 'no live video detail available' }}
|
{{ displayVideo?.source_detail ?? t('videoPanel.noSourceDetail') }}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
import { buildControlWebSocketUrl } from '@/lib/api'
|
import { buildControlWebSocketUrl } from '@/lib/api'
|
||||||
|
import { t } from '@/lib/locale'
|
||||||
|
|
||||||
type SocketState = 'connecting' | 'open' | 'closed'
|
type SocketState = 'connecting' | 'open' | 'closed'
|
||||||
type ControlInputMode = 'keyboard' | 'gamepad'
|
type ControlInputMode = 'keyboard' | 'gamepad'
|
||||||
@@ -35,7 +36,7 @@ const KEY_LABELS: Record<string, string> = {
|
|||||||
KeyE: 'E',
|
KeyE: 'E',
|
||||||
ShiftLeft: 'Shift',
|
ShiftLeft: 'Shift',
|
||||||
ShiftRight: 'Shift',
|
ShiftRight: 'Shift',
|
||||||
Space: 'Stop',
|
Space: 'Space',
|
||||||
}
|
}
|
||||||
const GAMEPAD_BUTTON_LABELS = ['A', 'B', 'X', 'Y', 'LB', 'RB', 'LT', 'RT', 'Back', 'Start', 'LS', 'RS']
|
const GAMEPAD_BUTTON_LABELS = ['A', 'B', 'X', 'Y', 'LB', 'RB', 'LT', 'RT', 'Back', 'Start', 'LS', 'RS']
|
||||||
|
|
||||||
@@ -57,10 +58,11 @@ const MAX_TURBO_MULTIPLIER = 3
|
|||||||
|
|
||||||
const pressedKeys = ref<Set<string>>(new Set())
|
const pressedKeys = ref<Set<string>>(new Set())
|
||||||
const socketState = ref<SocketState>('connecting')
|
const socketState = ref<SocketState>('connecting')
|
||||||
const lastServerMessage = ref('waiting')
|
const lastServerMessageOverride = ref('')
|
||||||
|
const lastServerMessagePreset = ref<'waiting' | 'live'>('waiting')
|
||||||
const gamepadSupported = ref(false)
|
const gamepadSupported = ref(false)
|
||||||
const gamepadConnected = ref(false)
|
const gamepadConnected = ref(false)
|
||||||
const gamepadName = ref('No gamepad detected')
|
const gamepadNameRaw = ref('')
|
||||||
const gamepadIndex = ref<number | null>(null)
|
const gamepadIndex = ref<number | null>(null)
|
||||||
const gamepadMapping = ref('')
|
const gamepadMapping = ref('')
|
||||||
const gamepadAxes = ref<number[]>([0, 0, 0, 0])
|
const gamepadAxes = ref<number[]>([0, 0, 0, 0])
|
||||||
@@ -444,7 +446,7 @@ function handleKeyup(event: KeyboardEvent) {
|
|||||||
|
|
||||||
function resetGamepadState() {
|
function resetGamepadState() {
|
||||||
gamepadConnected.value = false
|
gamepadConnected.value = false
|
||||||
gamepadName.value = 'No gamepad detected'
|
gamepadNameRaw.value = ''
|
||||||
gamepadIndex.value = null
|
gamepadIndex.value = null
|
||||||
gamepadMapping.value = ''
|
gamepadMapping.value = ''
|
||||||
gamepadAxes.value = [0, 0, 0, 0]
|
gamepadAxes.value = [0, 0, 0, 0]
|
||||||
@@ -484,9 +486,9 @@ function pollGamepadState() {
|
|||||||
|
|
||||||
lastGamepadSignature = signature
|
lastGamepadSignature = signature
|
||||||
gamepadConnected.value = true
|
gamepadConnected.value = true
|
||||||
gamepadName.value = pad.id || 'Unnamed gamepad'
|
gamepadNameRaw.value = pad.id || ''
|
||||||
gamepadIndex.value = pad.index
|
gamepadIndex.value = pad.index
|
||||||
gamepadMapping.value = pad.mapping || 'unknown'
|
gamepadMapping.value = pad.mapping || ''
|
||||||
gamepadAxes.value = axes
|
gamepadAxes.value = axes
|
||||||
gamepadButtonPressed.value = buttons
|
gamepadButtonPressed.value = buttons
|
||||||
if (controlInputMode.value === 'gamepad') {
|
if (controlInputMode.value === 'gamepad') {
|
||||||
@@ -506,18 +508,21 @@ function connectSocket() {
|
|||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
socketState.value = 'open'
|
socketState.value = 'open'
|
||||||
lastServerMessage.value = 'control link live'
|
lastServerMessagePreset.value = 'live'
|
||||||
|
lastServerMessageOverride.value = ''
|
||||||
refreshSendLoop(true, false)
|
refreshSendLoop(true, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
if (typeof event.data === 'string') {
|
if (typeof event.data === 'string') {
|
||||||
lastServerMessage.value = event.data
|
lastServerMessageOverride.value = event.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
socketState.value = 'closed'
|
socketState.value = 'closed'
|
||||||
|
lastServerMessagePreset.value = 'waiting'
|
||||||
|
lastServerMessageOverride.value = ''
|
||||||
stopSendLoop()
|
stopSendLoop()
|
||||||
socket = null
|
socket = null
|
||||||
if (manualClose) {
|
if (manualClose) {
|
||||||
@@ -596,20 +601,27 @@ function unmountConsumer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const socketLabel = computed(() => {
|
const socketLabel = computed(() => {
|
||||||
if (socketState.value === 'open') return 'ws open'
|
if (socketState.value === 'open') return t('control.socket.open')
|
||||||
if (socketState.value === 'connecting') return 'connecting'
|
if (socketState.value === 'connecting') return t('control.socket.connecting')
|
||||||
return 'reconnecting'
|
return t('control.socket.reconnecting')
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeSourceLabel = computed(() => {
|
const activeSourceLabel = computed(() => {
|
||||||
if (activeSource.value === 'keyboard') return 'Keyboard'
|
if (activeSource.value === 'keyboard') return t('common.keyboard')
|
||||||
if (activeSource.value === 'gamepad') return 'Gamepad'
|
if (activeSource.value === 'gamepad') return t('common.gamepad')
|
||||||
return 'Idle'
|
return t('common.idle')
|
||||||
})
|
})
|
||||||
|
|
||||||
const controlInputModeLabel = computed(() => {
|
const controlInputModeLabel = computed(() => {
|
||||||
if (controlInputMode.value === 'gamepad') return 'Gamepad'
|
if (controlInputMode.value === 'gamepad') return t('common.gamepad')
|
||||||
return 'Keyboard'
|
return t('common.keyboard')
|
||||||
|
})
|
||||||
|
|
||||||
|
const lastServerMessage = computed(() => {
|
||||||
|
if (lastServerMessageOverride.value) {
|
||||||
|
return lastServerMessageOverride.value
|
||||||
|
}
|
||||||
|
return lastServerMessagePreset.value === 'live' ? t('control.server.live') : t('control.server.waiting')
|
||||||
})
|
})
|
||||||
|
|
||||||
const commandValues = computed(() => {
|
const commandValues = computed(() => {
|
||||||
@@ -635,12 +647,12 @@ const commandMagnitude = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const pressedKeysLabel = computed(() => Array.from(pressedKeys.value).sort().join(', ') || 'none')
|
const pressedKeysLabel = computed(() => Array.from(pressedKeys.value).sort().join(', ') || t('common.none'))
|
||||||
|
|
||||||
const keyboardKeys = computed<KeyFeedback[]>(() =>
|
const keyboardKeys = computed<KeyFeedback[]>(() =>
|
||||||
TRACKED_KEYS.map((code) => ({
|
TRACKED_KEYS.map((code) => ({
|
||||||
code,
|
code,
|
||||||
label: KEY_LABELS[code] ?? code,
|
label: code === 'Space' ? t('control.key.stop') : (KEY_LABELS[code] ?? code),
|
||||||
pressed: pressedKeys.value.has(code),
|
pressed: pressedKeys.value.has(code),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
@@ -667,6 +679,13 @@ const gamepadButtons = computed<ButtonFeedback[]>(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const gamepadName = computed(() => {
|
||||||
|
if (!gamepadConnected.value) {
|
||||||
|
return t('control.gamepad.none')
|
||||||
|
}
|
||||||
|
return gamepadNameRaw.value || t('control.gamepad.unnamed')
|
||||||
|
})
|
||||||
|
|
||||||
const gamepadLeftStick = computed(() => ({
|
const gamepadLeftStick = computed(() => ({
|
||||||
x: gamepadAxes.value[0] ?? 0,
|
x: gamepadAxes.value[0] ?? 0,
|
||||||
y: gamepadAxes.value[1] ?? 0,
|
y: gamepadAxes.value[1] ?? 0,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
import { fetchDashboardSnapshot } from '@/lib/api'
|
import { fetchDashboardSnapshot } from '@/lib/api'
|
||||||
|
import { t } from '@/lib/locale'
|
||||||
import type { GpsTelemetry, NetworkTelemetry, VideoStatus } from '@/types'
|
import type { GpsTelemetry, NetworkTelemetry, VideoStatus } from '@/types'
|
||||||
|
|
||||||
type UseMonitoringDataOptions = {
|
type UseMonitoringDataOptions = {
|
||||||
@@ -25,7 +26,7 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
|
|||||||
video.value = snapshot.video
|
video.value = snapshot.video
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof Error ? error.message : 'Failed to load monitoring data'
|
errorMessage.value = error instanceof Error ? error.message : t('common.requestFailed', { status: '-', statusText: '' })
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -36,9 +37,9 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
|
|||||||
return errorMessage.value
|
return errorMessage.value
|
||||||
}
|
}
|
||||||
if (loading.value) {
|
if (loading.value) {
|
||||||
return 'Connecting to the Django backend and loading live monitoring data...'
|
return t('monitoring.loading')
|
||||||
}
|
}
|
||||||
return 'Dashboard connected. Video, GPS, and live session telemetry refresh continuously from the unified A-side daemon.'
|
return t('monitoring.connected')
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DashboardSnapshot, VideoStatus } from '@/types'
|
import type { DashboardSnapshot, VideoStatus } from '@/types'
|
||||||
|
import { t } from '@/lib/locale'
|
||||||
|
|
||||||
const envBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined
|
const envBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@ export const API_BASE = (envBaseUrl?.trim() || 'http://127.0.0.1:8001').replace(
|
|||||||
async function fetchJson<T>(path: string): Promise<T> {
|
async function fetchJson<T>(path: string): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE}${path}`)
|
const response = await fetch(`${API_BASE}${path}`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`请求失败: ${response.status} ${response.statusText}`)
|
throw new Error(t('common.requestFailed', { status: response.status, statusText: response.statusText }))
|
||||||
}
|
}
|
||||||
return response.json() as Promise<T>
|
return response.json() as Promise<T>
|
||||||
}
|
}
|
||||||
|
|||||||
512
frontend/src/lib/locale.ts
Normal file
512
frontend/src/lib/locale.ts
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
import { computed, readonly, ref } from 'vue'
|
||||||
|
|
||||||
|
export type Locale = 'zh-CN' | 'en-US'
|
||||||
|
|
||||||
|
const LOCALE_STORAGE_KEY = 'robot-command-center.locale'
|
||||||
|
const DEFAULT_LOCALE: Locale = 'zh-CN'
|
||||||
|
|
||||||
|
const zhCNMessages = {
|
||||||
|
'common.loading': '加载中',
|
||||||
|
'common.waiting': '等待中',
|
||||||
|
'common.unavailable': '不可用',
|
||||||
|
'common.unknown': '未知',
|
||||||
|
'common.none': '无',
|
||||||
|
'common.na': 'n/a',
|
||||||
|
'common.yes': '是',
|
||||||
|
'common.no': '否',
|
||||||
|
'common.keyboard': '键盘',
|
||||||
|
'common.gamepad': '手柄',
|
||||||
|
'common.idle': '空闲',
|
||||||
|
'common.control': '控制',
|
||||||
|
'common.video': '视频',
|
||||||
|
'common.online': '在线',
|
||||||
|
'common.offline': '离线',
|
||||||
|
'common.fresh': '新鲜',
|
||||||
|
'common.stale': '过期',
|
||||||
|
'common.stable': '稳定',
|
||||||
|
'common.rising': '上升',
|
||||||
|
'common.falling': '下降',
|
||||||
|
'common.selected': '已选中',
|
||||||
|
'common.standby': '待命',
|
||||||
|
'common.turbo': '加速',
|
||||||
|
'common.ackLoop': 'ACK 闭环',
|
||||||
|
'common.srttFallback': 'SRTT 回退',
|
||||||
|
'common.requestFailed': '请求失败: {status} {statusText}',
|
||||||
|
'app.brandTitle': '机器人指挥中心',
|
||||||
|
'app.brandSubtitle': '远程机器人控制台',
|
||||||
|
'app.nav.overview': '概览',
|
||||||
|
'app.nav.video': '视频',
|
||||||
|
'app.nav.map': '地图定位',
|
||||||
|
'app.nav.network': '网络状态',
|
||||||
|
'app.localeToggle': 'English',
|
||||||
|
'dashboard.eyebrow': '概览',
|
||||||
|
'dashboard.title': '机器人指挥中心',
|
||||||
|
'dashboard.description': 'A 端统一后台进程持续刷新视频、控制仲裁和链路遥测。',
|
||||||
|
'networkView.eyebrow': '网络',
|
||||||
|
'networkView.title': '网络遥测',
|
||||||
|
'networkView.description': '查看 A <-> D 与 D <-> B 两段链路的实时队列、重传、窗口压力和延迟估计。',
|
||||||
|
'videoView.eyebrow': '视频',
|
||||||
|
'videoView.title': '视频监控',
|
||||||
|
'videoView.description': '查看机器人实时 JPEG 视频流、画面新鲜度和端到端延迟估计。',
|
||||||
|
'mapView.eyebrow': '地图',
|
||||||
|
'mapView.title': '地图定位',
|
||||||
|
'mapView.description': '查看机器人最新 GPS 数据,并按需使用高德地图做坐标转换和展示。',
|
||||||
|
'monitoring.loading': '正在连接 Django 后端并加载实时监控数据...',
|
||||||
|
'monitoring.connected': '仪表盘已连接。视频、GPS 和会话遥测正在持续刷新。',
|
||||||
|
'control.socket.open': 'WebSocket 已连接',
|
||||||
|
'control.socket.connecting': '连接中',
|
||||||
|
'control.socket.reconnecting': '重连中',
|
||||||
|
'control.server.waiting': '等待控制链路就绪',
|
||||||
|
'control.server.live': '控制链路已建立',
|
||||||
|
'control.gamepad.none': '未检测到手柄',
|
||||||
|
'control.gamepad.unnamed': '未命名手柄',
|
||||||
|
'control.gamepad.unknownMapping': '未知映射',
|
||||||
|
'control.key.stop': '停止',
|
||||||
|
'controlPanel.eyebrow': '控制',
|
||||||
|
'controlPanel.title': '控制反馈',
|
||||||
|
'controlPanel.resetDefaults': '恢复默认',
|
||||||
|
'controlPanel.inputModeEyebrow': '输入模式',
|
||||||
|
'controlPanel.inputModeCopy': '同一时刻只能有一种本地输入模式控制页面。',
|
||||||
|
'controlPanel.keyboardDetail': '使用 W/S、A/D、Q/E、Shift 和 Space。',
|
||||||
|
'controlPanel.gamepadDetail': '仅使用浏览器识别到的手柄。',
|
||||||
|
'controlPanel.forward': '前进',
|
||||||
|
'controlPanel.strafe': '横移',
|
||||||
|
'controlPanel.turn': '转向',
|
||||||
|
'controlPanel.turbo': '加速',
|
||||||
|
'controlPanel.keyboardHint': '键盘映射: W/S 前后, A/D 横移, Q/E 转向, Shift 加速, Space 停止。',
|
||||||
|
'controlPanel.tuningHint': '速度调节由两种本地输入模式共享,并保存在当前浏览器中。',
|
||||||
|
'controlPanel.gamepadHint': '手柄模式下左摇杆控制移动,右摇杆控制转向,RB 加速,A 发送停止。',
|
||||||
|
'controlFeedback.modeChip': '{mode} 模式',
|
||||||
|
'controlFeedback.forward': '前进',
|
||||||
|
'controlFeedback.strafe': '横移',
|
||||||
|
'controlFeedback.turn': '转向',
|
||||||
|
'controlFeedback.tuningSummary': '调参: 前进 {forward} m/s, 横移 {strafe} m/s, 转向 {turn} rad/s, 加速 x{turbo}',
|
||||||
|
'controlFeedback.keyboard': '键盘',
|
||||||
|
'controlFeedback.gamepad': '手柄',
|
||||||
|
'controlFeedback.waitingForController': '等待手柄接入',
|
||||||
|
'controlFeedback.gamepadMeta': '#{index} / 映射={mapping}',
|
||||||
|
'controlFeedback.gamepadHint': '左摇杆控制移动,右摇杆控制转向,RB 加速,A 停止。',
|
||||||
|
'controlFeedback.leftStick': '左摇杆',
|
||||||
|
'controlFeedback.rightStick': '右摇杆',
|
||||||
|
'controlFeedback.outgoingCommand': '当前发出命令: {command}',
|
||||||
|
'videoPanel.eyebrow': '视频',
|
||||||
|
'videoPanel.title': '实时视频',
|
||||||
|
'videoPanel.frameAlt': '机器人实时画面',
|
||||||
|
'videoPanel.waitingFrames': '等待实时视频帧',
|
||||||
|
'videoPanel.mode.loading': '加载中',
|
||||||
|
'videoPanel.mode.live': '{fps} FPS 实时',
|
||||||
|
'videoPanel.stats.frames': '帧数',
|
||||||
|
'videoPanel.stats.latestSeq': '最新序号',
|
||||||
|
'videoPanel.stats.videoE2E': '视频端到端估计',
|
||||||
|
'videoPanel.stats.paintDelay': '绘制延迟',
|
||||||
|
'videoPanel.section.pipeline': '流水线估计',
|
||||||
|
'videoPanel.section.freshness': '新鲜度',
|
||||||
|
'videoPanel.section.operator': '操作员闭环',
|
||||||
|
'videoPanel.captureToSend': '采集到发送',
|
||||||
|
'videoPanel.networkOneWay': '网络单程',
|
||||||
|
'videoPanel.partialEstimate': '部分估计',
|
||||||
|
'videoPanel.endToEndEstimate': '端到端估计',
|
||||||
|
'videoPanel.interFrameAvg': '帧间平均',
|
||||||
|
'videoPanel.interFrameP95': '帧间 p95',
|
||||||
|
'videoPanel.repeatedRatio': '重复比例',
|
||||||
|
'videoPanel.skipRatio': '跳帧比例',
|
||||||
|
'videoPanel.longestFreeze': '最长卡顿',
|
||||||
|
'videoPanel.lagFrames': '落后帧数',
|
||||||
|
'videoPanel.inputToNextSeq': '输入到下一新序号',
|
||||||
|
'videoPanel.inputToChangedFrame': '输入到下一变化帧',
|
||||||
|
'videoPanel.inputToPaint': '输入到下一次绘制',
|
||||||
|
'videoPanel.displayProbeRequestToPaint': '显示探针请求到绘制',
|
||||||
|
'videoPanel.senderClockDelta': '发送端时钟差',
|
||||||
|
'videoPanel.timing.waiting': '等待中',
|
||||||
|
'videoPanel.timing.noTrailer': '正在等待第一帧带有效 trailer 的视频数据',
|
||||||
|
'videoPanel.timing.rawHint': '这里只显示发送端原始时钟差,设备时钟未同步',
|
||||||
|
'videoPanel.noSourceDetail': '暂无实时视频详情',
|
||||||
|
'networkPanel.eyebrow': '网络',
|
||||||
|
'networkPanel.title': '双段链路遥测',
|
||||||
|
'networkPanel.controlLoopRtt': '控制闭环 RTT',
|
||||||
|
'networkPanel.controlToPersist': '控制到持久化',
|
||||||
|
'networkPanel.controlSrttOneWay': '控制单程 SRTT',
|
||||||
|
'networkPanel.videoOneWayEst': '视频单程估计',
|
||||||
|
'networkPanel.txRate': '发送速率',
|
||||||
|
'networkPanel.rxRate': '接收速率',
|
||||||
|
'networkPanel.robotFault': '机器人故障',
|
||||||
|
'networkPanel.recoveryState': '恢复状态',
|
||||||
|
'networkPanel.healthConfidence': '健康置信度',
|
||||||
|
'networkPanel.healthUpdated': '健康更新时间',
|
||||||
|
'networkPanel.transport': '传输',
|
||||||
|
'networkPanel.activeControl': '当前控制源',
|
||||||
|
'networkPanel.lease': '租约',
|
||||||
|
'networkPanel.ackMode': 'ACK 模式',
|
||||||
|
'networkPanel.ackUpdated': 'ACK 更新时间',
|
||||||
|
'networkPanel.telemetryPeer': '遥测 Peer',
|
||||||
|
'networkPanel.telemetryRegistered': '遥测已注册',
|
||||||
|
'networkPanel.hubFreshness': 'Hub 新鲜度',
|
||||||
|
'networkPanel.hubState': 'Hub 状态',
|
||||||
|
'networkPanel.telemetryReconnects': '遥测重连次数',
|
||||||
|
'networkPanel.hubError': 'Hub 错误',
|
||||||
|
'networkPanel.telemetrySessionError': '遥测会话错误',
|
||||||
|
'networkPanel.online': '在线',
|
||||||
|
'networkPanel.maxPressure': '最大压力',
|
||||||
|
'networkPanel.queued': '排队量',
|
||||||
|
'networkPanel.inFlightBuffer': '在途缓冲',
|
||||||
|
'networkPanel.retransDelta': '重传增量',
|
||||||
|
'networkPanel.repairRate': '修复率',
|
||||||
|
'networkPanel.updated': '更新时间',
|
||||||
|
'networkPanel.srtt': 'SRTT',
|
||||||
|
'networkPanel.rttvar': 'RTTVAR',
|
||||||
|
'networkPanel.rto': 'RTO',
|
||||||
|
'networkPanel.sndWnd': '发送窗口',
|
||||||
|
'networkPanel.rmtWnd': '远端窗口',
|
||||||
|
'networkPanel.inflight': '在途',
|
||||||
|
'networkPanel.windowLimit': '窗口上限',
|
||||||
|
'networkPanel.pressure': '压力',
|
||||||
|
'networkPanel.sndQueue': '发送队列',
|
||||||
|
'networkPanel.sndBuffer': '发送缓冲',
|
||||||
|
'networkPanel.queueDelta': '队列增量',
|
||||||
|
'networkPanel.bufferDelta': '缓冲增量',
|
||||||
|
'networkPanel.retrans': '重传',
|
||||||
|
'networkPanel.fastRetrans': '快速重传',
|
||||||
|
'networkPanel.lost': '丢失',
|
||||||
|
'networkPanel.repeat': '重复',
|
||||||
|
'networkPanel.appBytes': '应用字节',
|
||||||
|
'networkPanel.registered': '已注册',
|
||||||
|
'networkPanel.serverError': '服务端错误',
|
||||||
|
'networkPanel.combined': '总计',
|
||||||
|
'networkPanel.videoE2E': '视频端到端估计',
|
||||||
|
'networkPanel.controlEstimateConfidence': '控制估计置信度',
|
||||||
|
'networkPanel.videoFreshness': '视频新鲜度',
|
||||||
|
'networkPanel.videoFreshnessRepeat': '重复',
|
||||||
|
'networkPanel.videoFreshnessSkip': '跳帧',
|
||||||
|
'networkPanel.videoFreshnessFreeze': '卡顿',
|
||||||
|
'networkPanel.nativeUdp': '原生 UDP',
|
||||||
|
'networkPanel.controlSender': '控制发送端',
|
||||||
|
'networkPanel.ackReceiver': 'ACK 接收端',
|
||||||
|
'networkPanel.controlReconnects': '控制重连次数',
|
||||||
|
'networkPanel.controlSessionError': '控制会话错误',
|
||||||
|
'networkPanel.loadingPeer': '加载中',
|
||||||
|
'networkPanel.unassigned': '未分配',
|
||||||
|
'gpsMap.eyebrow': 'GPS',
|
||||||
|
'gpsMap.title': '地图定位',
|
||||||
|
'gpsMap.intro': '这里展示机器人最新的 GPS 定位,并在需要时调用高德地图做坐标转换。',
|
||||||
|
'gpsMap.keyPlaceholder': '高德 Web 端 Key',
|
||||||
|
'gpsMap.jscodePlaceholder': '安全密钥 jscode',
|
||||||
|
'gpsMap.loadMap': '加载地图',
|
||||||
|
'gpsMap.stopMap': '停止加载',
|
||||||
|
'gpsMap.status.waitingInit': '等待加载高德地图。',
|
||||||
|
'gpsMap.status.fillCredentials': '请先填写高德 Key 和安全密钥 jscode。',
|
||||||
|
'gpsMap.status.loading': '正在加载高德地图...',
|
||||||
|
'gpsMap.status.loaded': '地图已加载。',
|
||||||
|
'gpsMap.status.stopped': '已停止高德地图加载与坐标转换。需要时再点击“加载地图”即可。',
|
||||||
|
'gpsMap.status.waitingGps': '等待 GPS 数据。',
|
||||||
|
'gpsMap.status.noFix': 'GPS 在线,但当前还没有有效定位。',
|
||||||
|
'gpsMap.status.convertFailed': 'GPS 坐标转换失败。',
|
||||||
|
'gpsMap.status.refreshedSource': '地图已刷新,数据源: {source}',
|
||||||
|
'gpsMap.status.restoredConfig': '已恢复高德配置。地图不会自动加载,按需点击“加载地图”。',
|
||||||
|
'gpsMap.status.loadFailed': '地图加载失败。',
|
||||||
|
'gpsMap.mapPlaceholder': '高德地图当前未加载。点击上方“加载地图”后才会开始请求地图与坐标转换服务。',
|
||||||
|
'gpsMap.wgs84': 'WGS84 坐标',
|
||||||
|
'gpsMap.gcj02': '高德 GCJ-02',
|
||||||
|
'gpsMap.rawLatHex': '纬度原始 8 字节',
|
||||||
|
'gpsMap.rawLonHex': '经度原始 8 字节',
|
||||||
|
'gpsMap.utcTime': 'UTC 时间',
|
||||||
|
'gpsMap.satAltitude': '卫星 / 海拔',
|
||||||
|
'gpsMap.coordMeta': '坐标系 / 格式',
|
||||||
|
'gpsMap.lastUpdated': '最近刷新',
|
||||||
|
'gpsMap.noValue': '暂无',
|
||||||
|
'gpsMap.noValidFix': '暂无有效定位',
|
||||||
|
'gpsMap.infoTitle': '机器人 GPS 定位',
|
||||||
|
'gpsMap.infoSatellites': '卫星数',
|
||||||
|
'gpsMap.infoAltitude': '海拔',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type MessageKey = keyof typeof zhCNMessages
|
||||||
|
|
||||||
|
const enUSMessages: Record<MessageKey, string> = {
|
||||||
|
'common.loading': 'Loading',
|
||||||
|
'common.waiting': 'Waiting',
|
||||||
|
'common.unavailable': 'Unavailable',
|
||||||
|
'common.unknown': 'Unknown',
|
||||||
|
'common.none': 'None',
|
||||||
|
'common.na': 'n/a',
|
||||||
|
'common.yes': 'Yes',
|
||||||
|
'common.no': 'No',
|
||||||
|
'common.keyboard': 'Keyboard',
|
||||||
|
'common.gamepad': 'Gamepad',
|
||||||
|
'common.idle': 'Idle',
|
||||||
|
'common.control': 'Control',
|
||||||
|
'common.video': 'Video',
|
||||||
|
'common.online': 'Online',
|
||||||
|
'common.offline': 'Offline',
|
||||||
|
'common.fresh': 'Fresh',
|
||||||
|
'common.stale': 'Stale',
|
||||||
|
'common.stable': 'Stable',
|
||||||
|
'common.rising': 'Rising',
|
||||||
|
'common.falling': 'Falling',
|
||||||
|
'common.selected': 'Selected',
|
||||||
|
'common.standby': 'Standby',
|
||||||
|
'common.turbo': 'Turbo',
|
||||||
|
'common.ackLoop': 'ACK loop',
|
||||||
|
'common.srttFallback': 'SRTT fallback',
|
||||||
|
'common.requestFailed': 'Request failed: {status} {statusText}',
|
||||||
|
'app.brandTitle': 'Robot Command Center',
|
||||||
|
'app.brandSubtitle': 'Remote robot command console',
|
||||||
|
'app.nav.overview': 'Overview',
|
||||||
|
'app.nav.video': 'Video',
|
||||||
|
'app.nav.map': 'Map',
|
||||||
|
'app.nav.network': 'Network',
|
||||||
|
'app.localeToggle': '中文',
|
||||||
|
'dashboard.eyebrow': 'Overview',
|
||||||
|
'dashboard.title': 'Robot Command Center',
|
||||||
|
'dashboard.description': 'The A-side unified backend keeps video, control arbitration, and live transport telemetry refreshed.',
|
||||||
|
'networkView.eyebrow': 'Network',
|
||||||
|
'networkView.title': 'Network Telemetry',
|
||||||
|
'networkView.description': 'Inspect queueing, retransmissions, window pressure, and latency estimates for the A <-> D and D <-> B legs.',
|
||||||
|
'videoView.eyebrow': 'Video',
|
||||||
|
'videoView.title': 'Video Monitor',
|
||||||
|
'videoView.description': 'Inspect the live robot JPEG stream, freshness metrics, and end-to-end latency estimates.',
|
||||||
|
'mapView.eyebrow': 'Map',
|
||||||
|
'mapView.title': 'Map Positioning',
|
||||||
|
'mapView.description': 'Inspect the latest robot GPS fix and use AMap for coordinate conversion when needed.',
|
||||||
|
'monitoring.loading': 'Connecting to the Django backend and loading live monitoring data...',
|
||||||
|
'monitoring.connected': 'Dashboard connected. Video, GPS, and session telemetry are refreshing continuously.',
|
||||||
|
'control.socket.open': 'WebSocket open',
|
||||||
|
'control.socket.connecting': 'Connecting',
|
||||||
|
'control.socket.reconnecting': 'Reconnecting',
|
||||||
|
'control.server.waiting': 'Waiting for control link',
|
||||||
|
'control.server.live': 'Control link live',
|
||||||
|
'control.gamepad.none': 'No gamepad detected',
|
||||||
|
'control.gamepad.unnamed': 'Unnamed gamepad',
|
||||||
|
'control.gamepad.unknownMapping': 'unknown',
|
||||||
|
'control.key.stop': 'Stop',
|
||||||
|
'controlPanel.eyebrow': 'Control',
|
||||||
|
'controlPanel.title': 'Control Feedback',
|
||||||
|
'controlPanel.resetDefaults': 'Reset Defaults',
|
||||||
|
'controlPanel.inputModeEyebrow': 'Input Mode',
|
||||||
|
'controlPanel.inputModeCopy': 'Only one local input mode can control the page at a time.',
|
||||||
|
'controlPanel.keyboardDetail': 'Use W/S, A/D, Q/E, Shift, and Space.',
|
||||||
|
'controlPanel.gamepadDetail': 'Use the browser-detected controller only.',
|
||||||
|
'controlPanel.forward': 'Forward',
|
||||||
|
'controlPanel.strafe': 'Strafe',
|
||||||
|
'controlPanel.turn': 'Turn',
|
||||||
|
'controlPanel.turbo': 'Turbo',
|
||||||
|
'controlPanel.keyboardHint': 'Keyboard mapping: W/S forward-back, A/D strafe, Q/E turn, Shift turbo, Space stop.',
|
||||||
|
'controlPanel.tuningHint': 'Speed tuning is shared by both local input modes and saved in this browser.',
|
||||||
|
'controlPanel.gamepadHint': 'Gamepad mode uses the left stick to drive, the right stick to turn, RB to boost, and A to stop.',
|
||||||
|
'controlFeedback.modeChip': '{mode} mode',
|
||||||
|
'controlFeedback.forward': 'Forward',
|
||||||
|
'controlFeedback.strafe': 'Strafe',
|
||||||
|
'controlFeedback.turn': 'Turn',
|
||||||
|
'controlFeedback.tuningSummary': 'Tuning: fwd {forward} m/s, strafe {strafe} m/s, turn {turn} rad/s, turbo x{turbo}',
|
||||||
|
'controlFeedback.keyboard': 'Keyboard',
|
||||||
|
'controlFeedback.gamepad': 'Gamepad',
|
||||||
|
'controlFeedback.waitingForController': 'Waiting for controller',
|
||||||
|
'controlFeedback.gamepadMeta': '#{index} / mapping={mapping}',
|
||||||
|
'controlFeedback.gamepadHint': 'Left stick drives, right stick turns, RB boosts, A stops.',
|
||||||
|
'controlFeedback.leftStick': 'Left stick',
|
||||||
|
'controlFeedback.rightStick': 'Right stick',
|
||||||
|
'controlFeedback.outgoingCommand': 'Outgoing command: {command}',
|
||||||
|
'videoPanel.eyebrow': 'Video',
|
||||||
|
'videoPanel.title': 'Live Video',
|
||||||
|
'videoPanel.frameAlt': 'Robot live frame',
|
||||||
|
'videoPanel.waitingFrames': 'waiting for live video frames',
|
||||||
|
'videoPanel.mode.loading': 'loading',
|
||||||
|
'videoPanel.mode.live': '{fps} FPS live',
|
||||||
|
'videoPanel.stats.frames': 'Frames',
|
||||||
|
'videoPanel.stats.latestSeq': 'Latest Seq',
|
||||||
|
'videoPanel.stats.videoE2E': 'Video E2E Est.',
|
||||||
|
'videoPanel.stats.paintDelay': 'Paint Delay',
|
||||||
|
'videoPanel.section.pipeline': 'Pipeline Estimate',
|
||||||
|
'videoPanel.section.freshness': 'Freshness',
|
||||||
|
'videoPanel.section.operator': 'Operator Loop',
|
||||||
|
'videoPanel.captureToSend': 'Capture to send',
|
||||||
|
'videoPanel.networkOneWay': 'Network one-way',
|
||||||
|
'videoPanel.partialEstimate': 'Partial estimate',
|
||||||
|
'videoPanel.endToEndEstimate': 'End-to-end estimate',
|
||||||
|
'videoPanel.interFrameAvg': 'Inter-frame avg',
|
||||||
|
'videoPanel.interFrameP95': 'Inter-frame p95',
|
||||||
|
'videoPanel.repeatedRatio': 'Repeated ratio',
|
||||||
|
'videoPanel.skipRatio': 'Skip ratio',
|
||||||
|
'videoPanel.longestFreeze': 'Longest freeze',
|
||||||
|
'videoPanel.lagFrames': 'Lag frames',
|
||||||
|
'videoPanel.inputToNextSeq': 'Input to next seq',
|
||||||
|
'videoPanel.inputToChangedFrame': 'Input to changed frame',
|
||||||
|
'videoPanel.inputToPaint': 'Input to paint',
|
||||||
|
'videoPanel.displayProbeRequestToPaint': 'Display probe request-to-paint',
|
||||||
|
'videoPanel.senderClockDelta': 'Sender Clock Delta',
|
||||||
|
'videoPanel.timing.waiting': 'waiting',
|
||||||
|
'videoPanel.timing.noTrailer': 'waiting for the first valid video trailer',
|
||||||
|
'videoPanel.timing.rawHint': 'raw sender clock delta only, unsynced clocks',
|
||||||
|
'videoPanel.noSourceDetail': 'no live video detail available',
|
||||||
|
'networkPanel.eyebrow': 'Network',
|
||||||
|
'networkPanel.title': 'Dual-Leg Telemetry',
|
||||||
|
'networkPanel.controlLoopRtt': 'Control Loop RTT',
|
||||||
|
'networkPanel.controlToPersist': 'Control to Persist',
|
||||||
|
'networkPanel.controlSrttOneWay': 'Control SRTT One-way',
|
||||||
|
'networkPanel.videoOneWayEst': 'Video One-way Est.',
|
||||||
|
'networkPanel.txRate': 'TX Rate',
|
||||||
|
'networkPanel.rxRate': 'RX Rate',
|
||||||
|
'networkPanel.robotFault': 'Robot Fault',
|
||||||
|
'networkPanel.recoveryState': 'Recovery State',
|
||||||
|
'networkPanel.healthConfidence': 'Health Confidence',
|
||||||
|
'networkPanel.healthUpdated': 'Health Updated',
|
||||||
|
'networkPanel.transport': 'Transport',
|
||||||
|
'networkPanel.activeControl': 'Active Control',
|
||||||
|
'networkPanel.lease': 'Lease',
|
||||||
|
'networkPanel.ackMode': 'ACK Mode',
|
||||||
|
'networkPanel.ackUpdated': 'ACK Updated',
|
||||||
|
'networkPanel.telemetryPeer': 'Telemetry Peer',
|
||||||
|
'networkPanel.telemetryRegistered': 'Telemetry Registered',
|
||||||
|
'networkPanel.hubFreshness': 'Hub Freshness',
|
||||||
|
'networkPanel.hubState': 'Hub State',
|
||||||
|
'networkPanel.telemetryReconnects': 'Telemetry Reconnects',
|
||||||
|
'networkPanel.hubError': 'Hub Error',
|
||||||
|
'networkPanel.telemetrySessionError': 'Telemetry Session Error',
|
||||||
|
'networkPanel.online': 'Online',
|
||||||
|
'networkPanel.maxPressure': 'Max Pressure',
|
||||||
|
'networkPanel.queued': 'Queued',
|
||||||
|
'networkPanel.inFlightBuffer': 'In Flight Buffer',
|
||||||
|
'networkPanel.retransDelta': 'Retrans Delta',
|
||||||
|
'networkPanel.repairRate': 'Repair Rate',
|
||||||
|
'networkPanel.updated': 'Updated',
|
||||||
|
'networkPanel.srtt': 'SRTT',
|
||||||
|
'networkPanel.rttvar': 'RTTVAR',
|
||||||
|
'networkPanel.rto': 'RTO',
|
||||||
|
'networkPanel.sndWnd': 'SND WND',
|
||||||
|
'networkPanel.rmtWnd': 'RMT WND',
|
||||||
|
'networkPanel.inflight': 'Inflight',
|
||||||
|
'networkPanel.windowLimit': 'Window Limit',
|
||||||
|
'networkPanel.pressure': 'Pressure',
|
||||||
|
'networkPanel.sndQueue': 'SND Queue',
|
||||||
|
'networkPanel.sndBuffer': 'SND Buffer',
|
||||||
|
'networkPanel.queueDelta': 'Queue Delta',
|
||||||
|
'networkPanel.bufferDelta': 'Buffer Delta',
|
||||||
|
'networkPanel.retrans': 'Retrans',
|
||||||
|
'networkPanel.fastRetrans': 'Fast Retrans',
|
||||||
|
'networkPanel.lost': 'Lost',
|
||||||
|
'networkPanel.repeat': 'Repeat',
|
||||||
|
'networkPanel.appBytes': 'App Bytes',
|
||||||
|
'networkPanel.registered': 'Registered',
|
||||||
|
'networkPanel.serverError': 'Server Error',
|
||||||
|
'networkPanel.combined': 'Combined',
|
||||||
|
'networkPanel.videoE2E': 'Video E2E Est.',
|
||||||
|
'networkPanel.controlEstimateConfidence': 'Control Estimate Confidence',
|
||||||
|
'networkPanel.videoFreshness': 'Video Freshness',
|
||||||
|
'networkPanel.videoFreshnessRepeat': 'repeat',
|
||||||
|
'networkPanel.videoFreshnessSkip': 'skip',
|
||||||
|
'networkPanel.videoFreshnessFreeze': 'freeze',
|
||||||
|
'networkPanel.nativeUdp': 'Native UDP',
|
||||||
|
'networkPanel.controlSender': 'Control Sender',
|
||||||
|
'networkPanel.ackReceiver': 'ACK Receiver',
|
||||||
|
'networkPanel.controlReconnects': 'Control Reconnects',
|
||||||
|
'networkPanel.controlSessionError': 'Control Session Error',
|
||||||
|
'networkPanel.loadingPeer': 'loading',
|
||||||
|
'networkPanel.unassigned': 'unassigned',
|
||||||
|
'gpsMap.eyebrow': 'GPS',
|
||||||
|
'gpsMap.title': 'Map Positioning',
|
||||||
|
'gpsMap.intro': 'This panel displays the latest robot GPS fix and uses AMap for coordinate conversion when needed.',
|
||||||
|
'gpsMap.keyPlaceholder': 'AMap Web Key',
|
||||||
|
'gpsMap.jscodePlaceholder': 'Security jscode',
|
||||||
|
'gpsMap.loadMap': 'Load Map',
|
||||||
|
'gpsMap.stopMap': 'Stop Loading',
|
||||||
|
'gpsMap.status.waitingInit': 'Waiting to load AMap.',
|
||||||
|
'gpsMap.status.fillCredentials': 'Please enter the AMap key and security jscode first.',
|
||||||
|
'gpsMap.status.loading': 'Loading AMap...',
|
||||||
|
'gpsMap.status.loaded': 'Map loaded.',
|
||||||
|
'gpsMap.status.stopped': 'Stopped AMap loading and coordinate conversion. Click "Load Map" again when needed.',
|
||||||
|
'gpsMap.status.waitingGps': 'Waiting for GPS data.',
|
||||||
|
'gpsMap.status.noFix': 'GPS is online, but there is no valid fix yet.',
|
||||||
|
'gpsMap.status.convertFailed': 'GPS coordinate conversion failed.',
|
||||||
|
'gpsMap.status.refreshedSource': 'Map refreshed, source: {source}',
|
||||||
|
'gpsMap.status.restoredConfig': 'Recovered saved AMap config. The map will not auto-load; click "Load Map" when needed.',
|
||||||
|
'gpsMap.status.loadFailed': 'Map loading failed.',
|
||||||
|
'gpsMap.mapPlaceholder': 'AMap is not loaded right now. Click "Load Map" above before requesting map and coordinate conversion services.',
|
||||||
|
'gpsMap.wgs84': 'WGS84 Coordinates',
|
||||||
|
'gpsMap.gcj02': 'AMap GCJ-02',
|
||||||
|
'gpsMap.rawLatHex': 'Raw Latitude 8 Bytes',
|
||||||
|
'gpsMap.rawLonHex': 'Raw Longitude 8 Bytes',
|
||||||
|
'gpsMap.utcTime': 'UTC Time',
|
||||||
|
'gpsMap.satAltitude': 'Satellites / Altitude',
|
||||||
|
'gpsMap.coordMeta': 'Coordinate System / Format',
|
||||||
|
'gpsMap.lastUpdated': 'Last Updated',
|
||||||
|
'gpsMap.noValue': 'Unavailable',
|
||||||
|
'gpsMap.noValidFix': 'No valid fix',
|
||||||
|
'gpsMap.infoTitle': 'Robot GPS Position',
|
||||||
|
'gpsMap.infoSatellites': 'Satellites',
|
||||||
|
'gpsMap.infoAltitude': 'Altitude',
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: Record<Locale, Record<MessageKey, string>> = {
|
||||||
|
'zh-CN': zhCNMessages,
|
||||||
|
'en-US': enUSMessages,
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLocale(raw: unknown): Locale {
|
||||||
|
return raw === 'en-US' ? 'en-US' : DEFAULT_LOCALE
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStoredLocale(): Locale {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return DEFAULT_LOCALE
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return normalizeLocale(window.localStorage.getItem(LOCALE_STORAGE_KEY))
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_LOCALE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localeState = ref<Locale>(loadStoredLocale())
|
||||||
|
|
||||||
|
function storeLocale(locale: Locale) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale)
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures; locale still works for current session.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(template: string, params?: Record<string, string | number | null | undefined>) {
|
||||||
|
if (!params) {
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(key: MessageKey, params?: Record<string, string | number | null | undefined>) {
|
||||||
|
const template = messages[localeState.value][key] ?? key
|
||||||
|
return interpolate(template, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value?: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return t('common.unavailable')
|
||||||
|
}
|
||||||
|
return new Date(value).toLocaleString(localeState.value, { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLocale(locale: Locale) {
|
||||||
|
const next = normalizeLocale(locale)
|
||||||
|
if (localeState.value === next) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
localeState.value = next
|
||||||
|
storeLocale(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleLocale() {
|
||||||
|
setLocale(localeState.value === 'zh-CN' ? 'en-US' : 'zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocale() {
|
||||||
|
return {
|
||||||
|
locale: readonly(localeState),
|
||||||
|
setLocale,
|
||||||
|
toggleLocale,
|
||||||
|
t,
|
||||||
|
formatDateTime,
|
||||||
|
nextLocaleLabel: computed(() => t('app.localeToggle')),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import ControlPanel from '@/components/ControlPanel.vue'
|
|||||||
import GpsMapPanel from '@/components/GpsMapPanel.vue'
|
import GpsMapPanel from '@/components/GpsMapPanel.vue'
|
||||||
import NetworkPanel from '@/components/NetworkPanel.vue'
|
import NetworkPanel from '@/components/NetworkPanel.vue'
|
||||||
import VideoPanel from '@/components/VideoPanel.vue'
|
import VideoPanel from '@/components/VideoPanel.vue'
|
||||||
|
import { t } from '@/lib/locale'
|
||||||
import { useMonitoringData } from '@/composables/useMonitoringData'
|
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||||
|
|
||||||
const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
|
const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
|
||||||
@@ -12,13 +13,10 @@ const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
|
|||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<header class="hero">
|
<header class="hero">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Overview</p>
|
<p class="eyebrow">{{ t('dashboard.eyebrow') }}</p>
|
||||||
<h1>Robot Command Center</h1>
|
<h1>{{ t('dashboard.title') }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="hero-text">
|
<p class="hero-text">{{ t('dashboard.description') }}</p>
|
||||||
The A-side daemon now owns video receive, control ingress arbitration, and live session
|
|
||||||
telemetry in one backend process.
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="banner" :class="{ error: !!errorMessage }">
|
<section class="banner" :class="{ error: !!errorMessage }">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import GpsMapPanel from '@/components/GpsMapPanel.vue'
|
import GpsMapPanel from '@/components/GpsMapPanel.vue'
|
||||||
|
import { t } from '@/lib/locale'
|
||||||
import { useMonitoringData } from '@/composables/useMonitoringData'
|
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||||
|
|
||||||
const { gps, errorMessage, headerStatus } = useMonitoringData({
|
const { gps, errorMessage, headerStatus } = useMonitoringData({
|
||||||
@@ -11,13 +12,10 @@ const { gps, errorMessage, headerStatus } = useMonitoringData({
|
|||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Map</p>
|
<p class="eyebrow">{{ t('mapView.eyebrow') }}</p>
|
||||||
<h1>地图定位页面</h1>
|
<h1>{{ t('mapView.title') }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="description">
|
<p class="description">{{ t('mapView.description') }}</p>
|
||||||
这里整合了 `GeoStream` 的 GPS 展示逻辑。只要原来的 GPS 模块继续写
|
|
||||||
`gps_latest.json`,这个页面就能直接显示实时定位。
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="banner" :class="{ error: !!errorMessage }">
|
<section class="banner" :class="{ error: !!errorMessage }">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import NetworkPanel from '@/components/NetworkPanel.vue'
|
import NetworkPanel from '@/components/NetworkPanel.vue'
|
||||||
|
import { t } from '@/lib/locale'
|
||||||
import { useMonitoringData } from '@/composables/useMonitoringData'
|
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||||
|
|
||||||
const { network, errorMessage, headerStatus } = useMonitoringData({
|
const { network, errorMessage, headerStatus } = useMonitoringData({
|
||||||
@@ -11,14 +12,10 @@ const { network, errorMessage, headerStatus } = useMonitoringData({
|
|||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Network</p>
|
<p class="eyebrow">{{ t('networkView.eyebrow') }}</p>
|
||||||
<h1>Network Telemetry</h1>
|
<h1>{{ t('networkView.title') }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="description">
|
<p class="description">{{ t('networkView.description') }}</p>
|
||||||
Live dual-leg OmniSocket telemetry from the A-side daemon, separating the local `A <-> D`
|
|
||||||
sessions from the hub-reported `D <-> B` leg with queue pressure, retransmission, and stale-link
|
|
||||||
visibility.
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="banner" :class="{ error: !!errorMessage }">
|
<section class="banner" :class="{ error: !!errorMessage }">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import VideoPanel from '@/components/VideoPanel.vue'
|
import VideoPanel from '@/components/VideoPanel.vue'
|
||||||
|
import { t } from '@/lib/locale'
|
||||||
import { useMonitoringData } from '@/composables/useMonitoringData'
|
import { useMonitoringData } from '@/composables/useMonitoringData'
|
||||||
|
|
||||||
const { video, network, errorMessage, headerStatus } = useMonitoringData()
|
const { video, network, errorMessage, headerStatus } = useMonitoringData()
|
||||||
@@ -9,10 +10,10 @@ const { video, network, errorMessage, headerStatus } = useMonitoringData()
|
|||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Video</p>
|
<p class="eyebrow">{{ t('videoView.eyebrow') }}</p>
|
||||||
<h1>视频流页面</h1>
|
<h1>{{ t('videoView.title') }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="description">这个页面专门用于看逐帧 JPEG 画面。前端会按固定频率请求单张 JPEG,后端每次返回一帧。</p>
|
<p class="description">{{ t('videoView.description') }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="banner" :class="{ error: !!errorMessage }">
|
<section class="banner" :class="{ error: !!errorMessage }">
|
||||||
|
|||||||
Reference in New Issue
Block a user