feat: 中英文切换

This commit is contained in:
2026-04-18 17:36:19 +08:00
parent 9c0e879aec
commit a8c9d5fa0d
14 changed files with 907 additions and 273 deletions

View File

@@ -1,12 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, RouterView } from 'vue-router'
const navItems = [
{ to: '/', label: '总览' },
{ to: '/video', label: '视频流' },
{ to: '/map', label: '地图定位' },
{ to: '/network', label: '网络状态' },
]
import { useLocale } from '@/lib/locale'
const { t, toggleLocale, nextLocaleLabel } = useLocale()
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>
<template>
@@ -15,21 +20,27 @@ const navItems = [
<div class="brand">
<p class="brand-mark">RCC</p>
<div>
<strong>Robot Command Center</strong>
<span>机器人竞赛指挥台</span>
<strong>{{ t('app.brandTitle') }}</strong>
<span>{{ t('app.brandSubtitle') }}</span>
</div>
</div>
<nav class="nav">
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="nav-link"
>
{{ item.label }}
</RouterLink>
</nav>
<div class="topbar-actions">
<nav class="nav">
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="nav-link"
>
{{ item.label }}
</RouterLink>
</nav>
<button type="button" class="locale-button" @click="toggleLocale">
{{ nextLocaleLabel }}
</button>
</div>
</header>
<main class="page-body">
@@ -128,6 +139,14 @@ const navItems = [
font-size: 13px;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.nav {
display: flex;
gap: 10px;
@@ -135,7 +154,8 @@ const navItems = [
justify-content: flex-end;
}
.nav-link {
.nav-link,
.locale-button {
padding: 10px 14px;
border-radius: 999px;
border: 1px solid rgba(133, 147, 169, 0.18);
@@ -148,7 +168,8 @@ const navItems = [
border-color 0.2s ease;
}
.nav-link:hover {
.nav-link:hover,
.locale-button:hover {
transform: translateY(-1px);
background: rgba(25, 38, 66, 0.9);
}
@@ -160,6 +181,13 @@ const navItems = [
font-weight: 700;
}
.locale-button {
cursor: pointer;
font: inherit;
font-weight: 700;
white-space: nowrap;
}
.page-body {
display: grid;
}
@@ -170,6 +198,7 @@ const navItems = [
align-items: stretch;
}
.topbar-actions,
.nav {
justify-content: flex-start;
}
@@ -180,6 +209,7 @@ const navItems = [
width: min(100%, calc(100% - 20px));
}
.topbar-actions,
.nav {
display: grid;
grid-template-columns: 1fr;

View File

@@ -2,6 +2,7 @@
import { computed } from 'vue'
import { useControlInterface } from '@/composables/useControlInterface'
import { t } from '@/lib/locale'
const props = withDefaults(defineProps<{
compact?: boolean
@@ -51,22 +52,43 @@ const keyClusters = computed(() => {
const commandBars = computed(() => [
{
label: 'Forward',
label: t('controlFeedback.forward'),
value: commandValues.value.lx,
max: controlLimits.value.forward,
},
{
label: 'Strafe',
label: t('controlFeedback.strafe'),
value: commandValues.value.ly,
max: controlLimits.value.strafe,
},
{
label: 'Turn',
label: t('controlFeedback.turn'),
value: commandValues.value.az,
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) {
const normalized = Math.max(-1, Math.min(1, value / max))
return `${50 + normalized * 45}%`
@@ -85,7 +107,7 @@ function stickOffset(value: number) {
{{ activeSourceLabel }}
</div>
<div class="input-chip">
{{ controlInputModeLabel }} mode
{{ t('controlFeedback.modeChip', { mode: controlInputModeLabel }) }}
</div>
</div>
<div class="status-stack">
@@ -112,19 +134,18 @@ function stickOffset(value: number) {
</div>
<p class="summary">
Tuning: fwd {{ controlTuning.forward.toFixed(2) }} m/s, strafe {{ controlTuning.strafe.toFixed(2) }} m/s,
turn {{ controlTuning.turn.toFixed(2) }} rad/s, turbo x{{ controlTuning.turbo.toFixed(2) }}
{{ tuningSummary }}
</p>
<div class="feedback-grid" :class="{ compact }">
<section class="feedback-card">
<div class="card-head">
<div>
<p class="label">Keyboard</p>
<p class="label">{{ t('controlFeedback.keyboard') }}</p>
<strong>{{ pressedKeysLabel }}</strong>
</div>
<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>
</div>
@@ -143,31 +164,27 @@ function stickOffset(value: number) {
<section class="feedback-card">
<div class="card-head">
<div>
<p class="label">Gamepad</p>
<strong>{{ gamepadConnected ? gamepadName : 'Waiting for controller' }}</strong>
<p class="label">{{ t('controlFeedback.gamepad') }}</p>
<strong>{{ gamepadConnected ? gamepadName : t('controlFeedback.waitingForController') }}</strong>
</div>
<span class="mode-chip" :class="{ hot: controlInputMode === 'gamepad' && gamepadActive }">
{{
gamepadConnected
? controlInputMode === 'gamepad'
? 'Selected'
: 'Standby'
: 'Offline'
? t('common.selected')
: t('common.standby')
: t('common.offline')
}}
</span>
</div>
<p class="subtle">
{{
gamepadConnected
? `#${gamepadIndex} / mapping=${gamepadMapping || 'unknown'}`
: 'Left stick drives, right stick turns, RB boosts, A stops.'
}}
{{ gamepadMeta }}
</p>
<div class="sticks">
<div class="stick-card">
<span>Left stick</span>
<span>{{ t('controlFeedback.leftStick') }}</span>
<div class="stick-pad">
<span class="crosshair crosshair-x" />
<span class="crosshair crosshair-y" />
@@ -181,7 +198,7 @@ function stickOffset(value: number) {
</div>
<div class="stick-card">
<span>Right stick</span>
<span>{{ t('controlFeedback.rightStick') }}</span>
<div class="stick-pad">
<span class="crosshair crosshair-x" />
<span class="crosshair crosshair-y" />
@@ -209,7 +226,7 @@ function stickOffset(value: number) {
</div>
<p v-if="!compact" class="summary accent">
Outgoing command: {{ commandLabel }}
{{ outgoingCommandText }}
</p>
</section>
</template>

View File

@@ -3,14 +3,16 @@ import { computed } from 'vue'
import ControlFeedback from '@/components/ControlFeedback.vue'
import { useControlInterface } from '@/composables/useControlInterface'
import { useLocale } from '@/lib/locale'
const { controlInputMode, controlInputModeLabel, controlTuning, resetControlTuning, setControlInputMode, setControlTuning } =
useControlInterface()
const { t } = useLocale()
const inputModes = [
{ id: 'keyboard', label: 'Keyboard', detail: 'Use W/S, A/D, Q/E, Shift, and Space.' },
{ id: 'gamepad', label: 'Gamepad', detail: 'Use the browser-detected controller only.' },
] as const
const inputModes = computed(() => [
{ id: 'keyboard', label: t('common.keyboard'), detail: t('controlPanel.keyboardDetail') },
{ id: 'gamepad', label: t('common.gamepad'), detail: t('controlPanel.gamepadDetail') },
] as const)
const forwardSpeed = computed({
get: () => controlTuning.value.forward,
@@ -37,24 +39,24 @@ const turboMultiplier = computed({
<section class="panel control-panel">
<div class="panel-head">
<div>
<p class="eyebrow">Control</p>
<h2>Control Feedback</h2>
<p class="eyebrow">{{ t('controlPanel.eyebrow') }}</p>
<h2>{{ t('controlPanel.title') }}</h2>
</div>
<button type="button" class="reset-button" @click="resetControlTuning">
Reset Defaults
{{ t('controlPanel.resetDefaults') }}
</button>
</div>
<section class="mode-panel">
<div class="mode-panel-head">
<div>
<p class="mode-eyebrow">Input Mode</p>
<p class="mode-copy">Only one local input mode can control the page at a time.</p>
<p class="mode-eyebrow">{{ t('controlPanel.inputModeEyebrow') }}</p>
<p class="mode-copy">{{ t('controlPanel.inputModeCopy') }}</p>
</div>
<strong class="mode-current">{{ controlInputModeLabel }}</strong>
</div>
<div class="mode-toggle" role="radiogroup" aria-label="Control input mode">
<div class="mode-toggle" role="radiogroup" :aria-label="t('controlPanel.inputModeEyebrow')">
<button
v-for="mode in inputModes"
:key="mode.id"
@@ -72,25 +74,25 @@ const turboMultiplier = computed({
<div class="tuning-grid">
<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" />
<small>m/s</small>
</label>
<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" />
<small>m/s</small>
</label>
<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" />
<small>rad/s</small>
</label>
<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" />
<small>x</small>
</label>
@@ -99,15 +101,13 @@ const turboMultiplier = computed({
<ControlFeedback />
<p class="hint">
Keyboard mapping: <code>W/S</code> forward-back, <code>A/D</code> strafe, <code>Q/E</code> turn,
<code>Shift</code> turbo, <code>Space</code> stop.
{{ t('controlPanel.keyboardHint') }}
</p>
<p class="hint subtle">
Speed tuning is shared by both local input modes and is saved in this browser.
{{ t('controlPanel.tuningHint') }}
</p>
<p class="hint subtle">
Browser gamepad mode uses the left stick to drive, the right stick to turn,
<code>RB</code> to boost, and <code>A</code> to send stop.
{{ t('controlPanel.gamepadHint') }}
</p>
</section>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { formatDateTime, useLocale, type MessageKey } from '@/lib/locale'
import type { GpsTelemetry } from '@/types'
declare global {
@@ -16,12 +17,19 @@ const props = defineProps<{
gps: GpsTelemetry | null
}>()
const { locale, t } = useLocale()
const STORAGE_KEY = 'robot_command_center_amap'
type StatusState = {
key: MessageKey
params?: Record<string, string | number | null | undefined>
}
const keyInput = ref('')
const securityCodeInput = ref('')
const statusText = ref('等待加载高德地图。')
const amapCoordinateText = ref('暂无')
const statusState = ref<StatusState>({ key: 'gpsMap.status.waitingInit' })
const amapCoordinateRaw = ref('')
const mapElement = ref<HTMLDivElement | null>(null)
const mapRunning = ref(false)
@@ -30,6 +38,12 @@ let mapInstance: any = null
let marker: 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() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
@@ -54,7 +68,7 @@ function formatNumber(value: number) {
}
function formatHexText(value: string | null | undefined) {
return value || '暂无'
return value || t('gpsMap.noValue')
}
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.async = true
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)
})
@@ -94,7 +108,7 @@ function ensureMap() {
marker = new window.AMap.Marker({
anchor: 'bottom-center',
title: 'Robot GPS',
title: t('gpsMap.infoTitle'),
})
infoWindow = new window.AMap.InfoWindow({
@@ -104,7 +118,7 @@ function ensureMap() {
function stopMap() {
mapRunning.value = false
amapCoordinateText.value = '已停止'
amapCoordinateRaw.value = ''
if (infoWindow) {
infoWindow.close()
@@ -128,7 +142,23 @@ function stopMap() {
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) {
@@ -137,10 +167,10 @@ function updateMap(gps: GpsTelemetry | null) {
}
if (!gps?.has_fix || gps.latitude == null || gps.longitude == null) {
amapCoordinateText.value = '暂无'
amapCoordinateRaw.value = ''
marker?.setMap(null)
infoWindow?.close()
statusText.value = gps ? 'GPS 在线,但当前还没有有效定位。' : '等待 GPS 数据。'
setStatus(gps ? 'gpsMap.status.noFix' : 'gpsMap.status.waitingGps')
return
}
@@ -149,7 +179,7 @@ function updateMap(gps: GpsTelemetry | null) {
window.AMap.convertFrom([rawLongitude, rawLatitude], 'gps', (status: string, result: any) => {
if (status !== 'complete' || !result?.locations?.length) {
statusText.value = 'GPS 坐标转换失败。'
setStatus('gpsMap.status.convertFailed')
return
}
@@ -157,27 +187,14 @@ function updateMap(gps: GpsTelemetry | null) {
const lng = typeof point.getLng === 'function' ? point.getLng() : point.lng
const lat = typeof point.getLat === 'function' ? point.getLat() : point.lat
amapCoordinateText.value = `${formatNumber(lat)}, ${formatNumber(lng)}`
amapCoordinateRaw.value = `${formatNumber(lat)}, ${formatNumber(lng)}`
marker.setPosition([lng, lat])
marker.setMap(mapInstance)
infoWindow.setContent(
[
'<div style="min-width: 240px; padding: 6px 2px; line-height: 1.75; font-size: 13px; color: #152033;">',
'<div style="margin-bottom: 8px; font-size: 14px; font-weight: 700; color: #0f172a;">Robot GPS 定位</div>',
`<div><span style="color: #667085;">WGS84:</span> <strong style="color: #0f172a;">${formatNumber(rawLatitude)}, ${formatNumber(rawLongitude)}</strong></div>`,
`<div><span style="color: #667085;">高德 GCJ-02:</span> <strong style="color: #0f172a;">${formatNumber(lat)}, ${formatNumber(lng)}</strong></div>`,
`<div><span style="color: #667085;">纬度原始 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.setContent(buildInfoWindowContent(gps, lat, lng))
infoWindow.open(mapInstance, [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()
if (!key || !securityJsCode) {
statusText.value = '请先填写高德 Key 和安全密钥 jscode。'
setStatus('gpsMap.status.fillCredentials')
return
}
statusText.value = '正在加载高德地图...'
setStatus('gpsMap.status.loading')
try {
await loadAmapScript(key, securityJsCode)
ensureMap()
saveCredentials()
mapRunning.value = true
statusText.value = '地图已加载。'
setStatus('gpsMap.status.loaded')
updateMap(props.gps)
} 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(() => {
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)}`
})
const amapCoordinateText = computed(() => amapCoordinateRaw.value || t('gpsMap.noValue'))
const rawLatitudeHexText = computed(() => formatHexText(props.gps?.raw_latitude_hex))
const rawLongitudeHexText = computed(() => formatHexText(props.gps?.raw_longitude_hex))
const coordinateMetaText = computed(() => {
if (!props.gps) {
return '暂无'
return t('gpsMap.noValue')
}
return `${props.gps.coordinate_system} / ${props.gps.raw_coordinate_format}`
})
const metaText = computed(() => {
if (!props.gps) {
return '暂无'
return t('gpsMap.noValue')
}
const satellites = props.gps.satellites ?? '未知'
const altitude = props.gps.altitude_m == null ? '未知' : `${props.gps.altitude_m} m`
return `${satellites} / ${altitude}`
const satellites = props.gps.satellites ?? t('common.unknown')
const altitude = props.gps.altitude_m == null ? t('common.unknown') : `${props.gps.altitude_m} m`
return `${satellites} / ${altitude}`
})
const updatedAtText = computed(() => {
if (!props.gps?.updated_at) {
return '暂无'
}
return new Date(props.gps.updated_at).toLocaleString('zh-CN', { hour12: false })
})
const updatedAtText = computed(() => formatDateTime(props.gps?.updated_at))
onMounted(() => {
const saved = readSavedCredentials()
@@ -245,7 +259,7 @@ onMounted(() => {
keyInput.value = saved.key ?? ''
securityCodeInput.value = saved.securityJsCode ?? ''
if (keyInput.value && securityCodeInput.value) {
statusText.value = '已恢复高德配置。高德地图不会自动加载,请按需点击“加载地图”。'
setStatus('gpsMap.status.restoredConfig')
}
}
})
@@ -257,70 +271,76 @@ watch(
},
{ deep: true },
)
watch(
() => locale.value,
() => {
if (mapRunning.value) {
updateMap(props.gps)
}
},
)
</script>
<template>
<section class="panel map-panel">
<div class="panel-head">
<div>
<p class="eyebrow">GPS</p>
<h2>地图定位</h2>
<p class="eyebrow">{{ t('gpsMap.eyebrow') }}</p>
<h2>{{ t('gpsMap.title') }}</h2>
</div>
<span class="badge">{{ gps?.source_mode ?? 'loading' }}</span>
<span class="badge">{{ gps?.source_mode ?? t('common.loading') }}</span>
</div>
<p class="intro">
这里复用了你原来 `GeoStream/gps_map.html` 的高德地图思路后端优先读取
`GeoStream/gps_latest.json`所以你运行 `parse_gps.c` 生成数据后这里会直接接上
</p>
<p class="intro">{{ t('gpsMap.intro') }}</p>
<div class="credentials">
<input v-model="keyInput" type="text" placeholder="高德 Web 端 Key" />
<input v-model="securityCodeInput" type="text" placeholder="安全密钥 jscode" />
<button type="button" @click="startMap">加载地图</button>
<button type="button" class="secondary" @click="stopMap">停止加载</button>
<input v-model="keyInput" type="text" :placeholder="t('gpsMap.keyPlaceholder')" />
<input v-model="securityCodeInput" type="text" :placeholder="t('gpsMap.jscodePlaceholder')" />
<button type="button" @click="startMap">{{ t('gpsMap.loadMap') }}</button>
<button type="button" class="secondary" @click="stopMap">{{ t('gpsMap.stopMap') }}</button>
</div>
<div class="status">{{ statusText }}</div>
<div class="details">
<div class="detail-card">
<span>WGS84 坐标</span>
<span>{{ t('gpsMap.wgs84') }}</span>
<strong>{{ rawCoordinateText }}</strong>
</div>
<div class="detail-card">
<span>高德 GCJ-02</span>
<span>{{ t('gpsMap.gcj02') }}</span>
<strong>{{ amapCoordinateText }}</strong>
</div>
<div class="detail-card">
<span>纬度原始 8 字节</span>
<span>{{ t('gpsMap.rawLatHex') }}</span>
<strong class="mono">{{ rawLatitudeHexText }}</strong>
</div>
<div class="detail-card">
<span>经度原始 8 字节</span>
<span>{{ t('gpsMap.rawLonHex') }}</span>
<strong class="mono">{{ rawLongitudeHexText }}</strong>
</div>
<div class="detail-card">
<span>UTC 时间</span>
<span>{{ t('gpsMap.utcTime') }}</span>
<strong>{{ gps?.utc_time ?? '--:--:--' }}</strong>
</div>
<div class="detail-card">
<span>卫星 / 海拔</span>
<span>{{ t('gpsMap.satAltitude') }}</span>
<strong>{{ metaText }}</strong>
</div>
<div class="detail-card">
<span>坐标系 / 格式</span>
<span>{{ t('gpsMap.coordMeta') }}</span>
<strong>{{ coordinateMetaText }}</strong>
</div>
<div class="detail-card">
<span>最近刷新</span>
<span>{{ t('gpsMap.lastUpdated') }}</span>
<strong>{{ updatedAtText }}</strong>
</div>
</div>
<div ref="mapElement" class="map-canvas" :class="{ stopped: !mapRunning }">
<div v-if="!mapRunning" class="map-placeholder">
高德地图当前未加载点击上方加载地图后才会开始请求地图与坐标转换服务
{{ t('gpsMap.mapPlaceholder') }}
</div>
</div>
</section>
@@ -423,7 +443,7 @@ h2 {
}
.detail-card strong.mono {
font-family: "JetBrains Mono", "SFMono-Regular", monospace;
font-family: 'JetBrains Mono', 'SFMono-Regular', monospace;
font-size: 14px;
}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatDateTime, t } from '@/lib/locale'
import type { LinkSessionTelemetry, LinkTelemetry, NetworkTelemetry } from '@/types'
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) {
if (!value) {
return 'unavailable'
}
return new Date(value).toLocaleString('zh-CN', { hour12: false })
return formatDateTime(value)
}
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 },
]
}
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>
<template>
<section class="panel network-panel">
<div class="panel-head">
<div>
<p class="eyebrow">Network</p>
<h2>Dual-Leg Telemetry</h2>
<p class="eyebrow">{{ t('networkPanel.eyebrow') }}</p>
<h2>{{ t('networkPanel.title') }}</h2>
</div>
<span class="badge" :class="{ stale: network?.telemetry_receiver?.hub_stale }">
{{ network?.peer_status ?? 'loading' }}
{{ network?.peer_status ?? t('networkPanel.loadingPeer') }}
</span>
</div>
<div class="stats">
<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>
</div>
<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>
</div>
<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>
</div>
<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>
</div>
<div class="stat-card">
<span>TX Rate</span>
<span>{{ t('networkPanel.txRate') }}</span>
<strong>{{ formatScalar(network?.tx_kbps, ' kbps') }}</strong>
</div>
<div class="stat-card">
<span>RX Rate</span>
<span>{{ t('networkPanel.rxRate') }}</span>
<strong>{{ formatScalar(network?.rx_kbps, ' kbps') }}</strong>
</div>
</div>
<div class="summary telemetry-strip">
<p><strong>Robot Fault:</strong> {{ network?.robot_health?.fault_reason ?? 'n/a' }}</p>
<p><strong>Recovery State:</strong> {{ network?.robot_health?.recovery_state ?? 'n/a' }}</p>
<p><strong>Health Confidence:</strong> {{ network?.robot_health?.confidence ?? 'n/a' }}</p>
<p><strong>Health Updated:</strong> {{ formatTime(network?.robot_health?.updated_at) }}</p>
<p><strong>Transport:</strong> {{ network?.transport ?? 'n/a' }} / {{ network?.source_mode ?? 'n/a' }}</p>
<p><strong>Active Control:</strong> {{ activeSource }}</p>
<p><strong>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>ACK Updated:</strong> {{ formatTime(network?.control_ack_status?.updated_at) }}</p>
<p><strong>Telemetry Peer:</strong> {{ network?.telemetry_receiver?.peer_id ?? 'n/a' }}</p>
<p><strong>Telemetry Registered:</strong> {{ network?.telemetry_receiver?.registered ? 'yes' : 'no' }}</p>
<p><strong>Hub Freshness:</strong> {{ formatTime(network?.telemetry_receiver?.hub_updated_at) }}</p>
<p><strong>Hub State:</strong> {{ network?.telemetry_receiver?.hub_stale ? 'stale' : 'fresh' }}</p>
<p><strong>Telemetry Reconnects:</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_server_error"><strong>Telemetry Session Error:</strong> {{ network?.telemetry_receiver?.last_server_error }}</p>
<p><strong>{{ t('networkPanel.robotFault') }}:</strong> {{ network?.robot_health?.fault_reason ?? t('common.na') }}</p>
<p><strong>{{ t('networkPanel.recoveryState') }}:</strong> {{ network?.robot_health?.recovery_state ?? t('common.na') }}</p>
<p><strong>{{ t('networkPanel.healthConfidence') }}:</strong> {{ network?.robot_health?.confidence ?? t('common.na') }}</p>
<p><strong>{{ t('networkPanel.healthUpdated') }}:</strong> {{ formatTime(network?.robot_health?.updated_at) }}</p>
<p><strong>{{ t('networkPanel.transport') }}:</strong> {{ network?.transport ?? t('common.na') }} / {{ network?.source_mode ?? t('common.na') }}</p>
<p><strong>{{ t('networkPanel.activeControl') }}:</strong> {{ activeSource }}</p>
<p><strong>{{ t('networkPanel.lease') }}:</strong> {{ formatScalar(network?.control_lease_remaining_ms, ' ms') }}</p>
<p><strong>{{ t('networkPanel.ackMode') }}:</strong> {{ formatAckMode(network?.control_ack_status?.ack_available) }}</p>
<p><strong>{{ t('networkPanel.ackUpdated') }}:</strong> {{ formatTime(network?.control_ack_status?.updated_at) }}</p>
<p><strong>{{ t('networkPanel.telemetryPeer') }}:</strong> {{ network?.telemetry_receiver?.peer_id ?? t('common.na') }}</p>
<p><strong>{{ t('networkPanel.telemetryRegistered') }}:</strong> {{ formatBoolean(network?.telemetry_receiver?.registered) }}</p>
<p><strong>{{ t('networkPanel.hubFreshness') }}:</strong> {{ formatTime(network?.telemetry_receiver?.hub_updated_at) }}</p>
<p><strong>{{ t('networkPanel.hubState') }}:</strong> {{ formatStale(network?.telemetry_receiver?.hub_stale) }}</p>
<p><strong>{{ t('networkPanel.telemetryReconnects') }}:</strong> {{ network?.telemetry_receiver?.reconnect_count ?? 0 }}</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>{{ t('networkPanel.telemetrySessionError') }}:</strong> {{ network?.telemetry_receiver?.last_server_error }}</p>
</div>
<div class="leg-grid">
@@ -110,11 +149,11 @@ function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: Li
<div class="leg-head">
<div>
<p class="leg-label">{{ leg.label }}</p>
<h3>{{ leg.data?.source ?? 'waiting' }}</h3>
<h3>{{ leg.data?.source ?? t('common.waiting') }}</h3>
</div>
<div class="leg-meta">
<span class="mini-badge" :class="{ stale: leg.data?.stale }">
{{ leg.data?.stale ? 'stale' : 'fresh' }}
{{ formatStale(leg.data?.stale) }}
</span>
<span class="mini-time">{{ formatTime(leg.data?.updated_at) }}</span>
</div>
@@ -122,27 +161,27 @@ function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: Li
<div class="aggregate-grid">
<div>
<span>Online</span>
<span>{{ t('networkPanel.online') }}</span>
<strong>{{ leg.data?.aggregate?.online_sessions ?? 0 }}</strong>
</div>
<div>
<span>Max Pressure</span>
<span>{{ t('networkPanel.maxPressure') }}</span>
<strong>{{ formatScalar(leg.data?.aggregate?.max_window_pressure_pct, '%') }}</strong>
</div>
<div>
<span>Queued</span>
<span>{{ t('networkPanel.queued') }}</span>
<strong>{{ leg.data?.aggregate?.sum_snd_queue ?? 0 }}</strong>
</div>
<div>
<span>In Flight Buffer</span>
<span>{{ t('networkPanel.inFlightBuffer') }}</span>
<strong>{{ leg.data?.aggregate?.sum_snd_buffer ?? 0 }}</strong>
</div>
<div>
<span>Retrans Delta</span>
<span>{{ t('networkPanel.retransDelta') }}</span>
<strong>{{ leg.data?.aggregate?.sum_retrans_delta ?? 0 }}</strong>
</div>
<div>
<span>Repair Rate</span>
<span>{{ t('networkPanel.repairRate') }}</span>
<strong>{{ formatScalar(leg.data?.aggregate?.repair_rate_pct, '%') }}</strong>
</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">
<div class="session-head">
<div>
<p class="session-label">{{ session.name }}</p>
<h4>{{ session.data?.peer_id ?? 'unassigned' }}</h4>
<p class="session-label">{{ formatSessionName(session.name) }}</p>
<h4>{{ session.data?.peer_id ?? t('networkPanel.unassigned') }}</h4>
</div>
<span class="mini-badge" :class="{ stale: session.data?.stale, active: session.data?.connected }">
{{ session.data?.connected ? 'online' : 'idle' }}
{{ formatOnline(session.data?.connected) }}
</span>
</div>
<div class="kv-grid">
<p><strong>Updated:</strong> {{ formatTime(session.data?.updated_at) }}</p>
<p><strong>SRTT:</strong> {{ formatScalar(session.data?.kcp?.srtt_ms, ' ms') }}</p>
<p><strong>RTTVAR:</strong> {{ formatScalar(session.data?.kcp?.srttvar_ms, ' ms') }}</p>
<p><strong>RTO:</strong> {{ formatScalar(session.data?.kcp?.rto_ms, ' ms') }}</p>
<p><strong>SND WND:</strong> {{ formatScalar(session.data?.kcp?.snd_wnd) }}</p>
<p><strong>RMT WND:</strong> {{ formatScalar(session.data?.kcp?.rmt_wnd) }}</p>
<p><strong>Inflight:</strong> {{ formatScalar(session.data?.kcp?.inflight) }}</p>
<p><strong>Window Limit:</strong> {{ formatScalar(session.data?.kcp?.window_limit) }}</p>
<p><strong>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>SND Buffer:</strong> {{ formatScalar(session.data?.kcp?.snd_buffer) }} / {{ session.data?.trend?.snd_buffer_trend ?? 'stable' }}</p>
<p><strong>Queue Delta:</strong> {{ formatScalar(session.data?.trend?.snd_queue_delta) }}</p>
<p><strong>Buffer Delta:</strong> {{ formatScalar(session.data?.trend?.snd_buffer_delta) }}</p>
<p><strong>Retrans:</strong> {{ formatScalar(session.data?.trend?.retrans_delta) }}</p>
<p><strong>Fast Retrans:</strong> {{ formatScalar(session.data?.trend?.fast_retrans_delta) }}</p>
<p><strong>Lost:</strong> {{ formatScalar(session.data?.trend?.lost_delta) }}</p>
<p><strong>Repeat:</strong> {{ formatScalar(session.data?.trend?.repeat_delta) }}</p>
<p><strong>Repair Rate:</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>Registered:</strong> {{ session.data.app.registered ? 'yes' : 'no' }}</p>
<p v-if="session.data?.app?.last_server_error"><strong>Server Error:</strong> {{ session.data.app.last_server_error }}</p>
<p><strong>{{ t('networkPanel.updated') }}:</strong> {{ formatTime(session.data?.updated_at) }}</p>
<p><strong>{{ t('networkPanel.srtt') }}:</strong> {{ formatScalar(session.data?.kcp?.srtt_ms, ' ms') }}</p>
<p><strong>{{ t('networkPanel.rttvar') }}:</strong> {{ formatScalar(session.data?.kcp?.srttvar_ms, ' ms') }}</p>
<p><strong>{{ t('networkPanel.rto') }}:</strong> {{ formatScalar(session.data?.kcp?.rto_ms, ' ms') }}</p>
<p><strong>{{ t('networkPanel.sndWnd') }}:</strong> {{ formatScalar(session.data?.kcp?.snd_wnd) }}</p>
<p><strong>{{ t('networkPanel.rmtWnd') }}:</strong> {{ formatScalar(session.data?.kcp?.rmt_wnd) }}</p>
<p><strong>{{ t('networkPanel.inflight') }}:</strong> {{ formatScalar(session.data?.kcp?.inflight) }}</p>
<p><strong>{{ t('networkPanel.windowLimit') }}:</strong> {{ formatScalar(session.data?.kcp?.window_limit) }}</p>
<p><strong>{{ t('networkPanel.pressure') }}:</strong> {{ formatScalar(session.data?.kcp?.window_pressure_pct, '%') }}</p>
<p><strong>{{ t('networkPanel.sndQueue') }}:</strong> {{ formatScalar(session.data?.kcp?.snd_queue) }} / {{ formatTrend(session.data?.trend?.snd_queue_trend) }}</p>
<p><strong>{{ t('networkPanel.sndBuffer') }}:</strong> {{ formatScalar(session.data?.kcp?.snd_buffer) }} / {{ formatTrend(session.data?.trend?.snd_buffer_trend) }}</p>
<p><strong>{{ t('networkPanel.queueDelta') }}:</strong> {{ formatScalar(session.data?.trend?.snd_queue_delta) }}</p>
<p><strong>{{ t('networkPanel.bufferDelta') }}:</strong> {{ formatScalar(session.data?.trend?.snd_buffer_delta) }}</p>
<p><strong>{{ t('networkPanel.retrans') }}:</strong> {{ formatScalar(session.data?.trend?.retrans_delta) }}</p>
<p><strong>{{ t('networkPanel.fastRetrans') }}:</strong> {{ formatScalar(session.data?.trend?.fast_retrans_delta) }}</p>
<p><strong>{{ t('networkPanel.lost') }}:</strong> {{ formatScalar(session.data?.trend?.lost_delta) }}</p>
<p><strong>{{ t('networkPanel.repeat') }}:</strong> {{ formatScalar(session.data?.trend?.repeat_delta) }}</p>
<p><strong>{{ t('networkPanel.repairRate') }}:</strong> {{ formatScalar(session.data?.trend?.repair_rate_pct, '%') }}</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>{{ t('networkPanel.registered') }}:</strong> {{ formatBoolean(session.data.app.registered) }}</p>
<p v-if="session.data?.app?.last_server_error"><strong>{{ t('networkPanel.serverError') }}:</strong> {{ session.data.app.last_server_error }}</p>
</div>
</section>
</div>
@@ -188,15 +227,15 @@ function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: Li
</div>
<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>Video E2E Est.:</strong> {{ formatScalar(network?.latency_estimate?.video_e2e_est_ms, ' ms') }} / confidence={{ network?.latency_estimate?.confidence?.video ?? 'n/a' }}</p>
<p><strong>Control Estimate Confidence:</strong> {{ network?.latency_estimate?.confidence?.control ?? 'n/a' }}</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>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>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>ACK Receiver:</strong> {{ network?.control?.ack_receiver?.peer_id ?? 'n/a' }} reconnects={{ network?.control?.ack_receiver?.reconnect_count ?? 0 }}</p>
<p><strong>Control Reconnects:</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><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>{{ 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>{{ t('networkPanel.controlEstimateConfidence') }}:</strong> {{ network?.latency_estimate?.confidence?.control ?? t('common.na') }}</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>{{ 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>{{ 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>{{ t('networkPanel.ackReceiver') }}:</strong> {{ network?.control?.ack_receiver?.peer_id ?? t('common.na') }} reconnects={{ network?.control?.ack_receiver?.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>{{ t('networkPanel.controlSessionError') }}:</strong> {{ network?.control?.sender?.last_server_error }}</p>
</div>
</section>
</template>

View File

@@ -2,6 +2,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { buildVideoFrameUrl, fetchClockCalibrationSample, fetchVideoStatus, postVideoDisplayProbe } from '@/lib/api'
import { t } from '@/lib/locale'
import { useOperatorInputTelemetry } from '@/composables/useControlInterface'
import type { NetworkTelemetry, VideoStatus } from '@/types'
@@ -62,10 +63,10 @@ const clockCalibration = ref<ClockCalibrationSnapshot>({ ...EMPTY_CLOCK_CALIBRAT
const modeLabel = computed(() => {
if (!displayVideo.value) {
return 'loading'
return t('videoPanel.mode.loading')
}
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
})
@@ -73,7 +74,7 @@ const modeLabel = computed(() => {
const timingHeadline = computed(() => {
const latest = senderClockDebug.value?.sender_clock_delta_ms_raw
if (latest == null) {
return 'waiting'
return t('videoPanel.timing.waiting')
}
return `${latest.toFixed(1)} ms`
})
@@ -81,9 +82,9 @@ const timingHeadline = computed(() => {
const timingHint = computed(() => {
const timing = senderClockDebug.value
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 = '') {
@@ -437,8 +438,8 @@ watch(
<section class="panel video-panel">
<div class="panel-head">
<div>
<p class="eyebrow">Video</p>
<h2>Live Video</h2>
<p class="eyebrow">{{ t('videoPanel.eyebrow') }}</p>
<h2>{{ t('videoPanel.title') }}</h2>
</div>
<span class="badge" :class="{ bad: !displayVideo?.available }">
{{ modeLabel }}
@@ -450,63 +451,63 @@ watch(
v-if="canRequestFrames"
class="video-frame"
:src="frameUrl"
alt="Robot live frame"
:alt="t('videoPanel.frameAlt')"
/>
<div v-else class="video-placeholder">
waiting for live video frames
{{ t('videoPanel.waitingFrames') }}
</div>
</div>
<div class="stats">
<div class="stat-card">
<span>Frames</span>
<span>{{ t('videoPanel.stats.frames') }}</span>
<strong>{{ displayVideo?.frame_count ?? 0 }}</strong>
</div>
<div class="stat-card">
<span>Latest Seq</span>
<span>{{ t('videoPanel.stats.latestSeq') }}</span>
<strong>{{ displayVideo?.receiver?.latest_sequence ?? '--' }}</strong>
</div>
<div class="stat-card">
<span>Video E2E Est.</span>
<span>{{ t('videoPanel.stats.videoE2E') }}</span>
<strong>{{ formatNumber(networkEstimate?.video_e2e_est_ms, ' ms') }}</strong>
</div>
<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>
</div>
</div>
<div class="metric-grid">
<div class="metric-group">
<h3>Pipeline Estimate</h3>
<p><strong>Capture to send:</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>Partial estimate:</strong> {{ formatNumber(networkEstimate?.video_partial_est_ms, ' ms') }}</p>
<p><strong>End-to-end estimate:</strong> {{ formatNumber(networkEstimate?.video_e2e_est_ms, ' ms') }}</p>
<h3>{{ t('videoPanel.section.pipeline') }}</h3>
<p><strong>{{ t('videoPanel.captureToSend') }}:</strong> {{ formatNumber(displayVideo?.receiver?.latest_capture_to_send_ms, ' ms') }}</p>
<p><strong>{{ t('videoPanel.networkOneWay') }}:</strong> {{ formatNumber(networkEstimate?.video_network_oneway_est_ms, ' ms') }}</p>
<p><strong>{{ t('videoPanel.partialEstimate') }}:</strong> {{ formatNumber(networkEstimate?.video_partial_est_ms, ' ms') }}</p>
<p><strong>{{ t('videoPanel.endToEndEstimate') }}:</strong> {{ formatNumber(networkEstimate?.video_e2e_est_ms, ' ms') }}</p>
</div>
<div class="metric-group">
<h3>Freshness</h3>
<p><strong>Inter-frame avg:</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>Repeated ratio:</strong> {{ formatNumber((freshness?.repeated_frame_ratio ?? 0) * 100, ' %') }}</p>
<p><strong>Skip ratio:</strong> {{ formatNumber((freshness?.skip_ratio ?? 0) * 100, ' %') }}</p>
<p><strong>Longest freeze:</strong> {{ formatNumber(freshness?.longest_freeze_ms, ' ms') }}</p>
<p><strong>Lag frames:</strong> {{ freshness?.relative_freshness_lag_frames ?? 0 }}</p>
<h3>{{ t('videoPanel.section.freshness') }}</h3>
<p><strong>{{ t('videoPanel.interFrameAvg') }}:</strong> {{ formatNumber(freshness?.inter_frame_avg_ms, ' ms') }}</p>
<p><strong>{{ t('videoPanel.interFrameP95') }}:</strong> {{ formatNumber(freshness?.inter_frame_p95_ms, ' ms') }}</p>
<p><strong>{{ t('videoPanel.repeatedRatio') }}:</strong> {{ formatNumber((freshness?.repeated_frame_ratio ?? 0) * 100, ' %') }}</p>
<p><strong>{{ t('videoPanel.skipRatio') }}:</strong> {{ formatNumber((freshness?.skip_ratio ?? 0) * 100, ' %') }}</p>
<p><strong>{{ t('videoPanel.longestFreeze') }}:</strong> {{ formatNumber(freshness?.longest_freeze_ms, ' ms') }}</p>
<p><strong>{{ t('videoPanel.lagFrames') }}:</strong> {{ freshness?.relative_freshness_lag_frames ?? 0 }}</p>
</div>
<div class="metric-group">
<h3>Operator Loop</h3>
<p><strong>Input to next seq:</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>Input to paint:</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>
<h3>{{ t('videoPanel.section.operator') }}</h3>
<p><strong>{{ t('videoPanel.inputToNextSeq') }}:</strong> {{ formatNumber(operatorMetrics.input_to_next_fresh_frame_ms, ' ms') }}</p>
<p><strong>{{ t('videoPanel.inputToChangedFrame') }}:</strong> {{ formatNumber(operatorMetrics.input_to_next_changed_frame_ms, ' ms') }}</p>
<p><strong>{{ t('videoPanel.inputToPaint') }}:</strong> {{ formatNumber(operatorMetrics.input_to_next_paint_ms, ' ms') }}</p>
<p><strong>{{ t('videoPanel.displayProbeRequestToPaint') }}:</strong> {{ formatNumber(displayVideo?.display_probe?.request_to_paint_ms, ' ms') }}</p>
</div>
</div>
<div class="timing-panel">
<div class="timing-head">
<span>Sender Clock Delta</span>
<span>{{ t('videoPanel.senderClockDelta') }}</span>
<strong>{{ timingHeadline }}</strong>
</div>
<div class="timing-grid">
@@ -524,7 +525,7 @@ watch(
</div>
<p class="hint">
{{ displayVideo?.source_detail ?? 'no live video detail available' }}
{{ displayVideo?.source_detail ?? t('videoPanel.noSourceDetail') }}
</p>
</section>
</template>

View File

@@ -1,6 +1,7 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { buildControlWebSocketUrl } from '@/lib/api'
import { t } from '@/lib/locale'
type SocketState = 'connecting' | 'open' | 'closed'
type ControlInputMode = 'keyboard' | 'gamepad'
@@ -35,7 +36,7 @@ const KEY_LABELS: Record<string, string> = {
KeyE: 'E',
ShiftLeft: 'Shift',
ShiftRight: 'Shift',
Space: 'Stop',
Space: 'Space',
}
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 socketState = ref<SocketState>('connecting')
const lastServerMessage = ref('waiting')
const lastServerMessageOverride = ref('')
const lastServerMessagePreset = ref<'waiting' | 'live'>('waiting')
const gamepadSupported = ref(false)
const gamepadConnected = ref(false)
const gamepadName = ref('No gamepad detected')
const gamepadNameRaw = ref('')
const gamepadIndex = ref<number | null>(null)
const gamepadMapping = ref('')
const gamepadAxes = ref<number[]>([0, 0, 0, 0])
@@ -444,7 +446,7 @@ function handleKeyup(event: KeyboardEvent) {
function resetGamepadState() {
gamepadConnected.value = false
gamepadName.value = 'No gamepad detected'
gamepadNameRaw.value = ''
gamepadIndex.value = null
gamepadMapping.value = ''
gamepadAxes.value = [0, 0, 0, 0]
@@ -484,9 +486,9 @@ function pollGamepadState() {
lastGamepadSignature = signature
gamepadConnected.value = true
gamepadName.value = pad.id || 'Unnamed gamepad'
gamepadNameRaw.value = pad.id || ''
gamepadIndex.value = pad.index
gamepadMapping.value = pad.mapping || 'unknown'
gamepadMapping.value = pad.mapping || ''
gamepadAxes.value = axes
gamepadButtonPressed.value = buttons
if (controlInputMode.value === 'gamepad') {
@@ -506,18 +508,21 @@ function connectSocket() {
socket.onopen = () => {
socketState.value = 'open'
lastServerMessage.value = 'control link live'
lastServerMessagePreset.value = 'live'
lastServerMessageOverride.value = ''
refreshSendLoop(true, false)
}
socket.onmessage = (event) => {
if (typeof event.data === 'string') {
lastServerMessage.value = event.data
lastServerMessageOverride.value = event.data
}
}
socket.onclose = () => {
socketState.value = 'closed'
lastServerMessagePreset.value = 'waiting'
lastServerMessageOverride.value = ''
stopSendLoop()
socket = null
if (manualClose) {
@@ -596,20 +601,27 @@ function unmountConsumer() {
}
const socketLabel = computed(() => {
if (socketState.value === 'open') return 'ws open'
if (socketState.value === 'connecting') return 'connecting'
return 'reconnecting'
if (socketState.value === 'open') return t('control.socket.open')
if (socketState.value === 'connecting') return t('control.socket.connecting')
return t('control.socket.reconnecting')
})
const activeSourceLabel = computed(() => {
if (activeSource.value === 'keyboard') return 'Keyboard'
if (activeSource.value === 'gamepad') return 'Gamepad'
return 'Idle'
if (activeSource.value === 'keyboard') return t('common.keyboard')
if (activeSource.value === 'gamepad') return t('common.gamepad')
return t('common.idle')
})
const controlInputModeLabel = computed(() => {
if (controlInputMode.value === 'gamepad') return 'Gamepad'
return 'Keyboard'
if (controlInputMode.value === 'gamepad') return t('common.gamepad')
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(() => {
@@ -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[]>(() =>
TRACKED_KEYS.map((code) => ({
code,
label: KEY_LABELS[code] ?? code,
label: code === 'Space' ? t('control.key.stop') : (KEY_LABELS[code] ?? 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(() => ({
x: gamepadAxes.value[0] ?? 0,
y: gamepadAxes.value[1] ?? 0,

View File

@@ -1,6 +1,7 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { fetchDashboardSnapshot } from '@/lib/api'
import { t } from '@/lib/locale'
import type { GpsTelemetry, NetworkTelemetry, VideoStatus } from '@/types'
type UseMonitoringDataOptions = {
@@ -25,7 +26,7 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
video.value = snapshot.video
errorMessage.value = ''
} 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 {
loading.value = false
}
@@ -36,9 +37,9 @@ export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
return errorMessage.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(() => {

View File

@@ -1,4 +1,5 @@
import type { DashboardSnapshot, VideoStatus } from '@/types'
import { t } from '@/lib/locale'
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> {
const response = await fetch(`${API_BASE}${path}`)
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>
}

512
frontend/src/lib/locale.ts Normal file
View 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')),
}
}

View File

@@ -3,6 +3,7 @@ import ControlPanel from '@/components/ControlPanel.vue'
import GpsMapPanel from '@/components/GpsMapPanel.vue'
import NetworkPanel from '@/components/NetworkPanel.vue'
import VideoPanel from '@/components/VideoPanel.vue'
import { t } from '@/lib/locale'
import { useMonitoringData } from '@/composables/useMonitoringData'
const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
@@ -12,13 +13,10 @@ const { gps, network, video, errorMessage, headerStatus } = useMonitoringData()
<div class="page-shell">
<header class="hero">
<div>
<p class="eyebrow">Overview</p>
<h1>Robot Command Center</h1>
<p class="eyebrow">{{ t('dashboard.eyebrow') }}</p>
<h1>{{ t('dashboard.title') }}</h1>
</div>
<p class="hero-text">
The A-side daemon now owns video receive, control ingress arbitration, and live session
telemetry in one backend process.
</p>
<p class="hero-text">{{ t('dashboard.description') }}</p>
</header>
<section class="banner" :class="{ error: !!errorMessage }">

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import GpsMapPanel from '@/components/GpsMapPanel.vue'
import { t } from '@/lib/locale'
import { useMonitoringData } from '@/composables/useMonitoringData'
const { gps, errorMessage, headerStatus } = useMonitoringData({
@@ -11,13 +12,10 @@ const { gps, errorMessage, headerStatus } = useMonitoringData({
<div class="page-shell">
<header class="page-header">
<div>
<p class="eyebrow">Map</p>
<h1>地图定位页面</h1>
<p class="eyebrow">{{ t('mapView.eyebrow') }}</p>
<h1>{{ t('mapView.title') }}</h1>
</div>
<p class="description">
这里整合了 `GeoStream` GPS 展示逻辑只要原来的 GPS 模块继续写
`gps_latest.json`这个页面就能直接显示实时定位
</p>
<p class="description">{{ t('mapView.description') }}</p>
</header>
<section class="banner" :class="{ error: !!errorMessage }">

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import NetworkPanel from '@/components/NetworkPanel.vue'
import { t } from '@/lib/locale'
import { useMonitoringData } from '@/composables/useMonitoringData'
const { network, errorMessage, headerStatus } = useMonitoringData({
@@ -11,14 +12,10 @@ const { network, errorMessage, headerStatus } = useMonitoringData({
<div class="page-shell">
<header class="page-header">
<div>
<p class="eyebrow">Network</p>
<h1>Network Telemetry</h1>
<p class="eyebrow">{{ t('networkView.eyebrow') }}</p>
<h1>{{ t('networkView.title') }}</h1>
</div>
<p class="description">
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>
<p class="description">{{ t('networkView.description') }}</p>
</header>
<section class="banner" :class="{ error: !!errorMessage }">

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import VideoPanel from '@/components/VideoPanel.vue'
import { t } from '@/lib/locale'
import { useMonitoringData } from '@/composables/useMonitoringData'
const { video, network, errorMessage, headerStatus } = useMonitoringData()
@@ -9,10 +10,10 @@ const { video, network, errorMessage, headerStatus } = useMonitoringData()
<div class="page-shell">
<header class="page-header">
<div>
<p class="eyebrow">Video</p>
<h1>视频流页面</h1>
<p class="eyebrow">{{ t('videoView.eyebrow') }}</p>
<h1>{{ t('videoView.title') }}</h1>
</div>
<p class="description">这个页面专门用于看逐帧 JPEG 画面前端会按固定频率请求单张 JPEG后端每次返回一帧</p>
<p class="description">{{ t('videoView.description') }}</p>
</header>
<section class="banner" :class="{ error: !!errorMessage }">