Files
OmniSocketGo_streaming/host/robot-command-center/frontend/src/components/ControlFeedback.vue
2026-08-09 15:56:20 +08:00

545 lines
12 KiB
Vue

<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>