Organize host and robot streaming releases

This commit is contained in:
Mike Mi
2026-08-09 15:56:20 +08:00
commit decff19daf
532 changed files with 127054 additions and 0 deletions

View File

@@ -0,0 +1,218 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, RouterView } from 'vue-router'
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>
<div class="app-shell">
<header class="topbar">
<div class="brand">
<p class="brand-mark">RCC</p>
<div>
<strong>{{ t('app.brandTitle') }}</strong>
<span>{{ t('app.brandSubtitle') }}</span>
</div>
</div>
<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">
<RouterView />
</main>
</div>
</template>
<style scoped>
:global(body) {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(91, 122, 255, 0.18), transparent 24%),
radial-gradient(circle at top right, rgba(77, 212, 172, 0.13), transparent 22%),
linear-gradient(180deg, #08101d 0%, #050914 58%, #02040a 100%);
color: #f5f7fb;
font-family:
'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
:global(*) {
box-sizing: border-box;
}
:global(a) {
color: inherit;
}
.app-shell {
width: min(1440px, calc(100% - 32px));
margin: 0 auto;
padding: 0 0 40px;
}
.topbar {
position: sticky;
top: 0;
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
padding: 14px 18px;
margin-bottom: 24px;
border-radius: 0 0 24px 24px;
background: linear-gradient(180deg, #0a1324 0%, #08101d 100%);
border: 1px solid rgba(133, 147, 169, 0.22);
border-top: none;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
overflow: hidden;
isolation: isolate;
}
:global(.panel) {
padding: 22px;
border-radius: 28px;
background: rgba(12, 20, 36, 0.84);
border: 1px solid rgba(133, 147, 169, 0.2);
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(12px);
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-mark {
width: 46px;
height: 46px;
margin: 0;
display: grid;
place-items: center;
border-radius: 14px;
background: linear-gradient(135deg, #5b7aff, #4dd4ac);
color: #06101d;
font-weight: 800;
letter-spacing: 0.08em;
}
.brand strong,
.brand span {
display: block;
}
.brand strong {
font-size: 16px;
}
.brand span {
margin-top: 4px;
color: #a9b6cf;
font-size: 13px;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.nav {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.nav-link,
.locale-button {
padding: 10px 14px;
border-radius: 999px;
border: 1px solid rgba(133, 147, 169, 0.18);
background: rgba(13, 22, 40, 0.78);
color: #dfe6f8;
text-decoration: none;
transition:
transform 0.2s ease,
background 0.2s ease,
border-color 0.2s ease;
}
.nav-link:hover,
.locale-button:hover {
transform: translateY(-1px);
background: rgba(25, 38, 66, 0.9);
}
.nav-link.router-link-exact-active {
background: linear-gradient(135deg, #5b7aff, #7bc4ff);
color: #08101d;
border-color: transparent;
font-weight: 700;
}
.locale-button {
cursor: pointer;
font: inherit;
font-weight: 700;
white-space: nowrap;
}
.page-body {
display: grid;
}
@media (max-width: 960px) {
.topbar {
flex-direction: column;
align-items: stretch;
}
.topbar-actions,
.nav {
justify-content: flex-start;
}
}
@media (max-width: 640px) {
.app-shell {
width: min(100%, calc(100% - 20px));
}
.topbar-actions,
.nav {
display: grid;
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,544 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useControlInterface } from '@/composables/useControlInterface'
import { t } from '@/lib/locale'
const props = withDefaults(defineProps<{
compact?: boolean
}>(), {
compact: false,
})
const {
activeSource,
activeSourceLabel,
commandLabel,
controlLimits,
controlInputMode,
controlInputModeLabel,
controlTuning,
commandValues,
gamepadActive,
gamepadButtons,
gamepadConnected,
gamepadIndex,
gamepadLeftStick,
gamepadMapping,
gamepadName,
gamepadRightStick,
keyboardActive,
keyboardKeys,
keyboardTurbo,
lastServerMessage,
pressedKeysLabel,
socketLabel,
socketState,
} = useControlInterface()
const keyClusters = computed(() => {
const lookup = new Map(keyboardKeys.value.map((entry) => [entry.code, entry]))
return [
lookup.get('KeyW'),
lookup.get('KeyA'),
lookup.get('KeyS'),
lookup.get('KeyD'),
lookup.get('KeyQ'),
lookup.get('KeyE'),
lookup.get('ShiftLeft'),
lookup.get('Space'),
].filter((entry): entry is NonNullable<typeof entry> => entry != null)
})
const commandBars = computed(() => [
{
label: t('controlFeedback.forward'),
value: commandValues.value.lx,
max: controlLimits.value.forward,
},
{
label: t('controlFeedback.strafe'),
value: commandValues.value.ly,
max: controlLimits.value.strafe,
},
{
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}%`
}
function stickOffset(value: number) {
return `${value * 22}px`
}
</script>
<template>
<section class="feedback-shell" :class="{ compact }">
<div class="feedback-topline">
<div class="headline-stack">
<div class="source-chip" :class="activeSource">
{{ activeSourceLabel }}
</div>
<div class="input-chip">
{{ t('controlFeedback.modeChip', { mode: controlInputModeLabel }) }}
</div>
</div>
<div class="status-stack">
<span class="socket-chip" :class="socketState">{{ socketLabel }}</span>
<span class="server-text">{{ lastServerMessage }}</span>
</div>
</div>
<div class="command-strip">
<div
v-for="bar in commandBars"
:key="bar.label"
class="command-card"
>
<div class="command-head">
<span>{{ bar.label }}</span>
<strong>{{ bar.value.toFixed(2) }}</strong>
</div>
<div class="command-meter">
<span class="center-line" />
<span class="command-dot" :style="{ left: meterPosition(bar.value, bar.max) }" />
</div>
</div>
</div>
<p class="summary">
{{ tuningSummary }}
</p>
<div class="feedback-grid" :class="{ compact }">
<section class="feedback-card">
<div class="card-head">
<div>
<p class="label">{{ t('controlFeedback.keyboard') }}</p>
<strong>{{ pressedKeysLabel }}</strong>
</div>
<span class="mode-chip" :class="{ hot: controlInputMode === 'keyboard' && keyboardActive }">
{{ controlInputMode === 'keyboard' ? (keyboardTurbo ? t('common.turbo') : t('common.selected')) : t('common.standby') }}
</span>
</div>
<div class="key-grid">
<span
v-for="key in keyClusters"
:key="key.code"
class="key-chip"
:class="{ active: key.pressed, wide: key.code === 'Space' || key.code === 'ShiftLeft' }"
>
{{ key.label }}
</span>
</div>
</section>
<section class="feedback-card">
<div class="card-head">
<div>
<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'
? t('common.selected')
: t('common.standby')
: t('common.offline')
}}
</span>
</div>
<p class="subtle">
{{ gamepadMeta }}
</p>
<div class="sticks">
<div class="stick-card">
<span>{{ t('controlFeedback.leftStick') }}</span>
<div class="stick-pad">
<span class="crosshair crosshair-x" />
<span class="crosshair crosshair-y" />
<span
class="stick-dot"
:style="{
transform: `translate(${stickOffset(gamepadLeftStick.x)}, ${stickOffset(gamepadLeftStick.y)})`,
}"
/>
</div>
</div>
<div class="stick-card">
<span>{{ t('controlFeedback.rightStick') }}</span>
<div class="stick-pad">
<span class="crosshair crosshair-x" />
<span class="crosshair crosshair-y" />
<span
class="stick-dot accent"
:style="{
transform: `translate(${stickOffset(gamepadRightStick.x)}, ${stickOffset(gamepadRightStick.y)})`,
}"
/>
</div>
</div>
</div>
<div class="button-grid">
<span
v-for="button in gamepadButtons"
:key="button.label"
class="button-chip"
:class="{ active: button.pressed }"
>
{{ button.label }}
</span>
</div>
</section>
</div>
<p v-if="!compact" class="summary accent">
{{ outgoingCommandText }}
</p>
</section>
</template>
<style scoped>
.feedback-shell {
display: grid;
gap: 14px;
}
.feedback-shell.compact {
gap: 12px;
}
.feedback-topline {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.headline-stack {
display: grid;
gap: 8px;
}
.status-stack {
display: grid;
justify-items: end;
gap: 6px;
min-width: 0;
}
.source-chip,
.input-chip,
.socket-chip,
.mode-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.source-chip {
background: rgba(78, 224, 168, 0.16);
color: #86f0c7;
}
.source-chip.keyboard {
background: rgba(91, 122, 255, 0.18);
color: #d3dcff;
}
.source-chip.gamepad {
background: rgba(255, 176, 87, 0.18);
color: #ffd8a6;
}
.source-chip.idle {
background: rgba(133, 147, 169, 0.16);
color: #cad3e8;
}
.input-chip {
background: rgba(123, 196, 255, 0.14);
color: #dff1ff;
}
.socket-chip {
background: rgba(40, 199, 111, 0.16);
color: #7ef0b5;
}
.socket-chip.connecting,
.socket-chip.closed {
background: rgba(255, 176, 87, 0.18);
color: #ffd29b;
}
.server-text {
max-width: 320px;
color: #aeb9d2;
font-size: 12px;
text-align: right;
line-height: 1.4;
word-break: break-word;
}
.command-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.command-card,
.feedback-card {
padding: 14px;
border-radius: 18px;
background: rgba(7, 14, 26, 0.86);
border: 1px solid rgba(133, 147, 169, 0.18);
}
.command-head,
.card-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.command-head span,
.label {
color: #8d99b3;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.command-head strong,
.card-head strong {
color: #f6f8fc;
font-size: 16px;
}
.command-meter {
position: relative;
height: 34px;
margin-top: 10px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 99, 99, 0.12), rgba(255, 255, 255, 0.05), rgba(78, 224, 168, 0.14));
border: 1px solid rgba(133, 147, 169, 0.16);
}
.center-line {
position: absolute;
top: 4px;
bottom: 4px;
left: 50%;
width: 1px;
background: rgba(222, 232, 255, 0.28);
}
.command-dot {
position: absolute;
top: 50%;
width: 16px;
height: 16px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #fdfefe, #63e6a9 62%, #2d8e68 100%);
box-shadow: 0 0 16px rgba(99, 230, 169, 0.38);
transform: translate(-50%, -50%);
}
.feedback-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
gap: 12px;
}
.feedback-grid.compact {
grid-template-columns: 1fr;
}
.subtle,
.summary {
margin: 0;
color: #8d99b3;
line-height: 1.6;
}
.summary.accent {
color: #aeb9d2;
}
.mode-chip {
background: rgba(133, 147, 169, 0.14);
color: #cad3e8;
}
.mode-chip.hot {
background: rgba(255, 176, 87, 0.18);
color: #ffd29b;
}
.key-grid,
.button-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.key-chip,
.button-chip {
min-width: 44px;
min-height: 42px;
padding: 0 12px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(10, 20, 37, 0.9);
border: 1px solid rgba(133, 147, 169, 0.18);
color: #dfe7fb;
font-size: 13px;
font-weight: 700;
}
.key-chip.wide {
min-width: 88px;
}
.key-chip.active,
.button-chip.active {
background: linear-gradient(135deg, rgba(91, 122, 255, 0.28), rgba(77, 212, 172, 0.28));
border-color: rgba(123, 196, 255, 0.6);
color: #ffffff;
box-shadow: 0 8px 24px rgba(91, 122, 255, 0.22);
}
.sticks {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 12px;
}
.stick-card {
display: grid;
gap: 10px;
}
.stick-card span {
color: #aeb9d2;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.stick-pad {
position: relative;
width: 84px;
height: 84px;
border-radius: 24px;
border: 1px solid rgba(133, 147, 169, 0.18);
background: radial-gradient(circle at center, rgba(91, 122, 255, 0.12), rgba(4, 8, 15, 0.95));
}
.crosshair {
position: absolute;
background: rgba(222, 232, 255, 0.18);
}
.crosshair-x {
left: 14px;
right: 14px;
top: 50%;
height: 1px;
transform: translateY(-50%);
}
.crosshair-y {
top: 14px;
bottom: 14px;
left: 50%;
width: 1px;
transform: translateX(-50%);
}
.stick-dot {
position: absolute;
top: 50%;
left: 50%;
width: 18px;
height: 18px;
margin: -9px 0 0 -9px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #f8fdff, #63e6a9 58%, #2a7e5f 100%);
box-shadow: 0 0 18px rgba(99, 230, 169, 0.35);
}
.stick-dot.accent {
background: radial-gradient(circle at 30% 30%, #fffaf4, #ffb057 58%, #b06d21 100%);
box-shadow: 0 0 18px rgba(255, 176, 87, 0.34);
}
@media (max-width: 960px) {
.command-strip,
.feedback-grid,
.sticks {
grid-template-columns: 1fr;
}
.status-stack {
justify-items: start;
}
.feedback-topline,
.command-head,
.card-head {
flex-direction: column;
align-items: start;
}
.server-text {
text-align: left;
}
}
</style>

View File

@@ -0,0 +1,323 @@
<script setup lang="ts">
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 = 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,
set: (value: number) => setControlTuning({ forward: value }),
})
const strafeSpeed = computed({
get: () => controlTuning.value.strafe,
set: (value: number) => setControlTuning({ strafe: value }),
})
const turnSpeed = computed({
get: () => controlTuning.value.turn,
set: (value: number) => setControlTuning({ turn: value }),
})
const turboMultiplier = computed({
get: () => controlTuning.value.turbo,
set: (value: number) => setControlTuning({ turbo: value }),
})
</script>
<template>
<section class="panel control-panel">
<div class="panel-head">
<div>
<p class="eyebrow">{{ t('controlPanel.eyebrow') }}</p>
<h2>{{ t('controlPanel.title') }}</h2>
</div>
<button type="button" class="reset-button" @click="resetControlTuning">
{{ t('controlPanel.resetDefaults') }}
</button>
</div>
<section class="mode-panel">
<div class="mode-panel-head">
<div>
<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="t('controlPanel.inputModeEyebrow')">
<button
v-for="mode in inputModes"
:key="mode.id"
type="button"
class="mode-button"
:class="{ active: controlInputMode === mode.id }"
:aria-pressed="controlInputMode === mode.id"
@click="setControlInputMode(mode.id)"
>
<strong>{{ mode.label }}</strong>
<span>{{ mode.detail }}</span>
</button>
</div>
</section>
<div class="tuning-grid">
<label class="tuning-field">
<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>{{ 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>{{ 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>{{ t('controlPanel.turbo') }}</span>
<input v-model.number="turboMultiplier" type="number" min="1" max="3" step="0.1" />
<small>x</small>
</label>
</div>
<ControlFeedback />
<p class="hint">
{{ t('controlPanel.keyboardHint') }}
</p>
<p class="hint subtle">
{{ t('controlPanel.tuningHint') }}
</p>
<p class="hint subtle">
{{ t('controlPanel.gamepadHint') }}
</p>
</section>
</template>
<style scoped>
.control-panel {
display: grid;
gap: 16px;
}
.panel-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.reset-button {
border: 1px solid rgba(133, 147, 169, 0.28);
background: rgba(10, 20, 37, 0.88);
color: #dfe7fb;
border-radius: 999px;
min-height: 36px;
padding: 0 14px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
}
.reset-button:hover {
border-color: rgba(123, 196, 255, 0.48);
color: #ffffff;
}
.eyebrow {
margin: 0 0 4px;
color: #ffb057;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 12px;
font-weight: 700;
}
h2 {
margin: 0;
font-size: 24px;
}
.mode-panel {
display: grid;
gap: 12px;
padding: 14px;
border-radius: 18px;
background: rgba(7, 14, 26, 0.86);
border: 1px solid rgba(133, 147, 169, 0.18);
}
.mode-panel-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.mode-eyebrow {
margin: 0 0 4px;
color: #7bc4ff;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 12px;
font-weight: 700;
}
.mode-copy {
margin: 0;
color: #d5dbee;
line-height: 1.6;
}
.mode-current {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: rgba(123, 196, 255, 0.14);
color: #dff1ff;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mode-toggle {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.mode-button {
display: grid;
gap: 6px;
min-height: 84px;
padding: 14px;
border-radius: 16px;
border: 1px solid rgba(133, 147, 169, 0.18);
background: rgba(10, 20, 37, 0.9);
color: #dfe7fb;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
}
.mode-button strong {
font-size: 15px;
}
.mode-button span {
color: #96a5c3;
font-size: 13px;
line-height: 1.5;
}
.mode-button:hover {
border-color: rgba(123, 196, 255, 0.4);
transform: translateY(-1px);
}
.mode-button.active {
border-color: rgba(123, 196, 255, 0.6);
background: linear-gradient(135deg, rgba(91, 122, 255, 0.24), rgba(77, 212, 172, 0.2));
box-shadow: 0 10px 28px rgba(91, 122, 255, 0.18);
}
.mode-button.active span {
color: #d5e7ff;
}
.tuning-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.tuning-field {
display: grid;
gap: 8px;
padding: 12px;
border-radius: 16px;
background: rgba(7, 14, 26, 0.86);
border: 1px solid rgba(133, 147, 169, 0.18);
}
.tuning-field span,
.tuning-field small {
color: #aeb9d2;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.tuning-field input {
width: 100%;
min-height: 42px;
border-radius: 12px;
border: 1px solid rgba(133, 147, 169, 0.24);
background: rgba(10, 20, 37, 0.96);
color: #f6f8fc;
padding: 0 12px;
font-size: 16px;
font-weight: 700;
}
.tuning-field input:focus {
outline: none;
border-color: rgba(123, 196, 255, 0.62);
box-shadow: 0 0 0 3px rgba(91, 122, 255, 0.18);
}
.hint {
margin: 0;
color: #d5dbee;
line-height: 1.7;
}
.hint.subtle {
color: #96a5c3;
}
@media (max-width: 960px) {
.panel-head {
flex-direction: column;
align-items: start;
}
.mode-panel-head {
flex-direction: column;
}
.tuning-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.mode-toggle,
.tuning-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,483 @@
<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 {
interface Window {
AMap?: any
_AMapSecurityConfig?: {
securityJsCode: string
}
}
}
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 statusState = ref<StatusState>({ key: 'gpsMap.status.waitingInit' })
const amapCoordinateRaw = ref('')
const mapElement = ref<HTMLDivElement | null>(null)
const mapRunning = ref(false)
let loadPromise: Promise<any> | null = null
let mapInstance: any = null
let marker: any = null
let infoWindow: any = null
function 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)
return raw ? JSON.parse(raw) : null
} catch {
return null
}
}
function saveCredentials() {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
key: keyInput.value.trim(),
securityJsCode: securityCodeInput.value.trim(),
}),
)
}
function formatNumber(value: number) {
return value.toFixed(6)
}
function formatHexText(value: string | null | undefined) {
return value || t('gpsMap.noValue')
}
async function loadAmapScript(key: string, securityJsCode: string) {
if (window.AMap) {
return window.AMap
}
if (loadPromise) {
return loadPromise
}
window._AMapSecurityConfig = { securityJsCode }
loadPromise = new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = `https://webapi.amap.com/maps?v=2.0&key=${encodeURIComponent(key)}`
script.async = true
script.onload = () => resolve(window.AMap)
script.onerror = () => reject(new Error(t('gpsMap.status.loadFailed')))
document.head.appendChild(script)
})
return loadPromise
}
function ensureMap() {
if (mapInstance || !mapElement.value || !window.AMap) {
return
}
mapInstance = new window.AMap.Map(mapElement.value, {
viewMode: '3D',
zoom: 15,
center: [121.4737, 31.2304],
mapStyle: 'amap://styles/normal',
})
marker = new window.AMap.Marker({
anchor: 'bottom-center',
title: t('gpsMap.infoTitle'),
})
infoWindow = new window.AMap.InfoWindow({
offset: new window.AMap.Pixel(0, -28),
})
}
function stopMap() {
mapRunning.value = false
amapCoordinateRaw.value = ''
if (infoWindow) {
infoWindow.close()
}
if (marker) {
marker.setMap(null)
marker = null
}
if (mapInstance) {
if (typeof mapInstance.destroy === 'function') {
mapInstance.destroy()
}
mapInstance = null
}
infoWindow = null
if (mapElement.value) {
mapElement.value.innerHTML = ''
}
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) {
if (!mapRunning.value || !mapInstance || !window.AMap) {
return
}
if (!gps?.has_fix || gps.latitude == null || gps.longitude == null) {
amapCoordinateRaw.value = ''
marker?.setMap(null)
infoWindow?.close()
setStatus(gps ? 'gpsMap.status.noFix' : 'gpsMap.status.waitingGps')
return
}
const rawLatitude = gps.latitude
const rawLongitude = gps.longitude
window.AMap.convertFrom([rawLongitude, rawLatitude], 'gps', (status: string, result: any) => {
if (status !== 'complete' || !result?.locations?.length) {
setStatus('gpsMap.status.convertFailed')
return
}
const point = result.locations[0]
const lng = typeof point.getLng === 'function' ? point.getLng() : point.lng
const lat = typeof point.getLat === 'function' ? point.getLat() : point.lat
amapCoordinateRaw.value = `${formatNumber(lat)}, ${formatNumber(lng)}`
marker.setPosition([lng, lat])
marker.setMap(mapInstance)
infoWindow.setContent(buildInfoWindowContent(gps, lat, lng))
infoWindow.open(mapInstance, [lng, lat])
mapInstance.setZoomAndCenter(17, [lng, lat])
setStatus('gpsMap.status.refreshedSource', { source: gps.source_mode })
})
}
async function startMap() {
const key = keyInput.value.trim()
const securityJsCode = securityCodeInput.value.trim()
if (!key || !securityJsCode) {
setStatus('gpsMap.status.fillCredentials')
return
}
setStatus('gpsMap.status.loading')
try {
await loadAmapScript(key, securityJsCode)
ensureMap()
saveCredentials()
mapRunning.value = true
setStatus('gpsMap.status.loaded')
updateMap(props.gps)
} catch (error) {
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 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 t('gpsMap.noValue')
}
return `${props.gps.coordinate_system} / ${props.gps.raw_coordinate_format}`
})
const metaText = computed(() => {
if (!props.gps) {
return t('gpsMap.noValue')
}
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(() => formatDateTime(props.gps?.updated_at))
onMounted(() => {
const saved = readSavedCredentials()
if (saved) {
keyInput.value = saved.key ?? ''
securityCodeInput.value = saved.securityJsCode ?? ''
if (keyInput.value && securityCodeInput.value) {
setStatus('gpsMap.status.restoredConfig')
}
}
})
watch(
() => props.gps,
(gps) => {
updateMap(gps)
},
{ 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">{{ t('gpsMap.eyebrow') }}</p>
<h2>{{ t('gpsMap.title') }}</h2>
</div>
<span class="badge">{{ gps?.source_mode ?? t('common.loading') }}</span>
</div>
<p class="intro">{{ t('gpsMap.intro') }}</p>
<div class="credentials">
<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>{{ t('gpsMap.wgs84') }}</span>
<strong>{{ rawCoordinateText }}</strong>
</div>
<div class="detail-card">
<span>{{ t('gpsMap.gcj02') }}</span>
<strong>{{ amapCoordinateText }}</strong>
</div>
<div class="detail-card">
<span>{{ t('gpsMap.rawLatHex') }}</span>
<strong class="mono">{{ rawLatitudeHexText }}</strong>
</div>
<div class="detail-card">
<span>{{ t('gpsMap.rawLonHex') }}</span>
<strong class="mono">{{ rawLongitudeHexText }}</strong>
</div>
<div class="detail-card">
<span>{{ t('gpsMap.utcTime') }}</span>
<strong>{{ gps?.utc_time ?? '--:--:--' }}</strong>
</div>
<div class="detail-card">
<span>{{ t('gpsMap.satAltitude') }}</span>
<strong>{{ metaText }}</strong>
</div>
<div class="detail-card">
<span>{{ t('gpsMap.coordMeta') }}</span>
<strong>{{ coordinateMetaText }}</strong>
</div>
<div class="detail-card">
<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>
</template>
<style scoped>
.map-panel {
display: grid;
gap: 16px;
}
.panel-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.eyebrow {
margin: 0 0 4px;
color: #f5a524;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 12px;
font-weight: 700;
}
h2 {
margin: 0;
font-size: 24px;
}
.badge {
padding: 8px 12px;
border-radius: 999px;
background: rgba(245, 165, 36, 0.15);
color: #ffd48a;
font-size: 12px;
font-weight: 700;
}
.intro,
.status {
margin: 0;
color: #d5dbee;
line-height: 1.65;
}
.credentials {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 140px 140px;
gap: 12px;
}
.credentials input,
.credentials button {
border: 1px solid rgba(133, 147, 169, 0.28);
border-radius: 14px;
padding: 12px 14px;
background: rgba(7, 14, 26, 0.78);
color: #f5f7fb;
font: inherit;
}
.credentials button {
cursor: pointer;
background: linear-gradient(135deg, #ffb347, #ff8f5a);
color: #10151f;
font-weight: 700;
}
.credentials button.secondary {
background: rgba(7, 14, 26, 0.78);
color: #f5f7fb;
}
.details {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.detail-card {
padding: 14px;
border-radius: 16px;
background: rgba(7, 14, 26, 0.78);
border: 1px solid rgba(133, 147, 169, 0.2);
}
.detail-card span {
display: block;
margin-bottom: 8px;
color: #8d99b3;
font-size: 12px;
}
.detail-card strong {
font-size: 17px;
word-break: break-word;
}
.detail-card strong.mono {
font-family: 'JetBrains Mono', 'SFMono-Regular', monospace;
font-size: 14px;
}
.map-canvas {
position: relative;
min-height: 420px;
border-radius: 20px;
overflow: hidden;
border: 1px solid rgba(133, 147, 169, 0.28);
background:
radial-gradient(circle at top left, rgba(255, 179, 71, 0.16), transparent 28%),
linear-gradient(180deg, #0b1220 0%, #070b14 100%);
}
.map-canvas.stopped {
display: grid;
place-items: center;
}
.map-placeholder {
width: min(560px, calc(100% - 40px));
padding: 20px 22px;
border-radius: 18px;
border: 1px solid rgba(133, 147, 169, 0.2);
background: rgba(7, 14, 26, 0.84);
color: #d5dbee;
text-align: center;
line-height: 1.75;
}
@media (max-width: 960px) {
.credentials,
.details {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,435 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatDateTime, t } from '@/lib/locale'
import type { LinkSessionTelemetry, LinkTelemetry, NetworkTelemetry } from '@/types'
const props = defineProps<{
network: NetworkTelemetry | null
}>()
const legCards = computed(() => [
{
key: 'a_to_d',
label: 'A <-> D',
data: props.network?.links?.a_to_d ?? null,
},
{
key: 'd_to_b',
label: 'D <-> B',
data: props.network?.links?.d_to_b ?? null,
},
])
const activeSource = computed(() => formatControlSource(props.network?.active_control_source))
function formatTime(value?: string | null) {
return formatDateTime(value)
}
function formatScalar(value?: number | string | null, suffix = '') {
if (value === null || value === undefined || value === '') {
return '--'
}
if (typeof value === 'number') {
return `${value.toFixed(1)}${suffix}`
}
return `${value}${suffix}`
}
function legSessions(link: LinkTelemetry | null): Array<{ name: string; data: LinkSessionTelemetry | null }> {
return [
{ name: 'control', data: link?.sessions?.control ?? 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>
<template>
<section class="panel network-panel">
<div class="panel-head">
<div>
<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 ?? t('networkPanel.loadingPeer') }}
</span>
</div>
<div class="stats">
<div class="stat-card">
<span>{{ t('networkPanel.controlLoopRtt') }}</span>
<strong>{{ formatScalar(network?.latency_estimate?.control_loop_rtt_ms, ' ms') }}</strong>
</div>
<div class="stat-card">
<span>{{ t('networkPanel.controlToPersist') }}</span>
<strong>{{ formatScalar(network?.latency_estimate?.control_to_persist_est_ms, ' ms') }}</strong>
</div>
<div class="stat-card">
<span>{{ t('networkPanel.controlSrttOneWay') }}</span>
<strong>{{ formatScalar(network?.latency_estimate?.control_oneway_srtt_est_ms, ' ms') }}</strong>
</div>
<div class="stat-card">
<span>{{ t('networkPanel.videoOneWayEst') }}</span>
<strong>{{ formatScalar(network?.latency_estimate?.video_network_oneway_est_ms, ' ms') }}</strong>
</div>
<div class="stat-card">
<span>{{ t('networkPanel.txRate') }}</span>
<strong>{{ formatScalar(network?.tx_kbps, ' kbps') }}</strong>
</div>
<div class="stat-card">
<span>{{ t('networkPanel.rxRate') }}</span>
<strong>{{ formatScalar(network?.rx_kbps, ' kbps') }}</strong>
</div>
</div>
<div class="summary telemetry-strip">
<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">
<article v-for="leg in legCards" :key="leg.key" class="leg-card" :class="{ stale: leg.data?.stale }">
<div class="leg-head">
<div>
<p class="leg-label">{{ leg.label }}</p>
<h3>{{ leg.data?.source ?? t('common.waiting') }}</h3>
</div>
<div class="leg-meta">
<span class="mini-badge" :class="{ stale: leg.data?.stale }">
{{ formatStale(leg.data?.stale) }}
</span>
<span class="mini-time">{{ formatTime(leg.data?.updated_at) }}</span>
</div>
</div>
<div class="aggregate-grid">
<div>
<span>{{ t('networkPanel.online') }}</span>
<strong>{{ leg.data?.aggregate?.online_sessions ?? 0 }}</strong>
</div>
<div>
<span>{{ t('networkPanel.maxPressure') }}</span>
<strong>{{ formatScalar(leg.data?.aggregate?.max_window_pressure_pct, '%') }}</strong>
</div>
<div>
<span>{{ t('networkPanel.queued') }}</span>
<strong>{{ leg.data?.aggregate?.sum_snd_queue ?? 0 }}</strong>
</div>
<div>
<span>{{ t('networkPanel.inFlightBuffer') }}</span>
<strong>{{ leg.data?.aggregate?.sum_snd_buffer ?? 0 }}</strong>
</div>
<div>
<span>{{ t('networkPanel.retransDelta') }}</span>
<strong>{{ leg.data?.aggregate?.sum_retrans_delta ?? 0 }}</strong>
</div>
<div>
<span>{{ t('networkPanel.repairRate') }}</span>
<strong>{{ formatScalar(leg.data?.aggregate?.repair_rate_pct, '%') }}</strong>
</div>
</div>
<div class="session-grid">
<section v-for="session in legSessions(leg.data)" :key="session.name" class="session-card">
<div class="session-head">
<div>
<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 }">
{{ formatOnline(session.data?.connected) }}
</span>
</div>
<div class="kv-grid">
<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>
</article>
</div>
<div class="summary">
<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>
<style scoped>
.network-panel {
display: grid;
gap: 18px;
}
.panel-head,
.leg-head,
.session-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.eyebrow,
.leg-label,
.session-label {
margin: 0 0 4px;
color: #5bd3b5;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
font-weight: 700;
}
h2,
h3,
h4 {
margin: 0;
}
h2 {
font-size: 24px;
}
h3 {
font-size: 22px;
}
h4 {
font-size: 16px;
}
.badge,
.mini-badge {
border-radius: 999px;
text-transform: uppercase;
font-weight: 700;
}
.badge {
padding: 8px 12px;
background: rgba(40, 199, 111, 0.16);
color: #63e6a9;
font-size: 12px;
}
.mini-badge {
padding: 6px 10px;
background: rgba(91, 211, 181, 0.12);
color: #8ff2db;
font-size: 11px;
}
.badge.stale,
.mini-badge.stale {
background: rgba(255, 165, 0, 0.16);
color: #ffd08a;
}
.mini-badge.active {
background: rgba(64, 187, 255, 0.16);
color: #98dcff;
}
.stats,
.leg-grid,
.session-grid,
.aggregate-grid,
.kv-grid {
display: grid;
gap: 12px;
}
.stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.leg-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.session-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.aggregate-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.kv-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stat-card,
.summary,
.leg-card,
.session-card {
padding: 14px;
border-radius: 18px;
background: rgba(7, 14, 26, 0.8);
border: 1px solid rgba(133, 147, 169, 0.2);
color: #d5dbee;
}
.stat-card span,
.aggregate-grid span {
display: block;
margin-bottom: 8px;
color: #8d99b3;
font-size: 12px;
}
.stat-card strong,
.aggregate-grid strong {
font-size: 22px;
}
.summary p,
.kv-grid p {
margin: 0;
}
.summary p + p {
margin-top: 8px;
}
.telemetry-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.leg-card {
display: grid;
gap: 16px;
}
.leg-card.stale {
border-color: rgba(255, 165, 0, 0.3);
}
.leg-meta {
display: grid;
justify-items: end;
gap: 8px;
}
.mini-time {
color: #9aa6c2;
font-size: 12px;
}
.session-card {
display: grid;
gap: 12px;
background: rgba(11, 19, 35, 0.86);
}
@media (max-width: 1200px) {
.stats,
.aggregate-grid,
.telemetry-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.leg-grid,
.session-grid,
.kv-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.stats,
.aggregate-grid,
.telemetry-strip,
.kv-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,808 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { buildVideoFrameUrl, fetchCameraStatus, fetchClockCalibrationSample, fetchVideoStatus, postVideoDisplayProbe, selectCamera } from '@/lib/api'
import { t } from '@/lib/locale'
import { useOperatorInputTelemetry } from '@/composables/useControlInterface'
import type { CameraName, CameraSelectionStatus, NetworkTelemetry, VideoStatus } from '@/types'
const props = defineProps<{
video: VideoStatus | null
network?: NetworkTelemetry | null
}>()
const STATUS_REFRESH_MS = 300
const DISPLAY_PROBE_INTERVAL_MS = 200
const CLOCK_CALIBRATION_INTERVAL_MS = 1000
const CLOCK_CALIBRATION_SAMPLE_WINDOW = 9
const CLOCK_CALIBRATION_STALE_MS = CLOCK_CALIBRATION_INTERVAL_MS * 3
type PendingInputProbe = {
token: number
triggeredPerfMs: number
baselineFrameSeq: number | null
baselineFrameHash: string
freshResolved: boolean
changedResolved: boolean
paintResolved: boolean
}
type ClockCalibrationSnapshot = {
offsetMs: number | null
rttMs: number | null
sampleCount: number
updatedAt: string | null
}
const liveVideo = ref<VideoStatus | null>(props.video)
const frameUrl = ref(buildVideoFrameUrl(0))
const displayVideo = computed(() => liveVideo.value ?? props.video)
const canRequestFrames = computed(() => displayVideo.value?.available === true)
const currentFps = computed(() => displayVideo.value?.fps ?? 30)
const operatorMetrics = ref({
input_to_next_fresh_frame_ms: null as number | null,
input_to_next_changed_frame_ms: null as number | null,
input_to_next_paint_ms: null as number | null,
})
const cameraStatus = ref<CameraSelectionStatus | null>(null)
const cameraSwitchPending = ref<CameraName | null>(null)
const cameraSwitchError = ref('')
const {
operatorInputSequence,
lastOperatorInputPerfMs,
} = useOperatorInputTelemetry()
const freshness = computed(() => displayVideo.value?.freshness)
const networkEstimate = computed(() => props.network?.latency_estimate ?? null)
const senderClockDebug = computed(() => displayVideo.value?.timing ?? null)
const EMPTY_CLOCK_CALIBRATION: ClockCalibrationSnapshot = {
offsetMs: null,
rttMs: null,
sampleCount: 0,
updatedAt: null,
}
const clockCalibration = ref<ClockCalibrationSnapshot>({ ...EMPTY_CLOCK_CALIBRATION })
const modeLabel = computed(() => {
if (!displayVideo.value) {
return t('videoPanel.mode.loading')
}
if (displayVideo.value.source_mode === 'omnisocket-jpeg-live') {
return t('videoPanel.mode.live', { fps: displayVideo.value.fps })
}
return displayVideo.value.source_mode
})
const timingHeadline = computed(() => {
const latest = senderClockDebug.value?.sender_clock_delta_ms_raw
if (latest == null) {
return t('videoPanel.timing.waiting')
}
return `${latest.toFixed(1)} ms`
})
const timingHint = computed(() => {
const timing = senderClockDebug.value
if (!timing?.available) {
return t('videoPanel.timing.noTrailer')
}
return t('videoPanel.timing.rawHint')
})
function formatNumber(value: number | null | undefined, suffix = '') {
if (value == null || Number.isNaN(value)) {
return '--'
}
return `${value.toFixed(1)}${suffix}`
}
function wallClockNowMs() {
return Date.now()
}
let frameTimer: number | null = null
let statusTimer: number | null = null
let probeTimer: number | null = null
let calibrationTimer: number | null = null
let frameKey = 0
let probeKey = 0
let statusRequestPending = false
let probeRequestPending = false
let calibrationRequestPending = false
let lastObservedFrameSeq: number | null = null
let lastObservedFrameHash = ''
let pendingInputProbe: PendingInputProbe | null = null
let clockOffsetSamples: number[] = []
let clockRttSamples: number[] = []
function boundedMedian(samples: number[]) {
if (samples.length === 0) {
return null
}
const sorted = [...samples].sort((left, right) => left - right)
const middle = Math.floor(sorted.length / 2)
if (sorted.length % 2 === 1) {
return sorted[middle] ?? null
}
const left = sorted[middle - 1]
const right = sorted[middle]
if (left == null || right == null) {
return null
}
return (left + right) / 2
}
function clearClockCalibration() {
clockOffsetSamples = []
clockRttSamples = []
clockCalibration.value = { ...EMPTY_CLOCK_CALIBRATION }
}
function isClockCalibrationFresh(snapshot: ClockCalibrationSnapshot, nowMs = wallClockNowMs()) {
if (snapshot.offsetMs == null || snapshot.rttMs == null || !snapshot.updatedAt) {
return false
}
const updatedAtMs = Date.parse(snapshot.updatedAt)
if (!Number.isFinite(updatedAtMs)) {
return false
}
return nowMs - updatedAtMs <= CLOCK_CALIBRATION_STALE_MS
}
function expireClockCalibrationIfStale(nowMs = wallClockNowMs()) {
if (clockCalibration.value.sampleCount > 0 && !isClockCalibrationFresh(clockCalibration.value, nowMs)) {
clearClockCalibration()
}
}
function currentClockCalibration(nowMs = wallClockNowMs()) {
expireClockCalibrationIfStale(nowMs)
return clockCalibration.value
}
function updateClockCalibration(offsetMs: number, rttMs: number) {
clockOffsetSamples.push(offsetMs)
clockRttSamples.push(rttMs)
if (clockOffsetSamples.length > CLOCK_CALIBRATION_SAMPLE_WINDOW) {
clockOffsetSamples = clockOffsetSamples.slice(-CLOCK_CALIBRATION_SAMPLE_WINDOW)
}
if (clockRttSamples.length > CLOCK_CALIBRATION_SAMPLE_WINDOW) {
clockRttSamples = clockRttSamples.slice(-CLOCK_CALIBRATION_SAMPLE_WINDOW)
}
const medianOffset = boundedMedian(clockOffsetSamples)
const medianRtt = boundedMedian(clockRttSamples)
clockCalibration.value = {
offsetMs: medianOffset == null ? null : Number(medianOffset.toFixed(3)),
rttMs: medianRtt == null ? null : Number(medianRtt.toFixed(3)),
sampleCount: clockOffsetSamples.length,
updatedAt: new Date().toISOString(),
}
}
async function refreshStatus() {
if (statusRequestPending) {
return
}
statusRequestPending = true
try {
liveVideo.value = await fetchVideoStatus()
} catch {
// Keep the last good state.
} finally {
statusRequestPending = false
}
}
async function refreshCameraStatus() {
try {
cameraStatus.value = await fetchCameraStatus()
} catch {
// Camera status is best-effort until the control session starts.
}
}
async function switchCamera(camera: CameraName) {
if (cameraSwitchPending.value != null || cameraStatus.value?.active_camera === camera) {
return
}
cameraSwitchPending.value = camera
cameraSwitchError.value = ''
try {
cameraStatus.value = await selectCamera(camera)
refreshFrame()
} catch (error) {
cameraSwitchError.value = error instanceof Error ? error.message : String(error)
await refreshCameraStatus()
} finally {
cameraSwitchPending.value = null
}
}
async function runClockCalibration() {
if (calibrationRequestPending) {
return
}
calibrationRequestPending = true
const clientSendUnixMs = wallClockNowMs()
expireClockCalibrationIfStale(clientSendUnixMs)
try {
const sample = await fetchClockCalibrationSample()
const clientRecvUnixMs = wallClockNowMs()
if (!Number.isFinite(sample.server_received_unix_ms) || !Number.isFinite(sample.server_sent_unix_ms)) {
return
}
const offsetMs = ((clientSendUnixMs - sample.server_received_unix_ms) + (clientRecvUnixMs - sample.server_sent_unix_ms)) / 2
const rawRttMs = (clientRecvUnixMs - clientSendUnixMs) - (sample.server_sent_unix_ms - sample.server_received_unix_ms)
updateClockCalibration(Number(offsetMs.toFixed(3)), Number(Math.max(0, rawRttMs).toFixed(3)))
} catch {
// Calibration is best-effort only.
} finally {
expireClockCalibrationIfStale()
calibrationRequestPending = false
}
}
function refreshFrame() {
if (!canRequestFrames.value) {
return
}
frameKey += 1
frameUrl.value = buildVideoFrameUrl(frameKey)
}
function startFrameLoop() {
if (frameTimer != null) {
window.clearInterval(frameTimer)
frameTimer = null
}
if (!canRequestFrames.value) {
return
}
refreshFrame()
const intervalMs = Math.max(33, Math.round(1000 / currentFps.value))
frameTimer = window.setInterval(refreshFrame, intervalMs)
}
function startStatusLoop() {
if (statusTimer != null) {
window.clearInterval(statusTimer)
statusTimer = null
}
void refreshStatus()
statusTimer = window.setInterval(() => {
void refreshStatus()
}, STATUS_REFRESH_MS)
}
function startClockCalibrationLoop() {
if (calibrationTimer != null) {
window.clearInterval(calibrationTimer)
calibrationTimer = null
}
void runClockCalibration()
calibrationTimer = window.setInterval(() => {
void runClockCalibration()
}, CLOCK_CALIBRATION_INTERVAL_MS)
}
function maybeTrackOperatorInput() {
pendingInputProbe = {
token: operatorInputSequence.value,
triggeredPerfMs: lastOperatorInputPerfMs.value,
baselineFrameSeq: lastObservedFrameSeq,
baselineFrameHash: lastObservedFrameHash,
freshResolved: false,
changedResolved: false,
paintResolved: false,
}
}
async function runDisplayProbe() {
if (probeRequestPending || !canRequestFrames.value) {
return
}
probeRequestPending = true
const requestStartedUnixMs = wallClockNowMs()
try {
probeKey += 1
const response = await fetch(buildVideoFrameUrl(probeKey), {
cache: 'no-store',
})
if (!response.ok) {
return
}
const frameSeqHeader = response.headers.get('X-Blitz-Frame-Seq')
const backendReceivedHeader = response.headers.get('X-Blitz-Backend-Received-Unix-Ns')
const frameHashHeader = response.headers.get('X-Blitz-Frame-Hash') ?? ''
const frameSeq = frameSeqHeader ? Number(frameSeqHeader) : null
const backendReceivedUnixNs = backendReceivedHeader ? Number(backendReceivedHeader) : null
const responseReceivedUnixMs = wallClockNowMs()
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
try {
const probeImage = new Image()
probeImage.src = objectUrl
await probeImage.decode()
const decodedUnixMs = wallClockNowMs()
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
const paintUnixMs = wallClockNowMs()
let inputToNextFreshFrameMs: number | null = null
let inputToNextChangedFrameMs: number | null = null
let inputToNextPaintMs: number | null = null
if (pendingInputProbe != null) {
if (
!pendingInputProbe.freshResolved &&
frameSeq != null &&
(pendingInputProbe.baselineFrameSeq == null || frameSeq > pendingInputProbe.baselineFrameSeq)
) {
inputToNextFreshFrameMs = Number((performance.now() - pendingInputProbe.triggeredPerfMs).toFixed(3))
pendingInputProbe.freshResolved = true
operatorMetrics.value.input_to_next_fresh_frame_ms = inputToNextFreshFrameMs
}
if (
!pendingInputProbe.changedResolved &&
frameHashHeader &&
frameHashHeader !== pendingInputProbe.baselineFrameHash
) {
inputToNextChangedFrameMs = Number((performance.now() - pendingInputProbe.triggeredPerfMs).toFixed(3))
pendingInputProbe.changedResolved = true
operatorMetrics.value.input_to_next_changed_frame_ms = inputToNextChangedFrameMs
}
if (!pendingInputProbe.paintResolved) {
inputToNextPaintMs = Number((performance.now() - pendingInputProbe.triggeredPerfMs).toFixed(3))
pendingInputProbe.paintResolved = true
operatorMetrics.value.input_to_next_paint_ms = inputToNextPaintMs
}
if (
pendingInputProbe.freshResolved &&
pendingInputProbe.changedResolved &&
pendingInputProbe.paintResolved
) {
pendingInputProbe = null
}
}
lastObservedFrameSeq = frameSeq
lastObservedFrameHash = frameHashHeader
const calibration = currentClockCalibration(paintUnixMs)
await postVideoDisplayProbe({
updated_at: new Date().toISOString(),
frame_seq: frameSeq,
backend_received_unix_ns: backendReceivedUnixNs,
frame_hash: frameHashHeader,
request_started_unix_ms: Number(requestStartedUnixMs.toFixed(3)),
response_received_unix_ms: Number(responseReceivedUnixMs.toFixed(3)),
image_decoded_unix_ms: Number(decodedUnixMs.toFixed(3)),
paint_unix_ms: Number(paintUnixMs.toFixed(3)),
input_to_next_fresh_frame_ms: inputToNextFreshFrameMs,
input_to_next_changed_frame_ms: inputToNextChangedFrameMs,
input_to_next_paint_ms: inputToNextPaintMs,
browser_backend_clock_offset_ms: calibration.offsetMs,
browser_backend_clock_rtt_ms: calibration.rttMs,
browser_backend_clock_sample_count: calibration.sampleCount,
browser_backend_clock_calibrated_at: calibration.updatedAt,
})
} finally {
URL.revokeObjectURL(objectUrl)
}
} catch {
// Probe is best-effort only.
} finally {
probeRequestPending = false
}
}
function startProbeLoop() {
if (probeTimer != null) {
window.clearInterval(probeTimer)
probeTimer = null
}
if (!canRequestFrames.value) {
return
}
void runDisplayProbe()
probeTimer = window.setInterval(() => {
void runDisplayProbe()
}, DISPLAY_PROBE_INTERVAL_MS)
}
onMounted(() => {
void refreshCameraStatus()
startStatusLoop()
startFrameLoop()
startProbeLoop()
startClockCalibrationLoop()
})
onUnmounted(() => {
if (frameTimer != null) {
window.clearInterval(frameTimer)
}
if (statusTimer != null) {
window.clearInterval(statusTimer)
}
if (probeTimer != null) {
window.clearInterval(probeTimer)
}
if (calibrationTimer != null) {
window.clearInterval(calibrationTimer)
}
})
watch(
() => props.video,
(nextVideo) => {
liveVideo.value = nextVideo
},
{ immediate: true },
)
watch([currentFps, canRequestFrames], () => {
startFrameLoop()
startProbeLoop()
})
watch(
() => operatorInputSequence.value,
() => {
maybeTrackOperatorInput()
},
)
</script>
<template>
<section class="panel video-panel">
<div class="panel-head">
<div>
<p class="eyebrow">{{ t('videoPanel.eyebrow') }}</p>
<h2>{{ t('videoPanel.title') }}</h2>
</div>
<div class="video-actions">
<div class="camera-switch" role="group" :aria-label="t('videoPanel.title')">
<button
type="button"
class="camera-button"
:class="{ active: cameraStatus?.active_camera === 'head' }"
:disabled="cameraSwitchPending != null"
@click="switchCamera('head')"
>
{{ t('videoPanel.camera.head') }}
</button>
<button
type="button"
class="camera-button"
:class="{ active: cameraStatus?.active_camera === 'waist' }"
:disabled="cameraSwitchPending != null"
@click="switchCamera('waist')"
>
{{ t('videoPanel.camera.waist') }}
</button>
</div>
<span class="badge" :class="{ bad: !displayVideo?.available }">
{{ modeLabel }}
</span>
</div>
</div>
<p v-if="cameraSwitchPending" class="camera-message">
{{ t('videoPanel.camera.switching') }}
</p>
<p v-else-if="cameraSwitchError" class="camera-message error">
{{ t('videoPanel.camera.switchFailed', { error: cameraSwitchError }) }}
</p>
<div class="video-shell">
<img
v-if="canRequestFrames"
class="video-frame"
:class="{ 'rotate-180': cameraStatus?.active_camera === 'waist' }"
:src="frameUrl"
:alt="t('videoPanel.frameAlt')"
/>
<div v-else class="video-placeholder">
{{ t('videoPanel.waitingFrames') }}
</div>
</div>
<div class="stats">
<div class="stat-card">
<span>{{ t('videoPanel.stats.frames') }}</span>
<strong>{{ displayVideo?.frame_count ?? 0 }}</strong>
</div>
<div class="stat-card">
<span>{{ t('videoPanel.stats.latestSeq') }}</span>
<strong>{{ displayVideo?.receiver?.latest_sequence ?? '--' }}</strong>
</div>
<div class="stat-card">
<span>{{ t('videoPanel.stats.videoE2E') }}</span>
<strong>{{ formatNumber(networkEstimate?.video_e2e_est_ms, ' ms') }}</strong>
</div>
<div class="stat-card">
<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>{{ 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>{{ 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>{{ 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>{{ t('videoPanel.senderClockDelta') }}</span>
<strong>{{ timingHeadline }}</strong>
</div>
<div class="timing-grid">
<span
v-for="(sample, index) in (senderClockDebug?.sender_clock_delta_samples_ms_raw ?? [])"
:key="index"
class="timing-label"
>
{{ `${sample.toFixed(1)} ms` }}
</span>
</div>
<p class="hint subtle">
{{ timingHint }}
</p>
</div>
<p class="hint">
{{ displayVideo?.source_detail ?? t('videoPanel.noSourceDetail') }}
</p>
</section>
</template>
<style scoped>
.video-panel {
display: grid;
gap: 16px;
}
.panel-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.video-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.camera-switch {
display: inline-flex;
padding: 3px;
gap: 3px;
border: 1px solid rgba(91, 122, 255, 0.26);
border-radius: 10px;
background: rgba(15, 23, 42, 0.5);
}
.camera-button {
border: 0;
border-radius: 7px;
padding: 7px 11px;
color: inherit;
background: transparent;
cursor: pointer;
font: inherit;
font-size: 13px;
}
.camera-button.active {
color: #fff;
background: #5b7aff;
}
.camera-button:disabled {
cursor: wait;
opacity: 0.55;
}
.camera-message {
margin: -8px 0 0;
font-size: 13px;
color: #8da2fb;
}
.camera-message.error {
color: #ff8f9d;
}
.eyebrow {
margin: 0 0 4px;
color: #5b7aff;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 12px;
font-weight: 700;
}
h2,
h3 {
margin: 0;
}
h2 {
font-size: 24px;
}
h3 {
font-size: 16px;
}
.badge {
padding: 8px 12px;
border-radius: 999px;
background: rgba(40, 199, 111, 0.16);
color: #63e6a9;
font-size: 12px;
font-weight: 700;
}
.badge.bad {
background: rgba(255, 107, 107, 0.18);
color: #ffb4b4;
}
.video-shell {
overflow: hidden;
border-radius: 8px;
border: 1px solid rgba(133, 147, 169, 0.28);
background: #050812;
}
.video-frame {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
.video-frame.rotate-180 {
transform: rotate(180deg);
}
.video-placeholder {
display: grid;
place-items: center;
aspect-ratio: 16 / 9;
color: #95a4c6;
}
.stats,
.metric-grid {
display: grid;
gap: 10px;
}
.stats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.metric-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stat-card,
.metric-group,
.timing-panel {
padding: 12px;
border-radius: 8px;
border: 1px solid rgba(133, 147, 169, 0.18);
background: rgba(7, 14, 26, 0.86);
}
.stat-card span,
.metric-group p,
.hint {
color: #d5dbee;
}
.stat-card span {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: #9aaccc;
text-transform: uppercase;
}
.stat-card strong {
font-size: 18px;
}
.metric-group {
display: grid;
gap: 8px;
}
.metric-group p {
margin: 0;
line-height: 1.6;
}
.timing-head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.timing-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.timing-label {
padding: 6px 8px;
border-radius: 8px;
background: rgba(91, 122, 255, 0.12);
color: #dbe5ff;
font-size: 12px;
}
.hint {
margin: 0;
line-height: 1.7;
}
.hint.subtle {
color: #96a5c3;
}
@media (max-width: 1100px) {
.stats,
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.stats,
.metric-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,748 @@
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'
type ControlSource = 'keyboard' | 'gamepad' | 'idle'
type CommandTuple = [number, number, number, number, number, number]
type KeyFeedback = {
code: string
label: string
pressed: boolean
}
type ButtonFeedback = {
label: string
pressed: boolean
}
type ControlTuning = {
forward: number
strafe: number
turn: number
turbo: number
}
const TRACKED_KEYS = ['KeyW', 'KeyS', 'KeyA', 'KeyD', 'KeyQ', 'KeyE', 'ShiftLeft', 'ShiftRight', 'Space']
const KEY_LABELS: Record<string, string> = {
KeyW: 'W',
KeyS: 'S',
KeyA: 'A',
KeyD: 'D',
KeyQ: 'Q',
KeyE: 'E',
ShiftLeft: 'Shift',
ShiftRight: 'Shift',
Space: 'Space',
}
const GAMEPAD_BUTTON_LABELS = ['A', 'B', 'X', 'Y', 'LB', 'RB', 'LT', 'RT', 'Back', 'Start', 'LS', 'RS']
const ZERO_COMMAND: CommandTuple = [0, 0, 0, 0, 0, 0]
const GAMEPAD_DEADZONE = 0.14
const COMMAND_SEND_INTERVAL_MS = 50
const DEFAULT_CONTROL_TUNING: ControlTuning = {
forward: 0.8,
strafe: 0.15,
turn: 0.4,
turbo: 1.5,
}
const CONTROL_INPUT_MODE_STORAGE_KEY = 'robot-command-center.control-input-mode'
const CONTROL_TUNING_STORAGE_KEY = 'robot-command-center.control-tuning'
const MIN_AXIS_SPEED = 0.05
const MAX_AXIS_SPEED = 3
const MIN_TURBO_MULTIPLIER = 1
const MAX_TURBO_MULTIPLIER = 3
const pressedKeys = ref<Set<string>>(new Set())
const socketState = ref<SocketState>('connecting')
const lastServerMessageOverride = ref('')
const lastServerMessagePreset = ref<'waiting' | 'live'>('waiting')
const gamepadSupported = ref(false)
const gamepadConnected = ref(false)
const gamepadNameRaw = ref('')
const gamepadIndex = ref<number | null>(null)
const gamepadMapping = ref('')
const gamepadAxes = ref<number[]>([0, 0, 0, 0])
const gamepadButtonPressed = ref<boolean[]>(Array.from({ length: GAMEPAD_BUTTON_LABELS.length }, () => false))
const activeSource = ref<ControlSource>('idle')
const operatorInputSequence = ref(0)
const lastOperatorInputPerfMs = ref(0)
function clampValue(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function sanitizeAxisSpeed(value: unknown, fallback: number) {
const numericValue = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numericValue)) {
return fallback
}
return roundValue(clampValue(numericValue, MIN_AXIS_SPEED, MAX_AXIS_SPEED))
}
function sanitizeTurboMultiplier(value: unknown, fallback: number) {
const numericValue = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numericValue)) {
return fallback
}
return roundValue(clampValue(numericValue, MIN_TURBO_MULTIPLIER, MAX_TURBO_MULTIPLIER))
}
function normalizeControlTuning(raw?: Partial<ControlTuning>): ControlTuning {
return {
forward: sanitizeAxisSpeed(raw?.forward, DEFAULT_CONTROL_TUNING.forward),
strafe: sanitizeAxisSpeed(raw?.strafe, DEFAULT_CONTROL_TUNING.strafe),
turn: sanitizeAxisSpeed(raw?.turn, DEFAULT_CONTROL_TUNING.turn),
turbo: sanitizeTurboMultiplier(raw?.turbo, DEFAULT_CONTROL_TUNING.turbo),
}
}
function normalizeControlInputMode(raw: unknown): ControlInputMode {
return raw === 'gamepad' ? 'gamepad' : 'keyboard'
}
function loadPersistedControlTuning() {
if (typeof window === 'undefined') {
return DEFAULT_CONTROL_TUNING
}
let raw: string | null = null
try {
raw = window.localStorage.getItem(CONTROL_TUNING_STORAGE_KEY)
} catch {
return DEFAULT_CONTROL_TUNING
}
if (raw == null) {
return DEFAULT_CONTROL_TUNING
}
try {
return normalizeControlTuning(JSON.parse(raw) as Partial<ControlTuning>)
} catch {
return DEFAULT_CONTROL_TUNING
}
}
function loadPersistedControlInputMode() {
if (typeof window === 'undefined') {
return normalizeControlInputMode(null)
}
try {
return normalizeControlInputMode(window.localStorage.getItem(CONTROL_INPUT_MODE_STORAGE_KEY))
} catch {
return normalizeControlInputMode(null)
}
}
const controlInputMode = ref<ControlInputMode>(loadPersistedControlInputMode())
const initialControlTuning = loadPersistedControlTuning()
const forwardSpeed = ref(initialControlTuning.forward)
const strafeSpeed = ref(initialControlTuning.strafe)
const turnSpeed = ref(initialControlTuning.turn)
const turboMultiplier = ref(initialControlTuning.turbo)
let socket: WebSocket | null = null
let sendTimer: number | null = null
let reconnectTimer: number | null = null
let gamepadTimer: number | null = null
let manualClose = false
let consumerCount = 0
let lastGamepadSignature = ''
let lastCommandSignature = ''
function noteOperatorInput() {
operatorInputSequence.value += 1
lastOperatorInputPerfMs.value = performance.now()
}
function normalizeAxis(raw: number) {
if (Math.abs(raw) < GAMEPAD_DEADZONE) {
return 0
}
const sign = raw >= 0 ? 1 : -1
return sign * ((Math.abs(raw) - GAMEPAD_DEADZONE) / (1 - GAMEPAD_DEADZONE))
}
function roundValue(value: number) {
return Math.round(value * 1000) / 1000
}
function persistControlTuning() {
if (typeof window === 'undefined') {
return
}
try {
window.localStorage.setItem(
CONTROL_TUNING_STORAGE_KEY,
JSON.stringify({
forward: forwardSpeed.value,
strafe: strafeSpeed.value,
turn: turnSpeed.value,
turbo: turboMultiplier.value,
}),
)
} catch {
// Ignore storage failures so tuning still works for the current session.
}
}
function persistControlInputMode() {
if (typeof window === 'undefined') {
return
}
try {
window.localStorage.setItem(CONTROL_INPUT_MODE_STORAGE_KEY, controlInputMode.value)
} catch {
// Ignore storage failures so mode switching still works for the current session.
}
}
function setControlInputMode(next: ControlInputMode) {
const resolved = normalizeControlInputMode(next)
const previous = controlInputMode.value
if (resolved === previous) {
return
}
controlInputMode.value = resolved
persistControlInputMode()
if (previous === 'keyboard') {
pressedKeys.value = new Set()
}
refreshSendLoop(true)
}
function setControlTuning(next: Partial<ControlTuning>) {
const resolved = normalizeControlTuning({
forward: next.forward ?? forwardSpeed.value,
strafe: next.strafe ?? strafeSpeed.value,
turn: next.turn ?? turnSpeed.value,
turbo: next.turbo ?? turboMultiplier.value,
})
const changed =
resolved.forward !== forwardSpeed.value ||
resolved.strafe !== strafeSpeed.value ||
resolved.turn !== turnSpeed.value ||
resolved.turbo !== turboMultiplier.value
forwardSpeed.value = resolved.forward
strafeSpeed.value = resolved.strafe
turnSpeed.value = resolved.turn
turboMultiplier.value = resolved.turbo
persistControlTuning()
if (changed) {
refreshSendLoop(true)
}
}
function resetControlTuning() {
setControlTuning(DEFAULT_CONTROL_TUNING)
}
function packCommand(values: CommandTuple) {
const buffer = new ArrayBuffer(24)
const view = new DataView(buffer)
values.forEach((value, index) => view.setFloat32(index * 4, value, true))
return buffer
}
function isZeroCommand(values: CommandTuple) {
return values.every((value) => Math.abs(value) < 0.0001)
}
function commandSignature(values: CommandTuple, source: ControlSource) {
return `${source}:${values.map((value) => value.toFixed(3)).join(',')}`
}
function activeTurnAxis() {
const axis2 = normalizeAxis(gamepadAxes.value[2] ?? 0)
const axis3 = normalizeAxis(gamepadAxes.value[3] ?? 0)
return Math.abs(axis2) >= Math.abs(axis3) ? axis2 : axis3
}
function keyboardCommandValues(): CommandTuple {
const keys = pressedKeys.value
const turbo = keys.has('ShiftLeft') || keys.has('ShiftRight') ? turboMultiplier.value : 1
let lx = 0
let ly = 0
let az = 0
if (keys.has('KeyW')) lx += forwardSpeed.value
if (keys.has('KeyS')) lx -= forwardSpeed.value
if (keys.has('KeyA')) ly += strafeSpeed.value
if (keys.has('KeyD')) ly -= strafeSpeed.value
if (keys.has('KeyQ')) az += turnSpeed.value
if (keys.has('KeyE')) az -= turnSpeed.value
if (keys.has('Space')) {
return ZERO_COMMAND
}
return [
roundValue(lx * turbo),
roundValue(ly * turbo),
0,
0,
0,
roundValue(az * turbo),
]
}
function gamepadCommandValues(): CommandTuple {
if (!gamepadConnected.value) {
return ZERO_COMMAND
}
const buttons = gamepadButtonPressed.value
const turbo = buttons[5] ? turboMultiplier.value : 1
if (buttons[0]) {
return ZERO_COMMAND
}
const lx = roundValue(-normalizeAxis(gamepadAxes.value[1] ?? 0) * forwardSpeed.value * turbo)
const ly = roundValue(-normalizeAxis(gamepadAxes.value[0] ?? 0) * strafeSpeed.value * turbo)
const az = roundValue(-activeTurnAxis() * turnSpeed.value * turbo)
return [lx, ly, 0, 0, 0, az]
}
function keyboardActiveRaw() {
return pressedKeys.value.size > 0
}
function keyboardActive() {
return controlInputMode.value === 'keyboard' && keyboardActiveRaw()
}
function gamepadActiveRaw() {
if (!gamepadConnected.value) {
return false
}
return !isZeroCommand(gamepadCommandValues()) || gamepadButtonPressed.value.some(Boolean)
}
function gamepadActiveInternal() {
return controlInputMode.value === 'gamepad' && gamepadActiveRaw()
}
function resolvedSource(): ControlSource {
if (controlInputMode.value === 'keyboard' && keyboardActiveRaw()) {
return 'keyboard'
}
if (controlInputMode.value === 'gamepad' && gamepadActiveRaw()) {
return 'gamepad'
}
return 'idle'
}
function resolvedCommandValues(): CommandTuple {
const source = resolvedSource()
activeSource.value = source
if (source === 'keyboard') {
return keyboardCommandValues()
}
if (source === 'gamepad') {
return gamepadCommandValues()
}
return ZERO_COMMAND
}
function stopSendLoop() {
if (sendTimer != null) {
window.clearInterval(sendTimer)
sendTimer = null
}
}
function sendCurrentCommand() {
if (socket == null || socket.readyState !== WebSocket.OPEN) {
return
}
socket.send(packCommand(resolvedCommandValues()))
}
function refreshSendLoop(force = false, noteInput = true) {
const source = resolvedSource()
const values = resolvedCommandValues()
const signature = commandSignature(values, source)
if (!force && signature === lastCommandSignature) {
return
}
lastCommandSignature = signature
if (noteInput) {
noteOperatorInput()
}
stopSendLoop()
if (socket == null || socket.readyState !== WebSocket.OPEN) {
return
}
sendCurrentCommand()
if (isZeroCommand(values)) {
return
}
sendTimer = window.setInterval(() => {
sendCurrentCommand()
}, COMMAND_SEND_INTERVAL_MS)
}
function clearKeyboardCommands() {
pressedKeys.value = new Set()
refreshSendLoop()
}
function handleKeydown(event: KeyboardEvent) {
if (!TRACKED_KEYS.includes(event.code)) {
return
}
if (controlInputMode.value !== 'keyboard') {
return
}
if (event.target instanceof HTMLElement) {
const tag = event.target.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
return
}
}
event.preventDefault()
const next = new Set(pressedKeys.value)
next.add(event.code)
pressedKeys.value = next
refreshSendLoop()
}
function handleKeyup(event: KeyboardEvent) {
if (!TRACKED_KEYS.includes(event.code)) {
return
}
if (controlInputMode.value !== 'keyboard') {
return
}
event.preventDefault()
const next = new Set(pressedKeys.value)
next.delete(event.code)
pressedKeys.value = next
refreshSendLoop()
}
function resetGamepadState() {
gamepadConnected.value = false
gamepadNameRaw.value = ''
gamepadIndex.value = null
gamepadMapping.value = ''
gamepadAxes.value = [0, 0, 0, 0]
gamepadButtonPressed.value = Array.from({ length: GAMEPAD_BUTTON_LABELS.length }, () => false)
}
function pollGamepadState() {
gamepadSupported.value = typeof navigator !== 'undefined' && typeof navigator.getGamepads === 'function'
if (!gamepadSupported.value) {
resetGamepadState()
if (controlInputMode.value === 'gamepad') {
refreshSendLoop()
}
return
}
const pad = Array.from(navigator.getGamepads()).find((entry): entry is Gamepad => entry != null)
if (pad == null) {
if (gamepadConnected.value) {
resetGamepadState()
lastGamepadSignature = ''
if (controlInputMode.value === 'gamepad') {
refreshSendLoop()
}
}
return
}
const axes = Array.from({ length: 4 }, (_, index) => roundValue(normalizeAxis(pad.axes[index] ?? 0)))
const buttons = GAMEPAD_BUTTON_LABELS.map((_, index) => Boolean(pad.buttons[index]?.pressed))
const signature = `${pad.index}:${pad.id}:${pad.mapping}:${axes.join(',')}:${buttons.map((pressed) => (pressed ? '1' : '0')).join('')}`
if (signature === lastGamepadSignature) {
return
}
lastGamepadSignature = signature
gamepadConnected.value = true
gamepadNameRaw.value = pad.id || ''
gamepadIndex.value = pad.index
gamepadMapping.value = pad.mapping || ''
gamepadAxes.value = axes
gamepadButtonPressed.value = buttons
if (controlInputMode.value === 'gamepad') {
refreshSendLoop()
}
}
function connectSocket() {
if (socket != null && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return
}
manualClose = false
socketState.value = 'connecting'
socket = new WebSocket(buildControlWebSocketUrl())
socket.binaryType = 'arraybuffer'
socket.onopen = () => {
socketState.value = 'open'
lastServerMessagePreset.value = 'live'
lastServerMessageOverride.value = ''
refreshSendLoop(true, false)
}
socket.onmessage = (event) => {
if (typeof event.data === 'string') {
lastServerMessageOverride.value = event.data
}
}
socket.onclose = () => {
socketState.value = 'closed'
lastServerMessagePreset.value = 'waiting'
lastServerMessageOverride.value = ''
stopSendLoop()
socket = null
if (manualClose) {
return
}
if (reconnectTimer != null) {
window.clearTimeout(reconnectTimer)
}
reconnectTimer = window.setTimeout(() => {
connectSocket()
}, 1000)
}
}
function disconnectSocket() {
manualClose = true
stopSendLoop()
if (reconnectTimer != null) {
window.clearTimeout(reconnectTimer)
reconnectTimer = null
}
socket?.close()
socket = null
}
function startGamepadLoop() {
if (gamepadTimer != null) {
window.clearInterval(gamepadTimer)
}
pollGamepadState()
gamepadTimer = window.setInterval(() => {
pollGamepadState()
}, COMMAND_SEND_INTERVAL_MS)
}
function stopGamepadLoop() {
if (gamepadTimer != null) {
window.clearInterval(gamepadTimer)
gamepadTimer = null
}
}
function attachGlobalListeners() {
connectSocket()
startGamepadLoop()
window.addEventListener('keydown', handleKeydown)
window.addEventListener('keyup', handleKeyup)
window.addEventListener('blur', clearKeyboardCommands)
window.addEventListener('gamepadconnected', pollGamepadState)
window.addEventListener('gamepaddisconnected', pollGamepadState)
}
function detachGlobalListeners() {
window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('keyup', handleKeyup)
window.removeEventListener('blur', clearKeyboardCommands)
window.removeEventListener('gamepadconnected', pollGamepadState)
window.removeEventListener('gamepaddisconnected', pollGamepadState)
clearKeyboardCommands()
stopGamepadLoop()
disconnectSocket()
}
function mountConsumer() {
consumerCount += 1
if (consumerCount === 1) {
attachGlobalListeners()
}
}
function unmountConsumer() {
consumerCount = Math.max(consumerCount - 1, 0)
if (consumerCount === 0) {
detachGlobalListeners()
}
}
const socketLabel = computed(() => {
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 t('common.keyboard')
if (activeSource.value === 'gamepad') return t('common.gamepad')
return t('common.idle')
})
const controlInputModeLabel = computed(() => {
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(() => {
const [lx, ly, lz, ax, ay, az] = resolvedCommandValues()
return { lx, ly, lz, ax, ay, az }
})
const commandLabel = computed(() => {
const { lx, ly, az } = commandValues.value
return `lx=${lx.toFixed(2)} ly=${ly.toFixed(2)} az=${az.toFixed(2)}`
})
const commandMagnitude = computed(() => {
const { lx, ly, az } = commandValues.value
const limits = controlLimits.value
return Math.min(
1,
Math.max(
Math.abs(lx) / Math.max(limits.forward, MIN_AXIS_SPEED),
Math.abs(ly) / Math.max(limits.strafe, MIN_AXIS_SPEED),
Math.abs(az) / Math.max(limits.turn, MIN_AXIS_SPEED),
),
)
})
const pressedKeysLabel = computed(() => Array.from(pressedKeys.value).sort().join(', ') || t('common.none'))
const keyboardKeys = computed<KeyFeedback[]>(() =>
TRACKED_KEYS.map((code) => ({
code,
label: code === 'Space' ? t('control.key.stop') : (KEY_LABELS[code] ?? code),
pressed: pressedKeys.value.has(code),
})),
)
const keyboardTurbo = computed(
() => controlInputMode.value === 'keyboard' && (pressedKeys.value.has('ShiftLeft') || pressedKeys.value.has('ShiftRight')),
)
const controlTuning = computed<ControlTuning>(() => ({
forward: forwardSpeed.value,
strafe: strafeSpeed.value,
turn: turnSpeed.value,
turbo: turboMultiplier.value,
}))
const controlLimits = computed(() => ({
forward: roundValue(forwardSpeed.value * turboMultiplier.value),
strafe: roundValue(strafeSpeed.value * turboMultiplier.value),
turn: roundValue(turnSpeed.value * turboMultiplier.value),
}))
const gamepadButtons = computed<ButtonFeedback[]>(() =>
GAMEPAD_BUTTON_LABELS.map((label, index) => ({
label,
pressed: gamepadButtonPressed.value[index] ?? false,
})),
)
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,
}))
const gamepadRightStick = computed(() => ({
x: activeTurnAxis(),
y: gamepadAxes.value[3] ?? 0,
}))
export function useControlInterface() {
onMounted(() => {
mountConsumer()
})
onUnmounted(() => {
unmountConsumer()
})
return {
controlInputMode,
controlInputModeLabel,
setControlInputMode,
socketState,
socketLabel,
lastServerMessage,
activeSource,
activeSourceLabel,
commandValues,
commandLabel,
commandMagnitude,
controlTuning,
controlLimits,
setControlTuning,
resetControlTuning,
pressedKeysLabel,
keyboardKeys,
keyboardTurbo,
keyboardActive: computed(() => keyboardActive()),
gamepadSupported,
gamepadConnected,
gamepadName,
gamepadIndex,
gamepadMapping,
gamepadButtons,
gamepadLeftStick,
gamepadRightStick,
gamepadAxes,
gamepadActive: computed(() => gamepadActiveInternal()),
operatorInputSequence,
lastOperatorInputPerfMs,
}
}
export function useOperatorInputTelemetry() {
return {
operatorInputSequence,
lastOperatorInputPerfMs,
}
}

View File

@@ -0,0 +1,67 @@
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 = {
refreshIntervalMs?: number
}
export function useMonitoringData(options: UseMonitoringDataOptions = {}) {
const gps = ref<GpsTelemetry | null>(null)
const network = ref<NetworkTelemetry | null>(null)
const video = ref<VideoStatus | null>(null)
const loading = ref(true)
const errorMessage = ref('')
const refreshIntervalMs = Math.max(200, options.refreshIntervalMs ?? 2000)
let refreshTimer: number | null = null
async function refreshDashboard() {
try {
const snapshot = await fetchDashboardSnapshot()
gps.value = snapshot.gps
network.value = snapshot.network
video.value = snapshot.video
errorMessage.value = ''
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : t('common.requestFailed', { status: '-', statusText: '' })
} finally {
loading.value = false
}
}
const headerStatus = computed(() => {
if (errorMessage.value) {
return errorMessage.value
}
if (loading.value) {
return t('monitoring.loading')
}
return t('monitoring.connected')
})
onMounted(() => {
refreshDashboard().catch(() => undefined)
refreshTimer = window.setInterval(() => {
refreshDashboard().catch(() => undefined)
}, refreshIntervalMs)
})
onUnmounted(() => {
if (refreshTimer != null) {
window.clearInterval(refreshTimer)
}
})
return {
gps,
network,
video,
loading,
errorMessage,
headerStatus,
refreshDashboard,
}
}

View File

@@ -0,0 +1,84 @@
import type { CameraName, CameraSelectionStatus, DashboardSnapshot, VideoStatus } from '@/types'
import { t } from '@/lib/locale'
const envBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined
export const API_BASE = (envBaseUrl?.trim() || 'http://127.0.0.1:8001').replace(/\/$/, '')
async function fetchJson<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE}${path}`)
if (!response.ok) {
throw new Error(t('common.requestFailed', { status: response.status, statusText: response.statusText }))
}
return response.json() as Promise<T>
}
export function fetchDashboardSnapshot() {
return fetchJson<DashboardSnapshot>('/api/dashboard/')
}
export function fetchVideoStatus() {
return fetchJson<VideoStatus>('/api/video/status/')
}
export function fetchCameraStatus() {
return fetchJson<CameraSelectionStatus>('/api/video/camera/')
}
export async function selectCamera(camera: CameraName) {
const response = await fetch(`${API_BASE}/api/video/camera/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ camera }),
})
const payload = (await response.json()) as CameraSelectionStatus & { detail?: string }
if (!response.ok) {
throw new Error(payload.detail || payload.last_error || t('common.requestFailed', {
status: response.status,
statusText: response.statusText,
}))
}
return payload
}
export async function fetchClockCalibrationSample() {
const response = await fetch(`${API_BASE}/api/clock/calibrate/`, {
cache: 'no-store',
})
if (!response.ok) {
throw new Error(`clock calibration failed: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<{
server_received_unix_ms: number
server_sent_unix_ms: number
}>
}
export function buildVideoFrameUrl(frameKey: number) {
return `${API_BASE}/api/video/frame/?frame=${frameKey}&t=${Date.now()}`
}
export async function postVideoDisplayProbe(payload: Record<string, unknown>) {
const response = await fetch(`${API_BASE}/api/video/display-probe/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error(`display probe post failed: ${response.status} ${response.statusText}`)
}
}
export function buildControlWebSocketUrl() {
const url = new URL(API_BASE, window.location.origin)
const basePath = url.pathname.replace(/\/$/, '')
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = `${basePath}/ws/control/`
url.search = ''
url.hash = ''
return url.toString()
}

View File

@@ -0,0 +1,520 @@
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.camera.head': '头部相机',
'videoPanel.camera.waist': '腰部相机',
'videoPanel.camera.switching': '正在切换相机…',
'videoPanel.camera.switchFailed': '相机切换失败:{error}',
'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.camera.head': 'Head camera',
'videoPanel.camera.waist': 'Waist camera',
'videoPanel.camera.switching': 'Switching camera…',
'videoPanel.camera.switchFailed': 'Camera switch failed: {error}',
'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

@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,34 @@
import { createRouter, createWebHistory } from 'vue-router'
import DashboardView from '@/views/DashboardView.vue'
import MapView from '@/views/MapView.vue'
import NetworkView from '@/views/NetworkView.vue'
import VideoView from '@/views/VideoView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'dashboard',
component: DashboardView,
},
{
path: '/video',
name: 'video',
component: VideoView,
},
{
path: '/map',
name: 'map',
component: MapView,
},
{
path: '/network',
name: 'network',
component: NetworkView,
},
],
})
export default router

View File

@@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

View File

@@ -0,0 +1,339 @@
export interface GpsTelemetry {
has_fix: boolean
utc_time: string
latitude: number | null
longitude: number | null
raw_latitude_hex?: string
raw_longitude_hex?: string
satellites: number | null
altitude_m: number | null
coordinate_system: string
source_sentence: string
raw_coordinate_format: string
source_mode: string
updated_at: string
}
export interface SessionAppStats {
connected: number
registered?: number
send_calls?: number
send_bytes?: number
send_errors?: number
recv_calls?: number
recv_bytes?: number
recv_timeouts?: number
recv_errors?: number
last_server_error?: string
}
export interface SessionKcpStats {
connected?: number
conv?: number
rto_ms?: number
srtt_ms?: number
min_srtt_ms?: number
srttvar_ms?: number
last_feedback_age_ms?: number
snd_wnd?: number
rmt_wnd?: number
inflight?: number
window_limit?: number
window_pressure_pct?: number
snd_queue?: number
rcv_queue?: number
snd_buffer?: number
out_segs_total?: number
retrans_total?: number
fast_retrans_total?: number
lost_total?: number
repeat_total?: number
xmit_total?: number
}
export interface SessionTelemetry {
app: SessionAppStats
kcp: SessionKcpStats
}
export interface SessionTrendStats {
snd_queue_delta: number
snd_buffer_delta: number
snd_queue_trend: string
snd_buffer_trend: string
retrans_delta: number
fast_retrans_delta: number
lost_delta: number
repeat_delta: number
out_segs_delta: number
repair_rate_pct: number
}
export interface LinkSessionTelemetry {
peer_id: string
connected: boolean
updated_at: string | null
stale: boolean
app: SessionAppStats | null
kcp: SessionKcpStats
trend: SessionTrendStats
}
export interface LinkAggregateTelemetry {
online_sessions: number
max_window_pressure_pct: number
sum_snd_queue: number
sum_snd_buffer: number
sum_retrans_delta: number
sum_out_segs_delta: number
repair_rate_pct: number
}
export interface LinkTelemetry {
source: string
updated_at: string | null
stale: boolean
aggregate: LinkAggregateTelemetry
sessions: {
control: LinkSessionTelemetry
video: LinkSessionTelemetry
}
}
export interface NativeUdpIngress {
started: boolean
bind_addr: string
packets_received: number
invalid_packets: number
last_sender: string
last_error: string
}
export interface ControlArbiterStatus {
active_source: string | null
control_lease_remaining_ms: number
packet_counts: Record<string, number>
send_rate_hz: number
source_lease_ms: number
zero_burst_packets: number
last_error: string
last_sent_at_monotonic: number
}
export interface ControlSenderStatus {
backend_ready: boolean
started: boolean
connected: boolean
registered: boolean
peer_id: string
target_peer: string
send_count: number
send_errors: number
drain_errors: number
reconnect_count: number
last_server_error: string
last_error: string
}
export interface ControlAckReceiverStatus {
backend_ready: boolean
started: boolean
connected: boolean
peer_id: string
expected_sender: string
reconnect_count: number
last_error: string
}
export interface TelemetryReceiverStatus {
hub_connected: boolean
hub_updated_at: string | null
hub_stale: boolean
last_error: string
peer_id: string
registered: boolean
last_server_error: string
reconnect_count: number
}
export interface RobotHealthStatus {
fault_reason: string
recovery_state: string
confidence: string
updated_at: string
}
export interface VideoFreshnessStatus {
inter_frame_avg_ms: number | null
inter_frame_p95_ms: number | null
repeated_frame_ratio: number
skip_ratio: number
longest_freeze_ms: number
stale_frame_run_length: number
relative_freshness_lag_frames: number
}
export interface LatencyEstimateStatus {
control_loop_rtt_ms: number | null
control_to_persist_est_ms: number | null
control_oneway_srtt_est_ms: number | null
control_oneway_bestcase_est_ms: number | null
video_network_oneway_est_ms: number | null
video_partial_est_ms: number | null
video_e2e_est_ms: number | null
estimate_method: {
control: string
video: string
}
clock_sync_required: boolean
assumptions: string[]
confidence: {
control: string
video: string
}
}
export interface ControlAckStatus {
ack_available: boolean
updated_at: string | null
control_loop_rtt_ms: number | null
b_recv_to_persist_ms: number | null
control_oneway_network_est_ms: number | null
control_to_persist_est_ms: number | null
sample_reason: string | null
receiver: ControlAckReceiverStatus
}
export interface NetworkTelemetry {
peer_status: string
latency_ms: number | null
jitter_ms: number | null
packet_loss_pct: number | null
tx_kbps: number
rx_kbps: number
transport: string
source_mode: string
updated_at: string
active_control_source: string | null
control_lease_remaining_ms: number
combined: {
connected_sessions: number
send_bytes: number
recv_bytes: number
tx_kbps: number
rx_kbps: number
}
sessions: {
video: SessionTelemetry
control: SessionTelemetry
}
links: {
a_to_d: LinkTelemetry
d_to_b: LinkTelemetry
}
latency_estimate: LatencyEstimateStatus
video_freshness: VideoFreshnessStatus
control_ack_status: ControlAckStatus
telemetry_receiver: TelemetryReceiverStatus
robot_health: RobotHealthStatus
ingress: {
native_udp: NativeUdpIngress
}
control: {
arbiter: ControlArbiterStatus
sender: ControlSenderStatus
ack_receiver: ControlAckReceiverStatus
}
}
export interface VideoStatus {
available: boolean
source_mode: string
frame_count: number
fps: number
frame_dir: string
source_detail?: string
timing?: {
available: boolean
sender_clock_delta_ms_raw: number | null
sender_clock_delta_samples_ms_raw: number[]
sample_count: number
sample_window_size: number
timestamp_unit: string | null
timestamp_endianness: string | null
unsynced_clock: boolean
}
freshness?: VideoFreshnessStatus
display_probe?: {
updated_at: string | null
frame_seq: number | null
frame_hash: string
input_to_next_fresh_frame_ms: number | null
input_to_next_changed_frame_ms: number | null
input_to_next_paint_ms: number | null
request_to_paint_ms: number | null
response_to_paint_ms: number | null
backend_to_request_ms: number | null
backend_to_request_ms_raw: number | null
backend_to_paint_ms: number | null
backend_to_paint_ms_raw: number | null
browser_backend_clock_offset_ms: number | null
browser_backend_clock_rtt_ms: number | null
browser_backend_clock_sample_count: number
browser_backend_clock_calibrated_at: string | null
}
receiver?: {
backend_ready: boolean
mode: string
connected: boolean
registered: boolean
has_recent_frame: boolean
frames_received: number
latest_sequence: number | null
latest_frame_hash?: string
latest_backend_received_unix_ns?: number | null
latest_backend_received_mono_ns?: number | null
latest_frame_bytes?: number
latest_capture_to_send_ms?: number | null
reconnect_count: number
last_server_error: string
last_error: string
config_path: string
server_addr?: string
relay_via?: string
peer_id?: string
buffer_bytes?: number
timing?: {
available: boolean
sender_clock_delta_ms_raw: number | null
sender_clock_delta_samples_ms_raw: number[]
sample_count: number
sample_window_size: number
timestamp_unit: string | null
timestamp_endianness: string | null
unsynced_clock: boolean
}
freshness?: VideoFreshnessStatus
}
}
export type CameraName = 'head' | 'waist'
export interface CameraSelectionStatus {
available: boolean
connected: boolean
registered: boolean
requested_camera: CameraName | null
active_camera: CameraName | null
command_count: number
ack_count: number
updated_at: string | null
last_error: string
confirmed?: boolean
}
export interface DashboardSnapshot {
gps: GpsTelemetry
network: NetworkTelemetry
video: VideoStatus
}

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
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()
</script>
<template>
<div class="page-shell">
<header class="hero">
<div>
<p class="eyebrow">{{ t('dashboard.eyebrow') }}</p>
<h1>{{ t('dashboard.title') }}</h1>
</div>
<p class="hero-text">{{ t('dashboard.description') }}</p>
</header>
<section class="banner" :class="{ error: !!errorMessage }">
{{ headerStatus }}
</section>
<main class="layout">
<section class="primary-grid">
<VideoPanel :video="video" :network="network" />
<ControlPanel />
</section>
<GpsMapPanel :gps="gps" />
<NetworkPanel :network="network" />
</main>
</div>
</template>
<style scoped>
.page-shell {
display: grid;
gap: 22px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 520px);
gap: 20px;
align-items: end;
}
.eyebrow {
margin: 0 0 8px;
color: #8da2fb;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(34px, 5vw, 64px);
line-height: 1.04;
}
.hero-text {
margin: 0;
color: #c8d2e8;
font-size: 16px;
line-height: 1.75;
}
.banner {
padding: 14px 16px;
border-radius: 18px;
background: rgba(11, 19, 35, 0.84);
border: 1px solid rgba(133, 147, 169, 0.2);
color: #d5dbee;
}
.banner.error {
color: #ffd0d0;
border-color: rgba(255, 107, 107, 0.28);
}
.layout {
display: grid;
gap: 20px;
}
.primary-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.95fr);
gap: 20px;
align-items: start;
}
@media (max-width: 1280px) {
.primary-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 960px) {
.hero {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,72 @@
<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({
refreshIntervalMs: 500,
})
</script>
<template>
<div class="page-shell">
<header class="page-header">
<div>
<p class="eyebrow">{{ t('mapView.eyebrow') }}</p>
<h1>{{ t('mapView.title') }}</h1>
</div>
<p class="description">{{ t('mapView.description') }}</p>
</header>
<section class="banner" :class="{ error: !!errorMessage }">
{{ headerStatus }}
</section>
<GpsMapPanel :gps="gps" />
</div>
</template>
<style scoped>
.page-shell {
display: grid;
gap: 22px;
}
.page-header {
display: grid;
gap: 10px;
}
.eyebrow {
margin: 0;
color: #f5a524;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(28px, 4vw, 48px);
}
.description,
.banner {
margin: 0;
color: #d5dbee;
line-height: 1.75;
}
.banner {
padding: 14px 16px;
border-radius: 18px;
background: rgba(11, 19, 35, 0.84);
border: 1px solid rgba(133, 147, 169, 0.2);
}
.banner.error {
color: #ffd0d0;
border-color: rgba(255, 107, 107, 0.28);
}
</style>

View File

@@ -0,0 +1,72 @@
<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({
refreshIntervalMs: 500,
})
</script>
<template>
<div class="page-shell">
<header class="page-header">
<div>
<p class="eyebrow">{{ t('networkView.eyebrow') }}</p>
<h1>{{ t('networkView.title') }}</h1>
</div>
<p class="description">{{ t('networkView.description') }}</p>
</header>
<section class="banner" :class="{ error: !!errorMessage }">
{{ headerStatus }}
</section>
<NetworkPanel :network="network" />
</div>
</template>
<style scoped>
.page-shell {
display: grid;
gap: 22px;
}
.page-header {
display: grid;
gap: 10px;
}
.eyebrow {
margin: 0;
color: #4dd4ac;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(28px, 4vw, 48px);
}
.description,
.banner {
margin: 0;
color: #d5dbee;
line-height: 1.75;
}
.banner {
padding: 14px 16px;
border-radius: 18px;
background: rgba(11, 19, 35, 0.84);
border: 1px solid rgba(133, 147, 169, 0.2);
}
.banner.error {
color: #ffd0d0;
border-color: rgba(255, 107, 107, 0.28);
}
</style>

View File

@@ -0,0 +1,70 @@
<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()
</script>
<template>
<div class="page-shell">
<header class="page-header">
<div>
<p class="eyebrow">{{ t('videoView.eyebrow') }}</p>
<h1>{{ t('videoView.title') }}</h1>
</div>
<p class="description">{{ t('videoView.description') }}</p>
</header>
<section class="banner" :class="{ error: !!errorMessage }">
{{ headerStatus }}
</section>
<VideoPanel :video="video" :network="network" />
</div>
</template>
<style scoped>
.page-shell {
display: grid;
gap: 22px;
}
.page-header {
display: grid;
gap: 10px;
}
.eyebrow {
margin: 0;
color: #8da2fb;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
font-weight: 700;
}
h1 {
margin: 0;
font-size: clamp(28px, 4vw, 48px);
}
.description,
.banner {
margin: 0;
color: #d5dbee;
line-height: 1.75;
}
.banner {
padding: 14px 16px;
border-radius: 18px;
background: rgba(11, 19, 35, 0.84);
border: 1px solid rgba(133, 147, 169, 0.2);
}
.banner.error {
color: #ffd0d0;
border-color: rgba(255, 107, 107, 0.28);
}
</style>