This commit is contained in:
team3
2026-07-12 16:13:50 +02:00
parent a284e03225
commit 31a42bbf1d
48 changed files with 3625 additions and 253 deletions

View File

@@ -5,7 +5,7 @@ import { computed, onUnmounted, ref, watch } from 'vue'
import { api } from '../api.js'
const props = defineProps({ state: Object, topic: String })
const emit = defineEmits(['reload'])
const emit = defineEmits(['reload', 'fehler'])
// Agenten-Zeit lokal weiterticken: der Server liefert laufzeit nur mit dem
// State-Snapshot — zwischen Deltas (lange LLM-Calls) fröre die Anzeige ein.
@@ -41,6 +41,7 @@ const EBENEN = [
{ key: 'inventar', titel: 'Atome' },
{ key: 'artefakte', titel: 'Artefakte' },
{ key: 'struktur', titel: 'Bausteine' },
{ key: 'diagramme', titel: 'Diagramme' },
{ key: 'guide', titel: 'Guide' },
]
@@ -79,6 +80,10 @@ function karten(key) {
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
badges: [[b.status, statusFarbe(b.status)]] }))
}
if (key === 'diagramme') {
return (s.diagramme || []).map((d) => ({ id: d.id, text: `${d.ord + 1}. ${d.titel}`,
badges: [[d.status, statusFarbe(d.status)], [`${d.typ} · ${d.quelle}`, 'blau']] }))
}
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
badges: [[b.stage || 'offen', b.stage === 'done' ? 'gruen' : 'blau']] }))
}
@@ -100,21 +105,21 @@ async function menueGenerieren() {
const m = menue.value
menue.value = null
if (!m || !generierenErlaubt(m.idx)) return
await api.start(props.topic)
emit('reload')
try { await api.start(props.topic); emit('reload') }
catch (e) { emit('fehler', String(e)) } // sonst schlägt der Start still fehl
}
async function menueEntfernen() {
const m = menue.value
menue.value = null
if (!m) return
await api.ebeneEntfernen(props.topic, m.key)
emit('reload')
try { await api.ebeneEntfernen(props.topic, m.key); emit('reload') }
catch (e) { emit('fehler', String(e)) }
}
async function autoSetzen(key, ev) {
await api.setAuto(props.topic, key, ev.target.checked)
emit('reload')
try { await api.setAuto(props.topic, key, ev.target.checked); emit('reload') }
catch (e) { emit('fehler', String(e)) }
}
const dauer = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`

View File

@@ -1,8 +1,14 @@
<script setup>
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import mermaid from 'mermaid'
import { api } from '../api.js'
import { render, lesestat } from '../markdown.js'
// securityLevel:'strict' → kein foreignObject/HTML-Label (umgeht DOMPurify-Filter),
// keine Klick-Handler. startOnLoad:false → wir rendern selbst nach jedem Einblenden.
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'neutral' })
let mmId = 0
const props = defineProps({ topic: String, state: Object })
const daten = ref(null)
const level = ref('E') // Durchgang-Wahl: jede Stufe ist ein eigener Lese-Durchgang
@@ -15,10 +21,14 @@ watch(fokus, (f) => document.body.classList.toggle('vollbild', f))
onUnmounted(() => document.body.classList.remove('vollbild'))
const aktiv = ref(0)
const kapEls = []
const scrollEl = ref(null)
let ladeSeq = 0
async function laden() {
if (!props.topic) return
daten.value = await api.guide(props.topic)
if (!props.topic) { daten.value = null; return }
const seq = ++ladeSeq
const d = await api.guide(props.topic)
if (seq === ladeSeq) daten.value = d // späte Antwort eines alten Topics verwerfen
}
watch(() => props.topic, laden, { immediate: true })
watch(() => props.state?.run?.status, laden) // nach Lauf-Ende neu ziehen
@@ -36,6 +46,36 @@ function html(s) {
return htmlCache.get(key)
}
// „Prüfe dich"-Antworten ebenfalls cachen — sonst rechnet jeder Scroll-Tick
// Markdown+KaTeX für alle sichtbaren Antworten neu (genau der Kostenpfad oben).
function htmlAntwort(s, fi) {
const key = `a|${s.baustein}|${fi}`
if (!htmlCache.has(key)) htmlCache.set(key, render(s.fragen[fi].antwort))
return htmlCache.get(key)
}
// Mermaid rendert asynchron ins DOM — nach jedem Einblenden (Scroll/Level/Ansicht)
// die noch nicht verarbeiteten ```mermaid-Blöcke im sichtbaren Bereich rendern.
async function renderMermaid() {
await nextTick()
const wurzel = scrollEl.value
if (!wurzel) return
for (const code of wurzel.querySelectorAll('code.language-mermaid:not([data-mm])')) {
code.setAttribute('data-mm', '1')
const pre = code.closest('pre')
try {
const { svg } = await mermaid.render(`mm-${mmId++}`, code.textContent)
const figur = document.createElement('div')
figur.className = 'mermaid-figur'
figur.innerHTML = svg
pre?.replaceWith(figur)
} catch {
pre?.classList.add('mermaid-fehler') // kaputt: als Codeblock stehen lassen
}
}
}
watch([aktiv, ansicht, level, daten], renderMermaid, { flush: 'post' })
// Nur aktives Kapitel ±1 im DOM; der Rest ist ein Platzhalter mit Schätzhöhe
const sichtbar = (i) => Math.abs(i - aktiv.value) <= 1
@@ -49,11 +89,13 @@ function setAnsicht(a) {
ansicht.value = a
}
// Nur Durchgänge anbieten, die es in diesem Thema gibt (ohne E- oder
// S-Bausteine überspringt der Toggle die Stufe)
// Nur Durchgänge anbieten, die WIRKLICH Inhalt haben (≥1 gefüllte Section) —
// sonst schaltet der Toggle in einen leeren Durchgang und läuft in eine Sackgasse
const levels = computed(() => {
const da = new Set((daten.value?.kapitel || []).map((k) => k.level))
const folge = ['E', 'M', 'S'].filter((l) => da.has(l))
const kaps = daten.value?.kapitel || []
const hatInhalt = (l) => kaps.some((k) => k.level === l
&& k.sections.some((s) => ((s.lang || '') + (s.kompakt || '')).trim()))
const folge = ['E', 'M', 'S'].filter(hatInhalt)
return folge.length ? folge : ['E']
})
watch(levels, (l) => { if (!l.includes(level.value)) level.value = l[0] })
@@ -74,7 +116,13 @@ const anzeige = computed(() => {
}
return out
})
watch([level, ansicht, daten], () => { kapEls.length = 0; aktiv.value = 0 })
watch([level, ansicht, daten], () => {
kapEls.length = 0
aktiv.value = 0
// Scrollposition zurücksetzen — sonst steht der Viewport nach dem Wechsel
// mitten in Platzhalter-Divs (echter Inhalt nur oben) und wirkt leer.
nextTick(() => { if (scrollEl.value) scrollEl.value.scrollTop = 0 })
})
// Lesezeit des SICHTBAREN Umfangs: 150 Wörter/min (technischer Text)
// + 5 s je Display-Formel (Medium ~265 wpm gilt nicht für Mathe)
@@ -121,7 +169,7 @@ function onScroll(e) {
<template>
<div class="guide-layout" :class="{ fokus }">
<nav v-if="anzeige.length" class="guide-nav">
<nav v-if="daten" class="guide-nav">
<div class="schalter-zeile">
<div class="schalter">
<button :class="{ aktiv: ansicht === 'erklaerend' }" title="ausführlich (viel Text)"
@@ -154,7 +202,7 @@ function onScroll(e) {
<span class="zeit">{{ zeit(stats.je[i]?.minuten || 0) }}</span>
</a>
</nav>
<div class="seite guide-text" @scroll="onScroll">
<div ref="scrollEl" class="seite guide-text" @scroll="onScroll">
<button v-if="fokus" class="fokus-aus" title="Fokus beenden"
@click="fokus = false"></button>
<template v-if="daten">
@@ -175,7 +223,7 @@ function onScroll(e) {
<summary>Antworten zu Prüfe dich"</summary>
<div v-for="(f, fi) in s.fragen" :key="fi" class="antwort">
<div class="frage">{{ f.frage }}</div>
<div v-html="render(f.antwort)"></div>
<div v-html="htmlAntwort(s, fi)"></div>
</div>
</details>
</div>

View File

@@ -6,7 +6,7 @@ const props = defineProps({ run: Object })
const daten = ref(null)
async function laden() {
if (!props.run) return
if (!props.run) { daten.value = null; return } // sonst bleiben Alt-Daten stehen
daten.value = await api.kennzahlen(props.run.id)
}
watch(() => props.run?.id, laden, { immediate: true })

View File

@@ -7,6 +7,7 @@ const props = defineProps({ topic: String })
const stapel = ref([])
const zeigeAntwort = ref(false)
const fertigGelernt = ref(0)
const sende = ref(false) // Doppelklick-Sperre: sonst POSTet die Karte zweimal
async function laden() {
if (!props.topic) return
@@ -17,11 +18,17 @@ async function laden() {
watch(() => props.topic, laden, { immediate: true })
async function antworten(richtig) {
const karte = stapel.value[0]
await api.antwort(karte.id, richtig)
stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte]
if (richtig) fertigGelernt.value++
zeigeAntwort.value = false
if (sende.value) return
sende.value = true
try {
const karte = stapel.value[0]
await api.antwort(karte.id, richtig)
stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte]
if (richtig) fertigGelernt.value++
zeigeAntwort.value = false
} finally {
sende.value = false
}
}
</script>
@@ -36,8 +43,10 @@ async function antworten(richtig) {
<template v-else>
<div class="antwort markdown" v-html="render(stapel[0].inhalt.antwort)"></div>
<div style="display: flex; gap: 10px; justify-content: center">
<button style="background: var(--gruen)" @click="antworten(true)">Richtig</button>
<button style="background: var(--rot)" @click="antworten(false)">Falsch</button>
<button style="background: var(--gruen)" :disabled="sende"
@click="antworten(true)">Richtig</button>
<button style="background: var(--rot)" :disabled="sende"
@click="antworten(false)">Falsch</button>
</div>
</template>
</div>