Files
yourpart3/frontend/src/views/social/VocabLessonView.vue
Torsten Schulz (local) 196b74bebb Enhance VocabLessonView and VocabService with new learning features
- Added a tabbed interface in VocabLessonView for 'Learn' and 'Exercises' sections, improving user navigation.
- Implemented logic to display important vocabulary and cultural notes in the learning section.
- Updated exercise result display to include correct answers and alternatives for better user feedback.
- Enhanced VocabService to extract correct answers and alternatives from exercise data, supporting the new UI features.
- Added new translations for vocabulary-related terms in both English and German, ensuring consistency across the application.
2026-01-19 16:41:10 +01:00

686 lines
20 KiB
Vue

<template>
<div class="vocab-lesson-view">
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="lesson">
<div class="lesson-header">
<button @click="back" class="btn-back">{{ $t('general.back') }}</button>
<h2>{{ lesson.title }}</h2>
</div>
<p v-if="lesson.description" class="lesson-description">{{ lesson.description }}</p>
<!-- Tabs für Lernen und Übungen -->
<div class="lesson-tabs">
<button
:class="{ active: activeTab === 'learn' }"
@click="activeTab = 'learn'"
class="tab-button"
>
{{ $t('socialnetwork.vocab.courses.learn') }}
</button>
<button
:class="{ active: activeTab === 'exercises' }"
@click="activeTab = 'exercises'"
class="tab-button"
:disabled="!hasExercises"
>
{{ $t('socialnetwork.vocab.courses.exercises') }}
</button>
</div>
<!-- Lernen-Tab -->
<div v-if="activeTab === 'learn'" class="learn-section">
<h3>{{ $t('socialnetwork.vocab.courses.learnVocabulary') }}</h3>
<!-- Kulturelle Notizen -->
<div v-if="lesson.culturalNotes" class="cultural-notes">
<h4>{{ $t('socialnetwork.vocab.courses.culturalNotes') }}</h4>
<p>{{ lesson.culturalNotes }}</p>
</div>
<!-- Wichtige Begriffe aus den Übungen -->
<div v-if="importantVocab.length > 0" class="vocab-list">
<h4>{{ $t('socialnetwork.vocab.courses.importantVocab') }}</h4>
<div class="vocab-items">
<div v-for="(vocab, index) in importantVocab" :key="index" class="vocab-item">
<strong>{{ vocab.learning }}</strong>
<span class="separator"></span>
<span>{{ vocab.reference }}</span>
</div>
</div>
</div>
<!-- Hinweis wenn keine Vokabeln vorhanden -->
<div v-else class="no-vocab-info">
<p>{{ $t('socialnetwork.vocab.courses.noVocabInfo') }}</p>
</div>
<!-- Button um zu Übungen zu wechseln -->
<div v-if="hasExercises" class="continue-to-exercises">
<button @click="activeTab = 'exercises'" class="btn-continue">
{{ $t('socialnetwork.vocab.courses.startExercises') }}
</button>
</div>
</div>
<!-- Übungen-Tab -->
<div v-if="activeTab === 'exercises' && lesson.grammarExercises && lesson.grammarExercises.length > 0" class="grammar-exercises">
<h3>{{ $t('socialnetwork.vocab.courses.grammarExercises') }}</h3>
<div v-for="exercise in lesson.grammarExercises" :key="exercise.id" class="exercise-item">
<h4>{{ exercise.title }}</h4>
<p v-if="exercise.instruction" class="exercise-instruction">{{ exercise.instruction }}</p>
<!-- Multiple Choice Übung -->
<div v-if="getExerciseType(exercise) === 'multiple_choice'" class="multiple-choice-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<div class="options">
<label v-for="(option, index) in getOptions(exercise)" :key="index" class="option-label">
<input
type="radio"
:name="'exercise-' + exercise.id"
:value="index"
v-model="exerciseAnswers[exercise.id]"
/>
<span>{{ option }}</span>
</label>
</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="!exerciseResults[exercise.id].correct && exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="!exerciseResults[exercise.id].correct && exerciseResults[exercise.id].alternatives && exerciseResults[exercise.id].alternatives.length > 0" class="alternatives">
{{ $t('socialnetwork.vocab.courses.alternatives') }}: {{ exerciseResults[exercise.id].alternatives.join(', ') }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].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="!exerciseResults[exercise.id].correct && exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="!exerciseResults[exercise.id].correct && exerciseResults[exercise.id].alternatives && exerciseResults[exercise.id].alternatives.length > 0" class="alternatives">
{{ $t('socialnetwork.vocab.courses.alternatives') }}: {{ exerciseResults[exercise.id].alternatives.join(', ') }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].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="!exerciseResults[exercise.id].correct && exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="!exerciseResults[exercise.id].correct && exerciseResults[exercise.id].alternatives && exerciseResults[exercise.id].alternatives.length > 0" class="alternatives">
{{ $t('socialnetwork.vocab.courses.alternatives') }}: {{ exerciseResults[exercise.id].alternatives.join(', ') }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].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 v-else>
<p>{{ $t('socialnetwork.vocab.courses.noExercises') }}</p>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '@/utils/axios.js';
export default {
name: 'VocabLessonView',
props: {
courseId: {
type: String,
required: true
},
lessonId: {
type: String,
required: true
}
},
data() {
return {
loading: false,
lesson: null,
exerciseAnswers: {},
exerciseResults: {},
activeTab: 'learn' // Standardmäßig "Lernen"-Tab
};
},
computed: {
...mapGetters(['user']),
hasExercises() {
return this.lesson && this.lesson.grammarExercises && this.lesson.grammarExercises.length > 0;
},
importantVocab() {
// Extrahiere wichtige Begriffe aus den Übungen
if (!this.lesson || !this.lesson.grammarExercises) return [];
const vocabMap = new Map();
this.lesson.grammarExercises.forEach(exercise => {
// Extrahiere aus questionData
const qData = this.getQuestionData(exercise);
const aData = this.getAnswerData(exercise);
if (qData && aData) {
// Für Multiple Choice: Extrahiere Optionen und richtige Antwort
if (this.getExerciseType(exercise) === 'multiple_choice') {
const correct = Array.isArray(aData.correct) ? aData.correct[0] : aData.correct;
const question = qData.text || '';
// Versuche die Frage zu analysieren (z.B. "Wie sagt man X auf Bisaya?")
const match = question.match(/['"]([^'"]+)['"]/);
if (match) {
const germanWord = match[1];
vocabMap.set(correct, { learning: correct, reference: germanWord });
} else if (correct) {
// Fallback: Verwende die richtige Antwort als Lernwort
vocabMap.set(correct, { learning: correct, reference: correct });
}
}
// Für Gap Fill: Extrahiere richtige Antworten
if (this.getExerciseType(exercise) === 'gap_fill' && aData.correct) {
const correct = Array.isArray(aData.correct) ? aData.correct[0] : aData.correct;
vocabMap.set(correct, { learning: correct, reference: correct });
}
}
});
return Array.from(vocabMap.values());
}
},
computed: {
...mapGetters(['user'])
},
watch: {
courseId() {
this.loadLesson();
},
lessonId() {
this.loadLesson();
}
},
methods: {
async loadLesson() {
this.loading = true;
// Setze Antworten und Ergebnisse zurück
this.exerciseAnswers = {};
this.exerciseResults = {};
try {
const res = await apiClient.get(`/api/vocab/lessons/${this.lessonId}`);
this.lesson = res.data;
// Initialisiere Übungen aus der Lektion oder lade sie separat
if (this.lesson && this.lesson.id) {
if (this.lesson.grammarExercises && this.lesson.grammarExercises.length > 0) {
// Übungen sind bereits in der Lektion enthalten
this.initializeExercises(this.lesson.grammarExercises);
} else {
// Lade Übungen separat (Fallback)
await this.loadGrammarExercises();
}
}
} catch (e) {
console.error('Konnte Lektion nicht laden:', e);
} finally {
this.loading = false;
}
},
initializeExercises(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.exerciseAnswers[exercise.id] = new Array(gapCount).fill('');
} else {
this.exerciseAnswers[exercise.id] = '';
}
this.exerciseResults[exercise.id] = null;
});
},
async loadGrammarExercises() {
try {
const res = await apiClient.get(`/api/vocab/lessons/${this.lessonId}/grammar-exercises`);
const exercises = res.data || [];
this.lesson.grammarExercises = exercises;
this.initializeExercises(exercises);
} catch (e) {
console.error('Konnte Grammatik-Übungen nicht laden:', e);
this.lesson.grammarExercises = [];
}
},
getExerciseType(exercise) {
// Hole den Typ aus questionData oder exerciseType
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) {
try {
const exercise = this.lesson.grammarExercises.find(e => e.id === exerciseId);
if (!exercise) return;
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.exerciseResults[exerciseId] = res.data;
} catch (e) {
console.error('Fehler beim Prüfen der Antwort:', e);
alert(e.response?.data?.error || 'Fehler beim Prüfen der Antwort');
}
},
back() {
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}`);
}
},
async mounted() {
await this.loadLesson();
}
};
</script>
<style scoped>
.vocab-lesson-view {
padding: 20px;
}
.lesson-header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 20px;
}
.btn-back {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.lesson-description {
color: #666;
margin-bottom: 20px;
}
.grammar-exercises {
margin-top: 30px;
}
.exercise-item {
background: white;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 15px;
}
.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%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.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 {
margin-top: 15px;
padding: 15px;
border-radius: 4px;
}
.exercise-result.correct {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.exercise-result.wrong {
background: #f8d7da;
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;
}
/* Tabs */
.lesson-tabs {
display: flex;
gap: 10px;
margin: 20px 0;
border-bottom: 2px solid #ddd;
}
.tab-button {
padding: 10px 20px;
border: none;
background: transparent;
cursor: pointer;
font-size: 1em;
color: #666;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
}
.tab-button:hover:not(:disabled) {
color: #333;
background: #f5f5f5;
}
.tab-button.active {
color: #007bff;
border-bottom-color: #007bff;
font-weight: bold;
}
.tab-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Lernen-Sektion */
.learn-section {
margin-top: 20px;
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
}
.learn-section h3 {
margin-top: 0;
color: #333;
}
.cultural-notes {
margin: 20px 0;
padding: 15px;
background: #e7f3ff;
border-left: 4px solid #007bff;
border-radius: 4px;
}
.cultural-notes h4 {
margin-top: 0;
color: #007bff;
}
.vocab-list {
margin: 20px 0;
}
.vocab-list h4 {
margin-bottom: 15px;
color: #333;
}
.vocab-items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
}
.vocab-item {
padding: 10px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
display: flex;
align-items: center;
gap: 10px;
}
.vocab-item strong {
color: #007bff;
}
.separator {
color: #999;
}
.no-vocab-info {
padding: 15px;
background: #fff3cd;
border-left: 4px solid #ffc107;
border-radius: 4px;
color: #856404;
}
.continue-to-exercises {
margin-top: 30px;
text-align: center;
}
.btn-continue {
padding: 12px 24px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 1.1em;
cursor: pointer;
transition: background 0.2s;
}
.btn-continue:hover {
background: #0056b3;
}
</style>