Enhance grammar exercise functionality in VocabLessonView

- Added support for multiple exercise types including multiple choice, gap fill, and transformation.
- Updated UI to display exercise instructions and results with improved styling.
- Implemented logic to handle answer checking based on exercise type, enhancing user interaction.
- Added new translations for exercise-related terms in both English and German.
This commit is contained in:
Torsten Schulz (local)
2026-01-19 15:23:16 +01:00
parent 0572a0eb50
commit 4bb75de3f0
3 changed files with 288 additions and 21 deletions

View File

@@ -359,7 +359,14 @@
"forAllLanguages": "Für alle Sprachen", "forAllLanguages": "Für alle Sprachen",
"optional": "Optional", "optional": "Optional",
"invalidCode": "Ungültiger Code", "invalidCode": "Ungültiger Code",
"courseNotFound": "Kurs nicht gefunden" "courseNotFound": "Kurs nicht gefunden",
"grammarExercises": "Grammatik-Übungen",
"noExercises": "Keine Übungen verfügbar",
"enterAnswer": "Antwort eingeben",
"checkAnswer": "Antwort prüfen",
"correct": "Richtig!",
"wrong": "Falsch",
"explanation": "Erklärung"
} }
} }
} }

View File

@@ -359,7 +359,14 @@
"forAllLanguages": "For All Languages", "forAllLanguages": "For All Languages",
"optional": "Optional", "optional": "Optional",
"invalidCode": "Invalid code", "invalidCode": "Invalid code",
"courseNotFound": "Course not found" "courseNotFound": "Course not found",
"grammarExercises": "Grammar Exercises",
"noExercises": "No exercises available",
"enterAnswer": "Enter answer",
"checkAnswer": "Check Answer",
"correct": "Correct!",
"wrong": "Wrong",
"explanation": "Explanation"
} }
} }
} }

View File

@@ -13,15 +13,73 @@
<h3>{{ $t('socialnetwork.vocab.courses.grammarExercises') }}</h3> <h3>{{ $t('socialnetwork.vocab.courses.grammarExercises') }}</h3>
<div v-for="exercise in lesson.grammarExercises" :key="exercise.id" class="exercise-item"> <div v-for="exercise in lesson.grammarExercises" :key="exercise.id" class="exercise-item">
<h4>{{ exercise.title }}</h4> <h4>{{ exercise.title }}</h4>
<p v-if="exercise.description">{{ exercise.description }}</p> <p v-if="exercise.instruction" class="exercise-instruction">{{ exercise.instruction }}</p>
<div v-if="exercise.type === 'gap_fill'" class="gap-fill-exercise">
<p v-html="formatGapFill(exercise.content)"></p> <!-- Multiple Choice Übung -->
<input v-model="exerciseAnswers[exercise.id]" :placeholder="$t('socialnetwork.vocab.courses.enterAnswer')" /> <div v-if="getExerciseType(exercise) === 'multiple_choice'" class="multiple-choice-exercise">
<button @click="checkAnswer(exercise.id)">{{ $t('socialnetwork.vocab.courses.checkAnswer') }}</button> <p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'"> <div class="options">
{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }} <label v-for="(option, index) in getOptions(exercise)" :key="index" class="option-label">
<p v-if="exercise.explanation">{{ exercise.explanation }}</p> <input
type="radio"
:name="'exercise-' + exercise.id"
:value="index"
v-model="exerciseAnswers[exercise.id]"
/>
<span>{{ option }}</span>
</label>
</div> </div>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id] && exerciseAnswers[exercise.id] !== 0">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exercise.explanation" class="exercise-explanation">{{ exercise.explanation }}</p>
</div>
</div>
<!-- Gap Fill Übung -->
<div v-else-if="getExerciseType(exercise) === 'gap_fill'" class="gap-fill-exercise">
<p class="exercise-text" v-html="formatGapFill(exercise)"></p>
<div class="gap-inputs">
<input
v-for="(gap, index) in getGapCount(exercise)"
:key="index"
v-model="exerciseAnswers[exercise.id][index]"
:placeholder="$t('socialnetwork.vocab.courses.enterAnswer')"
class="gap-input"
/>
</div>
<button @click="checkAnswer(exercise.id)" :disabled="!hasAllGapsFilled(exercise)">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exercise.explanation" class="exercise-explanation">{{ exercise.explanation }}</p>
</div>
</div>
<!-- Transformation Übung -->
<div v-else-if="getExerciseType(exercise) === 'transformation'" class="transformation-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.enterAnswer')"
class="transformation-input"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exercise.explanation" class="exercise-explanation">{{ exercise.explanation }}</p>
</div>
</div>
<!-- Fallback für unbekannte Typen -->
<div v-else class="unknown-exercise">
<p>Übungstyp: {{ getExerciseType(exercise) }}</p>
<pre>{{ JSON.stringify(exercise, null, 2) }}</pre>
</div> </div>
</div> </div>
</div> </div>
@@ -87,23 +145,115 @@ export default {
async loadGrammarExercises() { async loadGrammarExercises() {
try { try {
const res = await apiClient.get(`/api/vocab/lessons/${this.lessonId}/grammar-exercises`); const res = await apiClient.get(`/api/vocab/lessons/${this.lessonId}/grammar-exercises`);
this.lesson.grammarExercises = res.data || []; const exercises = res.data || [];
this.lesson.grammarExercises = exercises;
// Initialisiere Antwort-Arrays für Gap Fill Übungen
exercises.forEach(exercise => {
const exerciseType = this.getExerciseType(exercise);
if (exerciseType === 'gap_fill') {
const gapCount = this.getGapCount(exercise);
this.$set(this.exerciseAnswers, exercise.id, new Array(gapCount).fill(''));
} else {
this.$set(this.exerciseAnswers, exercise.id, '');
}
});
} catch (e) { } catch (e) {
console.error('Konnte Grammatik-Übungen nicht laden:', e); console.error('Konnte Grammatik-Übungen nicht laden:', e);
this.lesson.grammarExercises = []; this.lesson.grammarExercises = [];
} }
}, },
formatGapFill(content) { getExerciseType(exercise) {
// Ersetze Platzhalter mit Input-Feldern // Hole den Typ aus questionData oder exerciseType
return content.replace(/\{gap\}/g, '<span class="gap">_____</span>'); if (exercise.questionData) {
const qData = typeof exercise.questionData === 'string'
? JSON.parse(exercise.questionData)
: exercise.questionData;
return qData.type;
}
// Fallback: Prüfe exerciseTypeId
const typeMap = {
1: 'gap_fill',
2: 'multiple_choice',
3: 'sentence_building',
4: 'transformation',
5: 'conjugation',
6: 'declension'
};
return typeMap[exercise.exerciseTypeId] || 'unknown';
},
getQuestionData(exercise) {
if (!exercise.questionData) return null;
return typeof exercise.questionData === 'string'
? JSON.parse(exercise.questionData)
: exercise.questionData;
},
getAnswerData(exercise) {
if (!exercise.answerData) return null;
return typeof exercise.answerData === 'string'
? JSON.parse(exercise.answerData)
: exercise.answerData;
},
getQuestionText(exercise) {
const qData = this.getQuestionData(exercise);
if (!qData) return exercise.title;
if (qData.question) return qData.question;
if (qData.text) return qData.text;
return exercise.title;
},
getOptions(exercise) {
const qData = this.getQuestionData(exercise);
return qData?.options || [];
},
formatGapFill(exercise) {
const qData = this.getQuestionData(exercise);
if (!qData || !qData.text) return '';
// Ersetze {gap} mit Platzhaltern
return qData.text.replace(/\{gap\}/g, '<span class="gap">_____</span>');
},
getGapCount(exercise) {
const qData = this.getQuestionData(exercise);
if (!qData) return 0;
// Zähle {gap} im Text
const matches = qData.text?.match(/\{gap\}/g);
return matches ? matches.length : (qData.gaps || 1);
},
hasAllGapsFilled(exercise) {
const answers = this.exerciseAnswers[exercise.id];
if (!answers || !Array.isArray(answers)) return false;
const gapCount = this.getGapCount(exercise);
return answers.length === gapCount && answers.every(a => a && a.trim());
}, },
async checkAnswer(exerciseId) { async checkAnswer(exerciseId) {
try { try {
const answer = this.exerciseAnswers[exerciseId]; const exercise = this.lesson.grammarExercises.find(e => e.id === exerciseId);
const res = await apiClient.post(`/api/vocab/exercises/${exerciseId}/check`, { answer }); if (!exercise) return;
this.exerciseResults[exerciseId] = res.data;
const exerciseType = this.getExerciseType(exercise);
let answer = this.exerciseAnswers[exerciseId];
// Formatiere Antwort je nach Typ
if (exerciseType === 'gap_fill') {
// Gap Fill: Array von Antworten
if (!Array.isArray(answer)) {
answer = [answer];
}
} else if (exerciseType === 'multiple_choice') {
// Multiple Choice: Index als Zahl
answer = Number(answer);
} else if (exerciseType === 'transformation') {
// Transformation: String
answer = String(answer || '').trim();
}
const res = await apiClient.post(`/api/vocab/grammar-exercises/${exerciseId}/check`, { answer });
this.$set(this.exerciseResults, exerciseId, res.data);
} catch (e) { } catch (e) {
console.error('Fehler beim Prüfen der Antwort:', e); console.error('Fehler beim Prüfen der Antwort:', e);
alert(e.response?.data?.error || 'Fehler beim Prüfen der Antwort');
} }
}, },
back() { back() {
@@ -153,27 +303,130 @@ export default {
margin-bottom: 15px; margin-bottom: 15px;
} }
.gap-fill-exercise input { .exercise-instruction {
color: #666;
font-style: italic;
margin-bottom: 15px;
}
.exercise-question {
font-weight: 600;
margin-bottom: 15px;
font-size: 1.1em;
}
.exercise-text {
margin-bottom: 15px;
line-height: 1.6;
}
.gap {
display: inline-block;
min-width: 100px;
border-bottom: 2px solid #333;
margin: 0 5px;
padding: 0 5px;
}
.multiple-choice-exercise .options {
margin: 15px 0;
}
.option-label {
display: block;
padding: 10px;
margin: 8px 0;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.option-label:hover {
background-color: #f5f5f5;
}
.option-label input[type="radio"] {
margin-right: 10px;
}
.gap-fill-exercise .gap-inputs {
display: flex;
flex-direction: column;
gap: 10px;
margin: 15px 0;
}
.gap-input {
width: 100%; width: 100%;
padding: 8px; padding: 8px;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
margin: 10px 0; }
.transformation-input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
margin: 15px 0;
}
.exercise-item button {
padding: 10px 20px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
margin-top: 10px;
transition: background-color 0.2s;
}
.exercise-item button:hover:not(:disabled) {
background: #45a049;
}
.exercise-item button:disabled {
background: #ccc;
cursor: not-allowed;
} }
.exercise-result { .exercise-result {
margin-top: 10px; margin-top: 15px;
padding: 10px; padding: 15px;
border-radius: 4px; border-radius: 4px;
} }
.exercise-result.correct { .exercise-result.correct {
background: #d4edda; background: #d4edda;
color: #155724; color: #155724;
border: 1px solid #c3e6cb;
} }
.exercise-result.wrong { .exercise-result.wrong {
background: #f8d7da; background: #f8d7da;
color: #721c24; color: #721c24;
border: 1px solid #f5c6cb;
}
.exercise-explanation {
margin-top: 10px;
font-style: italic;
}
.unknown-exercise {
padding: 15px;
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
margin-top: 10px;
}
.unknown-exercise pre {
margin-top: 10px;
font-size: 0.85em;
overflow-x: auto;
} }
</style> </style>