feat(vocab): enhance vocabulary exercises and localization support
All checks were successful
Deploy to production / deploy (push) Successful in 2m51s

- Updated core patterns in BISAYA_PHASE5_DIDACTICS to include gloss translations for better understanding.
- Refactored vocabulary exercise generation in update-food-care-exercises.js to improve randomization and user engagement.
- Added new exercise types and improved question structures for vocabulary lessons, enhancing the learning experience.
- Enhanced localization files for German, English, and Spanish to support new exercise features and improve user guidance.
- Updated VocabLessonView to incorporate sequential navigation for exercises, providing a more structured learning flow.
This commit is contained in:
Torsten Schulz (local)
2026-04-07 09:09:43 +02:00
parent d192bcae2d
commit e17f0cdce0
6 changed files with 348 additions and 38 deletions

View File

@@ -125,8 +125,14 @@
: $t('socialnetwork.vocab.courses.vocabPrepStep2') }}
</p>
<div class="vocab-prep-card">
<div class="vocab-prep-card__target">{{ currentPrepItem.target }}</div>
<div v-if="currentPrepItem.gloss" class="vocab-prep-card__gloss">{{ currentPrepItem.gloss }}</div>
<div class="vocab-prep-card__row">
<span class="vocab-prep-card__label">{{ $t('socialnetwork.vocab.courses.vocabPrepTargetLabel') }}</span>
<div class="vocab-prep-card__target">{{ currentPrepItem.target }}</div>
</div>
<div class="vocab-prep-card__row">
<span class="vocab-prep-card__label">{{ $t('socialnetwork.vocab.courses.vocabPrepGlossLabel') }}</span>
<div class="vocab-prep-card__gloss">{{ currentPrepItem.gloss || '—' }}</div>
</div>
</div>
<button type="button" class="btn-prep-pass" @click="advancePrepPass">
{{ isLastPrepItemInPass
@@ -487,9 +493,38 @@
</article>
</div>
</div>
<div
v-if="sequentialPanelActive && scrambledChapterExamExercises.length > 0"
class="exercise-sequential-nav surface-card"
>
<p class="exercise-sequential-nav__progress">
{{ $t('socialnetwork.vocab.courses.exerciseSequentialProgress', {
current: exerciseSequentialIndex + 1,
total: scrambledChapterExamExercises.length
}) }}
</p>
<div class="exercise-sequential-nav__buttons">
<button
type="button"
class="btn-seq"
:disabled="exerciseSequentialIndex <= 0"
@click="stepExercisePanel(-1)"
>
{{ $t('socialnetwork.vocab.courses.exerciseSequentialBack') }}
</button>
<button
type="button"
class="btn-seq btn-seq--primary"
:disabled="!canStepExercisePanelForward"
@click="stepExercisePanel(1)"
>
{{ $t('socialnetwork.vocab.courses.exerciseSequentialNext') }}
</button>
</div>
</div>
<div class="exercise-flow-list">
<div
v-for="(exercise, index) in effectiveExercises"
v-for="(exercise, index) in exercisesPanelExercises"
:key="exercise.id"
class="exercise-item surface-card"
:class="{
@@ -500,7 +535,7 @@
>
<div class="exercise-item__header">
<div>
<span class="exercise-item__index">{{ $t('socialnetwork.vocab.courses.exerciseCardLabel', { number: index + 1 }) }}</span>
<span class="exercise-item__index">{{ $t('socialnetwork.vocab.courses.exerciseCardLabel', { number: exercisePanelDisplayNumber(index) }) }}</span>
<h4>{{ exercise.title }}</h4>
</div>
<span
@@ -865,6 +900,31 @@
</div>
</div>
</div>
<!-- Nach falscher Kapitel-Antwort: zuerst Lösung, dann optional zum Üben -->
<div v-if="showExerciseReinforcementDialog" class="dialog-overlay" @click.self="closeExerciseReinforcementDialog">
<div class="dialog" style="width: 440px; height: auto;">
<div class="dialog-header">
<span class="dialog-title">{{ $t('socialnetwork.vocab.courses.exerciseWrongTitle') }}</span>
<span class="dialog-close" @click="closeExerciseReinforcementDialog"></span>
</div>
<div class="dialog-body">
<p v-if="exerciseReinforcementCorrectAnswer" class="exercise-reinforcement-correct">
<strong>{{ $t('socialnetwork.vocab.courses.correctAnswer') }}:</strong>
{{ exerciseReinforcementCorrectAnswer }}
</p>
<p>{{ exerciseReinforcementMessage }}</p>
</div>
<div class="dialog-footer dialog-footer--stack">
<button type="button" class="dialog-button dialog-button--primary" @click="confirmExerciseReinforcement">
{{ $t('socialnetwork.vocab.courses.exerciseReinforcementGoPractice') }}
</button>
<button type="button" class="dialog-button" @click="closeExerciseReinforcementDialog">
{{ $t('socialnetwork.vocab.courses.exerciseReinforcementStay') }}
</button>
</div>
</div>
</div>
</template>
<script>
@@ -945,6 +1005,12 @@ export default {
showCompletionDialog: false,
showErrorDialog: false,
errorMessage: '',
showExerciseReinforcementDialog: false,
exerciseReinforcementPrepMode: false,
exerciseReinforcementCorrectAnswer: '',
exerciseReinforcementMessage: '',
/** Index in scrambledChapterExamExercises bei Ein-Frage-Ansicht */
exerciseSequentialIndex: 0,
/** Aus vorherigen Lektionen (MC-Optionen nach Fragentyp Ziel-/Muttersprache) */
distractorPool: { target: [], native: [] },
/** { [exerciseId]: { options: string[], useTextAnswer: boolean } } */
@@ -1058,6 +1124,32 @@ export default {
exerciseTargetScore() {
return Number(this.lesson?.targetScorePercent) || 80;
},
/** Kapitel-Prüfung: eine Frage pro Ansicht (Essen & Trinken: deterministisch gemischt). */
scrambledChapterExamExercises() {
const raw = this.effectiveExercises;
if (!raw.length) return [];
if ((this.lesson?.title || '').trim() === 'Essen & Trinken') {
return this._deterministicShuffle(raw.slice(), Number(this.lessonId) || 1);
}
return raw;
},
sequentialPanelActive() {
return (this.scrambledChapterExamExercises?.length || 0) > 1;
},
exercisesPanelExercises() {
const list = this.scrambledChapterExamExercises;
if (!list.length) return [];
if (!this.sequentialPanelActive) return list;
const idx = Math.max(0, Math.min(this.exerciseSequentialIndex, list.length - 1));
return [list[idx]];
},
canStepExercisePanelForward() {
const list = this.scrambledChapterExamExercises;
if (!list.length) return false;
const ex = list[this.exerciseSequentialIndex];
if (!ex) return false;
return Boolean(this.exerciseResults[ex.id]?.correct) && this.exerciseSequentialIndex < list.length - 1;
},
exerciseRetryUnlockAttempts() {
return Math.min(8, Math.max(2, Math.ceil(this.trainerNewFocusTarget * 0.25)));
},
@@ -1324,7 +1416,8 @@ export default {
vocabTrainerCurrentAttempts: this.vocabTrainerCurrentAttempts,
vocabTrainerReviewAttempts: this.vocabTrainerReviewAttempts,
exerciseRetryPending: this.exerciseRetryPending,
exerciseRetryPendingSinceAttempts: this.exerciseRetryPendingSinceAttempts
exerciseRetryPendingSinceAttempts: this.exerciseRetryPendingSinceAttempts,
exerciseSequentialIndex: this.exerciseSequentialIndex
};
}
},
@@ -1592,6 +1685,8 @@ export default {
this.vocabTrainerReviewAttempts = Math.max(0, Number(parsedState.vocabTrainerReviewAttempts) || 0);
this.exerciseRetryPending = Boolean(parsedState.exerciseRetryPending);
this.exerciseRetryPendingSinceAttempts = Math.max(0, Number(parsedState.exerciseRetryPendingSinceAttempts) || 0);
const maxIdx = Math.max(0, this.scrambledChapterExamExercises.length - 1);
this.exerciseSequentialIndex = Math.max(0, Math.min(maxIdx, Number(parsedState.exerciseSequentialIndex) || 0));
this.vocabTrainerMixedPool = this._buildMixedPool();
const knownRepeatKeys = new Set([...this.trainableLessonVocab, ...this.vocabTrainerMixedPool].map((entry) => this.getVocabKey(entry)));
this.vocabTrainerRepeatQueue = this.normalizeRepeatQueue(parsedState.vocabTrainerRepeatQueue)
@@ -1661,6 +1756,59 @@ export default {
if (!n) return '';
return n.gloss ? `${n.target} (${n.gloss})` : n.target;
},
_deterministicShuffle(arr, seed) {
const out = arr.slice();
let s = Number(seed) || 1;
const rnd = () => {
s = (s * 1103515245 + 12345) & 0x7fffffff;
return s / 0x7fffffff;
};
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
},
exercisePanelDisplayNumber(panelIndex) {
if (this.sequentialPanelActive) {
return this.exerciseSequentialIndex + 1;
}
return panelIndex + 1;
},
stepExercisePanel(delta) {
const list = this.scrambledChapterExamExercises;
if (!list.length) return;
const next = this.exerciseSequentialIndex + delta;
if (next < 0 || next >= list.length) return;
this.exerciseSequentialIndex = next;
this.persistLessonState();
},
confirmExerciseReinforcement() {
this.showExerciseReinforcementDialog = false;
if (this.exerciseReinforcementPrepMode) {
this.lessonPrepStage = 0;
this.lessonPrepIndex = 0;
} else {
this.exerciseRetryPending = true;
this.exerciseRetryPendingSinceAttempts = this.vocabTrainerTotalAttempts;
}
this.exerciseReinforcementPrepMode = false;
this.exerciseReinforcementCorrectAnswer = '';
this.exerciseReinforcementMessage = '';
this.activeTab = 'learn';
this.$nextTick(() => {
const scrollEl = document.querySelector('.app-content__scroll.contentscroll');
if (scrollEl) scrollEl.scrollTop = 0;
else window.scrollTo(0, 0);
});
this.persistLessonState();
},
closeExerciseReinforcementDialog() {
this.showExerciseReinforcementDialog = false;
this.exerciseReinforcementPrepMode = false;
this.exerciseReinforcementCorrectAnswer = '';
this.exerciseReinforcementMessage = '';
},
advancePrepPass() {
if (this.lessonPrepStage >= 2 || this.prepItems.length === 0) {
return;
@@ -1776,8 +1924,10 @@ export default {
const question = qData.question || qData.text || '';
debugLog(`[importantVocab] Frage:`, question);
// Pattern 1: "Wie sagt man 'X' auf Bisaya?" -> X ist Muttersprache (z.B. "Großmutter"), correctAnswer ist Bisaya (z.B. "Lola")
// Pattern 1: Muttersprache → Zielsprache (MC: richtige Option = Zielsprache)
let match = question.match(/Wie sagt man ['"]([^'"]+)['"]/i);
if (!match) match = question.match(/Wie heißt ['"]([^'"]+)['"]/i);
if (!match) match = question.match(/How do you say ['"]([^'"]+)['"]/i);
if (match) {
const nativeWord = match[1]; // Das Wort in der Muttersprache
// Nur hinzufügen, wenn Muttersprache und Bisaya unterschiedlich sind (verhindert "ko" -> "ko")
@@ -1789,8 +1939,9 @@ export default {
debugLog(`[importantVocab] Pattern 1 übersprungen - Muttersprache und Bisaya sind gleich:`, nativeWord, correctAnswer);
}
} else {
// Pattern 2: "Was bedeutet 'X'?" -> X ist Bisaya, correctAnswer ist Muttersprache
// Pattern 2: Zielsprache im Satz → richtige Option = Muttersprache
match = question.match(/Was bedeutet ['"]([^'"]+)['"]/i);
if (!match) match = question.match(/Was heißt ['"]([^'"]+)['"]/i);
if (match) {
const bisayaWord = match[1];
// Nur hinzufügen, wenn Bisaya und Muttersprache unterschiedlich sind (verhindert "ko" -> "ko")
@@ -1876,6 +2027,7 @@ export default {
this.assistantError = '';
this.exerciseRetryPending = false;
this.exerciseRetryPendingSinceAttempts = 0;
this.exerciseSequentialIndex = 0;
this.exercisePreparationCompleted = false;
this.lessonPrepStage = 0;
this.lessonPrepIndex = 0;
@@ -2319,32 +2471,33 @@ export default {
this.exerciseResults[exerciseId] = res.data;
if (!res.data?.correct) {
const correctText = res.data?.correctAnswer
? String(res.data.correctAnswer)
: '';
if (this.trainableLessonVocab.length === 0 && this.prepItems.length > 0) {
this.lessonPrepStage = 0;
this.lessonPrepIndex = 0;
this.errorMessage = this.$t('socialnetwork.vocab.courses.exercisePrepReinforcementHint');
this.exerciseReinforcementPrepMode = true;
this.exerciseReinforcementCorrectAnswer = correctText;
this.exerciseReinforcementMessage = this.$t('socialnetwork.vocab.courses.exercisePrepReinforcementHint');
} else {
this.exerciseRetryPending = true;
this.exerciseRetryPendingSinceAttempts = this.vocabTrainerTotalAttempts;
this.errorMessage = this.$t('socialnetwork.vocab.courses.exerciseReinforcementHint', {
this.exerciseReinforcementPrepMode = false;
this.exerciseReinforcementCorrectAnswer = correctText;
this.exerciseReinforcementMessage = this.$t('socialnetwork.vocab.courses.exerciseReinforcementHint', {
count: this.exerciseRetryUnlockAttempts
});
}
this.activeTab = 'learn';
this.showErrorDialog = true;
this.$nextTick(() => {
const scrollEl = document.querySelector('.app-content__scroll.contentscroll');
if (scrollEl) {
scrollEl.scrollTop = 0;
} else {
window.scrollTo(0, 0);
}
});
this.showExerciseReinforcementDialog = true;
return;
}
// Prüfe ob alle Übungen bestanden sind (mit Verzögerung, um mehrfache Aufrufe zu vermeiden)
// Nächste Frage im Einzel-Panel / Abschluss prüfen
this.$nextTick(() => {
if (this.sequentialPanelActive) {
const list = this.scrambledChapterExamExercises;
const idx = list.findIndex((e) => e.id === exerciseId);
if (idx >= 0 && idx < list.length - 1) {
this.exerciseSequentialIndex = idx + 1;
}
}
this.checkLessonCompletion();
});
} catch (e) {
@@ -3435,6 +3588,26 @@ export default {
border-radius: 10px;
}
.vocab-prep-card__row {
display: grid;
grid-template-columns: minmax(0, 110px) 1fr;
gap: 10px 14px;
align-items: baseline;
margin-top: 10px;
}
.vocab-prep-card__row:first-of-type {
margin-top: 0;
}
.vocab-prep-card__label {
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a7658;
}
.vocab-prep-card__target {
font-size: 1.2rem;
font-weight: 700;
@@ -3442,7 +3615,6 @@ export default {
}
.vocab-prep-card__gloss {
margin-top: 8px;
font-size: 1rem;
color: #6a5a44;
}
@@ -3610,6 +3782,67 @@ export default {
margin-top: 30px;
}
.exercise-sequential-nav {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
margin-bottom: 14px;
border: 1px solid rgba(80, 118, 178, 0.14);
background: rgba(255, 255, 255, 0.85);
}
.exercise-sequential-nav__progress {
margin: 0;
font-weight: 600;
color: #2a3f5f;
}
.exercise-sequential-nav__buttons {
display: flex;
gap: 10px;
}
.btn-seq {
padding: 8px 14px;
border-radius: 8px;
border: 1px solid rgba(58, 117, 196, 0.35);
background: #fff;
color: #27528f;
font-weight: 600;
cursor: pointer;
}
.btn-seq:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-seq--primary {
background: rgba(58, 117, 196, 0.12);
border-color: rgba(58, 117, 196, 0.45);
}
.exercise-reinforcement-correct {
padding: 10px 12px;
margin: 0 0 12px;
border-radius: 8px;
background: rgba(45, 106, 62, 0.08);
color: #1f3d2a;
}
.dialog-footer--stack {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.dialog-button--primary {
font-weight: 700;
}
.exercise-flow-header {
display: flex;
justify-content: space-between;