mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-06 17:53:19 +08:00
refactor(flightlab): remove instructor flow and simplify takeoff controls
This commit is contained in:
@@ -99,10 +99,6 @@
|
||||
Starten
|
||||
</v-btn>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/flightlab/takeoff/instructor" class="text-center text-xs text-white/40 transition hover:text-cyan-300">
|
||||
<v-icon icon="mdi-account-tie" size="14" class="mr-1" />
|
||||
Instructor Panel
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,20 +8,13 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ Sidebar Toggle Button ═══════ -->
|
||||
<button
|
||||
class="fixed left-0 top-1/2 -translate-y-1/2 z-40 flex h-12 w-6 items-center justify-center rounded-r-lg border border-l-0 border-white/10 bg-[#0b1328]/95 backdrop-blur text-white/40 hover:text-white/80 hover:bg-[#0b1328] transition-all duration-300"
|
||||
:class="{ 'left-[300px]': sidebarOpen }"
|
||||
@click="sidebarOpen = !sidebarOpen"
|
||||
<!-- ═══════ Sidebar + Attached Toggle ═══════ -->
|
||||
<div
|
||||
class="fixed left-0 top-0 bottom-0 z-40 flex transition-transform duration-300"
|
||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-[300px]'"
|
||||
>
|
||||
<v-icon :icon="sidebarOpen ? 'mdi-chevron-left' : 'mdi-chevron-right'" size="16" />
|
||||
</button>
|
||||
|
||||
<!-- ═══════ Sidebar ═══════ -->
|
||||
<Transition name="sidebar">
|
||||
<aside
|
||||
v-show="sidebarOpen"
|
||||
class="fixed left-0 top-0 bottom-0 z-30 w-[300px] border-r border-white/5 bg-[#070d1a]/98 backdrop-blur-xl flex flex-col pt-6"
|
||||
class="w-[300px] border-r border-white/5 bg-[#070d1a]/98 backdrop-blur-xl flex flex-col pt-6"
|
||||
>
|
||||
<!-- Sidebar Header -->
|
||||
<div class="px-4 mb-4">
|
||||
@@ -148,7 +141,13 @@
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</Transition>
|
||||
<button
|
||||
class="flex h-12 w-6 self-center items-center justify-center rounded-r-lg border border-l-0 border-white/10 bg-[#0b1328]/95 backdrop-blur text-white/40 hover:text-white/80 hover:bg-[#0b1328] transition-colors duration-300"
|
||||
@click="sidebarOpen = !sidebarOpen"
|
||||
>
|
||||
<v-icon :icon="sidebarOpen ? 'mdi-chevron-left' : 'mdi-chevron-right'" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ Header Bar ═══════ -->
|
||||
<header class="shrink-0 border-b border-white/5 bg-[#070d1a]/90 backdrop-blur px-4 py-3 mt-1 relative z-20">
|
||||
@@ -164,10 +163,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Auto-Advance Toggle + Session + Controls -->
|
||||
<!-- Right: Auto-Advance Toggle -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Auto-Advance Toggle -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex items-center gap-1.5" title="Wechselt automatisch erst weiter, wenn die Sim-Bedingungen erfüllt sind.">
|
||||
<v-switch
|
||||
:model-value="engine.autoAdvanceEnabled.value"
|
||||
density="compact"
|
||||
@@ -176,58 +175,8 @@
|
||||
class="auto-advance-toggle"
|
||||
@update:model-value="engine.toggleAutoAdvance()"
|
||||
/>
|
||||
<span class="text-xs text-white/40 hidden sm:inline">Sim Auto</span>
|
||||
<span class="text-xs text-white/40 hidden sm:inline">Auto bei Sim-Bedingungen</span>
|
||||
</div>
|
||||
|
||||
<!-- Session indicator -->
|
||||
<div v-if="sync.isConnected.value" class="flex items-center gap-1.5 rounded-full bg-emerald-500/10 border border-emerald-400/30 px-3 py-1">
|
||||
<div class="h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span class="text-xs text-emerald-300">{{ sync.sessionCode.value }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Session input (when not connected) -->
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<input
|
||||
v-model="joinCode"
|
||||
type="text"
|
||||
maxlength="4"
|
||||
placeholder="CODE"
|
||||
class="w-16 rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-center text-xs font-mono uppercase text-white/80 placeholder:text-white/30 focus:border-cyan-400/50 focus:outline-none"
|
||||
@keyup.enter="handleJoin"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="joinCode.length === 4"
|
||||
size="x-small"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
class="rounded-lg"
|
||||
@click="handleJoin"
|
||||
>
|
||||
Join
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar toggle (mobile) -->
|
||||
<v-btn
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="text-white/30 hover:text-white/70 sm:hidden"
|
||||
@click="sidebarOpen = !sidebarOpen"
|
||||
>
|
||||
<v-icon icon="mdi-menu" size="18" />
|
||||
</v-btn>
|
||||
|
||||
<!-- Restart -->
|
||||
<v-btn
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="text-white/30 hover:text-white/70"
|
||||
@click="showRestartConfirm = true"
|
||||
>
|
||||
<v-icon icon="mdi-refresh" size="18" />
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -235,14 +184,6 @@
|
||||
<!-- ═══════ Main Content ═══════ -->
|
||||
<main class="flex-1 flex flex-col justify-center px-4 py-6 sm:py-10 transition-all duration-300" :class="{ 'sm:ml-[300px]': sidebarOpen }">
|
||||
<div class="mx-auto w-full max-w-screen-sm space-y-6">
|
||||
<!-- Paused overlay -->
|
||||
<Transition name="fade-slide">
|
||||
<div v-if="engine.isPaused.value" class="rounded-2xl border border-amber-400/30 bg-amber-500/10 p-4 text-center">
|
||||
<v-icon icon="mdi-pause-circle" size="24" class="text-amber-300 mb-1" />
|
||||
<p class="text-sm text-amber-200">Pausiert - der Instructor setzt gleich fort</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Help Message Banner -->
|
||||
<Transition name="fade-slide">
|
||||
<div
|
||||
@@ -321,7 +262,7 @@
|
||||
|
||||
<!-- More details button -->
|
||||
<v-btn
|
||||
v-if="currentPhase.explanation || currentPhase.simConditions || currentPhase.instructorNote"
|
||||
v-if="currentPhase.explanation || currentPhase.simConditions"
|
||||
size="small"
|
||||
variant="text"
|
||||
class="text-white/40 -mr-2"
|
||||
@@ -399,11 +340,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructor Note -->
|
||||
<div v-if="currentPhase?.instructorNote">
|
||||
<p class="text-xs text-white/40 uppercase tracking-wider mb-1">Instructor-Hinweis</p>
|
||||
<p class="text-sm text-white/60 leading-relaxed italic">{{ currentPhase.instructorNote }}</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions class="px-5 pb-5">
|
||||
<v-spacer />
|
||||
@@ -412,20 +348,6 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- ═══════ Restart confirmation dialog ═══════ -->
|
||||
<v-dialog v-model="showRestartConfirm" max-width="340">
|
||||
<v-card class="rounded-2xl bg-[#0b1328] border border-white/10">
|
||||
<v-card-title class="text-base font-semibold pt-5 px-5">Nochmal von vorne?</v-card-title>
|
||||
<v-card-text class="text-sm text-white/60 px-5">
|
||||
Der aktuelle Fortschritt geht verloren.
|
||||
</v-card-text>
|
||||
<v-card-actions class="px-5 pb-5">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" class="text-white/50" @click="showRestartConfirm = false">Abbrechen</v-btn>
|
||||
<v-btn color="primary" variant="flat" class="rounded-xl" @click="handleRestart">Neustart</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -434,7 +356,6 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { takeoffEddf } from '~~/shared/data/flightlab/takeoff-eddf'
|
||||
import { useFlightLabEngine } from '~~/shared/composables/flightlab/useFlightLabEngine'
|
||||
import { useFlightLabAudio } from '~~/shared/composables/flightlab/useFlightLabAudio'
|
||||
import { useFlightLabSync } from '~~/shared/composables/flightlab/useFlightLabSync'
|
||||
import type { FlightLabButton, SimCondition, FlightLabTelemetryState } from '~~/shared/data/flightlab/types'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
@@ -443,20 +364,17 @@ useHead({ title: 'FlightLab - Dein erster Start' })
|
||||
|
||||
const engine = useFlightLabEngine(takeoffEddf)
|
||||
const audio = useFlightLabAudio()
|
||||
const sync = useFlightLabSync()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const joinCode = ref('')
|
||||
const showRestartConfirm = ref(false)
|
||||
const showDetails = ref(false)
|
||||
const sidebarOpen = ref(false)
|
||||
const sidebarOpen = ref(true)
|
||||
|
||||
// --- Direct Bridge Telemetry Polling (solo mode, no WS session) ---
|
||||
let telemetryPollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startTelemetryPolling() {
|
||||
stopTelemetryPolling()
|
||||
if (!authStore.accessToken || sync.isConnected.value) return
|
||||
if (!authStore.accessToken) return
|
||||
telemetryPollInterval = setInterval(async () => {
|
||||
try {
|
||||
const res = await $fetch<{ telemetry: FlightLabTelemetryState | null }>('/api/flightlab/telemetry', {
|
||||
@@ -478,7 +396,7 @@ function stopTelemetryPolling() {
|
||||
|
||||
// Start polling when phase has simConditions (solo mode only)
|
||||
watch(() => engine.currentPhase.value, (phase) => {
|
||||
if (phase?.simConditions && !sync.isConnected.value) {
|
||||
if (phase?.simConditions) {
|
||||
startTelemetryPolling()
|
||||
} else {
|
||||
stopTelemetryPolling()
|
||||
@@ -563,9 +481,6 @@ function getPhaseStepClass(phaseId: string, idx: number): string {
|
||||
|
||||
function handlePhaseJump(phaseId: string) {
|
||||
engine.goToPhase(phaseId)
|
||||
if (sync.isConnected.value) {
|
||||
sync.sendParticipantAction(engine.currentPhaseId.value, 'jump', phaseId)
|
||||
}
|
||||
}
|
||||
|
||||
// --- SimBridge Condition Helpers ---
|
||||
@@ -688,33 +603,6 @@ function getButtonColor(type?: string) {
|
||||
function handleButtonPress(btn: FlightLabButton) {
|
||||
if (engine.isPaused.value || audio.isSpeaking.value) return
|
||||
engine.selectOption(btn)
|
||||
if (sync.isConnected.value) {
|
||||
sync.sendParticipantAction(engine.currentPhaseId.value, btn.id, btn.next)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJoin() {
|
||||
if (joinCode.value.length !== 4) return
|
||||
try {
|
||||
await sync.joinSession(joinCode.value)
|
||||
// Switch to WS-based telemetry
|
||||
stopTelemetryPolling()
|
||||
if (authStore.user?.id) {
|
||||
sync.subscribeTelemetry(authStore.user.id)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[FlightLab] Join failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function handleRestart() {
|
||||
showRestartConfirm.value = false
|
||||
audio.stopAllSounds()
|
||||
audio.clearReplayCache()
|
||||
engine.restart()
|
||||
if (sync.isConnected.value) {
|
||||
sync.sendParticipantAction('restart', 'restart', 'welcome')
|
||||
}
|
||||
}
|
||||
|
||||
// --- Help message TTS callback ---
|
||||
@@ -735,29 +623,6 @@ watch(() => engine.currentPhaseId.value, async (newId, oldId) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for telemetry from WebSocket (bridge → server → here)
|
||||
sync.onTelemetry((data: any) => {
|
||||
engine.updateTelemetry(data as FlightLabTelemetryState)
|
||||
})
|
||||
|
||||
// Listen for instructor commands via WebSocket
|
||||
sync.onStateChange((state) => {
|
||||
if (state.currentPhaseId !== engine.currentPhaseId.value) {
|
||||
engine.goToPhase(state.currentPhaseId)
|
||||
}
|
||||
engine.isPaused.value = state.isPaused
|
||||
})
|
||||
|
||||
sync.onInstructorMessage(async (text, withRadioEffect) => {
|
||||
if (withRadioEffect) {
|
||||
await audio.speakAtcMessage(text, { speed: 0.9, readability: 5 })
|
||||
}
|
||||
})
|
||||
|
||||
sync.onError((msg) => {
|
||||
console.warn('[FlightLab] WS error:', msg)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await audio.preloadSounds(allSoundIds.value)
|
||||
|
||||
@@ -774,7 +639,6 @@ onBeforeUnmount(() => {
|
||||
stopTelemetryPolling()
|
||||
engine.cleanup()
|
||||
audio.dispose()
|
||||
sync.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -784,16 +648,6 @@ onBeforeUnmount(() => {
|
||||
box-shadow: 0 0 12px rgba(34, 211, 238, 0.4), 0 0 4px rgba(34, 211, 238, 0.2);
|
||||
}
|
||||
|
||||
/* ═══════ Sidebar transition ═══════ */
|
||||
.sidebar-enter-active,
|
||||
.sidebar-leave-active {
|
||||
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.sidebar-enter-from,
|
||||
.sidebar-leave-to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar-scroll::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#070d1a] text-white">
|
||||
<!-- Header -->
|
||||
<header class="shrink-0 border-b border-white/5 bg-[#070d1a]/90 backdrop-blur px-4 py-3">
|
||||
<div class="mx-auto flex max-w-screen-2xl items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<NuxtLink to="/flightlab" class="text-white/40 transition hover:text-white/80">
|
||||
<v-icon icon="mdi-arrow-left" size="20" />
|
||||
</NuxtLink>
|
||||
<v-icon icon="mdi-account-tie" size="20" class="text-cyan-400/70" />
|
||||
<span class="text-sm font-medium text-white/70">Instructor Panel</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Session Code -->
|
||||
<div class="rounded-xl border border-cyan-400/30 bg-cyan-500/10 px-4 py-2 text-center">
|
||||
<p class="text-[10px] uppercase tracking-widest text-cyan-300/60">Session</p>
|
||||
<p class="text-2xl font-bold font-mono tracking-[0.2em] text-cyan-200">{{ sync.sessionCode.value || '----' }}</p>
|
||||
</div>
|
||||
<!-- Connection status -->
|
||||
<div class="flex items-center gap-2 rounded-full px-3 py-1.5" :class="participantConnected ? 'bg-emerald-500/10 border border-emerald-400/30' : 'bg-white/5 border border-white/10'">
|
||||
<div class="h-2 w-2 rounded-full" :class="participantConnected ? 'bg-emerald-400 animate-pulse' : 'bg-white/30'" />
|
||||
<span class="text-xs" :class="participantConnected ? 'text-emerald-300' : 'text-white/40'">
|
||||
{{ participantConnected ? 'Teilnehmer verbunden' : 'Warte auf Teilnehmer...' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mx-auto max-w-screen-2xl p-4 sm:p-6">
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr_420px]">
|
||||
<!-- Left: Participant Mirror -->
|
||||
<div class="space-y-4">
|
||||
<!-- Current phase info -->
|
||||
<div class="rounded-2xl border border-white/10 bg-[#0b1328]/90 p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs uppercase tracking-widest text-white/40">Aktuelle Phase</span>
|
||||
<span class="rounded-full bg-cyan-500/10 border border-cyan-400/30 px-2.5 py-0.5 text-xs font-mono text-cyan-300">
|
||||
{{ engine.currentPhaseId.value }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-1.5 w-32 rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-cyan-400 transition-all duration-500"
|
||||
:style="{ width: `${engine.progress.value}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs text-white/40 tabular-nums">{{ engine.progress.value }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ATC Message -->
|
||||
<p v-if="currentPhase" class="text-base leading-relaxed text-white/80">
|
||||
{{ currentPhase.atcMessage }}
|
||||
</p>
|
||||
|
||||
<!-- Explanation -->
|
||||
<p v-if="currentPhase?.explanation" class="mt-3 text-sm text-white/40 border-t border-white/5 pt-3">
|
||||
{{ currentPhase.explanation }}
|
||||
</p>
|
||||
|
||||
<!-- Participant buttons (read-only mirror) -->
|
||||
<div v-if="currentPhase" class="mt-4 flex flex-wrap gap-2">
|
||||
<v-chip
|
||||
v-for="btn in currentPhase.buttons"
|
||||
:key="btn.id"
|
||||
size="small"
|
||||
:color="btn.type === 'primary' ? 'cyan' : btn.type === 'comfort' ? 'amber' : 'default'"
|
||||
variant="outlined"
|
||||
:prepend-icon="btn.icon"
|
||||
>
|
||||
{{ btn.label }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- Instructor note -->
|
||||
<div v-if="currentPhase?.instructorNote" class="mt-4 rounded-xl border border-amber-400/20 bg-amber-500/5 p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<v-icon icon="mdi-lightbulb-outline" size="16" class="mt-0.5 text-amber-300/70" />
|
||||
<p class="text-sm text-amber-200/80">{{ currentPhase.instructorNote }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Anxiety alerts -->
|
||||
<div v-if="anxietyAlerts.length > 0" class="rounded-2xl border border-red-400/20 bg-red-500/5 p-4">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<v-icon icon="mdi-alert-circle" size="18" class="text-red-400" />
|
||||
<span class="text-sm font-semibold text-red-300">Angst-Signale</span>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<div v-for="alert in anxietyAlerts" :key="alert.timestamp" class="flex items-center gap-2 text-sm text-red-200/70">
|
||||
<span class="text-xs text-red-400/50 tabular-nums">{{ formatTime(alert.timestamp) }}</span>
|
||||
<span>{{ alert.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Participant log -->
|
||||
<div class="rounded-2xl border border-white/10 bg-[#0b1328]/90 p-4">
|
||||
<h3 class="mb-3 text-xs uppercase tracking-widest text-white/40">Teilnehmer-Verlauf</h3>
|
||||
<div class="max-h-60 space-y-1.5 overflow-y-auto">
|
||||
<div v-if="engine.history.value.length === 0" class="text-sm text-white/30 italic">Noch keine Aktionen</div>
|
||||
<div
|
||||
v-for="(entry, idx) in [...engine.history.value].reverse()"
|
||||
:key="idx"
|
||||
class="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span class="text-xs text-white/30 tabular-nums">{{ formatTime(entry.timestamp) }}</span>
|
||||
<span class="rounded bg-white/5 px-1.5 py-0.5 font-mono text-xs text-cyan-300/70">{{ entry.phaseId }}</span>
|
||||
<v-icon icon="mdi-arrow-right" size="12" class="text-white/20" />
|
||||
<span class="text-white/60">{{ entry.buttonId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Instructor Controls -->
|
||||
<div class="space-y-4">
|
||||
<!-- Phase Control -->
|
||||
<div class="rounded-2xl border border-white/10 bg-[#0b1328]/90 p-4">
|
||||
<h3 class="mb-3 text-xs uppercase tracking-widest text-white/40">Phasen-Steuerung</h3>
|
||||
|
||||
<div class="flex gap-2 mb-3">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
class="flex-1 rounded-xl border-white/10"
|
||||
prepend-icon="mdi-skip-previous"
|
||||
:disabled="!canSkipBack"
|
||||
@click="handleSkipBack"
|
||||
>
|
||||
Zurück
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
class="flex-1 rounded-xl border-white/10"
|
||||
append-icon="mdi-skip-next"
|
||||
:disabled="!canSkipForward"
|
||||
@click="handleSkipForward"
|
||||
>
|
||||
Weiter
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Phase selector -->
|
||||
<v-select
|
||||
v-model="selectedPhaseId"
|
||||
:items="mainPhases"
|
||||
item-title="label"
|
||||
item-value="id"
|
||||
label="Zu Phase springen"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mb-3"
|
||||
@update:model-value="handleGoToPhase"
|
||||
/>
|
||||
|
||||
<!-- Pause/Resume -->
|
||||
<v-btn
|
||||
:color="engine.isPaused.value ? 'success' : 'warning'"
|
||||
variant="flat"
|
||||
block
|
||||
class="rounded-xl"
|
||||
:prepend-icon="engine.isPaused.value ? 'mdi-play' : 'mdi-pause'"
|
||||
@click="handlePauseToggle"
|
||||
>
|
||||
{{ engine.isPaused.value ? 'Fortsetzen' : 'Pausieren' }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Custom Message -->
|
||||
<div class="rounded-2xl border border-white/10 bg-[#0b1328]/90 p-4">
|
||||
<h3 class="mb-3 text-xs uppercase tracking-widest text-white/40">Nachricht an Teilnehmer</h3>
|
||||
<v-textarea
|
||||
v-model="customMessage"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
rows="2"
|
||||
placeholder="Nachricht eingeben..."
|
||||
hide-details
|
||||
class="mb-3"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<v-checkbox
|
||||
v-model="withRadioEffect"
|
||||
label="Mit Funkeffekt"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="flex-1"
|
||||
/>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="small"
|
||||
class="rounded-xl"
|
||||
:disabled="!customMessage.trim()"
|
||||
prepend-icon="mdi-send"
|
||||
@click="handleSendMessage"
|
||||
>
|
||||
Senden
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restart -->
|
||||
<div class="rounded-2xl border border-white/10 bg-[#0b1328]/90 p-4">
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="outlined"
|
||||
block
|
||||
class="rounded-xl"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="showRestartConfirm = true"
|
||||
>
|
||||
Szenario neustarten
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Elapsed time -->
|
||||
<div class="rounded-2xl border border-white/10 bg-[#0b1328]/60 p-4 text-center">
|
||||
<p class="text-xs text-white/30 uppercase tracking-widest mb-1">Laufzeit</p>
|
||||
<p class="text-2xl font-mono text-white/60 tabular-nums">{{ elapsedTime }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restart dialog -->
|
||||
<v-dialog v-model="showRestartConfirm" max-width="340">
|
||||
<v-card class="rounded-2xl bg-[#0b1328] border border-white/10">
|
||||
<v-card-title class="text-base font-semibold pt-5 px-5">Szenario neustarten?</v-card-title>
|
||||
<v-card-text class="text-sm text-white/60 px-5">
|
||||
Der gesamte Fortschritt wird zurückgesetzt. Der Teilnehmer startet bei Phase 1.
|
||||
</v-card-text>
|
||||
<v-card-actions class="px-5 pb-5">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" class="text-white/50" @click="showRestartConfirm = false">Abbrechen</v-btn>
|
||||
<v-btn color="error" variant="flat" class="rounded-xl" @click="handleRestart">Neustart</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { takeoffEddf } from '~~/shared/data/flightlab/takeoff-eddf'
|
||||
import { useFlightLabEngine } from '~~/shared/composables/flightlab/useFlightLabEngine'
|
||||
import { useFlightLabSync } from '~~/shared/composables/flightlab/useFlightLabSync'
|
||||
|
||||
definePageMeta({ layout: false, middleware: ['require-auth'] })
|
||||
useHead({ title: 'FlightLab - Instructor Panel' })
|
||||
|
||||
// Guard: skip all logic on server
|
||||
if (!import.meta.client) throw new Error('Client-only page')
|
||||
|
||||
const engine = useFlightLabEngine(takeoffEddf)
|
||||
const sync = useFlightLabSync()
|
||||
|
||||
const customMessage = ref('')
|
||||
const withRadioEffect = ref(true)
|
||||
const showRestartConfirm = ref(false)
|
||||
const selectedPhaseId = ref(engine.currentPhaseId.value)
|
||||
const participantConnected = ref(false)
|
||||
const elapsedTime = ref('00:00')
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const currentPhase = computed(() => engine.currentPhase.value)
|
||||
|
||||
const mainPhaseIds = ['welcome', 'briefing', 'runway', 'engines_pre', 'engines_spool', 'takeoff_roll', 'rotation', 'gear_retract', 'climb', 'climb_high', 'leveloff', 'debrief', 'end']
|
||||
|
||||
const mainPhases = mainPhaseIds.map(id => ({
|
||||
id,
|
||||
label: getPhaseLabel(id),
|
||||
}))
|
||||
|
||||
const canSkipBack = computed(() => {
|
||||
const idx = mainPhaseIds.indexOf(engine.currentPhaseId.value)
|
||||
return idx > 0
|
||||
})
|
||||
|
||||
const canSkipForward = computed(() => {
|
||||
const idx = mainPhaseIds.indexOf(engine.currentPhaseId.value)
|
||||
return idx >= 0 && idx < mainPhaseIds.length - 1
|
||||
})
|
||||
|
||||
// Track anxiety signals
|
||||
const anxietyAlerts = computed(() => {
|
||||
const alerts: Array<{ message: string; timestamp: number }> = []
|
||||
for (const entry of engine.history.value) {
|
||||
const phase = takeoffEddf.phases.find(p => p.id === entry.phaseId)
|
||||
if (phase) {
|
||||
const btn = phase.buttons.find(b => b.id === entry.buttonId)
|
||||
if (btn?.instructorAlert) {
|
||||
alerts.push({ message: btn.instructorAlert, timestamp: entry.timestamp })
|
||||
}
|
||||
}
|
||||
}
|
||||
return alerts
|
||||
})
|
||||
|
||||
function getPhaseLabel(id: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
welcome: 'Willkommen',
|
||||
briefing: 'Briefing',
|
||||
runway: 'Startbahn',
|
||||
engines_pre: 'Triebwerke (Vorbereitung)',
|
||||
engines_spool: 'Triebwerke (Schub)',
|
||||
takeoff_roll: 'Startlauf',
|
||||
rotation: 'Abheben',
|
||||
gear_retract: 'Fahrwerk einfahren',
|
||||
climb: 'Steigflug',
|
||||
climb_high: 'Steigflug (hoch)',
|
||||
leveloff: 'Level-off',
|
||||
debrief: 'Nachbesprechung',
|
||||
end: 'Ende',
|
||||
}
|
||||
return labels[id] || id
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function updateElapsed() {
|
||||
const diff = Math.floor((Date.now() - engine.startedAt.value) / 1000)
|
||||
const m = Math.floor(diff / 60).toString().padStart(2, '0')
|
||||
const s = (diff % 60).toString().padStart(2, '0')
|
||||
elapsedTime.value = `${m}:${s}`
|
||||
}
|
||||
|
||||
function handlePauseToggle() {
|
||||
if (engine.isPaused.value) {
|
||||
engine.resume()
|
||||
sync.sendInstructorCommand('resume')
|
||||
} else {
|
||||
engine.pause()
|
||||
sync.sendInstructorCommand('pause')
|
||||
}
|
||||
}
|
||||
|
||||
function handleSkipBack() {
|
||||
engine.skipBack()
|
||||
selectedPhaseId.value = engine.currentPhaseId.value
|
||||
sync.sendInstructorCommand('goto', engine.currentPhaseId.value)
|
||||
}
|
||||
|
||||
function handleSkipForward() {
|
||||
engine.skipForward()
|
||||
selectedPhaseId.value = engine.currentPhaseId.value
|
||||
sync.sendInstructorCommand('goto', engine.currentPhaseId.value)
|
||||
}
|
||||
|
||||
function handleGoToPhase(phaseId: string) {
|
||||
engine.goToPhase(phaseId)
|
||||
sync.sendInstructorCommand('goto', phaseId)
|
||||
}
|
||||
|
||||
function handleSendMessage() {
|
||||
if (!customMessage.value.trim()) return
|
||||
sync.sendInstructorMessage(customMessage.value.trim(), withRadioEffect.value)
|
||||
customMessage.value = ''
|
||||
}
|
||||
|
||||
function handleRestart() {
|
||||
showRestartConfirm.value = false
|
||||
engine.restart()
|
||||
selectedPhaseId.value = 'welcome'
|
||||
sync.sendInstructorCommand('restart')
|
||||
}
|
||||
|
||||
// Sync: listen for participant actions
|
||||
sync.onStateChange((state) => {
|
||||
if (state.currentPhaseId !== engine.currentPhaseId.value) {
|
||||
engine.goToPhase(state.currentPhaseId)
|
||||
selectedPhaseId.value = state.currentPhaseId
|
||||
}
|
||||
participantConnected.value = state.participantConnected
|
||||
// Sync history
|
||||
engine.history.value = state.history
|
||||
})
|
||||
|
||||
sync.onPeerJoined((role) => {
|
||||
if (role === 'participant') participantConnected.value = true
|
||||
})
|
||||
|
||||
sync.onPeerLeft((role) => {
|
||||
if (role === 'participant') participantConnected.value = false
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Create session
|
||||
try {
|
||||
await sync.createSession('takeoff-eddf')
|
||||
} catch (e) {
|
||||
console.error('[Instructor] WS connect failed:', e)
|
||||
}
|
||||
|
||||
// Start elapsed timer
|
||||
elapsedTimer = setInterval(updateElapsed, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (elapsedTimer) clearInterval(elapsedTimer)
|
||||
sync.disconnect()
|
||||
})
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@ export function useFlightLabEngine(scenario: FlightLabScenario) {
|
||||
const startedAt = ref(Date.now())
|
||||
|
||||
// --- Auto-Advance / SimConnect ---
|
||||
const autoAdvanceEnabled = ref(false)
|
||||
const autoAdvanceEnabled = ref(true)
|
||||
const currentTelemetry = ref<FlightLabTelemetryState | null>(null)
|
||||
const showingHelpMessage = ref(false)
|
||||
const helpMessageText = ref<string | null>(null)
|
||||
|
||||
Reference in New Issue
Block a user