update
This commit is contained in:
1069
frontend/package-lock.json
generated
1069
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
"katex": "^0.17.0",
|
||||
"marked": "^18.0.4",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"mermaid": "^11.16.0",
|
||||
"vue": "^3.5.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -7,6 +7,7 @@ import TopicDetail from './components/TopicDetail.vue'
|
||||
import BausteineUebersicht from './components/BausteineUebersicht.vue'
|
||||
import ElementsSidebar from './components/elements/ElementsSidebar.vue'
|
||||
import ElementsOverview from './components/ElementsOverview.vue'
|
||||
import GeneralExamPanel from './components/GeneralExamPanel.vue'
|
||||
|
||||
const guides = ref([])
|
||||
const backendTopics = ref([])
|
||||
@@ -27,7 +28,9 @@ const provider = ref(localStorage.getItem('provider') || 'claude')
|
||||
const providers = ref([])
|
||||
const folders = ref({ projekt: [], uni: [] }) // Ordner für die Quellen-Auswahl
|
||||
const bausteineView = ref(false) // Bausteine-Übersicht im Hauptbereich
|
||||
const allgemeinView = ref(false) // Allgemeine Prüfung (themenweit) im Hauptbereich
|
||||
const ansichtModus = ref('kompakt') // kompakt | erklärend — je Thema, Default kompakt
|
||||
const stufeAnsicht = ref(Number(localStorage.getItem('stufe')) || 4) // 1=A · 2=F · 3=E · 4=V (Stufen-Ansicht)
|
||||
const stats = ref(null)
|
||||
const fortschritt = ref({})
|
||||
const locks = ref({}) // Sperr-Gründe pro Format (Backend = einzige Regel-Quelle)
|
||||
@@ -192,6 +195,7 @@ function selectTopic(topic) {
|
||||
elementsOpen.value = false
|
||||
elementsView.value = false
|
||||
bausteineView.value = true // Thema-Klick → Baustein-Übersicht (Guide erst auf Pill-Klick)
|
||||
allgemeinView.value = false
|
||||
elementOpenId.value = null
|
||||
ansichtModus.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'kompakt'
|
||||
localStorage.setItem('lastTopic', topic)
|
||||
@@ -266,10 +270,16 @@ function setAnsicht(modus) {
|
||||
if (selectedTopic.value) localStorage.setItem('ansicht_' + selectedTopic.value, modus)
|
||||
}
|
||||
|
||||
function setStufe(k) {
|
||||
stufeAnsicht.value = k
|
||||
localStorage.setItem('stufe', String(k))
|
||||
}
|
||||
|
||||
function handleOpenBausteineView() {
|
||||
if (!selectedTopic.value) return
|
||||
bausteineView.value = true
|
||||
elementsView.value = false
|
||||
allgemeinView.value = false
|
||||
previewGuide.value = null
|
||||
}
|
||||
|
||||
@@ -296,12 +306,22 @@ function handlePreview(guide) {
|
||||
previewGuide.value = guide
|
||||
elementsView.value = false
|
||||
bausteineView.value = false
|
||||
allgemeinView.value = false
|
||||
}
|
||||
|
||||
function handleGeneralExam() {
|
||||
if (!selectedTopic.value) return
|
||||
allgemeinView.value = true
|
||||
bausteineView.value = false
|
||||
elementsView.value = false
|
||||
previewGuide.value = null
|
||||
}
|
||||
|
||||
function handleOpenElements() {
|
||||
if (!selectedTopic.value) return
|
||||
elementsView.value = true
|
||||
bausteineView.value = false
|
||||
allgemeinView.value = false
|
||||
// Rechte Sidebar bleibt zu — sie öffnet erst beim Klick auf ein Element.
|
||||
}
|
||||
|
||||
@@ -380,9 +400,12 @@ onMounted(async () => {
|
||||
:providers="providers"
|
||||
:folders="folders"
|
||||
:ansichtModus="ansichtModus"
|
||||
:stufeAnsicht="stufeAnsicht"
|
||||
@setProvider="setProvider"
|
||||
@toggleDark="toggleDark"
|
||||
@setAnsicht="setAnsicht"
|
||||
@setStufe="setStufe"
|
||||
@generalExam="handleGeneralExam"
|
||||
@select="selectTopic"
|
||||
@create="createTopic"
|
||||
@createThema="handleCreateThema"
|
||||
@@ -413,6 +436,13 @@ onMounted(async () => {
|
||||
:version="elementsVersion"
|
||||
@open="handleOpenElementDetail"
|
||||
/>
|
||||
<GeneralExamPanel
|
||||
v-else-if="selectedTopic && allgemeinView"
|
||||
:topic="selectedTopic"
|
||||
:provider="provider"
|
||||
@progressChanged="loadStats(); loadBausteine()"
|
||||
@fokus-active="fokusOffen = $event"
|
||||
/>
|
||||
<TopicDetail
|
||||
v-else-if="selectedTopic"
|
||||
:previewGuide="previewGuide"
|
||||
@@ -422,6 +452,7 @@ onMounted(async () => {
|
||||
:doneByFormat="doneByFormat"
|
||||
:themaAbgeschlossen="!!fortschritt.abgeschlossen"
|
||||
:ansichtModus="ansichtModus"
|
||||
:stufeAnsicht="stufeAnsicht"
|
||||
@progressChanged="loadStats(); loadBausteine()"
|
||||
@setAnsicht="setAnsicht"
|
||||
@open-sidebar="sidebarSticky = true"
|
||||
|
||||
@@ -146,12 +146,19 @@ export async function deleteGuide(id, slots = false) {
|
||||
await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function fetchGuideContent(id) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/content`)
|
||||
export async function fetchGuideContent(id, ebene = 4) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/content?ebene=${ebene}`)
|
||||
if (!res.ok) throw new Error(`Inhalt nicht verfügbar (${res.status})`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Lern-Artefakte (Karteikarten/Beispiele/Diagramme) je Thema, gruppiert nach Baustein-Norm.
|
||||
export async function fetchArtefakte(topic) {
|
||||
const res = await fetch(`${BASE}/bausteine/artefakte?topic=${encodeURIComponent(topic)}`)
|
||||
if (!res.ok) return { artefakte: {} }
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Einen Markdown-Block on-demand gegen die Guide-Regeln prüfen (Fokus, Rechtsklick).
|
||||
export async function pruefeBlock(id, { baustein, stelle, block, hinweis = '', provider }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, {
|
||||
|
||||
@@ -25,7 +25,9 @@ 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 = 4×relevante Subbausteine.
|
||||
const MODI = ['quiz_leicht', 'quiz_schwer', 'lueck_leicht', 'lueck_schwer', 'erklaeren']
|
||||
// Vier Frageformen, zufällig. Kein leicht/schwer mehr — die Schwierigkeit setzt das Backend
|
||||
// über das Lerner-Niveau (Adressaten-Rolle). lueckwahl = Begriff-Auswahl · lueckentext = frei.
|
||||
const MODI = ['quiz', 'lueckwahl', 'lueckentext', '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)
|
||||
@@ -238,20 +240,20 @@ function naechsterModus() {
|
||||
|
||||
// Ein Frage-Objekt eines bestimmten Modus generieren (aus gezogener Saat; Erklären hat Fallback).
|
||||
function frageEinzeln(modus = naechsterModus()) {
|
||||
const form = modus.startsWith('quiz') ? 'quiz' : modus.startsWith('lueck') ? 'lueckentext' : 'erklaeren'
|
||||
const istSchwer = modus.endsWith('_schwer')
|
||||
const form = modus === 'quiz' ? 'quiz' : modus.startsWith('lueck') ? 'lueckentext' : 'erklaeren'
|
||||
const istLueckFrei = modus === 'lueckentext' // frei tippen vs. lueckwahl = Begriff aus 4
|
||||
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 }))
|
||||
return pruefeBaustein({ ...basis, aktion: 'quiz_frage', muster }) // Single-Choice, Niveau steuert
|
||||
.then((r) => ({ form, schwer: false, frage: r.frage, optionen: r.optionen }))
|
||||
}
|
||||
if (form === 'lueckentext' && muster) {
|
||||
return pruefeBaustein({ ...basis, aktion: 'lueck_frage', muster, schwer: istSchwer })
|
||||
.then((r) => istSchwer
|
||||
return pruefeBaustein({ ...basis, aktion: 'lueck_frage', muster, schwer: istLueckFrei })
|
||||
.then((r) => istLueckFrei
|
||||
? ({ 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 }))
|
||||
}
|
||||
|
||||
@@ -11,12 +11,14 @@ const items = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
|
||||
// Stufen-Reihenfolge + Anzeige-Label (Farbe via CSS-Klasse st-<key>)
|
||||
// Lernpfad-Stufen: Reihenfolge + Label (Farbe via CSS-Klasse st-<key>).
|
||||
const STUFEN = [
|
||||
{ key: 'einfach', label: 'Einfach' },
|
||||
{ key: 'mittel', label: 'Mittel' },
|
||||
{ key: 'schwer', label: 'Schwer' },
|
||||
{ key: 'anfaenger', label: 'Anfänger' },
|
||||
{ key: 'fortgeschritten', label: 'Fortgeschritten' },
|
||||
{ key: 'experte', label: 'Experte' },
|
||||
]
|
||||
// Alt-Themen tragen noch einfach/mittel/schwer → auf die neuen Keys mappen.
|
||||
const ALT_STUFE = { einfach: 'anfaenger', mittel: 'fortgeschritten', schwer: 'experte' }
|
||||
|
||||
watch(() => props.topic, load, { immediate: true })
|
||||
|
||||
@@ -43,7 +45,7 @@ function relevant(b) {
|
||||
// Nur nicht-leere Stufen-Gruppen je Baustein (v-if + v-for nicht auf einem Element)
|
||||
function gruppen(b) {
|
||||
return STUFEN
|
||||
.map((st) => ({ ...st, subs: (b.subbausteine || []).filter((s) => s.stufe === st.key) }))
|
||||
.map((st) => ({ ...st, subs: (b.subbausteine || []).filter((s) => (ALT_STUFE[s.stufe] || s.stufe) === st.key) }))
|
||||
.filter((g) => g.subs.length)
|
||||
}
|
||||
|
||||
@@ -185,9 +187,9 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
|
||||
border-left: 3px solid var(--st);
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
.bk-stufe.st-einfach { --st: var(--success-border); }
|
||||
.bk-stufe.st-mittel { --st: var(--warning-border); }
|
||||
.bk-stufe.st-schwer { --st: var(--danger); }
|
||||
.bk-stufe.st-anfaenger { --st: var(--stufe-anfaenger); }
|
||||
.bk-stufe.st-fortgeschritten { --st: var(--stufe-fortgeschritten); }
|
||||
.bk-stufe.st-experte { --st: var(--stufe-experte); }
|
||||
|
||||
.bk-stufe-label {
|
||||
font-size: 0.66rem;
|
||||
|
||||
106
frontend/src/components/FlashcardWidget.vue
Normal file
106
frontend/src/components/FlashcardWidget.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { renderMarkdownInline } from '../markdown.js'
|
||||
|
||||
const props = defineProps({ karten: { type: Array, default: () => [] } })
|
||||
|
||||
const offen = ref(false)
|
||||
const reihenfolge = ref([])
|
||||
const pos = ref(0)
|
||||
const flipped = ref(false)
|
||||
|
||||
function reset() {
|
||||
reihenfolge.value = props.karten.map((_, i) => i)
|
||||
pos.value = 0
|
||||
flipped.value = false
|
||||
}
|
||||
watch(() => props.karten, reset, { immediate: true })
|
||||
|
||||
const aktuelle = computed(() => props.karten[reihenfolge.value[pos.value]] || null)
|
||||
const zaehler = computed(() => `${Math.min(pos.value + 1, reihenfolge.value.length)} / ${reihenfolge.value.length}`)
|
||||
|
||||
function weiter(gewusst) {
|
||||
if (gewusst) {
|
||||
pos.value++
|
||||
} else {
|
||||
// „Nochmal" → Karte ans Ende der Runde schieben (leichtes Spacing).
|
||||
const [k] = reihenfolge.value.splice(pos.value, 1)
|
||||
reihenfolge.value.push(k)
|
||||
}
|
||||
if (pos.value >= reihenfolge.value.length) pos.value = 0
|
||||
flipped.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="karten.length" class="flashcards">
|
||||
<button class="art-head" @click="offen = !offen">
|
||||
<span class="art-icon">🃏</span> Karteikarten
|
||||
<span class="art-count">{{ karten.length }}</span>
|
||||
<span class="art-toggle">{{ offen ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-if="offen && aktuelle" class="fc-body">
|
||||
<div class="fc-card" :class="{ flipped }" @click="flipped = !flipped">
|
||||
<div class="fc-zaehler">{{ zaehler }}</div>
|
||||
<div v-if="!flipped" class="fc-seite">
|
||||
<span class="fc-label">Frage</span>
|
||||
<div class="fc-text" v-html="renderMarkdownInline(aktuelle.frage)"></div>
|
||||
<span class="fc-hint">Klick zum Umdrehen</span>
|
||||
</div>
|
||||
<div v-else class="fc-seite">
|
||||
<span class="fc-label">Antwort</span>
|
||||
<div class="fc-text" v-html="renderMarkdownInline(aktuelle.antwort)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="flipped" class="fc-aktionen">
|
||||
<button class="fc-btn nochmal" @click="weiter(false)">Nochmal</button>
|
||||
<button class="fc-btn gewusst" @click="weiter(true)">Gewusst</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flashcards { margin-top: 0.75rem; }
|
||||
.art-head {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||
background: none; border: none; cursor: pointer; padding: 0.35rem 0;
|
||||
font-size: 0.82rem; font-weight: 600; color: var(--text-muted);
|
||||
}
|
||||
.art-icon { font-size: 0.95rem; }
|
||||
.art-count {
|
||||
background: var(--panel-soft); border: 1px solid var(--border);
|
||||
border-radius: 999px; padding: 0 0.45rem; font-size: 0.72rem;
|
||||
}
|
||||
.art-toggle { margin-left: auto; color: var(--text-faint); }
|
||||
|
||||
.fc-body { margin-top: 0.5rem; }
|
||||
.fc-card {
|
||||
position: relative; min-height: 120px; cursor: pointer;
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 1rem 1.1rem; background: var(--panel-soft);
|
||||
display: flex; align-items: center; justify-content: center; text-align: center;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.fc-card.flipped { border-color: var(--accent); }
|
||||
.fc-zaehler {
|
||||
position: absolute; top: 6px; right: 10px;
|
||||
font-size: 0.68rem; color: var(--text-faint);
|
||||
}
|
||||
.fc-seite { display: flex; flex-direction: column; gap: 0.4rem; align-items: center; }
|
||||
.fc-label {
|
||||
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: var(--text-faint); font-weight: 700;
|
||||
}
|
||||
.fc-text { font-size: 0.98rem; line-height: 1.45; }
|
||||
.fc-hint { font-size: 0.7rem; color: var(--text-faint); margin-top: 0.2rem; }
|
||||
|
||||
.fc-aktionen { display: flex; gap: 8px; margin-top: 0.5rem; }
|
||||
.fc-btn {
|
||||
flex: 1; padding: 0.5rem; border-radius: 8px; cursor: pointer;
|
||||
font-size: 0.82rem; font-weight: 600; border: 1px solid var(--border-strong);
|
||||
background: var(--panel);
|
||||
}
|
||||
.fc-btn.gewusst { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||||
.fc-btn.nochmal { color: var(--text-muted); }
|
||||
</style>
|
||||
120
frontend/src/components/GeneralExamPanel.vue
Normal file
120
frontend/src/components/GeneralExamPanel.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
// Themenweite Misch-Prüfung: wählt einen Baustein (priorisiert: noch nicht auf Max), darunter
|
||||
// zufällig, und nutzt dafür das normale BausteinPanel. Jeder Baustein hat seinen eigenen
|
||||
// Prüfungs-Slot (pruefungCache), also bleiben die Frage-Pools getrennt. Buchung läuft je
|
||||
// Baustein über die normale Prüfungs-Route. Gesamt-cap = Σ cap_final aller prüfbaren Bausteine.
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { fetchGuides, fetchGuideContent, fetchBausteinLernstand } from '../api.js'
|
||||
import BausteinPanel from './BausteinPanel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
provider: { type: String, default: 'claude' },
|
||||
})
|
||||
const emit = defineEmits(['progressChanged', 'fokus-active'])
|
||||
|
||||
const sections = ref([]) // [{titel, md, kompakt, num}] — prüfbare Sections der Vollfassung
|
||||
const lernstand = ref({}) // titel -> {gute_antworten, streak, cap, cap_aktuell, freie_ebene}
|
||||
const aktiv = ref(null) // aktueller Baustein-Titel
|
||||
const ladefehler = ref(null)
|
||||
|
||||
const pruefbare = computed(() => sections.value.filter((s) => (lernstand.value[s.titel]?.cap || 0) > 0))
|
||||
|
||||
// Gesamt-Fortschritt: Σ min(score, cap_final) / Σ cap_final über alle prüfbaren Bausteine.
|
||||
const gesamt = computed(() => {
|
||||
let score = 0, cap = 0
|
||||
for (const s of pruefbare.value) {
|
||||
const l = lernstand.value[s.titel] || {}
|
||||
score += Math.min(l.gute_antworten || 0, l.cap || 0)
|
||||
cap += l.cap || 0
|
||||
}
|
||||
return { score, cap }
|
||||
})
|
||||
|
||||
const aktivSection = computed(() => sections.value.find((s) => s.titel === aktiv.value) || null)
|
||||
const aktivStatus = computed(() => lernstand.value[aktiv.value] || null)
|
||||
|
||||
// Priorität: Bausteine, die noch nicht auf cap_final sind; darunter zufällig.
|
||||
function waehleBaustein() {
|
||||
const offen = pruefbare.value.filter((s) => {
|
||||
const l = lernstand.value[s.titel] || {}
|
||||
return (l.gute_antworten || 0) < (l.cap || 0)
|
||||
})
|
||||
const pool = offen.length ? offen : pruefbare.value
|
||||
aktiv.value = pool.length ? pool[Math.floor(Math.random() * pool.length)].titel : null
|
||||
}
|
||||
|
||||
async function ladeLernstand() {
|
||||
try {
|
||||
lernstand.value = (await fetchBausteinLernstand(props.topic)).bausteine || {}
|
||||
} catch { /* offline → alter Stand */ }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
emit('fokus-active', true)
|
||||
try {
|
||||
const guides = await fetchGuides()
|
||||
const g = guides
|
||||
.filter((x) => x.topic === props.topic && x.format === 'Guide' && x.status === 'done')
|
||||
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))[0]
|
||||
if (!g) { ladefehler.value = 'Kein fertiger Guide — erst einen Guide erstellen.'; return }
|
||||
const content = await fetchGuideContent(g.id, 4) // Vollfassung (alle Subs)
|
||||
sections.value = (content.chapters || [])
|
||||
.flatMap((ch) => ch.sections || [])
|
||||
.filter((s) => s.pruefbar !== false)
|
||||
.map((s) => ({ titel: s.title, md: s.md, kompakt: s.kompakt, num: s.num }))
|
||||
await ladeLernstand()
|
||||
waehleBaustein()
|
||||
} catch {
|
||||
ladefehler.value = 'Konnte die Prüfung nicht laden.'
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => emit('fokus-active', false))
|
||||
|
||||
async function onStatus() {
|
||||
await ladeLernstand() // cap_aktuell/freie_ebene können nach einer Antwort gewachsen sein
|
||||
emit('progressChanged')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ge-panel">
|
||||
<div class="ge-head">
|
||||
<h2>Allgemeine Prüfung</h2>
|
||||
<span v-if="!ladefehler" class="ge-score">{{ gesamt.score }} / {{ gesamt.cap }}</span>
|
||||
<button v-if="aktiv" class="ge-next" title="Anderen Baustein prüfen" @click="waehleBaustein">⟳ Anderer Baustein</button>
|
||||
</div>
|
||||
|
||||
<p v-if="ladefehler" class="ge-msg">{{ ladefehler }}</p>
|
||||
<p v-else-if="!pruefbare.length" class="ge-msg">Noch keine prüfbaren Bausteine.</p>
|
||||
<p v-else-if="!aktiv" class="ge-msg">Alles auf Max — nichts mehr zu prüfen. 🎉</p>
|
||||
|
||||
<div v-else class="ge-body">
|
||||
<div class="ge-baustein">Baustein: <strong>{{ aktiv }}</strong></div>
|
||||
<BausteinPanel
|
||||
:key="aktiv"
|
||||
mode="full"
|
||||
:topic="topic"
|
||||
:baustein="aktiv"
|
||||
:section="aktivSection?.md || ''"
|
||||
:section-kompakt="aktivSection?.kompakt || ''"
|
||||
:provider="provider"
|
||||
:status="aktivStatus"
|
||||
:cap="aktivStatus?.cap || 0"
|
||||
@statusChanged="onStatus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ge-panel { padding: 16px 20px; max-width: 900px; margin: 0 auto; }
|
||||
.ge-head { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; }
|
||||
.ge-head h2 { margin: 0; font-size: 1.1rem; }
|
||||
.ge-score { font-variant-numeric: tabular-nums; color: var(--text-muted); font-weight: 600; }
|
||||
.ge-next { margin-left: auto; padding: 5px 10px; border: 1px solid var(--border-strong); border-radius: 6px; background: var(--bg); cursor: pointer; }
|
||||
.ge-next:hover { color: var(--accent-hover); }
|
||||
.ge-msg { color: var(--text-muted); }
|
||||
.ge-baustein { margin-bottom: 8px; color: var(--text-muted); font-size: 0.9rem; }
|
||||
</style>
|
||||
@@ -1,11 +1,22 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { fetchGuideContent, chatGuide, fetchBausteinLernstand } from '../api.js'
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
import { fetchGuideContent, chatGuide, fetchBausteinLernstand, fetchArtefakte } from '../api.js'
|
||||
import { renderMarkdown, renderMermaid } from '../markdown.js'
|
||||
import { stufeFuer, schwelle } from '../stufen.js'
|
||||
import { useChat } from '../composables/useChat.js'
|
||||
import BausteinPanel from './BausteinPanel.vue'
|
||||
import BausteinFokus from './BausteinFokus.vue'
|
||||
import FlashcardWidget from './FlashcardWidget.vue'
|
||||
import WorkedExampleBlock from './WorkedExampleBlock.vue'
|
||||
|
||||
// Titel-Normalisierung wie backend _norm_titel (casefold ≈ toLowerCase + ß→ss) — für das
|
||||
// Andocken der Artefakte (gekeyt nach baustein_norm) an die Section-Titel.
|
||||
function normTitel(s) {
|
||||
return (s || '').normalize('NFKC')
|
||||
.replace(/[`'"<>„“”‚’«»*_]/g, '').replace(/[–—‐]/g, '-')
|
||||
.replace(/\s+/g, ' ').trim().replace(/^[.:;]+|[.:;]+$/g, '').trim()
|
||||
.toLowerCase().replace(/ß/g, 'ss')
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
previewGuide: { type: Object, default: null },
|
||||
@@ -15,6 +26,7 @@ const props = defineProps({
|
||||
doneByFormat: { type: Object, default: () => ({}) }, // Format → fertiger Guide (Themen-bezogen)
|
||||
themaAbgeschlossen: { type: Boolean, default: false },
|
||||
ansichtModus: { type: String, default: 'kompakt' }, // kompakt | erklärend
|
||||
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
|
||||
})
|
||||
|
||||
const emit = defineEmits(['progressChanged', 'setAnsicht', 'openSidebar', 'fokusActive'])
|
||||
@@ -27,6 +39,11 @@ const content = ref(null)
|
||||
const loadError = ref(null)
|
||||
const scrollEl = ref(null)
|
||||
const lernstand = ref({}) // Prüfungs-Stand pro Baustein-Titel — VOR dem immediate-Watch (loadContent nutzt es)
|
||||
const artefakte = ref({}) // baustein_norm → {karteikarte[], beispiel[], diagramm}
|
||||
|
||||
function artefakteVon(title) {
|
||||
return artefakte.value[normTitel(title)] || null
|
||||
}
|
||||
|
||||
// --- Lazy-Render + Markdown-Cache: nur sichtbare Sections parsen, jede nur einmal.
|
||||
// Behebt das Blockieren beim Öffnen (160× marked/highlight.js) und Re-Parse bei jedem Update. ---
|
||||
@@ -53,15 +70,20 @@ function setupLazy() {
|
||||
if (!e.isIntersecting) continue
|
||||
sichtbar[Number(e.target.dataset.num)] = true
|
||||
mdObserver.unobserve(e.target)
|
||||
nextTick(() => renderMermaid(e.target)) // Diagramme der nun sichtbaren Section rendern
|
||||
}
|
||||
}, { root: scrollEl.value, rootMargin: '800px 0px' })
|
||||
for (const el of scrollEl.value.querySelectorAll('.section-card')) mdObserver.observe(el)
|
||||
}
|
||||
|
||||
watch(content, () => nextTick(setupLazy))
|
||||
// Artefakte kommen asynchron nach dem Content — bereits sichtbare Diagramme nachrendern.
|
||||
watch(artefakte, () => nextTick(() => renderMermaid(scrollEl.value)))
|
||||
onUnmounted(() => mdObserver?.disconnect())
|
||||
|
||||
watch(() => props.previewGuide?.id, loadContent, { immediate: true })
|
||||
// Stufen-Ansicht (E/M/S/F) gewechselt → Guide-Inhalt neu mit passendem Ebenen-Filter laden.
|
||||
watch(() => props.stufeAnsicht, loadContent)
|
||||
|
||||
async function loadContent() {
|
||||
content.value = null
|
||||
@@ -72,7 +94,7 @@ async function loadContent() {
|
||||
const g = props.previewGuide
|
||||
if (!g || g.status !== 'done') return
|
||||
try {
|
||||
content.value = await fetchGuideContent(g.id)
|
||||
content.value = await fetchGuideContent(g.id, props.stufeAnsicht)
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden des Guides:', e)
|
||||
loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).'
|
||||
@@ -81,6 +103,9 @@ async function loadContent() {
|
||||
try {
|
||||
lernstand.value = (await fetchBausteinLernstand(g.topic)).bausteine || {}
|
||||
} catch { /* offline → leer */ }
|
||||
try {
|
||||
artefakte.value = (await fetchArtefakte(g.topic)).artefakte || {}
|
||||
} catch { artefakte.value = {} }
|
||||
// Beim Öffnen zum ersten noch nicht gemeisterten prüfbaren Baustein scrollen.
|
||||
await nextTick()
|
||||
const ziel = bausteine.value.find((s) => istPruefbar(s) && stufeVon(s.title)?.key !== 'meister')
|
||||
@@ -277,6 +302,11 @@ function extractContext() {
|
||||
</h3>
|
||||
<div v-if="sichtbar[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div>
|
||||
<div v-else class="section-body skeleton"></div>
|
||||
<template v-if="sichtbar[s.num] && artefakteVon(s.title)">
|
||||
<pre v-if="artefakteVon(s.title).diagramm" class="mermaid art-diagramm">{{ artefakteVon(s.title).diagramm.mermaid }}</pre>
|
||||
<WorkedExampleBlock :beispiele="artefakteVon(s.title).beispiel || []" />
|
||||
<FlashcardWidget :karten="artefakteVon(s.title).karteikarte || []" />
|
||||
</template>
|
||||
<BausteinPanel
|
||||
v-if="istPruefbar(s)"
|
||||
mode="trigger"
|
||||
@@ -519,6 +549,19 @@ function extractContext() {
|
||||
damit der IntersectionObserver die folgenden Karten gestaffelt erkennt. */
|
||||
.section-body.skeleton { min-height: 160px; }
|
||||
|
||||
/* Diagramm-Artefakt (Mermaid): zentriert, dezent gerahmt. Vor dem Rendern zeigt
|
||||
<pre> den Quelltext — danach ersetzt mermaid.run ihn durch SVG. */
|
||||
.art-diagramm {
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--panel-soft);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.art-diagramm :deep(svg) { max-width: 100%; height: auto; }
|
||||
|
||||
.empty-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -23,9 +23,10 @@ const props = defineProps({
|
||||
providers: { type: Array, default: () => [] },
|
||||
folders: { type: Object, default: () => ({ projekt: [], uni: [] }) },
|
||||
ansichtModus: { type: String, default: 'kompakt' }, // kompakt | erklärend
|
||||
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'create', 'createThema', 'updateQuelle', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setProvider'])
|
||||
const emit = defineEmits(['select', 'create', 'createThema', 'updateQuelle', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider'])
|
||||
|
||||
// Accordion: höchstens ein Panel offen. IDs: 'bausteine', 'fmt-<Format>', 'topic-<Name>'.
|
||||
const openPanel = ref(null)
|
||||
@@ -54,8 +55,14 @@ const trackerItems = computed(() => {
|
||||
|
||||
const formats = [
|
||||
{ key: 'Guide', label: 'Guide' },
|
||||
{ key: 'FullGuide', label: 'FullGuide' },
|
||||
{ key: 'Rest', label: 'Rest' },
|
||||
]
|
||||
|
||||
// Stufen-Ansichten des EINEN Guides (gefiltert nach Subbaustein-Ebene).
|
||||
const STUFEN_ANSICHT = [
|
||||
{ k: 1, label: 'A', titel: 'Anfänger' },
|
||||
{ k: 2, label: 'F', titel: 'Anfänger + Fortgeschritten' },
|
||||
{ k: 3, label: 'E', titel: 'bis Experte' },
|
||||
{ k: 4, label: 'V', titel: 'Vollständig (inkl. Rand)' },
|
||||
]
|
||||
|
||||
const bausteineState = computed(() => {
|
||||
@@ -317,6 +324,11 @@ function saveQuelle() {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="selectedTopic" class="stufe-toggle" title="Tiefe der Guide-Ansicht">
|
||||
<button v-for="s in STUFEN_ANSICHT" :key="s.k"
|
||||
:class="{ active: stufeAnsicht === s.k }" :title="s.titel"
|
||||
@click="emit('setStufe', s.k)">{{ s.label }}</button>
|
||||
</div>
|
||||
<button class="new-topic-toggle" :class="{ active: dlg }" title="Thema erstellen" @click="toggleErstellen">+</button>
|
||||
</div>
|
||||
|
||||
@@ -462,6 +474,11 @@ function saveQuelle() {
|
||||
<button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="format-row ord-pruefung">
|
||||
<button class="format-name elements-btn" @click="emit('generalExam')">
|
||||
<span class="format-label">Allgemeine Prüfung</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="format-row ord-elemente">
|
||||
<button class="format-name elements-btn" @click="emit('openElements')">
|
||||
<span class="format-label">Elemente</span>
|
||||
@@ -607,6 +624,21 @@ function saveQuelle() {
|
||||
.new-topic .ansicht-toggle button:hover { color: var(--accent-hover); }
|
||||
.new-topic .ansicht-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
.ansicht-toggle svg { fill: currentColor; display: block; }
|
||||
.stufe-toggle { display: inline-flex; margin-left: 4px; }
|
||||
.new-topic .stufe-toggle button {
|
||||
padding: 4px 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-strong);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 20px;
|
||||
}
|
||||
.new-topic .stufe-toggle button:first-child { border-radius: 6px 0 0 6px; }
|
||||
.new-topic .stufe-toggle button:last-child { border-radius: 0 6px 6px 0; }
|
||||
.new-topic .stufe-toggle button:not(:first-child) { border-left: none; }
|
||||
.new-topic .stufe-toggle button:hover { color: var(--accent-hover); }
|
||||
.new-topic .stufe-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
@@ -800,10 +832,14 @@ function saveQuelle() {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.ord-elemente {
|
||||
.ord-pruefung {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.ord-elemente {
|
||||
order: 5;
|
||||
}
|
||||
|
||||
.elements-btn {
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
|
||||
67
frontend/src/components/WorkedExampleBlock.vue
Normal file
67
frontend/src/components/WorkedExampleBlock.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { renderMarkdownInline } from '../markdown.js'
|
||||
|
||||
defineProps({ beispiele: { type: Array, default: () => [] } })
|
||||
const offen = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="beispiele.length" class="worked">
|
||||
<button class="art-head" @click="offen = !offen">
|
||||
<span class="art-icon">📝</span> Beispiele
|
||||
<span class="art-count">{{ beispiele.length }}</span>
|
||||
<span class="art-toggle">{{ offen ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-if="offen" class="we-body">
|
||||
<div v-for="(b, i) in beispiele" :key="i" class="we-card">
|
||||
<div v-if="b.subbaustein" class="we-sub">{{ b.subbaustein }}</div>
|
||||
<div class="we-problem" v-html="renderMarkdownInline(b.problem)"></div>
|
||||
<ol class="we-schritte">
|
||||
<li v-for="(s, j) in b.schritte" :key="j" v-html="renderMarkdownInline(s)"></li>
|
||||
</ol>
|
||||
<div v-if="b.ergebnis" class="we-ergebnis">
|
||||
<span class="we-label">Ergebnis</span>
|
||||
<span v-html="renderMarkdownInline(b.ergebnis)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.worked { margin-top: 0.5rem; }
|
||||
.art-head {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||
background: none; border: none; cursor: pointer; padding: 0.35rem 0;
|
||||
font-size: 0.82rem; font-weight: 600; color: var(--text-muted);
|
||||
}
|
||||
.art-icon { font-size: 0.95rem; }
|
||||
.art-count {
|
||||
background: var(--panel-soft); border: 1px solid var(--border);
|
||||
border-radius: 999px; padding: 0 0.45rem; font-size: 0.72rem;
|
||||
}
|
||||
.art-toggle { margin-left: auto; color: var(--text-faint); }
|
||||
|
||||
.we-body { margin-top: 0.4rem; display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.we-card {
|
||||
border: 1px solid var(--border); border-left: 3px solid var(--accent);
|
||||
border-radius: 8px; padding: 0.7rem 0.9rem; background: var(--panel-soft);
|
||||
font-size: 0.92rem; line-height: 1.5;
|
||||
}
|
||||
.we-sub {
|
||||
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: var(--text-faint); font-weight: 700; margin-bottom: 0.3rem;
|
||||
}
|
||||
.we-problem { font-weight: 600; margin-bottom: 0.4rem; }
|
||||
.we-schritte { margin: 0 0 0.4rem 1.1rem; padding: 0; }
|
||||
.we-schritte li { margin: 0.2rem 0; }
|
||||
.we-ergebnis {
|
||||
display: flex; gap: 6px; align-items: baseline;
|
||||
padding-top: 0.35rem; border-top: 1px dashed var(--border);
|
||||
}
|
||||
.we-label {
|
||||
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: var(--success); font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,17 @@ import katex from 'katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
// Mermaid lazy laden (schwere Lib): erst beim ersten Diagramm, hält das Initial-Bundle klein.
|
||||
// startOnLoad:false → wir rendern gezielt; securityLevel strict: kein HTML/JS in Graphen.
|
||||
let _mermaid = null
|
||||
async function getMermaid() {
|
||||
if (!_mermaid) {
|
||||
_mermaid = (await import('mermaid')).default
|
||||
_mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default', flowchart: { htmlLabels: false } })
|
||||
}
|
||||
return _mermaid
|
||||
}
|
||||
|
||||
marked.use(markedHighlight({
|
||||
langPrefix: 'hljs language-',
|
||||
highlight(code, lang) {
|
||||
@@ -49,7 +60,33 @@ const inlineMath = {
|
||||
renderer(token) { return renderTex(token.text, false) },
|
||||
}
|
||||
|
||||
marked.use({ extensions: [blockMath, inlineMath] })
|
||||
// ```mermaid → <pre class="mermaid">…</pre> (Quelltext escaped; mermaid.run liest den textContent).
|
||||
const mermaidBlock = {
|
||||
name: 'mermaidBlock',
|
||||
level: 'block',
|
||||
start(src) { const i = src.indexOf('```mermaid'); return i < 0 ? undefined : i },
|
||||
tokenizer(src) {
|
||||
const m = /^```mermaid[^\n]*\n([\s\S]+?)\n```/.exec(src)
|
||||
if (m) return { type: 'mermaidBlock', raw: m[0], text: m[1] }
|
||||
},
|
||||
renderer(token) {
|
||||
const esc = token.text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
return `<pre class="mermaid">${esc}</pre>`
|
||||
},
|
||||
}
|
||||
|
||||
marked.use({ extensions: [blockMath, inlineMath, mermaidBlock] })
|
||||
|
||||
// Mermaid-Knoten in einem Container rendern (nach v-html). Idempotent: bereits
|
||||
// verarbeitete (data-processed) überspringt mermaid.run; defekte Graphen bleiben als Text.
|
||||
export async function renderMermaid(root) {
|
||||
const nodes = root?.querySelectorAll?.('pre.mermaid:not([data-processed="true"])')
|
||||
if (!nodes || !nodes.length) return
|
||||
try {
|
||||
const m = await getMermaid()
|
||||
await m.run({ nodes })
|
||||
} catch { /* defektes Diagramm → Quelltext bleibt sichtbar */ }
|
||||
}
|
||||
|
||||
// Rohes HTML im Markdown (z. B. <p>, <img> ohne Backticks aus Agenten-Output)
|
||||
// als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Lernstufen je Baustein — identisch zum Backend (lernen.py STUFEN).
|
||||
// Floor in % des cap (cap = clamp(4×relevante Subbausteine, 10, 50)).
|
||||
// Floor in % des cap_final (cap_final = alle Subbausteine × 25).
|
||||
// grün=Anfänger 20% · blau=Fortgeschritten 40% · lila=Experte 60% · gold=Meister 100%.
|
||||
export const STUFEN = [
|
||||
{ key: 'anfaenger', label: 'Anfänger', kurz: '✓', floor: 0.2, farbe: 'var(--stufe-anfaenger)' },
|
||||
@@ -25,11 +25,11 @@ export function naechste(score, cap) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Fehler-Strafe-Anzeige nach Fortschritt: 0–20%→0 · 21–40%→−1 · 41–60%→−2 · 61–100%→−3.
|
||||
// Fehler-Strafe-Anzeige nach Fortschritt (gegen cap_aktuell): ≤25%→−5 · ≤50%→−10 · ≤75%→−15 · >75%→−20.
|
||||
export function malusRegel(score, cap) {
|
||||
const pct = cap ? score / cap : 0
|
||||
if (pct <= 0.2) return '0'
|
||||
if (pct <= 0.4) return '−1'
|
||||
if (pct <= 0.6) return '−2'
|
||||
return '−3'
|
||||
if (pct <= 0.25) return '−5'
|
||||
if (pct <= 0.5) return '−10'
|
||||
if (pct <= 0.75) return '−15'
|
||||
return '−20'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user