Enhance VocabLessonView with new vocabulary trainer features and improved statistics

- Added functionality for tracking total attempts and success rates in the vocabulary trainer, enhancing user feedback on performance.
- Introduced multiple choice and typing modes for vocabulary practice, allowing users to switch between different learning styles.
- Updated translations in both English and German to include new vocabulary terms and exercise instructions, ensuring consistency across languages.
- Improved UI layout for displaying vocabulary statistics and answer options, enhancing overall user experience.
This commit is contained in:
Torsten Schulz (local)
2026-01-19 21:23:13 +01:00
parent 053588ae74
commit 045d32c245
3 changed files with 320 additions and 26 deletions

View File

@@ -383,6 +383,13 @@
"translateTo": "Übersetze ins Deutsche",
"translateFrom": "Übersetze ins Bisaya",
"next": "Weiter",
"totalAttempts": "Versuche",
"successRate": "Erfolgsrate",
"modeMultipleChoice": "Multiple Choice",
"modeTyping": "Texteingabe",
"lessonCompleted": "Lektion abgeschlossen!",
"goToNextLesson": "Zur nächsten Lektion wechseln?",
"allLessonsCompleted": "Alle Lektionen abgeschlossen!",
"startExercises": "Zur Kapitel-Prüfung",
"correctAnswer": "Richtige Antwort",
"alternatives": "Alternative Antworten"

View File

@@ -383,6 +383,13 @@
"translateTo": "Translate to English",
"translateFrom": "Translate to Target Language",
"next": "Next",
"totalAttempts": "Attempts",
"successRate": "Success Rate",
"modeMultipleChoice": "Multiple Choice",
"modeTyping": "Text Input",
"lessonCompleted": "Lesson completed!",
"goToNextLesson": "Go to next lesson?",
"allLessonsCompleted": "All lessons completed!",
"startExercises": "Start Chapter Test",
"correctAnswer": "Correct Answer",
"alternatives": "Alternative Answers"

View File

@@ -64,9 +64,23 @@
</div>
<div v-else class="vocab-trainer-active">
<div class="vocab-trainer-stats">
<span>{{ $t('socialnetwork.vocab.courses.correct') }}: {{ vocabTrainerCorrect }}</span>
<span>{{ $t('socialnetwork.vocab.courses.wrong') }}: {{ vocabTrainerWrong }}</span>
<button @click="stopVocabTrainer" class="btn-stop-trainer">{{ $t('socialnetwork.vocab.courses.stopTrainer') }}</button>
<div class="stats-row">
<span>{{ $t('socialnetwork.vocab.courses.correct') }}: {{ vocabTrainerCorrect }}</span>
<span>{{ $t('socialnetwork.vocab.courses.wrong') }}: {{ vocabTrainerWrong }}</span>
<span>{{ $t('socialnetwork.vocab.courses.totalAttempts') }}: {{ vocabTrainerTotalAttempts }}</span>
<span v-if="vocabTrainerTotalAttempts > 0" class="success-rate">
{{ $t('socialnetwork.vocab.courses.successRate') }}: {{ Math.round((vocabTrainerCorrect / vocabTrainerTotalAttempts) * 100) }}%
</span>
</div>
<div class="stats-row">
<span class="mode-badge" :class="{ 'mode-active': vocabTrainerMode === 'multiple_choice', 'mode-completed': vocabTrainerMode === 'typing' }">
{{ $t('socialnetwork.vocab.courses.modeMultipleChoice') }}
</span>
<span class="mode-badge" :class="{ 'mode-active': vocabTrainerMode === 'typing' }">
{{ $t('socialnetwork.vocab.courses.modeTyping') }}
</span>
<button @click="stopVocabTrainer" class="btn-stop-trainer">{{ $t('socialnetwork.vocab.courses.stopTrainer') }}</button>
</div>
</div>
<div v-if="currentVocabQuestion" class="vocab-question">
<div class="vocab-prompt">
@@ -79,7 +93,25 @@
{{ $t('socialnetwork.vocab.courses.wrong') }}. {{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ currentVocabQuestion.answer }}
</div>
</div>
<div v-else class="vocab-answer-area">
<!-- Multiple Choice Modus -->
<div v-else-if="vocabTrainerMode === 'multiple_choice'" class="vocab-answer-area multiple-choice">
<div class="choice-buttons">
<button
v-for="(option, index) in vocabTrainerChoiceOptions"
:key="index"
@click="selectVocabChoice(option)"
class="choice-button"
:class="{ selected: vocabTrainerSelectedChoice === option }"
>
{{ option }}
</button>
</div>
<button @click="checkVocabAnswer" :disabled="!vocabTrainerSelectedChoice" class="btn-check">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
</div>
<!-- Texteingabe Modus -->
<div v-else class="vocab-answer-area typing">
<input
v-model="vocabTrainerAnswer"
@keydown.enter.prevent="checkVocabAnswer"
@@ -87,11 +119,12 @@
class="vocab-input"
ref="vocabInput"
/>
<button @click="checkVocabAnswer" :disabled="!vocabTrainerAnswer.trim()">
<button @click="checkVocabAnswer" :disabled="!vocabTrainerAnswer.trim()" class="btn-check">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
</div>
<div v-if="vocabTrainerAnswered" class="vocab-next">
<!-- "Weiter"-Button nur im Multiple Choice Modus oder bei falscher Antwort im Typing Modus -->
<div v-if="vocabTrainerAnswered && (vocabTrainerMode === 'multiple_choice' || !vocabTrainerLastCorrect)" class="vocab-next">
<button @click="nextVocabQuestion">{{ $t('socialnetwork.vocab.courses.next') }}</button>
</div>
</div>
@@ -252,10 +285,15 @@ export default {
// Vokabeltrainer
vocabTrainerActive: false,
vocabTrainerPool: [],
vocabTrainerMode: 'multiple_choice', // 'multiple_choice' oder 'typing'
vocabTrainerCorrect: 0,
vocabTrainerWrong: 0,
vocabTrainerTotalAttempts: 0,
vocabTrainerStats: {}, // { [vocabKey]: { attempts: 0, correct: 0, wrong: 0 } }
vocabTrainerChoiceOptions: [],
currentVocabQuestion: null,
vocabTrainerAnswer: '',
vocabTrainerSelectedChoice: null,
vocabTrainerAnswered: false,
vocabTrainerLastCorrect: false,
vocabTrainerDirection: 'L2R' // L2R: learning->reference, R2L: reference->learning
@@ -299,17 +337,22 @@ export default {
// Extrahiere wichtige Begriffe aus den Übungen
try {
if (!this.lesson || !this.lesson.grammarExercises || !Array.isArray(this.lesson.grammarExercises)) {
console.log('[importantVocab] Keine Übungen vorhanden');
return [];
}
const vocabMap = new Map();
this.lesson.grammarExercises.forEach(exercise => {
this.lesson.grammarExercises.forEach((exercise, idx) => {
try {
console.log(`[importantVocab] Verarbeite Übung ${idx + 1}:`, exercise.title);
// Extrahiere aus questionData
const qData = this.getQuestionData(exercise);
const aData = this.getAnswerData(exercise);
console.log(`[importantVocab] qData:`, qData);
console.log(`[importantVocab] aData:`, aData);
if (qData && aData) {
// Für Multiple Choice: Extrahiere Optionen und richtige Antwort
if (this.getExerciseType(exercise) === 'multiple_choice') {
@@ -317,22 +360,28 @@ export default {
const correctIndex = aData.correctAnswer !== undefined ? aData.correctAnswer : (aData.correct || 0);
const correctAnswer = options[correctIndex] || '';
console.log(`[importantVocab] Multiple Choice - options:`, options, `correctIndex:`, correctIndex, `correctAnswer:`, correctAnswer);
if (correctAnswer) {
// Versuche die Frage zu analysieren (z.B. "Wie sagt man 'X' auf Bisaya?" oder "Was bedeutet 'X'?")
const question = qData.question || qData.text || '';
console.log(`[importantVocab] Frage:`, question);
// Pattern 1: "Wie sagt man 'X' auf Bisaya?" -> X ist Deutsch, correctAnswer ist Bisaya
let match = question.match(/['"]([^'"]+)['"]/);
if (match) {
const germanWord = match[1];
console.log(`[importantVocab] Pattern 1 gefunden - Bisaya:`, correctAnswer, `Deutsch:`, germanWord);
vocabMap.set(correctAnswer, { learning: correctAnswer, reference: germanWord });
} else {
// Pattern 2: "Was bedeutet 'X'?" -> X ist Bisaya, correctAnswer ist Deutsch
match = question.match(/Was bedeutet ['"]([^'"]+)['"]/);
if (match) {
const bisayaWord = match[1];
console.log(`[importantVocab] Pattern 2 gefunden - Bisaya:`, bisayaWord, `Deutsch:`, correctAnswer);
vocabMap.set(bisayaWord, { learning: bisayaWord, reference: correctAnswer });
} else {
console.log(`[importantVocab] Kein Pattern gefunden, Fallback`);
// Fallback: Verwende die richtige Antwort als Lernwort
vocabMap.set(correctAnswer, { learning: correctAnswer, reference: correctAnswer });
}
@@ -343,6 +392,7 @@ export default {
// Für Gap Fill: Extrahiere richtige Antworten
if (this.getExerciseType(exercise) === 'gap_fill') {
const answers = aData.answers || (aData.correct ? (Array.isArray(aData.correct) ? aData.correct : [aData.correct]) : []);
console.log(`[importantVocab] Gap Fill - answers:`, answers);
if (answers.length > 0) {
// Versuche aus dem Text Kontext zu extrahieren
const text = qData.text || '';
@@ -361,7 +411,9 @@ export default {
}
});
return Array.from(vocabMap.values());
const result = Array.from(vocabMap.values());
console.log(`[importantVocab] Ergebnis:`, result);
return result;
} catch (e) {
console.error('Fehler in importantVocab computed property:', e);
return [];
@@ -522,11 +574,72 @@ export default {
const res = await apiClient.post(`/api/vocab/grammar-exercises/${exerciseId}/check`, { answer });
this.exerciseResults[exerciseId] = res.data;
// Prüfe ob alle Übungen bestanden sind
await this.checkLessonCompletion();
} catch (e) {
console.error('Fehler beim Prüfen der Antwort:', e);
alert(e.response?.data?.error || 'Fehler beim Prüfen der Antwort');
}
},
async checkLessonCompletion() {
// Prüfe ob alle Übungen korrekt beantwortet wurden
if (!this.lesson || !this.lesson.grammarExercises) return;
const allExercises = this.lesson.grammarExercises;
const allCompleted = allExercises.every(exercise => {
const result = this.exerciseResults[exercise.id];
return result && result.correct;
});
if (allCompleted) {
// Berechne Gesamt-Score
const totalExercises = allExercises.length;
const correctExercises = allExercises.filter(ex => this.exerciseResults[ex.id]?.correct).length;
const score = Math.round((correctExercises / totalExercises) * 100);
// Aktualisiere Fortschritt
try {
await apiClient.put(`/api/vocab/lessons/${this.lessonId}/progress`, {
completed: true,
score: score,
timeSpentMinutes: 0 // TODO: Zeit tracken
});
// Weiterleitung zur nächsten Lektion
await this.navigateToNextLesson();
} catch (e) {
console.error('Fehler beim Aktualisieren des Fortschritts:', e);
}
}
},
async navigateToNextLesson() {
try {
// Lade Kurs mit allen Lektionen
const courseRes = await apiClient.get(`/api/vocab/courses/${this.courseId}`);
const course = courseRes.data;
if (!course.lessons || course.lessons.length === 0) return;
// Finde aktuelle Lektion
const currentLessonIndex = course.lessons.findIndex(l => l.id === parseInt(this.lessonId));
if (currentLessonIndex >= 0 && currentLessonIndex < course.lessons.length - 1) {
// Nächste Lektion gefunden
const nextLesson = course.lessons[currentLessonIndex + 1];
// Zeige Erfolgs-Meldung und leite weiter
if (confirm(this.$t('socialnetwork.vocab.courses.lessonCompleted') + '\n' + this.$t('socialnetwork.vocab.courses.goToNextLesson'))) {
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${nextLesson.id}`);
}
} else {
// Letzte Lektion - zeige Abschluss-Meldung
alert(this.$t('socialnetwork.vocab.courses.allLessonsCompleted'));
}
} catch (e) {
console.error('Fehler beim Laden der nächsten Lektion:', e);
}
},
back() {
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}`);
},
@@ -535,52 +648,149 @@ export default {
if (!this.importantVocab || this.importantVocab.length === 0) return;
this.vocabTrainerActive = true;
this.vocabTrainerPool = [...this.importantVocab];
this.vocabTrainerMode = 'multiple_choice';
this.vocabTrainerCorrect = 0;
this.vocabTrainerWrong = 0;
this.vocabTrainerTotalAttempts = 0;
this.vocabTrainerStats = {};
this.nextVocabQuestion();
this.$nextTick(() => {
this.$refs.vocabInput?.focus?.();
});
},
stopVocabTrainer() {
this.vocabTrainerActive = false;
this.currentVocabQuestion = null;
this.vocabTrainerAnswer = '';
this.vocabTrainerSelectedChoice = null;
this.vocabTrainerAnswered = false;
},
getVocabKey(vocab) {
return `${vocab.learning}|${vocab.reference}`;
},
getVocabStats(vocab) {
const key = this.getVocabKey(vocab);
if (!this.vocabTrainerStats[key]) {
this.vocabTrainerStats[key] = { attempts: 0, correct: 0, wrong: 0 };
}
return this.vocabTrainerStats[key];
},
checkVocabModeSwitch() {
// Wechsle zu Texteingabe wenn 80% erreicht und mindestens 20 Versuche
if (this.vocabTrainerMode === 'multiple_choice' && this.vocabTrainerTotalAttempts >= 20) {
const successRate = (this.vocabTrainerCorrect / this.vocabTrainerTotalAttempts) * 100;
if (successRate >= 80) {
this.vocabTrainerMode = 'typing';
// Reset Stats für Texteingabe-Modus
this.vocabTrainerCorrect = 0;
this.vocabTrainerWrong = 0;
this.vocabTrainerTotalAttempts = 0;
}
}
},
buildChoiceOptions(correctAnswer, allVocabs) {
const options = new Set([correctAnswer]);
// Füge 3 Distraktoren hinzu
while (options.size < 4 && allVocabs.length > 1) {
const randomVocab = allVocabs[Math.floor(Math.random() * allVocabs.length)];
const distractor = this.vocabTrainerDirection === 'L2R' ? randomVocab.reference : randomVocab.learning;
if (distractor !== correctAnswer) {
options.add(distractor);
}
}
// Shuffle
const arr = Array.from(options);
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
},
nextVocabQuestion() {
if (!this.vocabTrainerPool || this.vocabTrainerPool.length === 0) {
this.currentVocabQuestion = null;
return;
}
// Prüfe ob Modus-Wechsel nötig ist
this.checkVocabModeSwitch();
// Wähle zufällige Vokabel
const randomIndex = Math.floor(Math.random() * this.vocabTrainerPool.length);
const vocab = this.vocabTrainerPool[randomIndex];
this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L';
this.currentVocabQuestion = {
vocab: vocab,
prompt: this.vocabTrainerDirection === 'L2R' ? vocab.learning : vocab.reference,
answer: this.vocabTrainerDirection === 'L2R' ? vocab.reference : vocab.learning
answer: this.vocabTrainerDirection === 'L2R' ? vocab.reference : vocab.learning,
key: this.getVocabKey(vocab)
};
// Reset UI
this.vocabTrainerAnswer = '';
this.vocabTrainerSelectedChoice = null;
this.vocabTrainerAnswered = false;
this.$nextTick(() => {
this.$refs.vocabInput?.focus?.();
});
// Erstelle Choice-Optionen für Multiple Choice
if (this.vocabTrainerMode === 'multiple_choice') {
this.vocabTrainerChoiceOptions = this.buildChoiceOptions(this.currentVocabQuestion.answer, this.vocabTrainerPool);
}
// Fokussiere Eingabefeld im Typing-Modus
if (this.vocabTrainerMode === 'typing') {
this.$nextTick(() => {
this.$refs.vocabInput?.focus?.();
});
}
},
selectVocabChoice(option) {
this.vocabTrainerSelectedChoice = option;
},
normalizeVocab(s) {
return String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
},
checkVocabAnswer() {
if (!this.currentVocabQuestion || !this.vocabTrainerAnswer.trim()) return;
const userAnswer = this.normalizeVocab(this.vocabTrainerAnswer);
const correctAnswer = this.normalizeVocab(this.currentVocabQuestion.answer);
this.vocabTrainerLastCorrect = userAnswer === correctAnswer;
if (!this.currentVocabQuestion) return;
let userAnswer = '';
if (this.vocabTrainerMode === 'multiple_choice') {
if (!this.vocabTrainerSelectedChoice) return;
userAnswer = this.vocabTrainerSelectedChoice;
} else {
if (!this.vocabTrainerAnswer.trim()) return;
userAnswer = this.vocabTrainerAnswer;
}
const normalizedUser = this.normalizeVocab(userAnswer);
const normalizedCorrect = this.normalizeVocab(this.currentVocabQuestion.answer);
this.vocabTrainerLastCorrect = normalizedUser === normalizedCorrect;
// Update Stats
const stats = this.getVocabStats(this.currentVocabQuestion.vocab);
stats.attempts++;
this.vocabTrainerTotalAttempts++;
if (this.vocabTrainerLastCorrect) {
this.vocabTrainerCorrect++;
stats.correct++;
} else {
this.vocabTrainerWrong++;
stats.wrong++;
}
this.vocabTrainerAnswered = true;
// Im Typing-Modus: Automatisch zur nächsten Frage nach kurzer Pause (nur bei richtiger Antwort)
if (this.vocabTrainerMode === 'typing' && this.vocabTrainerLastCorrect) {
setTimeout(() => {
this.nextVocabQuestion();
}, 500);
}
// Im Typing-Modus bei falscher Antwort: Eingabefeld fokussieren für erneuten Versuch
if (this.vocabTrainerMode === 'typing' && !this.vocabTrainerLastCorrect) {
this.$nextTick(() => {
this.$refs.vocabInput?.focus?.();
this.vocabTrainerAnswer = '';
});
}
}
},
async mounted() {
@@ -921,15 +1131,48 @@ export default {
}
.vocab-trainer-stats {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding: 10px;
background: #f5f5f5;
border-radius: 4px;
}
.stats-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
margin-bottom: 10px;
}
.stats-row:last-child {
margin-bottom: 0;
}
.success-rate {
font-weight: bold;
color: #28a745;
}
.mode-badge {
padding: 5px 10px;
border-radius: 4px;
font-size: 0.9em;
background: #e9ecef;
color: #6c757d;
}
.mode-badge.mode-active {
background: #007bff;
color: white;
font-weight: bold;
}
.mode-badge.mode-completed {
background: #28a745;
color: white;
}
.btn-stop-trainer {
padding: 5px 15px;
background: #dc3545;
@@ -969,9 +1212,46 @@ export default {
}
.vocab-answer-area {
margin-bottom: 15px;
}
.vocab-answer-area.typing {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.vocab-answer-area.multiple-choice {
display: flex;
flex-direction: column;
gap: 10px;
}
.choice-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 10px;
}
.choice-button {
padding: 12px;
border: 2px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 1em;
transition: all 0.2s;
}
.choice-button:hover {
border-color: #007bff;
background: #f0f8ff;
}
.choice-button.selected {
border-color: #007bff;
background: #007bff;
color: white;
}
.vocab-input {
@@ -982,7 +1262,7 @@ export default {
font-size: 1em;
}
.vocab-answer-area button {
.btn-check {
padding: 10px 20px;
background: #4CAF50;
color: white;
@@ -992,11 +1272,11 @@ export default {
font-size: 1em;
}
.vocab-answer-area button:hover:not(:disabled) {
.btn-check:hover:not(:disabled) {
background: #45a049;
}
.vocab-answer-area button:disabled {
.btn-check:disabled {
background: #ccc;
cursor: not-allowed;
}