@@ -2,7 +2,7 @@
import { computed , nextTick , onMounted , onUnmounted , ref } from 'vue'
import { chatBaustein , pruefeBaustein , fetchFrageMuster } from '../api.js'
import { usePruefSlot } from '../pruefungCache.js'
import { renderMarkdown } from '../markdown.js'
import { renderMarkdown , renderMarkdownInline } from '../markdown.js'
import { useChat , istUnten } from '../composables/useChat.js'
const props = defineProps ( {
@@ -12,8 +12,7 @@ const props = defineProps({
sectionKompakt : { type : String , default : '' } , // kompakte Fassung (Merksätze) — Prüfungs-/Chat-Kontext
provider : { type : String , default : 'claude' } ,
status : { type : Object , default : null } , // {gute_antworten, streak, absolviert, verstanden, gemeistert}
tier2 : { type : Boolean , default : false } , // Tier 2 frei (ganzer Guide absolviert )
tier3 : { type : Boolean , default : false } , // Meisterpfad frei (ganzer Guide verstanden)
cap : { type : Number , default : 6 } , // Score-Deckel = Max des höchsten Formats (6/12/18/30 )
// 'trigger' (Liste): nur Tab-Leiste, Klick öffnet den Vollbild-Fokus.
// 'full' (Fokus): Tabs + Panel-Inhalt wie gehabt.
mode : { type : String , default : 'trigger' } ,
@@ -23,10 +22,52 @@ const props = defineProps({
const emit = defineEmits ( [ 'statusChanged' , 'openFokus' , 'setAnsicht' ] )
const NOETIG = 3 // absolviert
const MAX = 10 // verstanden
const MEISTERN = 25 // gemeistert (Maximum)
// Meilensteine (Score-Bänder): Quiz→6, Lückentext→12, Erklären→18, FullGuide-Max 30.
const ABSOLVIERT = 6
const VERSTANDEN = 12
const GEMEISTERT = 18
const VOLL = 30
const st = computed ( ( ) => props . status || { gute _antworten : 0 , streak : 0 , absolviert : false , verstanden : false , gemeistert : false } )
// Aktiver Modus aus Score-Band (unter 30 fix) + Schwierigkeit (ALT+E). Ab 30 frei.
// Ohne Frage-Muster (alter Baustein) gibt es nur Erklären — Quiz/Lückentext brauchen die Saat.
const MODI = [ 'quiz_leicht' , 'quiz_schwer' , 'lueck_leicht' , 'lueck_schwer' , 'erklaeren' ]
const score = computed ( ( ) => st . value . gute _antworten || 0 )
const schwerWahl = ref ( false ) // leicht/schwer unter Score 30 (ALT+E)
const freiModus = ref ( 'quiz_leicht' ) // gewählter Modus ab Score 30 (ALT+E zyklisch)
const aktiverModus = computed ( ( ) => {
if ( ! musterModus . value ) return 'erklaeren'
if ( score . value >= GEMEISTERT ) return freiModus . value
if ( score . value >= VERSTANDEN ) return 'erklaeren'
if ( score . value >= ABSOLVIERT ) return schwerWahl . value ? 'lueck_schwer' : 'lueck_leicht'
return schwerWahl . value ? 'quiz_schwer' : 'quiz_leicht'
} )
const aktiveForm = computed ( ( ) => aktiverModus . value . startsWith ( 'quiz' ) ? 'quiz' : aktiverModus . value . startsWith ( 'lueck' ) ? 'lueckentext' : 'erklaeren' )
const schwer = computed ( ( ) => aktiverModus . value . endsWith ( '_schwer' ) )
// Angezeigte Form: folgt der aktiven Form, friert aber ein, solange eine gezogene Frage
// sichtbar ist (sonst verschwindet das Ergebnis, wenn die Antwort eine Bandgrenze überschreitet).
const anzeigeForm = ref ( null )
const zeigeForm = computed ( ( ) => anzeigeForm . value || aktiveForm . value )
// Schwierigkeit des gerade gezeigten Widgets (eingefroren) bzw. der aktiven Wahl.
const aktSchwer = computed ( ( ) => {
if ( zeigeForm . value === 'quiz' && quizAktuell . value ) return quizAktuell . value . schwer
if ( zeigeForm . value === 'lueckentext' && lueckAktuell . value ) return lueckAktuell . value . schwer
return schwer . value
} )
const modusRegel = computed ( ( ) => {
if ( zeigeForm . value === 'quiz' ) return aktSchwer . value ? 'x von 4 · +3/− 1' : '1 von 4 · +1/− 1'
if ( zeigeForm . value === 'lueckentext' ) return aktSchwer . value ? 'frei tippen · +3/− 1' : 'Begriff aus 4 · +1/− 1'
return '+1/+2/+3 · Nachfragen max +1 · schwach − 1'
} )
// Ende des aktuellen Bands (nächster Meilenstein) und ob der Score am Format-Deckel klebt.
const bandZiel = computed ( ( ) => {
if ( score . value >= GEMEISTERT ) return VOLL
if ( score . value >= VERSTANDEN ) return GEMEISTERT
if ( score . value >= ABSOLVIERT ) return VERSTANDEN
return ABSOLVIERT
} )
const amDeckel = computed ( ( ) => score . value >= props . cap && props . cap < VOLL )
const FORM _NAME = { quiz : 'Quiz' , lueckentext : 'Lückentext' , erklaeren : 'Erklären' }
const NAECHSTES _FORMAT = { 6 : 'Guide' , 12 : 'ProGuide' , 18 : 'FullGuide' }
// --- Toggle-Bereich ---
const activeTab = ref ( props . mode === 'full' ? props . initialTab : null ) // null | 'chat' | 'pruefung'
@@ -56,6 +97,9 @@ const letztesFeedback = slot.letztesFeedback // Kontext für die Diskussion üb
const musterQuelle = slot . musterQuelle // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback)
const musterPool = slot . musterPool // Arbeitskopie, ohne Zurücklegen; leer → Reset
const musterGeladen = slot . musterGeladen
const poolForm = slot . poolForm // Form, für die der Pool gefüllt ist
const quizAktuell = slot . quizAktuell // laufende Quiz-Frage (Widget-State)
const lueckAktuell = slot . lueckAktuell // laufende Lückentext-Aufgabe (Widget-State)
// Transient (pro Instanz): Eingabe-Entwurf, Lade-Spinner.
const pruefInput = ref ( '' )
const pruefLoading = ref ( false )
@@ -127,25 +171,35 @@ function gestellteFragen() {
return new Set ( pruefMessages . value . filter ( ( m ) => m . kind === 'frage' ) . map ( ( m ) => normFrage ( m . content ) ) )
}
// --- Fragen-Pool: ZIEL vorformulierte, unbeantwortete Fragen je Baustein (im Slot) ---
// --- Fragen-Pool: ZIEL vorformulierte Frage-OBJEKTE der aktiven Form (im Slot) ---
// Jedes Objekt: {form, frage, optionen?|satz?|loesung?|alternativen?}. Wechselt die
// aktive Form, wird der Pool geleert und für die neue Form neu gefüllt.
const POOL _ZIEL = 5
const pool = slot . pool
const inflight = slot . inflight
const pruefFehler = ref ( '' ) // Fehlerhinweis für Quiz/Lückentext (kein Bubble-Verlauf)
let poolMiss = 0 // nur Fallback: aufeinanderfolgende Dubletten/Fehler — Cap gegen Endlosschleife
// Muster-Modus aktiv, sobald geladen und Muster vorhanden; sonst Fallback auf Live-Generierung .
// Muster-Modus aktiv, sobald geladen und Muster vorhanden; sonst Fallback (nur Erklären) .
const musterModus = computed ( ( ) => musterGeladen . value && musterQuelle . value . length > 0 )
function istBekannt ( text ) {
const n = normFrage ( text )
return ! n || gestellteFragen ( ) . has ( n ) || pool . value . some ( ( f ) => normFrage ( f ) === n )
function objText ( o ) { return ( o && ( o . frage || o . satz ) ) || '' }
// Lückensatz rendern: an „___" splitten, Teile inline (mit $…$-Mathe), Lücke als Span.
// So bleibt die Lücke erhalten und Markdown fasst die Unterstriche nicht an.
function lueckSatzHtml ( satz ) {
return String ( satz || '' ) . split ( '___' ) . map ( ( t ) => renderMarkdownInline ( t ) ) . join ( '<span class="bp-luecke">______</span>' )
}
// Schon gestellte + bereits vorgemerkte Fragen — der Generator soll sie (auch
// sinngemäß) meiden. Nur Fallback-Modus. Original-Texte, nicht normalisiert.
function istBekannt ( o ) {
const n = normFrage ( objText ( o ) )
return ! n || gestellteFragen ( ) . has ( n ) || pool . value . some ( ( f ) => normFrage ( objText ( f ) ) === n )
}
// Schon gestellte + vorgemerkte Fragen — nur Fallback-Modus (Erklären ohne Muster).
function vermeideListe ( ) {
const gestellt = pruefMessages . value . filter ( ( m ) => m . kind === 'frage' ) . map ( ( m ) => m . content )
return [ ... gestellt , ... pool . value ]
return [ ... gestellt , ... pool . value . map ( objText ) ]
}
// Muster-Sidecar einmal je Baustein laden (Slot-cached). Leer → Fallback.
@@ -176,31 +230,55 @@ function musterNehmen() {
return musterPool . value . shift ( )
}
// Pool-Schlüssel = Form + Schwierigkeit (Quiz leicht/schwer ziehen verschiedene Fragen).
const poolKey = computed ( ( ) => ` ${ aktiveForm . value } / ${ schwer . value } ` )
// Ein Frage-Objekt des aktiven Modus generieren (aus gezogener Saat; Erklären hat Fallback).
function frageEinzeln ( ) {
const form = aktiveForm . value
const istSchwer = schwer . value
const muster = musterNehmen ( )
if ( muster ) {
// Muster-Modus: aus gezogener Saat eine konkrete, leicht andere Frage formulieren.
return pruefeBaustein ( {
topic : props . topic , baustein : props . baustein , section : props . section , section _kompakt : props . sectionKompakt ,
provider : props . provider , aktion : 'frage' , muster ,
} ) . then ( ( res ) => res . frage )
const basis = {
topic : props . topic , baustein : props . baustein , section : props . section ,
section _kompakt : props . sectionKompakt , provider : props . provider ,
}
// Fallback (kein Muster-Sidecar): Live-Generierung mit Vermeide-Liste.
return pruefeBaustein ( {
topic : props . topic , baustein : props . baustein , section : props . section , sec tion_kompakt : props . sec tionKompakt ,
provider : props . provider , aktion : 'frage' , messages : pruefDialog ( ) , vermeide : vermeideListe ( ) ,
} ) . then ( ( res ) => res . frage )
if ( form === 'quiz' && muster ) {
return pruefeBaustein ( { ... basis , aktion : 'quiz_frage' , muster , schwer : istSchwer } )
. then ( ( r ) => ( { form , schwer : istSchwer , frage : r . frage , op tionen : r . op tionen } ) )
}
if ( form === 'lueckentext' && muster ) {
return pruefeBaustein ( { ... basis , aktion : 'lueck_frage' , muster , schwer : istSchwer } )
. then ( ( r ) => istSchwer
? ( { form , schwer : true , frage : r . satz , satz : r . satz , loesung : r . loesung , alternativen : r . alternativen } )
: ( { form , schwer : false , frage : r . satz , satz : r . satz , optionen : r . optionen } ) )
}
if ( muster ) { // erklaeren aus Muster
return pruefeBaustein ( { ... basis , aktion : 'frage' , muster } ) . then ( ( r ) => ( { form : 'erklaeren' , frage : r . frage } ) )
}
// Fallback (kein Muster-Sidecar): Erklären live mit Vermeide-Liste.
return pruefeBaustein ( { ... basis , aktion : 'frage' , messages : pruefDialog ( ) , vermeide : vermeideListe ( ) } )
. then ( ( r ) => ( { form : 'erklaeren' , frage : r . frage } ) )
}
// Pool PARALLEL auffüllen: pro fehlender Frage ein eigener Agent. Im Muster-Modus
// zieht jeder ein DISTINKTES Muster (ohne Zurücklegen) → keine Kollision, kein Miss-Cap.
// Pool leeren, wenn Form oder Schwierigkeit gewechselt hat (Bandgrenze oder ALT+E).
function poolFormPruefen ( ) {
if ( poolForm . value !== poolKey . value ) {
pool . value = [ ]
poolForm . value = poolKey . value
poolMiss = 0
}
}
// Pool PARALLEL auffüllen: jeder zieht ein DISTINKTES Muster → keine Kollision.
function poolFuellen ( ) {
poolFormPruefen ( )
const keyBeginn = poolKey . value
const cap = musterModus . value ? Infinity : 3
while ( pool . value . length + inflight . value < POOL _ZIEL && poolMiss < cap ) {
inflight . value ++
frageEinzeln ( )
. then ( ( text ) => {
if ( text && ! istBekannt ( text ) ) { pool . value . push ( text ) ; poolMiss = 0 }
. then ( ( obj ) => {
if ( obj && objText ( obj ) && poolKey . value === keyBeginn && ( musterModus . value || ! istBekannt ( obj ) ) ) { pool . value . push ( obj ) ; poolMiss = 0 }
else poolMiss ++
} )
. catch ( ( ) => { poolMiss ++ } )
@@ -208,7 +286,7 @@ function poolFuellen() {
}
}
// Eine Frage aus dem Pool nehmen; ist er leer, auf die erste eintreffende warten.
// Ein Frage-Objekt aus dem Pool nehmen; ist er leer, auf das erste eintreffende warten.
async function poolNehmen ( ) {
if ( ! pool . value . length ) {
const cap = musterModus . value ? Infinity : 3
@@ -223,35 +301,100 @@ async function poolNehmen() {
return pool . value . length ? pool . value . shift ( ) : null
}
// Erste Frage (Phase idle): Muster laden, ZIEL parallel anstoßen, schnellste zuerst zeigen .
async function frageAnfordern ( ) {
// Ein gezogenes Objekt der passenden Form anzeigen (Erklären-Bubble bzw. Quiz/Lück-Widget) .
function frageObjZeigen ( obj ) {
pruefFehler . value = ''
anzeigeForm . value = obj . form // Anzeige an die gezogene Frage binden (Form einfrieren)
if ( obj . form === 'quiz' ) {
quizAktuell . value = { frage : obj . frage , optionen : obj . optionen , schwer : obj . schwer , gewaehlt : [ ] , fertig : false , punkte : null , bewertung : null , feedback : '' }
} else if ( obj . form === 'lueckentext' ) {
lueckAktuell . value = { satz : obj . satz , schwer : obj . schwer , optionen : obj . optionen || null , loesung : obj . loesung || '' , alternativen : obj . alternativen || [ ] , eingabe : '' , gewaehlt : [ ] , fertig : false , punkte : null , bewertung : null , feedback : '' }
if ( obj . schwer ) nextTick ( ( ) => document . getElementById ( 'bp-lueck-input' ) ? . focus ( ) )
} else {
frageZeigen ( obj . frage )
nextTick ( ( ) => pruefInputEl . value ? . focus ( ) )
}
}
// Erste / nächste Frage: Muster laden, Pool füllen, schnellste zuerst zeigen.
async function naechsteFrageZeigen ( ) {
if ( pruefLoading . value ) return
poolMiss = 0
pruefFehler . value = ''
await musterLaden ( )
poolFuellen ( )
const text = await poolNehmen ( )
if ( text ) {
frageZeigen ( text )
nextTick ( ( ) => pruefInputEl . value ? . focus ( ) )
} else {
const obj = await poolNehmen ( )
if ( obj ) {
frageObj Zeigen ( obj )
pruefScroll ( )
} else if ( aktiveForm . value === 'erklaeren' ) {
pruefMessages . value . push ( { role : 'assistant' , kind : 'fehler' , content : 'Keine Frage — bitte erneut.' } )
} else {
pruefFehler . value = 'Keine Frage — bitte erneut.'
}
poolFuellen ( )
}
const frageAnfordern = naechsteFrageZeigen
// „Nächste Frage": sofort aus dem Pool, dann nachfüllen.
async function naechsteFrageZeigen ( ) {
if ( pruefLoading . value ) return
await musterLaden ( )
const text = await poolNehmen ( )
if ( text ) {
frageZeigen ( text )
pruefScroll ( )
nextTick ( ( ) => pruefInputEl . value ? . focus ( ) )
} else {
pruefMessages . value . push ( { role : 'assistant' , kind : 'fehler' , content : 'Keine neue Frage — bitte erneut.' } )
// --- Quiz: leicht = Single-Select (genau 1) · schwer = Multi-Select. Deterministisch. ---
function quizToggle ( i ) {
const q = quizAktuell . value
if ( ! q || q . fertig ) return
if ( ! q . schwer ) { q . gewaehlt = q . gewaehlt . includes ( i ) ? [ ] : [ i ] ; return }
const idx = q . gewaehlt . indexOf ( i )
if ( idx >= 0 ) q . gewaehlt . splice ( idx , 1 )
else q . gewaehlt . push ( i )
}
async function quizAntworten ( ) {
const q = quizAktuell . value
if ( ! q || q . fertig || pruefLoading . value ) return
pruefLoading . value = true
pruefFehler . value = ''
try {
const korrekt = q . optionen . map ( ( o , i ) => ( o . korrekt ? i : - 1 ) ) . filter ( ( i ) => i >= 0 )
const res = await pruefeBaustein ( {
topic : props . topic , baustein : props . baustein , section : props . section , section _kompakt : props . sectionKompakt ,
provider : props . provider , aktion : 'quiz_antwort' , frage : q . frage , cap : props . cap ,
auswahl : q . gewaehlt , korrekt , schwer : q . schwer ,
} )
q . fertig = true ; q . punkte = res . punkte ; q . bewertung = res . bewertung ; q . feedback = res . feedback
applyPruefung ( res )
} catch {
pruefFehler . value = 'Hat nicht geklappt — bitte erneut.'
} finally {
pruefLoading . value = false
}
}
// --- Lückentext: leicht = Begriff aus 4 (Single-Select) · schwer = frei tippen ---
function lueckToggle ( i ) {
const l = lueckAktuell . value
if ( ! l || l . fertig || l . schwer ) return
l . gewaehlt = l . gewaehlt . includes ( i ) ? [ ] : [ i ]
}
async function lueckAntworten ( ) {
const l = lueckAktuell . value
if ( ! l || l . fertig || pruefLoading . value ) return
if ( l . schwer ? ! l . eingabe . trim ( ) : ! l . gewaehlt . length ) return
pruefLoading . value = true
pruefFehler . value = ''
try {
const spez = l . schwer
? { schwer : true , loesung : l . loesung , alternativen : l . alternativen , eingabe : l . eingabe }
: { schwer : false , auswahl : l . gewaehlt , korrekt : l . optionen . map ( ( o , i ) => ( o . korrekt ? i : - 1 ) ) . filter ( ( i ) => i >= 0 ) }
const res = await pruefeBaustein ( {
topic : props . topic , baustein : props . baustein , section : props . section , section _kompakt : props . sectionKompakt ,
provider : props . provider , aktion : 'lueck_antwort' , frage : l . satz , cap : props . cap , ... spez ,
} )
l . fertig = true ; l . punkte = res . punkte ; l . bewertung = res . bewertung ; l . feedback = res . feedback
applyPruefung ( res )
} catch {
pruefFehler . value = 'Hat nicht geklappt — bitte erneut.'
} finally {
pruefLoading . value = false
}
poolFuellen ( )
}
function nachfragen ( ) {
@@ -270,7 +413,7 @@ let letzteFeedbackMsg = null // zuletzt gezeigte Bewertungs-Bubble
let pruefenRun = 0 // nur die jüngste Schnell-Bewertung darf anzeigen
function bewertungPayload ( ) {
return { frage : aktuelleFrage . value , tier2 : props . tier2 , tier3 : props . tier3 , nachgefragt : nachgefragt . value }
return { frage : aktuelleFrage . value , cap : props . cap , nachgefragt : nachgefragt . value }
}
// Agent 1 (schnell): sofortiges Niveau + Punkte zeigen, dann Agent 2 (genau) im Hintergrund.
@@ -378,30 +521,81 @@ onMounted(() => {
if ( props . mode !== 'full' ) return // Trigger-Modus (Liste): nur Tab-Leiste, keine Prüfungs-/Text-Logik
window . addEventListener ( 'keydown' , onWindowKey )
window . addEventListener ( 'keydown' , onPruefKey )
musterLaden ( ) // GET (kein Agent) — setzt die aktive Form früh korrekt (Quiz/Lückentext/Erklären)
} )
onUnmounted ( ( ) => {
window . removeEventListener ( 'keydown' , onWindowKey )
window . removeEventListener ( 'keydown' , onPruefKey )
} )
// ALT+1 Nachfragen (nur frage_offen) · ALT+2 primärer Button je Phase (idle: Frage anfordern,
// frage_offen: Antwort abgeben, bewertet: Nächste Frage) · ALT+3 Verlauf bewerten (frage_offen).
// Gründlich prüfen läuft per Rechtsklick auf die Bewertung, nicht über ALT.
function onPruefKey ( e ) {
if ( ! e . altKey || e . repeat ) return
if ( props . mod e ! == 'full ' || ac tiveTab . value ! == 'pruefung' ) return
if ( ! [ '1' , '2' , '3' ] . includes ( e . key ) ) return
e . preventDefault ( )
if ( pruefLoading . value ) return
if ( e . key === '1' ) {
if ( pruefPhase . value === 'frage_offen' && pruefInput . value . trim ( ) ) nachfragen ( )
} else if ( e . key === '2' ) {
if ( pruefPhase . value === 'idle' ) frageAnfordern ( )
else if ( pruefPhase . value === 'frage_offen' ) { if ( pruefInput . value . trim ( ) ) antwortAbgeben ( ) }
else naechsteFrageZeigen ( )
} else if ( e . key === '3' ) {
if ( pruefPhase . value === 'frage_offen' && hatDiskussion . value ) verlaufBewerten ( )
// ALT+E: unter 30 leicht↔schwer der Form (Quiz/Lückentext); ab 30 zyklisch durch alle 5 Modi.
function variantWechseln ( ) {
const vorher = aktiverModus . value
if ( score . value >= GEMEISTERT ) {
freiModus . v alue = MODI [ ( MODI . indexOf ( freiModus . value ) + 1 ) % MODI . length ]
} else if ( aktiveForm . valu e = == 'quiz ' || ak tiveForm . value = == 'lueckentext' ) {
schwerWahl . value = ! schwerWahl . value
}
if ( aktiverModus . value === vorher ) return // nichts geändert (z.B. Erklären-Band)
// Einfrieren + altes Widget verwerfen → frische Frage der neuen Variante anfordern.
anzeigeForm . value = null
quizAktuell . value = null
lueckAktuell . value = null
}
// Quiz / Lückentext-Auswahl ist ein Wahl-Widget mit 1– 4-Tasten; sonst Textfeld/Bubble.
const wahlWidget = computed ( ( ) => {
if ( zeigeForm . value === 'quiz' ) return quizAktuell . value
if ( zeigeForm . value === 'lueckentext' && lueckAktuell . value && ! lueckAktuell . value . schwer ) return lueckAktuell . value
return null
} )
// Eingabefeld der aktuellen Form (Lückentext frei / Erklären) — für „Alt fokussiert".
function eingabeFeld ( ) {
if ( zeigeForm . value === 'lueckentext' && lueckAktuell . value && lueckAktuell . value . schwer && ! lueckAktuell . value . fertig ) {
return document . getElementById ( 'bp-lueck-input' )
}
if ( zeigeForm . value === 'erklaeren' && pruefPhase . value === 'frage_offen' ) return pruefInputEl . value
return null
}
// Primäre Aktion je Form: keine Frage → anfordern · offen → antworten · fertig → nächste.
function primaerAktion ( ) {
if ( pruefLoading . value ) return
const f = zeigeForm . value
if ( f === 'quiz' ) { if ( ! quizAktuell . value ) frageAnfordern ( ) ; else if ( ! quizAktuell . value . fertig ) quizAntworten ( ) ; else naechsteFrageZeigen ( ) ; return }
if ( f === 'lueckentext' ) { if ( ! lueckAktuell . value ) frageAnfordern ( ) ; else if ( ! lueckAktuell . value . fertig ) lueckAntworten ( ) ; else naechsteFrageZeigen ( ) ; return }
if ( pruefPhase . value === 'idle' ) frageAnfordern ( )
else if ( pruefPhase . value === 'frage_offen' ) { if ( pruefInput . value . trim ( ) ) antwortAbgeben ( ) }
else naechsteFrageZeigen ( )
}
// 1– 4 (nackt): Option im Wahl-Widget toggeln. Alt allein: ins Eingabefeld springen.
// Alt+2 primär (antworten/nächste) · Alt+Q Ansicht · Alt+E Variante · Alt+1/3 Erklären.
function onPruefKey ( e ) {
if ( e . repeat ) return
if ( props . mode !== 'full' || activeTab . value !== 'pruefung' ) return
// Alt allein → ins Eingabefeld (Lückentext frei / Erklären), wie bei Frage-Antwort.
if ( e . key === 'Alt' ) {
const el = eingabeFeld ( )
if ( el ) { e . preventDefault ( ) ; el . focus ( ) }
return
}
if ( ! e . altKey ) {
// Nackte 1– 4: Wahl-Widget toggeln — aber nicht, während ein Textfeld den Fokus hat.
if ( [ '1' , '2' , '3' , '4' ] . includes ( e . key ) && wahlWidget . value && ! wahlWidget . value . fertig ) {
const ae = document . activeElement
if ( ae && ( ae . tagName === 'INPUT' || ae . tagName === 'TEXTAREA' ) ) return
e . preventDefault ( )
const i = Number ( e . key ) - 1
if ( i < wahlWidget . value . optionen . length ) ( zeigeForm . value === 'quiz' ? quizToggle : lueckToggle ) ( i )
}
return
}
if ( e . code === 'KeyQ' ) { e . preventDefault ( ) ; emit ( 'setAnsicht' , props . ansicht === 'kompakt' ? 'erklärend' : 'kompakt' ) ; return }
if ( e . code === 'KeyE' ) { e . preventDefault ( ) ; variantWechseln ( ) ; return }
if ( e . key === '2' ) { e . preventDefault ( ) ; primaerAktion ( ) ; return }
// Erklären-Sondertasten
if ( e . key === '1' ) { e . preventDefault ( ) ; if ( zeigeForm . value === 'erklaeren' && pruefPhase . value === 'frage_offen' && pruefInput . value . trim ( ) ) nachfragen ( ) ; return }
if ( e . key === '3' ) { e . preventDefault ( ) ; if ( zeigeForm . value === 'erklaeren' && pruefPhase . value === 'frage_offen' && hatDiskussion . value ) verlaufBewerten ( ) }
}
< / script >
@@ -418,12 +612,10 @@ function onPruefKey(e) {
< / button >
< button : class = "{ active: activeTab === 'pruefung' }" @click ="tabKlick('pruefung')" >
Prüfung
< span v-if = "st.gemeistert" class="bp-chip gold" title="Gemeistert (25/25)" > ✓ ✓ ✓ Max < / span >
< span v-else-if = "st.verstanden && tier3 " class="bp-chip lila" title="Meisterpfad " > ✓ ✓ {{ st.gute_antworten }} / {{ MEISTERN }} < / span >
< span v-else-if = "st.verstanden " class="bp-chip lila " title="Verstanden (10/10)" > ✓ ✓ < / span >
< span v-else-if = "st.absolviert && tier2 " class="bp-chip done " > ✓ {{ st.gute_antworten }} / {{ MAX }} < / span >
< span v-else-if = "st.absolviert" class="bp-chip done" > ✓ < / span >
< span v-else-if = "st.gute_antworten" class="bp-chip" > {{ Math.min ( st.gute_antworten , NOETIG ) }} / {{ NOETIG }} < / span >
< span v-if = "st.gemeistert" class="bp-chip gold" : title="` Gemeistert (${GEMEISTERT})`" > ✓ ✓ ✓ {{ score }} / {{ cap }} < / span >
< span v-else-if = "st.verstanden" class="bp-chip lila" : title="`Verstanden (${VERSTANDEN})` " > ✓ ✓ {{ score }} / {{ cap }} < / span >
< span v-else-if = "st.absolviert " class="bp-chip done " : title="`Absolviert (${ABSOLVIERT})`" > ✓ {{ score }} / {{ cap }} < / span >
< span v-else-if = "score " class="bp-chip" > {{ score }} / {{ cap }} < / span >
< / button >
< / div >
@@ -455,58 +647,135 @@ function onPruefKey(e) {
< ! - - Prüfung : gesteuerter Dialog - - >
< div v-else >
< p class = "bp-hint" >
< template v-if = "st.gemeistert" > ✓ ✓ ✓ Gemeistert ( {{ MEISTERN }} / {{ MEISTERN }} ) — Max. Du kannst dich weiter prüfen , ohne Punkte .< / template >
< template v-else-if = "st.verstanden && tier3" > Meisterpfad : {{ st.gute_antworten }} / {{ MEISTERN }} . Je besser , desto mehr ( + 1 / + 2 / + 3 ) , schwach = − 1 ( nicht unter {{ MAX }} ) . Bei {{ MEISTERN }} gemeistert. < / template >
< template v-else-if = "st.verstanden" > ✓ ✓ Verstanden. Der Meisterpfad ( {{ MAX }} → {{ MEISTERN }} ) öffnet , sobald der ganze Guide verstanden ist. < / template >
< template v-else-if = "st.absolviert && tier2" > Mastery : {{ st.gute_antworten }} / {{ MAX }} . Je besser , desto mehr ( + 1 / + 2 / + 3 ) , schwach = − 1 ( nicht unter {{ NOETIG }} ) . Bei {{ MAX }} verstanden. < / template >
< template v-else-if = "st.absolviert" > ✓ Absolviert. Mehr ( {{ NOETIG }} → {{ MAX }} ) gibt ' s , sobald der ganze Guide absolviert ist. < / template >
< template v-else > {{ Math.min ( st.gute_antworten , NOETIG ) }} / {{ NOETIG }} guten Antworten. Je besser deine Antwort , desto mehr Punkte ( bis + 3 ) . Nachfragen ist erlaubt — dann max + 1. < / template >
< template v-if = "amDeckel" > {{ FORM_NAME [ zeigeForm ] }} · {{ score }} / {{ cap }} — Max für dieses Format. Erstelle den {{ NAECHSTES_FORMAT [ cap ] }} , um weiterzukommen .< / template >
< template v-else> {{ FORM_NAME [ zeigeForm ] }} · {{ score }} / {{ score > = GE MEISTERT ? VOLL : bandZiel } } · { { modusRegel } } · < span class = "bp-hint-key" > Alt + E : { { score >= GEMEISTERT ? 'Modus' : 'Schwierigkeit' } } < / span > < / template >
< / p >
< div v-if = "pruefMessages.length" ref="pruefMessagesEl" class="bp-messages" @scroll="onPruefScroll" >
< template v-for = "(m, i) in pruefMessages" :key="i" >
< div v-if = "m.kind === 'feedback'" class="bp-feedback" :class="m.bewertung" title="Klick: gründlich prüfen" @click="gruendlichOeffnen(m)" >
< span v-if = "m.punkte != null" class="bp-niveau">{{ punkteLabel(m.punkte) }}</span>{{ m.content }}<span v-if="!m.geprueft" class="bp-pruefend" > · wird geprüft … < / span >
< div v-if = "gruendlichMsg === m" class="bp-gruendlich" @click.stop >
< input id = "bp-gruendlich-input" v-model = "gruendlichText" placeholder="Warum unzufrieden? (optional)" @keyup.enter="gruendlichAbsenden" / >
< button class = "bp-action primary" @click ="gruendlichAbsenden" > Gründlich prüfen < / button >
< button class = "bp-action" @click ="gruendlichMsg = null" > × < / button >
< / div >
< / div >
< div v-else-if = "m.kind === 'fehler'" class="bp-error" > {{ m.content }} < / div >
< div v-else-if = "m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)" > < / div >
< div v-else class = "bp-msg user" > { { m . content } } < / div >
< / template >
< div v-if = "pruefLoading" class="bp-msg assistant bp-typing" > … < / div >
< / div >
<!-- Am Format - Deckel : kein Widget , nur der Hinweis oben . -- >
< template v-i f = "amDeckel" > < / template >
< ! - - Phase idle : Frage anfordern - - >
< div v-if = "pruefPhase === 'idle'" class="bp-actions ">
< button v-if = "pruefLoading " class="bp-action cancel" title="ESC" @click="pruefAbbrechen" > Abbrechen < / button >
< button v-else class = "bp-action primary" @click ="frageAnfordern" > Frage anfordern < / button >
< / div >
< ! - - Phase frage_offen : Textfeld + Aktionen · bewertet : nur „ Nächste Frage " - - >
< template v-else >
< div v-if = "pruefPhase === 'frage_offen'" class="bp-input" >
< textarea
ref = "pruefInputEl"
v-model = "pruefInput"
rows = "2"
placeholder = "Antwort — oder Nachfrage bei Unklarheit…"
> < / textarea >
< / div >
< div class = "bp-actions" >
< ! - - Quiz : Frage + Mehrfachauswahl - - >
< template v-else-if = "zeigeForm === 'quiz' ">
< div v-if = "!quizAktuell " class="bp-actions" >
< button v-if = "pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen" > Abbrechen < / button >
< template v-else-if = "pruefPhase === 'frage_offen'" >
< button class = "bp-action" title = "Alt+1" :disabled = "!pruefInput.trim()" @click ="nachfragen" > < span class = "bp-kbd" > 1 < / span > Nachfragen < / button >
< button class = "bp-action primary" title = "Alt+2" :disabled = "!pruefInput.trim()" @click ="antwortAbgeben" > < span class = "bp-kbd" > 2 < / span > Antwort abgeben < / button >
< button v-if = "hatDiskussion" class="bp-action" title="Alt+3" @click="verlaufBewerten"><span class="bp-kbd" > 3 < / span > Verlauf bewerten < / button >
< / template >
< template v-else >
< button class = "bp-action primary" title = "Alt+2" @click ="naechsteFrageZeigen" > < span class = "bp-kbd" > 2 < / span > Nächste Frage < / button >
< / template >
< button v-else class = "bp-action primary" @click ="frageAnfordern" > Frage anfordern < / button >
< / div >
< div v-else class = "bp-quiz" >
< p class = "bp-quiz-frage markdown" v-html = "renderMarkdownInline(quizAktuell.frage)" > < / p >
< div class = "bp-quiz-grid" >
< button
v-for = "(o, i) in quizAktuell.optionen" :key="i"
type = "button"
class = "bp-quiz-opt"
: class = "{ gewaehlt: quizAktuell.gewaehlt.includes(i), richtig: quizAktuell.fertig && o.korrekt, falsch: quizAktuell.fertig && !o.korrekt && quizAktuell.gewaehlt.includes(i) }"
:disabled = "quizAktuell.fertig"
: title = "`${i + 1}`"
@click ="quizToggle(i)"
>
< span class = "markdown" v-html = "renderMarkdownInline(o.text)" > < / span >
< / button >
< / div >
< p v-if = "pruefFehler" class="bp-error" > {{ pruefFehler }} < / p >
< div class = "bp-actions" >
< button v-if = "!quizAktuell.fertig" class="bp-action primary" title="Alt+2" :disabled="pruefLoading" @click="quizAntworten" > Antworten < / button >
< template v-else >
< span class = "bp-niveau" :class = "quizAktuell.bewertung" > { { punkteLabel ( quizAktuell . punkte ) } } < / span >
< span class = "bp-form-feedback" > { { quizAktuell . feedback } } < / span >
< button class = "bp-action primary" @click ="naechsteFrageZeigen" > Nächste < / button >
< / template >
< / div >
< / div >
< / template >
< ! - - Lückentext : Satz mit Lücke + Eingabe - - >
< template v-else-if = "zeigeForm === 'lueckentext'" >
< div v-if = "!lueckAktuell" class="bp-actions" >
< button v-if = "pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen" > Abbrechen < / button >
< button v-else class = "bp-action primary" @click ="frageAnfordern" > Frage anfordern < / button >
< / div >
< div v-else class = "bp-lueck" >
< p class = "bp-lueck-satz markdown" v-html = "lueckSatzHtml(lueckAktuell.satz)" > < / p >
< ! - - schwer : frei tippen - - >
< input
v-if = "lueckAktuell.schwer"
id = "bp-lueck-input"
v-model = "lueckAktuell.eingabe"
:disabled = "lueckAktuell.fertig"
placeholder = "Begriff für die Lücke…"
@keyup.enter ="lueckAntworten"
/ >
< p v-if = "lueckAktuell.schwer && lueckAktuell.fertig && !lueckAktuell.feedback.startsWith('Richtig')" class="bp-lueck-loesung">Lösung: <span class="markdown" v-html="renderMarkdownInline(lueckAktuell.loesung)" > < / span > < / p >
< ! - - leicht : Begriff aus 4 wählen - - >
< div v-if = "!lueckAktuell.schwer" class="bp-quiz-grid" >
< button
v-for = "(o, i) in lueckAktuell.optionen" :key="i"
type = "button"
class = "bp-quiz-opt"
: class = "{ gewaehlt: lueckAktuell.gewaehlt.includes(i), richtig: lueckAktuell.fertig && o.korrekt, falsch: lueckAktuell.fertig && !o.korrekt && lueckAktuell.gewaehlt.includes(i) }"
:disabled = "lueckAktuell.fertig"
: title = "`${i + 1}`"
@click ="lueckToggle(i)"
>
< span class = "markdown" v-html = "renderMarkdownInline(o.text)" > < / span >
< / button >
< / div >
< p v-if = "pruefFehler" class="bp-error" > {{ pruefFehler }} < / p >
< div class = "bp-actions" >
< button v-if = "!lueckAktuell.fertig" class="bp-action primary" title="Alt+2" :disabled="pruefLoading || (lueckAktuell.schwer ? !lueckAktuell.eingabe.trim() : !lueckAktuell.gewaehlt.length)" @click="lueckAntworten" > Antworten < / button >
< template v-else >
< span class = "bp-niveau" :class = "lueckAktuell.bewertung" > { { punkteLabel ( lueckAktuell . punkte ) } } < / span >
< span class = "bp-form-feedback" > { { lueckAktuell . feedback } } < / span >
< button class = "bp-action primary" @click ="naechsteFrageZeigen" > Nächste < / button >
< / template >
< / div >
< / div >
< / template >
< ! - - Erklären : gesteuerter Dialog ( bestehend ) - - >
< template v-else >
< div v-if = "pruefMessages.length" ref="pruefMessagesEl" class="bp-messages" @scroll="onPruefScroll" >
< template v-for = "(m, i) in pruefMessages" :key="i" >
< div v-if = "m.kind === 'feedback'" class="bp-feedback" :class="m.bewertung" title="Klick: gründlich prüfen" @click="gruendlichOeffnen(m)" >
< span v-if = "m.punkte != null" class="bp-niveau">{{ punkteLabel(m.punkte) }}</span>{{ m.content }}<span v-if="!m.geprueft" class="bp-pruefend" > · wird geprüft … < / span >
< div v-if = "gruendlichMsg === m" class="bp-gruendlich" @click.stop >
< input id = "bp-gruendlich-input" v-model = "gruendlichText" placeholder="Warum unzufrieden? (optional)" @keyup.enter="gruendlichAbsenden" / >
< button class = "bp-action primary" @click ="gruendlichAbsenden" > Gründlich prüfen < / button >
< button class = "bp-action" @click ="gruendlichMsg = null" > × < / button >
< / div >
< / div >
< div v-else-if = "m.kind === 'fehler'" class="bp-error" > {{ m.content }} < / div >
< div v-else-if = "m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)" > < / div >
< div v-else class = "bp-msg user" > { { m . content } } < / div >
< / template >
< div v-if = "pruefLoading" class="bp-msg assistant bp-typing" > … < / div >
< / div >
< div v-if = "pruefPhase === 'idle'" class="bp-actions" >
< button v-if = "pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen" > Abbrechen < / button >
< button v-else class = "bp-action primary" @click ="frageAnfordern" > Frage anfordern < / button >
< / div >
< template v-else >
< div v-if = "pruefPhase === 'frage_offen'" class="bp-input" >
< textarea
ref = "pruefInputEl"
v-model = "pruefInput"
rows = "2"
placeholder = "Antwort — oder Nachfrage bei Unklarheit…"
> < / textarea >
< / div >
< div class = "bp-actions" >
< button v-if = "pruefLoading" class="bp-action cancel" title="ESC" @click="pruefAbbrechen" > Abbrechen < / button >
< template v-else-if = "pruefPhase === 'frage_offen'" >
< button class = "bp-action" title = "Alt+1" :disabled = "!pruefInput.trim()" @click ="nachfragen" > < span class = "bp-kbd" > 1 < / span > Nachfragen < / button >
< button class = "bp-action primary" title = "Alt+2" :disabled = "!pruefInput.trim()" @click ="antwortAbgeben" > < span class = "bp-kbd" > 2 < / span > Antwort abgeben < / button >
< button v-if = "hatDiskussion" class="bp-action" title="Alt+3" @click="verlaufBewerten"><span class="bp-kbd" > 3 < / span > Verlauf bewerten < / button >
< / template >
< template v-else >
< button class = "bp-action primary" title = "Alt+2" @click ="naechsteFrageZeigen" > < span class = "bp-kbd" > 2 < / span > Nächste Frage < / button >
< / template >
< / div >
< / template >
< / template >
< / div >
< / div >
@@ -566,6 +835,7 @@ function onPruefKey(e) {
}
. bp - hint { font - size : 0.85 rem ; color : var ( -- text - muted ) ; margin : 0 0 0.5 rem ; }
. bp - hint - key { font - size : 0.72 rem ; opacity : 0.7 ; white - space : nowrap ; }
. bp - error { font - size : 0.85 rem ; color : var ( -- danger ) ; margin : 0.5 rem 0 0 ; }
. bp - action {
@@ -624,6 +894,35 @@ function onPruefKey(e) {
. bp - feedback . neutral { background : var ( -- warning - soft ) ; border - color : var ( -- warning - border ) ; color : var ( -- warning ) ; }
. bp - feedback . schlecht { background : var ( -- danger - soft , # fee2e2 ) ; border - color : var ( -- danger - border , # f87171 ) ; color : var ( -- danger ) ; }
. bp - niveau { font - weight : 700 ; text - transform : uppercase ; font - size : 0.62 rem ; letter - spacing : 0.03 em ; margin - right : 0.4 rem ; opacity : 0.85 ; }
. bp - niveau . gut { color : var ( -- success ) ; }
. bp - niveau . neutral { color : var ( -- warning ) ; }
. bp - niveau . schlecht { color : var ( -- danger ) ; }
/* Quiz: Frage + Mehrfachauswahl */
. bp - quiz , . bp - lueck { margin - top : 0.6 rem ; }
. bp - quiz - frage , . bp - lueck - satz { font - size : 0.9 rem ; font - weight : 600 ; margin : 0 0 0.5 rem ; line - height : 1.5 ; }
. bp - luecke { font - weight : 700 ; letter - spacing : 1 px ; color : var ( -- accent ) ; padding : 0 0.15 rem ; }
. bp - quiz - opt . markdown , . bp - quiz - frage . markdown , . bp - lueck - satz . markdown { display : inline ; }
. bp - quiz - grid { display : grid ; grid - template - columns : 1 fr 1 fr ; gap : 0.5 rem ; margin - bottom : 0.5 rem ; }
. bp - quiz - opt {
display : flex ; gap : 0.45 rem ; align - items : flex - start ; text - align : left ;
padding : 0.5 rem 0.6 rem ;
border : 1 px solid var ( -- border ) ; border - radius : 8 px ;
background : var ( -- panel ) ; color : var ( -- text ) ; cursor : pointer ; font - size : 0.85 rem ;
}
. bp - quiz - opt : disabled { cursor : default ; }
. bp - quiz - opt . gewaehlt { border - color : var ( -- accent ) ; background : var ( -- accent - soft , rgba ( 99 , 102 , 241 , 0.12 ) ) ; }
. bp - quiz - opt . richtig { background : var ( -- success - soft ) ; border - color : var ( -- success - border ) ; }
. bp - quiz - opt . falsch { background : var ( -- danger - soft , # fee2e2 ) ; border - color : var ( -- danger - border , # f87171 ) ; }
. bp - form - feedback { font - size : 0.82 rem ; color : var ( -- text - muted ) ; flex : 1 ; }
/* Lückentext: Satz + Eingabe */
. bp - lueck input {
width : 100 % ; box - sizing : border - box ; padding : 0.45 rem 0.6 rem ; font : inherit ; font - size : 0.88 rem ;
border : 1 px solid var ( -- border ) ; border - radius : 8 px ; background : var ( -- panel ) ; color : var ( -- text ) ;
}
. bp - lueck input : disabled { opacity : 0.7 ; }
. bp - lueck - loesung { font - size : 0.82 rem ; color : var ( -- success ) ; margin : 0.35 rem 0 0 ; }
/* Gründlich-prüfen-Feld (per Rechtsklick) innerhalb der Bewertungs-Bubble */
. bp - gruendlich { display : flex ; gap : 0.3 rem ; margin - top : 0.4 rem ; align - items : center ; }
. bp - gruendlich input {