This commit is contained in:
team3
2026-07-13 05:18:55 +02:00
parent 7ee425f7fa
commit 8926f87185
19 changed files with 703 additions and 41 deletions

View File

@@ -25,6 +25,9 @@ export const api = {
req(`/api/topics/${e(topic)}/auto`, { method: 'PATCH', body: JSON.stringify({ ebene, an }) }),
ebeneEntfernen: (topic, ebene) =>
req(`/api/topics/${e(topic)}/ebene/${e(ebene)}/entfernen`, { method: 'POST', body: '{}' }),
ebeneAktualisieren: (topic, ebene, tief = false) =>
req(`/api/topics/${e(topic)}/ebene/${e(ebene)}/aktualisieren${tief ? '?tief=1' : ''}`,
{ method: 'POST', body: '{}' }),
topicLoeschen: (topic) => req(`/api/topics/${e(topic)}`, { method: 'DELETE' }),
state: (topic) => req(`/api/topics/${e(topic)}/state`),
guide: (topic) => req(`/api/topics/${e(topic)}/guide`),
@@ -36,6 +39,11 @@ export const api = {
lernstand: (topic) => req(`/api/topics/${e(topic)}/lernstand`),
bausteinFertig: (topic, bausteinId) =>
req(`/api/topics/${e(topic)}/baustein/${e(bausteinId)}/fertig`, { method: 'POST', body: '{}' }),
pruefung: (topic, bausteinId) =>
req(`/api/topics/${e(topic)}/baustein/${e(bausteinId)}/pruefung`),
pruefungAntwort: (topic, ids, angekreuzt) =>
req(`/api/topics/${e(topic)}/pruefung/antwort`,
{ method: 'POST', body: JSON.stringify({ ids, angekreuzt }) }),
kennzahlen: (runId) => req(`/api/runs/${e(runId)}/kennzahlen`),
transferInfo: () => req('/api/transfer'),
transfer: (topic, richtung) =>

View File

@@ -104,6 +104,14 @@ async function menueGenerieren() {
catch (e) { emit('fehler', String(e)) } // sonst schlägt der Start still fehl
}
async function menueAktualisieren(tief = false) {
const m = menue.value
menue.value = null
if (!m || props.state?.run?.status === 'running') return
try { await api.ebeneAktualisieren(props.topic, m.key, tief); emit('reload') }
catch (e) { emit('fehler', String(e)) }
}
async function menueEntfernen() {
const m = menue.value
menue.value = null
@@ -167,6 +175,12 @@ const k = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.ro
<button :disabled="!generierenErlaubt(menue.idx)" @click.stop="menueGenerieren">
Generieren
</button>
<button :disabled="state?.run?.status === 'running'" @click.stop="menueAktualisieren(false)">
Aktualisieren (prüfen + auffüllen)
</button>
<button :disabled="state?.run?.status === 'running'" @click.stop="menueAktualisieren(true)">
Tief aktualisieren (alles neu verifizieren)
</button>
<button class="rot" @click.stop="menueEntfernen">
{{ menue.titel }}" + folgende Ebenen entfernen
</button>

View File

@@ -6,12 +6,14 @@ import { render, lesestat } from '../markdown.js'
const props = defineProps({ daten: Object, topic: String })
const emit = defineEmits(['schliessen'])
const SCHWELLE = 10 // Anzeige; die Wahrheit entscheidet der Server (PRUEFUNG_SCHWELLE)
const wurzel = ref(null)
const pos = ref(0)
const fertig = ref(new Set()) // baustein-ids mit abgeschlossenem Abruf
const fertig = ref(new Set()) // baustein-ids mit bestandener Prüfung
const xpMap = ref({}) // baustein-id → Saldo
// Flache Screen-Liste aus den Guide-Daten (alle Kapitel/Level in ord):
// je Kapitel ein Intro, je Baustein Fundament + Abruf, am Ende ein Abschluss.
// Screen-Liste: je Kapitel ein Intro, je Baustein Fundament → Abruf → Prüfung.
const screens = computed(() => {
const out = []
const kaps = props.daten?.kapitel || []
@@ -20,6 +22,7 @@ const screens = computed(() => {
for (const s of kap.sections) {
out.push({ typ: 'fundament', baustein: s.baustein, titel: s.titel, lang: s.lang })
out.push({ typ: 'abruf', baustein: s.baustein, titel: s.titel })
out.push({ typ: 'pruefung', baustein: s.baustein, titel: s.titel })
}
})
out.push({ typ: 'ende' })
@@ -45,26 +48,20 @@ function fundamentHtml(s) {
return htmlCache.get(s.baustein)
}
// ── Navigation ───────────────────────────────────────────────────────────────
// Kein Zwang, alle Karten zu sehen: Weiter ist immer frei. Verlässt man einen
// Abruf-Screen vorwärts, gilt der Baustein als erledigt (Fortschritt persistiert).
function vor() {
if (pos.value >= screens.value.length - 1) return
if (screen.value.typ === 'abruf') markFertig(screen.value.baustein)
pos.value++
}
// ── Navigation + Prüfungs-Gate ─────────────────────────────────────────────────
// Lesen + Abruf sind frei. Nur die Prüfung sperrt: weiter erst ab Saldo ≥ Schwelle.
// 0 Panels sperren NICHT (leerer Baustein darf den Guide nie blockieren).
const gesperrt = computed(() =>
screen.value.typ === 'pruefung' && panels.value.length > 0
&& !fertig.value.has(screen.value.baustein))
function vor() { if (!gesperrt.value && pos.value < screens.value.length - 1) pos.value++ }
function zurueck() { if (pos.value > 0) pos.value-- }
function taste(e) {
if (e.key === 'ArrowRight') vor()
else if (e.key === 'ArrowLeft') zurueck()
}
async function markFertig(bausteinId) {
if (fertig.value.has(bausteinId)) return
fertig.value = new Set([...fertig.value, bausteinId])
await api.bausteinFertig(props.topic, bausteinId).catch(() => {})
}
// ── Abrufprüfung (Karteikarten, Loop wie Ueben.vue) ─────────────────────────────
// ── Abrufprüfung (Karteikarten) ────────────────────────────────────────────────
const stapel = ref([])
const zeigeAntwort = ref(false)
const sende = ref(false)
@@ -81,26 +78,73 @@ async function antworten(richtig) {
await api.antwort(karte.id, richtig)
stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte]
zeigeAntwort.value = false
if (!stapel.value.length) markFertig(screen.value.baustein)
} finally { sende.value = false }
}
// Beim Betreten eines Abruf-Screens die Karten des Bausteins laden.
// ── Prüfung (4er-Panels, exakte Teilmenge ankreuzen) ───────────────────────────
const panels = ref([])
const panelIdx = ref(0)
const auswahl = ref(new Set())
const aufloesung = ref(null) // nach dem Prüfen: [{id, wahr, erklaerung}]
const warRichtig = ref(false)
const saldo = ref(0)
const panel = computed(() => panels.value[panelIdx.value]?.panel || [])
async function pruefungLaden(bausteinId) {
panels.value = await api.pruefung(props.topic, bausteinId)
panelIdx.value = 0; auswahl.value = new Set(); aufloesung.value = null
saldo.value = xpMap.value[bausteinId] || 0
if (!panels.value.length && !fertig.value.has(bausteinId)) {
// keine Aussagen generiert → Baustein nicht blockieren, als erledigt werten
fertig.value = new Set([...fertig.value, bausteinId])
await api.bausteinFertig(props.topic, bausteinId).catch(() => {})
}
}
function toggle(id) {
if (aufloesung.value) return
const s = new Set(auswahl.value)
s.has(id) ? s.delete(id) : s.add(id)
auswahl.value = s
}
async function pruefen() {
if (aufloesung.value || sende.value) return
sende.value = true
try {
const ids = panel.value.map(x => x.id)
const r = await api.pruefungAntwort(props.topic, ids, [...auswahl.value])
aufloesung.value = r.aufloesung
warRichtig.value = r.richtig
saldo.value = r.xp
xpMap.value = { ...xpMap.value, [screen.value.baustein]: r.xp }
if (r.fertig) fertig.value = new Set([...fertig.value, screen.value.baustein])
} finally { sende.value = false }
}
async function naechstesPanel() {
aufloesung.value = null; auswahl.value = new Set()
if (panelIdx.value < panels.value.length - 1) panelIdx.value++
else await pruefungLaden(screen.value.baustein) // neu mischen, Nachschub
}
function urteil(id) {
const a = (aufloesung.value || []).find(x => x.id === id)
return a ? a.wahr : null
}
watch(screen, (s) => {
if (s.typ === 'abruf') abrufLaden(s.baustein)
else if (s.typ === 'pruefung') pruefungLaden(s.baustein)
})
// ── Fullscreen + Resume ────────────────────────────────────────────────────────
// ── Fullscreen + Resume ────────────────────────────────────────────────────────
function schliessen() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
emit('schliessen')
}
onMounted(async () => {
window.addEventListener('keydown', taste)
try { await wurzel.value?.requestFullscreen() } catch { /* CSS-Overlay reicht als Fallback */ }
try { await wurzel.value?.requestFullscreen() } catch { /* CSS-Overlay reicht */ }
const stand = await api.lernstand(props.topic).catch(() => [])
fertig.value = new Set(stand.filter(s => s.status === 'fertig').map(s => s.baustein_id))
// Resume: zum Fundament des ersten noch nicht erledigten Bausteins springen.
xpMap.value = Object.fromEntries(stand.map(s => [s.baustein_id, s.xp]))
const idx = screens.value.findIndex(s => s.typ === 'fundament' && !fertig.value.has(s.baustein))
pos.value = idx >= 0 ? idx : screens.value.length - 1
})
@@ -151,6 +195,32 @@ onUnmounted(() => {
<div v-else class="lm-bestanden"> Abruf durch weiter mit </div>
</section>
<section v-else-if="screen.typ === 'pruefung'" class="lm-screen lm-pruefung">
<div class="lm-label">Prüfung · {{ screen.titel }} · Saldo {{ saldo }} / {{ SCHWELLE }}</div>
<template v-if="panel.length">
<h3 class="pf-frage">Was gilt? <span class="pf-hinweis">(0{{ panel.length }} Aussagen können wahr sein)</span></h3>
<div class="pf-panel">
<label v-for="x in panel" :key="x.id" class="pf-aussage"
:class="{ gewaehlt: auswahl.has(x.id),
'pf-wahr': urteil(x.id) === true,
'pf-falsch2': urteil(x.id) === false }"
@click.prevent="toggle(x.id)">
<input type="checkbox" :checked="auswahl.has(x.id)" @click.stop="toggle(x.id)" />
<span class="markdown" v-html="render(x.text)"></span>
</label>
</div>
<div v-if="aufloesung" class="pf-feedback" :class="warRichtig ? 'pf-fb-ok' : 'pf-fb-bad'">
{{ warRichtig ? ' Panel korrekt (+1)' : ' Nicht exakt (1)' }}
</div>
<div class="pf-akt">
<button v-if="!aufloesung" @click="pruefen">Prüfen</button>
<button v-else @click="naechstesPanel">Nächstes Panel </button>
<span v-if="fertig.has(screen.baustein)" class="lm-bestanden" style="margin: 0"> bestanden</span>
</div>
</template>
<div v-else class="lm-bestanden">Keine Prüfungs-Aussagen für diesen Baustein weiter mit </div>
</section>
<section v-else class="lm-screen lm-ende">
<h1>Fundament abgeschlossen 🎉</h1>
<p class="lm-introtext">Schritt 2 (Anwendung) und Schritt 3 (eigene Aufgaben) folgen.</p>
@@ -159,7 +229,9 @@ onUnmounted(() => {
<footer class="lm-fuss">
<button :disabled="pos === 0" @click="zurueck"> Zurück</button>
<button :disabled="pos >= screens.length - 1" @click="vor">Weiter </button>
<button :disabled="gesperrt || pos >= screens.length - 1" @click="vor">
{{ gesperrt ? `Saldo ${saldo} / ${SCHWELLE} weiter prüfen` : 'Weiter ' }}
</button>
</footer>
</div>
</template>

View File

@@ -323,3 +323,24 @@ table.kennzahlen th { color: var(--dim); }
.lm-rest { margin-top: 16px; color: var(--dim); font-size: 13px; }
.lm-bestanden { margin-top: 8vh; font-size: 18px; color: var(--akzent); text-align: center; }
.lm-ende { text-align: center; padding-top: 14vh; }
/* Prüfung (4er-Panels) */
.pf-frage { margin: 4vh 0 14px; }
.pf-hinweis { font-size: 14px; color: var(--dim); font-weight: 400; }
.pf-panel { display: flex; flex-direction: column; gap: 10px; }
.pf-aussage {
display: flex; gap: 12px; align-items: flex-start; cursor: pointer;
padding: 12px 14px; border: 1px solid var(--rand); border-radius: 10px;
background: var(--karte);
}
.pf-aussage input { margin-top: 5px; flex: 0 0 auto; }
.pf-aussage.gewaehlt { border-color: var(--akzent); }
.pf-aussage.pf-wahr { border-color: #2f7d32; background: color-mix(in srgb, #2f7d32 14%, var(--karte)); }
.pf-aussage.pf-falsch2 { border-color: #a23b3b; background: color-mix(in srgb, #a23b3b 12%, var(--karte)); }
.pf-feedback { margin-top: 14px; padding: 10px 14px; border-radius: 8px; font-size: 15px; }
.pf-fb-ok { background: color-mix(in srgb, #2f7d32 18%, transparent); }
.pf-fb-bad { background: color-mix(in srgb, #a23b3b 18%, transparent); }
.pf-akt { display: flex; gap: 14px; align-items: center; margin-top: 16px; }
.pf-akt button {
padding: 10px 22px; border: 1px solid var(--rand); border-radius: 8px;
background: var(--panel); color: var(--text); cursor: pointer; font-size: 15px;
}