989 lines
46 KiB
Vue
989 lines
46 KiB
Vue
<script setup>
|
||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||
import { chatBaustein, pruefeBaustein, fetchFrageMuster } from '../api.js'
|
||
import { usePruefSlot } from '../pruefungCache.js'
|
||
import { renderMarkdown, renderMarkdownInline } from '../markdown.js'
|
||
import { stufeFuer, malusRegel } from '../stufen.js'
|
||
import { useChat, istUnten } from '../composables/useChat.js'
|
||
|
||
const props = defineProps({
|
||
topic: { type: String, required: true },
|
||
baustein: { type: String, required: true },
|
||
section: { type: String, default: '' }, // ausführliche Fassung
|
||
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}
|
||
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' },
|
||
initialTab: { type: String, default: null },
|
||
ansicht: { type: String, default: 'kompakt' }, // kompakt | erklärend — steuert die linke Guide-Spalte
|
||
})
|
||
|
||
const emit = defineEmits(['statusChanged', 'openFokus', 'setAnsicht'])
|
||
|
||
// Lernstufen relativ zum cap (stufen.js): grün/blau/lila/gold @ 20/40/60/100 %.
|
||
// Prüfung IMMER zufällig aus 5 Formen (mit Muster); cap = 2×relevante Subbausteine.
|
||
const MODI = ['quiz_leicht', 'quiz_schwer', 'lueck_leicht', 'lueck_schwer', 'erklaeren']
|
||
const st = computed(() => props.status || { gute_antworten: 0, streak: 0, cap: props.cap })
|
||
const score = computed(() => st.value.gute_antworten || 0)
|
||
const cap = computed(() => st.value.cap || props.cap)
|
||
const scoreAnzeige = computed(() => Math.min(score.value, cap.value)) // Altbestand kann cap übersteigen
|
||
const stufe = computed(() => stufeFuer(score.value, cap.value)) // Objekt {key,label,kurz,farbe} | null
|
||
const schwerWahl = ref(false) // ungenutzt im Zufallsmodus (immer zufällig)
|
||
const amDeckel = computed(() => score.value >= cap.value)
|
||
// Mit Frage-Muster zufällig aus 5 Formen; ohne Muster (alter Baustein) nur Erklären.
|
||
const aktuellesBand = computed(() => musterModus.value ? 'zufall' : 'erklaeren')
|
||
const imZufall = computed(() => aktuellesBand.value === 'zufall')
|
||
const aktiverModus = computed(() => aktuellesBand.value) // 'zufall' | 'erklaeren'
|
||
const aktiveForm = computed(() => imZufall.value ? 'quiz' : 'erklaeren') // Platzhalter; gezogenes Objekt überschreibt
|
||
const schwer = computed(() => false)
|
||
// Angezeigte Form: folgt der aktiven Form, friert ein, solange eine gezogene Frage sichtbar ist.
|
||
const anzeigeForm = ref(null)
|
||
const zeigeForm = computed(() => anzeigeForm.value || aktiveForm.value)
|
||
const anzeigeBand = ref(null)
|
||
const abschnittWechsel = computed(() => anzeigeBand.value && anzeigeBand.value !== aktuellesBand.value)
|
||
// Schwierigkeit des gerade gezeigten Widgets (vom gezogenen Objekt).
|
||
const aktSchwer = computed(() => {
|
||
if (zeigeForm.value === 'quiz' && quizAktuell.value) return quizAktuell.value.schwer
|
||
if (zeigeForm.value === 'lueckentext' && lueckAktuell.value) return lueckAktuell.value.schwer
|
||
return false
|
||
})
|
||
const modusRegel = computed(() => {
|
||
const m = malusRegel(score.value, cap.value) // '0' | '−1' | '−2' | '−3' nach Fortschritt
|
||
if (zeigeForm.value === 'quiz') return aktSchwer.value ? `x von 4 · +3/${m}` : `1 von 4 · +1/${m}`
|
||
if (zeigeForm.value === 'lueckentext') return aktSchwer.value ? `frei tippen · +3/${m}` : `Begriff aus 4 · +1/${m}`
|
||
return `+1/+2/+3 · Fehler ${m}`
|
||
})
|
||
const bandZiel = computed(() => cap.value)
|
||
const FORM_NAME = { quiz: 'Quiz', lueckentext: 'Lückentext', erklaeren: 'Erklären' }
|
||
|
||
// --- Toggle-Bereich ---
|
||
const activeTab = ref(props.mode === 'full' ? props.initialTab : null) // null | 'chat' | 'pruefung'
|
||
|
||
// Klick auf einen Tab: in der Liste den Fokus öffnen, im Fokus den Tab umschalten.
|
||
function tabKlick(tab) {
|
||
if (props.mode === 'trigger') { emit('openFokus', tab); return }
|
||
activeTab.value = activeTab.value === tab ? null : tab
|
||
}
|
||
|
||
// --- Bausteinchat (flüchtig) ---
|
||
const chat = useChat((msgs) => chatBaustein({
|
||
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
|
||
messages: msgs, provider: props.provider,
|
||
}))
|
||
|
||
// --- Prüfung: gesteuerter Dialog ---
|
||
// Phasen: 'idle' (Frage anfordern) | 'frage_offen' (antworten/nachfragen) | 'bewertet' (diskutieren/neu bewerten/weiter)
|
||
// Durabler State liegt in einem GETEILTEN Slot pro Baustein (Modul-Store): überlebt
|
||
// das Remounten und eine spät eintreffende Frage ist sofort reaktiv sichtbar.
|
||
const pruefKey = `${props.topic}::${props.baustein}`
|
||
const slot = usePruefSlot(pruefKey)
|
||
const pruefMessages = slot.messages // {role, kind: 'frage'|'nachfrage'|'antwort'|'feedback'|'diskussion'|'fehler', content, bewertung?}
|
||
const pruefPhase = slot.phase
|
||
const aktuelleFrage = slot.aktuelleFrage // ankert Bewertung/Diskussion
|
||
const letztesFeedback = slot.letztesFeedback // Kontext für die Diskussion über eine Bewertung
|
||
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)
|
||
const nachgefragt = ref(false) // für die laufende Frage wurde nachgefragt → Gewinn max +1
|
||
const gruendlichMsg = ref(null) // Feedback-Bubble, für die das Gründlich-Feld offen ist
|
||
const gruendlichText = ref('') // optionale Begründung für „Gründlich prüfen"
|
||
|
||
const pruefMessagesEl = ref(null)
|
||
const pruefInputEl = ref(null)
|
||
const pruefStick = ref(true) // nur auto-scrollen, wenn der Nutzer (fast) unten ist
|
||
let pruefRun = 0
|
||
|
||
function onPruefScroll() {
|
||
if (pruefMessagesEl.value) pruefStick.value = istUnten(pruefMessagesEl.value)
|
||
}
|
||
|
||
function applyPruefung(res) {
|
||
// res.streak nur bei antwort_pruefen gesetzt (verbindlich, persistiert); sonst Streak unverändert lassen.
|
||
emit('statusChanged', { baustein: props.baustein, ...st.value, gute_antworten: res.gute_antworten, streak: res.streak ?? st.value.streak, cap: res.cap ?? cap.value })
|
||
}
|
||
|
||
async function pruefScroll() {
|
||
await nextTick()
|
||
if (pruefMessagesEl.value && pruefStick.value) pruefMessagesEl.value.scrollTop = pruefMessagesEl.value.scrollHeight
|
||
}
|
||
|
||
// Nur echte Gesprächs-Turns ans Backend; Feedback bleibt reines UI-Artefakt.
|
||
function pruefDialog() {
|
||
return pruefMessages.value
|
||
.filter((m) => m.kind !== 'feedback' && m.kind !== 'fehler')
|
||
.map((m) => ({ role: m.role, content: m.content }))
|
||
}
|
||
|
||
async function pruefSenden(payload, onOk) {
|
||
const run = ++pruefRun
|
||
pruefStick.value = true // eigene Aktion = ans Ende; Hochscrollen während des Wartens setzt es wieder false
|
||
pruefLoading.value = true
|
||
pruefScroll()
|
||
try {
|
||
const res = await pruefeBaustein({
|
||
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
|
||
provider: props.provider, messages: pruefDialog(), ...payload,
|
||
})
|
||
if (run !== pruefRun) return
|
||
onOk(res)
|
||
applyPruefung(res)
|
||
pruefScroll()
|
||
nextTick(() => pruefInputEl.value?.focus())
|
||
} catch {
|
||
if (run === pruefRun) pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Hat nicht geklappt — bitte erneut.' })
|
||
} finally {
|
||
if (run === pruefRun) pruefLoading.value = false
|
||
}
|
||
}
|
||
|
||
// Frage anzeigen (frisch oder vorgemerkt). Basis/Streak führt der Server je Frage.
|
||
function frageZeigen(text) {
|
||
aktuelleFrage.value = text
|
||
letztesFeedback.value = ''
|
||
nachgefragt.value = false
|
||
gruendlichMsg.value = null
|
||
pruefMessages.value.push({ role: 'assistant', kind: 'frage', content: text })
|
||
pruefPhase.value = 'frage_offen'
|
||
}
|
||
|
||
// Dedup: schon gestellte Fragen erkennen (Generator wiederholt sich auf kleinen Bausteinen).
|
||
function normFrage(t) { return (t || '').toLowerCase().replace(/\s+/g, ' ').trim() }
|
||
function gestellteFragen() {
|
||
return new Set(pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => normFrage(m.content)))
|
||
}
|
||
|
||
// --- 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 (nur Erklären).
|
||
const musterModus = computed(() => musterGeladen.value && musterQuelle.value.length > 0)
|
||
|
||
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) {
|
||
satz = String(satz || '')
|
||
const m = satz.match(/_{3,}/) // erste Lücke (drei oder mehr Unterstriche)
|
||
if (!m) return renderMarkdownInline(satz)
|
||
// Liegt die Lücke INNERHALB einer $…$-Formel (ungerade Zahl $ davor)? Dann nicht splitten
|
||
// (das zerreißt die Formel) — stattdessen durch eine KaTeX-Linie ersetzen, Satz am Stück rendern.
|
||
const inFormel = ((satz.slice(0, m.index).match(/\$/g) || []).length % 2) === 1
|
||
if (inFormel) return renderMarkdownInline(satz.replace(/_{3,}/, '\\rule{2.5em}{0.4pt}'))
|
||
// Lücke im Text: splitten + Lücken-Span.
|
||
return satz.split(/_{3,}/).map((t) => renderMarkdownInline(t)).join('<span class="bp-luecke">______</span>')
|
||
}
|
||
|
||
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.map(objText)]
|
||
}
|
||
|
||
// Muster-Sidecar einmal je Baustein laden (Slot-cached). Leer → Fallback.
|
||
async function musterLaden() {
|
||
if (musterGeladen.value) return
|
||
try {
|
||
const res = await fetchFrageMuster(props.topic, props.baustein)
|
||
musterQuelle.value = (res.muster || []).map((m) => (m.frage || '').trim()).filter(Boolean)
|
||
} catch {
|
||
musterQuelle.value = []
|
||
}
|
||
musterGeladen.value = true
|
||
}
|
||
|
||
function mischen(arr) { // Fisher-Yates
|
||
const a = [...arr]
|
||
for (let i = a.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1))
|
||
;[a[i], a[j]] = [a[j], a[i]]
|
||
}
|
||
return a
|
||
}
|
||
|
||
// Ein Muster ohne Zurücklegen ziehen; Pool leer → Reset (neu mischen). null = Fallback.
|
||
function musterNehmen() {
|
||
if (!musterQuelle.value.length) return null
|
||
if (!musterPool.value.length) musterPool.value = mischen(musterQuelle.value)
|
||
return musterPool.value.shift()
|
||
}
|
||
|
||
// Pool-Schlüssel = Form + Schwierigkeit; im Zufallband ein gemischter Pool ('zufall').
|
||
const poolKey = computed(() => imZufall.value ? 'zufall' : `${aktiveForm.value}/${schwer.value}`)
|
||
|
||
// Modus der nächsten Frage: im Zufallband (Score 19–30) zufällig aus allen 5 Modi.
|
||
function naechsterModus() {
|
||
return imZufall.value ? MODI[Math.floor(Math.random() * MODI.length)] : aktiverModus.value
|
||
}
|
||
|
||
// Ein Frage-Objekt des aktiven Modus generieren (aus gezogener Saat; Erklären hat Fallback).
|
||
function frageEinzeln() {
|
||
const modus = naechsterModus()
|
||
const form = modus.startsWith('quiz') ? 'quiz' : modus.startsWith('lueck') ? 'lueckentext' : 'erklaeren'
|
||
const istSchwer = modus.endsWith('_schwer')
|
||
const muster = musterNehmen()
|
||
const basis = {
|
||
topic: props.topic, baustein: props.baustein, section: props.section,
|
||
section_kompakt: props.sectionKompakt, provider: props.provider,
|
||
}
|
||
if (form === 'quiz' && muster) {
|
||
return pruefeBaustein({ ...basis, aktion: 'quiz_frage', muster, schwer: istSchwer })
|
||
.then((r) => ({ form, schwer: istSchwer, frage: r.frage, optionen: r.optionen }))
|
||
}
|
||
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 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 missCap = musterModus.value ? Infinity : 3
|
||
while (pool.value.length + inflight.value < POOL_ZIEL && poolMiss < missCap) {
|
||
inflight.value++
|
||
frageEinzeln()
|
||
.then((obj) => {
|
||
if (obj && objText(obj) && poolKey.value === keyBeginn && (musterModus.value || !istBekannt(obj))) { pool.value.push(obj); poolMiss = 0 }
|
||
else poolMiss++
|
||
})
|
||
.catch(() => { poolMiss++ })
|
||
.finally(() => { inflight.value--; poolFuellen() })
|
||
}
|
||
}
|
||
|
||
// Ein Frage-Objekt aus dem Pool nehmen; ist er leer, auf das erste eintreffende warten.
|
||
async function poolNehmen() {
|
||
if (!pool.value.length) {
|
||
const missCap = musterModus.value ? Infinity : 3
|
||
pruefLoading.value = true
|
||
try {
|
||
while (!pool.value.length && (inflight.value > 0 || poolMiss < missCap)) {
|
||
if (!inflight.value) poolFuellen()
|
||
await new Promise((r) => setTimeout(r, 120))
|
||
}
|
||
} finally { pruefLoading.value = false }
|
||
}
|
||
return pool.value.length ? pool.value.shift() : null
|
||
}
|
||
|
||
// 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)
|
||
anzeigeBand.value = aktuellesBand.value // Band zum Zieh-Zeitpunkt (vor der Antwort)
|
||
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 obj = await poolNehmen()
|
||
if (obj) {
|
||
frageObjZeigen(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
|
||
|
||
// Meilenstein überschritten: Einfrieren lösen (Ergebnis war sichtbar), ohne neue Frage zu
|
||
// ziehen → Leerlauf des neuen Bands („Frage anfordern").
|
||
function zumNaechstenAbschnitt() {
|
||
anzeigeForm.value = null
|
||
anzeigeBand.value = null
|
||
quizAktuell.value = null
|
||
lueckAktuell.value = null
|
||
pruefMessages.value = [] // alten Erklären-Verlauf verwerfen (neuer Abschnitt)
|
||
pruefPhase.value = 'idle'
|
||
pruefFehler.value = ''
|
||
}
|
||
|
||
// --- 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
|
||
}
|
||
}
|
||
|
||
function nachfragen() {
|
||
const text = pruefInput.value.trim()
|
||
if (!text || pruefLoading.value) return
|
||
nachgefragt.value = true // Hilfe genutzt → Gewinn für diese Frage max +1
|
||
pruefMessages.value.push({ role: 'user', kind: 'nachfrage', content: text })
|
||
pruefInput.value = ''
|
||
pruefSenden(
|
||
{ aktion: 'diskussion', frage: aktuelleFrage.value, letzte_bewertung: letztesFeedback.value },
|
||
(res) => pruefMessages.value.push({ role: 'assistant', kind: 'diskussion', content: res.reply }),
|
||
)
|
||
}
|
||
|
||
let letzteFeedbackMsg = null // zuletzt gezeigte Bewertungs-Bubble
|
||
let pruefenRun = 0 // nur die jüngste Schnell-Bewertung darf anzeigen
|
||
|
||
function bewertungPayload() {
|
||
return { frage: aktuelleFrage.value, cap: props.cap, nachgefragt: nachgefragt.value }
|
||
}
|
||
|
||
// Agent 1 (schnell): sofortiges Niveau + Punkte zeigen, dann Agent 2 (genau) im Hintergrund.
|
||
async function schnellBewerten() {
|
||
const meine = ++pruefenRun
|
||
pruefStick.value = true
|
||
pruefLoading.value = true
|
||
pruefScroll()
|
||
try {
|
||
const res = await pruefeBaustein({
|
||
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
|
||
provider: props.provider, messages: pruefDialog(), aktion: 'antwort', ...bewertungPayload(),
|
||
})
|
||
if (meine !== pruefenRun) return
|
||
// Agent 1 ändert den Score NICHT — nur Feedback + voraussichtliche Punkte zeigen.
|
||
letztesFeedback.value = res.feedback || ''
|
||
pruefMessages.value.push({ role: 'assistant', kind: 'feedback', content: res.feedback || '', bewertung: res.bewertung, punkte: res.punkte, geprueft: false })
|
||
letzteFeedbackMsg = pruefMessages.value[pruefMessages.value.length - 1]
|
||
pruefPhase.value = 'bewertet'
|
||
pruefScroll()
|
||
nextTick(() => pruefInputEl.value?.focus())
|
||
genauBewerten() // Agent 2 wertet verbindlich (setzt den Score)
|
||
} catch {
|
||
if (meine === pruefenRun) pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Hat nicht geklappt — bitte erneut.' })
|
||
} finally {
|
||
if (meine === pruefenRun) pruefLoading.value = false
|
||
}
|
||
}
|
||
|
||
// Agent 2 (genau): Bewerter + Kritiker. Korrigiert Bubble + Score (Server-Wahrheit, immer übernehmen).
|
||
// gruendlich=true: starkes Modell, optional mit Begründung des Lerners.
|
||
async function genauBewerten(gruendlich = false, begruendung = '') {
|
||
const ziel = letzteFeedbackMsg
|
||
if (gruendlich) pruefLoading.value = true
|
||
try {
|
||
const res = await pruefeBaustein({
|
||
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
|
||
provider: props.provider, messages: pruefDialog(), aktion: 'antwort_pruefen', ...bewertungPayload(), gruendlich, begruendung,
|
||
})
|
||
applyPruefung(res)
|
||
if (ziel) {
|
||
ziel.content = res.feedback || ziel.content
|
||
ziel.bewertung = res.bewertung
|
||
ziel.punkte = res.punkte
|
||
ziel.geprueft = true
|
||
}
|
||
} catch {
|
||
if (ziel) ziel.geprueft = true
|
||
} finally {
|
||
if (gruendlich) pruefLoading.value = false
|
||
}
|
||
}
|
||
|
||
function antwortAbgeben() {
|
||
const text = pruefInput.value.trim()
|
||
if (!text || pruefLoading.value) return
|
||
pruefMessages.value.push({ role: 'user', kind: 'antwort', content: text })
|
||
pruefInput.value = ''
|
||
schnellBewerten()
|
||
}
|
||
|
||
// Lief schon eine Diskussion? Dann darf der Verlauf direkt bewertet werden.
|
||
const hatDiskussion = computed(() => pruefMessages.value.some((m) => m.kind === 'nachfrage' || m.kind === 'diskussion'))
|
||
|
||
// „Verlauf bewerten": den bisherigen Dialog als Antwort werten (Feld wird ignoriert).
|
||
function verlaufBewerten() {
|
||
if (pruefLoading.value) return
|
||
schnellBewerten()
|
||
}
|
||
|
||
// Rechtsklick auf eine Bewertung → Gründlich-Feld öffnen; Absenden re-bewertet (mit Begründung).
|
||
function gruendlichOeffnen(msg) {
|
||
gruendlichText.value = ''
|
||
gruendlichMsg.value = msg
|
||
nextTick(() => document.getElementById('bp-gruendlich-input')?.focus())
|
||
}
|
||
function gruendlichAbsenden() {
|
||
if (pruefLoading.value) return
|
||
letzteFeedbackMsg = gruendlichMsg.value || letzteFeedbackMsg
|
||
const text = gruendlichText.value.trim()
|
||
gruendlichMsg.value = null
|
||
genauBewerten(true, text)
|
||
}
|
||
|
||
function punkteLabel(p) {
|
||
if (p == null) return ''
|
||
return p > 0 ? `+${p}` : p < 0 ? String(p) : '±0'
|
||
}
|
||
|
||
// Abbruch wie im Chat: Run-Counter hochzählen → laufendes Ergebnis (pruefSenden
|
||
// und schnellBewerten) wird verworfen, Buttons sofort frei. Agent läuft serverseitig aus.
|
||
function pruefAbbrechen() {
|
||
if (!pruefLoading.value) return
|
||
pruefRun++
|
||
pruefenRun++
|
||
pruefLoading.value = false
|
||
pruefMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Abgebrochen.' })
|
||
}
|
||
|
||
// ESC bricht ab — window-Listener, da nach Button-Klick der Fokus das Panel verlässt.
|
||
function onWindowKey(e) {
|
||
if (e.key === 'Escape' && pruefLoading.value) { e.preventDefault(); pruefAbbrechen() }
|
||
}
|
||
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+E: leicht↔schwer der Form (Quiz/Lückentext). Im Erklären- und Zufallband ohne Wirkung.
|
||
function variantWechseln() {
|
||
if (imZufall.value) return
|
||
if (aktiveForm.value !== 'quiz' && aktiveForm.value !== 'lueckentext') return
|
||
schwerWahl.value = !schwerWahl.value
|
||
// Einfrieren + altes Widget verwerfen → frische Frage der neuen Variante anfordern.
|
||
anzeigeForm.value = null
|
||
anzeigeBand.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
|
||
})
|
||
// Nach einer Antwort: bei Meilenstein-Überschreitung in den neuen Abschnitt, sonst nächste Frage.
|
||
function weiterAktion() {
|
||
if (abschnittWechsel.value) zumNaechstenAbschnitt()
|
||
else naechsteFrageZeigen()
|
||
}
|
||
// Primäre Aktion je Form: keine Frage → anfordern · offen → antworten · fertig → weiter.
|
||
function primaerAktion() {
|
||
if (pruefLoading.value) return
|
||
const f = zeigeForm.value
|
||
if (f === 'quiz') { if (!quizAktuell.value) frageAnfordern(); else if (!quizAktuell.value.fertig) quizAntworten(); else weiterAktion(); return }
|
||
if (f === 'lueckentext') { if (!lueckAktuell.value) frageAnfordern(); else if (!lueckAktuell.value.fertig) lueckAntworten(); else weiterAktion(); return }
|
||
if (pruefPhase.value === 'idle') frageAnfordern()
|
||
else if (pruefPhase.value === 'frage_offen') { if (pruefInput.value.trim()) antwortAbgeben() }
|
||
else weiterAktion()
|
||
}
|
||
|
||
// 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 toggelt der Fokus (BausteinFokus lone-tap) → hier NICHT fokussieren,
|
||
// sonst kämpfen keydown-Fokus (hier) und keyup-Toggle (Fokus) → „nur beim Halten".
|
||
if (e.key === 'Alt') 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>
|
||
|
||
<template>
|
||
<div class="bp">
|
||
<div class="bp-toggles">
|
||
<!-- Ansicht-Umschalter (nur Vollbild): steuert die linke Guide-Spalte, links vor den Tabs -->
|
||
<div v-if="mode === 'full'" class="bp-ansicht">
|
||
<button :class="{ active: ansicht === 'kompakt' }" title="Merksätze" @click="emit('setAnsicht', 'kompakt')">Kompakt</button>
|
||
<button :class="{ active: ansicht === 'erklärend' }" title="Ausführliche Erklärung" @click="emit('setAnsicht', 'erklärend')">Erklärend</button>
|
||
</div>
|
||
<button :class="{ active: activeTab === 'chat' }" @click="tabKlick('chat')">
|
||
Chat
|
||
</button>
|
||
<button :class="{ active: activeTab === 'pruefung' }" @click="tabKlick('pruefung')">
|
||
Prüfung
|
||
<span v-if="stufe" class="bp-chip" :style="{ borderColor: stufe.farbe, color: stufe.farbe }" :title="`${stufe.label} (${cap})`">{{ stufe.kurz }} {{ scoreAnzeige }}/{{ cap }}</span>
|
||
<span v-else-if="score" class="bp-chip">{{ scoreAnzeige }}/{{ cap }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="mode === 'full' && activeTab" class="bp-panel">
|
||
<!-- Bausteinchat -->
|
||
<div v-if="activeTab === 'chat'">
|
||
<div :ref="chat.messagesEl" class="bp-messages" @scroll="chat.onScroll">
|
||
<p v-if="!chat.messages.value.length" class="bp-hint">Frag etwas zu diesem Baustein. Der Verlauf wird nicht gespeichert.</p>
|
||
<template v-for="(m, i) in chat.messages.value" :key="i">
|
||
<div v-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="chat.loading.value" class="bp-msg assistant bp-typing">Denkt…</div>
|
||
</div>
|
||
<div class="bp-input">
|
||
<textarea
|
||
:ref="chat.inputEl"
|
||
v-model="chat.input.value"
|
||
rows="2"
|
||
placeholder="Frage zum Baustein…"
|
||
@keydown.enter.exact.prevent="chat.send"
|
||
></textarea>
|
||
<button :disabled="!chat.input.value.trim() && !chat.loading.value" :class="{ cancel: chat.loading.value }" @click="chat.send">
|
||
{{ chat.loading.value ? '✕' : '➤' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Prüfung: gesteuerter Dialog -->
|
||
<div v-else>
|
||
<p class="bp-hint">
|
||
<template v-if="amDeckel">{{ FORM_NAME[zeigeForm] }} · {{ scoreAnzeige }}/{{ cap }} — <strong>Max</strong>.</template>
|
||
<template v-else-if="imZufall && !anzeigeForm">Zufällig · {{ scoreAnzeige }}/{{ cap }}</template>
|
||
<template v-else-if="imZufall">{{ FORM_NAME[zeigeForm] }} · {{ scoreAnzeige }}/{{ cap }} · {{ modusRegel }}</template>
|
||
<template v-else>{{ FORM_NAME[zeigeForm] }} · {{ score }}/{{ bandZiel }} · {{ modusRegel }}</template>
|
||
</p>
|
||
<button
|
||
v-if="!amDeckel && !imZufall && zeigeForm !== 'erklaeren'"
|
||
class="bp-variant"
|
||
title="Alt+E"
|
||
@click="variantWechseln"
|
||
>{{ aktSchwer ? 'Schwer' : 'Leicht' }} · wechseln</button>
|
||
|
||
<!-- Quiz: Frage + Mehrfachauswahl (Widget bleibt auch am Deckel — Üben ohne Punkte) -->
|
||
<template v-if="zeigeForm === 'quiz'">
|
||
<div v-if="!quizAktuell" 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-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 v-if="abschnittWechsel" class="bp-action primary" @click="zumNaechstenAbschnitt">Zum nächsten Abschnitt</button>
|
||
<button v-else 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 v-if="abschnittWechsel" class="bp-action primary" @click="zumNaechstenAbschnitt">Zum nächsten Abschnitt</button>
|
||
<button v-else 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 v-if="abschnittWechsel" class="bp-action primary" title="Alt+2" @click="zumNaechstenAbschnitt"><span class="bp-kbd">2</span>Zum nächsten Abschnitt</button>
|
||
<button v-else 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>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.bp { margin-top: 0.75rem; }
|
||
|
||
.bp-toggles { display: flex; gap: 0.4rem; }
|
||
.bp-toggles button {
|
||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||
padding: 0.25rem 0.7rem;
|
||
font-size: 0.8rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: 999px;
|
||
background: var(--panel-soft);
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
}
|
||
.bp-toggles button:hover { border-color: var(--border-strong); color: var(--text); }
|
||
.bp-toggles button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||
|
||
/* Ansicht-Umschalter (Kompakt/Erklärend) — segmentiert, links vor den Tabs */
|
||
.bp-ansicht { display: inline-flex; margin-right: 0.5rem; }
|
||
.bp-ansicht button {
|
||
padding: 0.25rem 0.6rem;
|
||
font-size: 0.75rem;
|
||
border: 1px solid var(--border);
|
||
background: var(--panel-soft);
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
}
|
||
.bp-ansicht button:first-child { border-radius: 999px 0 0 999px; }
|
||
.bp-ansicht button:last-child { border-radius: 0 999px 999px 0; }
|
||
.bp-ansicht button + button { border-left: none; }
|
||
.bp-ansicht button:hover { color: var(--text); }
|
||
.bp-ansicht button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||
|
||
/* Schwierigkeits-Umschalter (Alt+E) — eigener Button, mobil tippbar */
|
||
.bp-variant {
|
||
margin: 0 0 0.6rem;
|
||
padding: 0.2rem 0.7rem;
|
||
font-size: 0.72rem;
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: 999px;
|
||
background: var(--panel-soft);
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
}
|
||
.bp-variant:hover { border-color: var(--accent); color: var(--text); }
|
||
|
||
.bp-chip {
|
||
font-size: 0.7rem;
|
||
padding: 0 0.35rem;
|
||
border-radius: 999px;
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
color: var(--text-muted);
|
||
}
|
||
.bp-chip.done { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||
.bp-chip.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
|
||
.bp-chip.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
|
||
|
||
.bp-panel {
|
||
margin-top: 0.6rem;
|
||
padding: 0.75rem 0.9rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: var(--panel-soft);
|
||
}
|
||
|
||
.bp-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
|
||
.bp-hint-key { font-size: 0.72rem; opacity: 0.7; white-space: nowrap; }
|
||
.bp-error { font-size: 0.85rem; color: var(--danger); margin: 0.5rem 0 0; }
|
||
|
||
.bp-action {
|
||
margin-top: 0.5rem;
|
||
padding: 0.3rem 0.8rem;
|
||
font-size: 0.8rem;
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: 6px;
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
}
|
||
.bp-action:hover { border-color: var(--accent); }
|
||
.bp-action:disabled { opacity: 0.5; cursor: default; }
|
||
.bp-action.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||
.bp-action.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||
.bp-action.cancel { background: var(--danger); border-color: var(--danger); color: var(--on-accent); }
|
||
|
||
.bp-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; }
|
||
.bp-actions .bp-action { margin-top: 0; }
|
||
|
||
.bp-kbd {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
min-width: 1rem; height: 1rem; padding: 0 0.2rem; margin-right: 0.35rem;
|
||
font-size: 0.65rem; font-weight: 600; border-radius: 3px;
|
||
background: var(--panel); border: 1px solid var(--border); color: var(--text-muted);
|
||
}
|
||
.bp-action.primary .bp-kbd { background: color-mix(in srgb, var(--on-accent) 20%, transparent); border-color: transparent; color: var(--on-accent); }
|
||
|
||
.bp-messages { display: flex; flex-direction: column; gap: 0.4rem; max-height: 320px; overflow-y: auto; }
|
||
.bp-msg {
|
||
max-width: 88%;
|
||
padding: 0.4rem 0.65rem;
|
||
border-radius: 10px;
|
||
font-size: 0.88rem;
|
||
line-height: 1.45;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.bp-msg.user { align-self: flex-end; background: var(--accent); color: var(--on-accent); white-space: pre-wrap; }
|
||
.bp-msg.assistant { align-self: flex-start; background: var(--panel); border: 1px solid var(--border); }
|
||
.bp-typing { color: var(--text-faint); font-style: italic; }
|
||
|
||
/* Bewertung der letzten Antwort — getrennt über der nächsten Frage */
|
||
.bp-feedback {
|
||
align-self: flex-start;
|
||
max-width: 88%;
|
||
padding: 0.3rem 0.6rem;
|
||
border-radius: 8px;
|
||
font-size: 0.82rem;
|
||
line-height: 1.4;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.bp-pruefend { font-style: italic; opacity: 0.7; font-size: 0.92em; }
|
||
.bp-feedback { cursor: pointer; }
|
||
.bp-feedback.gut { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||
.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.62rem; letter-spacing: 0.03em; margin-right: 0.4rem; 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.6rem; }
|
||
.bp-quiz-frage, .bp-lueck-satz { font-size: 0.9rem; font-weight: 600; margin: 0 0 0.5rem; line-height: 1.5; }
|
||
.bp-luecke { font-weight: 700; letter-spacing: 1px; color: var(--accent); padding: 0 0.15rem; }
|
||
.bp-quiz-opt .markdown, .bp-quiz-frage.markdown, .bp-lueck-satz.markdown { display: inline; }
|
||
.bp-quiz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||
.bp-quiz-opt {
|
||
display: flex; gap: 0.45rem; align-items: flex-start; text-align: left;
|
||
padding: 0.5rem 0.6rem;
|
||
border: 1px solid var(--border); border-radius: 8px;
|
||
background: var(--panel); color: var(--text); cursor: pointer; font-size: 0.85rem;
|
||
}
|
||
.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.82rem; color: var(--text-muted); flex: 1; }
|
||
|
||
/* Lückentext: Satz + Eingabe */
|
||
.bp-lueck input {
|
||
width: 100%; box-sizing: border-box; padding: 0.45rem 0.6rem; font: inherit; font-size: 0.88rem;
|
||
border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text);
|
||
}
|
||
.bp-lueck input:disabled { opacity: 0.7; }
|
||
.bp-lueck-loesung { font-size: 0.82rem; color: var(--success); margin: 0.35rem 0 0; }
|
||
/* Gründlich-prüfen-Feld (per Rechtsklick) innerhalb der Bewertungs-Bubble */
|
||
.bp-gruendlich { display: flex; gap: 0.3rem; margin-top: 0.4rem; align-items: center; }
|
||
.bp-gruendlich input {
|
||
flex: 1; min-width: 0; padding: 0.3rem 0.5rem; font: inherit; font-size: 0.8rem;
|
||
border: 1px solid var(--border-strong); border-radius: 6px; background: var(--panel); color: var(--text);
|
||
}
|
||
.bp-gruendlich .bp-action { margin-top: 0; }
|
||
|
||
.bp-input { display: flex; gap: 0.4rem; margin-top: 0.55rem; align-items: flex-end; }
|
||
.bp-input textarea {
|
||
flex: 1;
|
||
resize: none;
|
||
padding: 0.45rem 0.6rem;
|
||
font: inherit;
|
||
font-size: 0.88rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
}
|
||
.bp-input button {
|
||
padding: 0.45rem 0.7rem;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: var(--accent);
|
||
color: var(--on-accent);
|
||
cursor: pointer;
|
||
}
|
||
.bp-input button:disabled { opacity: 0.5; cursor: default; }
|
||
.bp-input button.cancel { background: var(--danger); }
|
||
</style>
|