Add language assistant settings and related features: Introduce new routes and controller methods for managing language assistant settings, including retrieval and saving of LLM configurations. Update navigation structure to include language assistant options. Enhance vocab course model to support additional learning attributes such as learning goals and core patterns. Update SQL scripts to reflect new database schema changes for vocab courses. Improve localization for language assistant settings in German and English.

This commit is contained in:
Torsten Schulz (local)
2026-03-25 15:53:49 +01:00
parent 8af726c65a
commit d50d3c4016
40 changed files with 3145 additions and 56 deletions

View File

@@ -30,26 +30,80 @@
<!-- Lernen-Tab -->
<div v-if="activeTab === 'learn'" class="learn-section">
<h3>{{ $t('socialnetwork.vocab.courses.learnVocabulary') }}</h3>
<!-- Lektions-Beschreibung -->
<div v-if="lesson && lesson.description" class="lesson-description-box">
<h4>{{ $t('socialnetwork.vocab.courses.lessonDescription') }}</h4>
<p>{{ lesson.description }}</p>
<div class="lesson-overview-card">
<div>
<h3>{{ $t('socialnetwork.vocab.courses.learnVocabulary') }}</h3>
<p class="lesson-overview-text">
{{ $t('socialnetwork.vocab.courses.lessonOverviewText') }}
</p>
</div>
<div class="lesson-meta-grid">
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.lessonTypeLabel') }}</span>
<strong>{{ getLessonTypeLabel(lesson.lessonType) }}</strong>
</div>
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.recommendedDuration') }}</span>
<strong>{{ formatTargetMinutes(lesson.targetMinutes) }}</strong>
</div>
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.exerciseLoad') }}</span>
<strong>{{ effectiveExercises?.length || 0 }} {{ $t('socialnetwork.vocab.courses.exercisesShort') }}</strong>
</div>
</div>
</div>
<!-- Kulturelle Notizen -->
<div v-if="lesson && lesson.culturalNotes" class="cultural-notes">
<h4>{{ $t('socialnetwork.vocab.courses.culturalNotes') }}</h4>
<p>{{ lesson.culturalNotes }}</p>
</div>
<div class="learn-grid">
<div v-if="lesson && lesson.description" class="lesson-description-box">
<h4>{{ $t('socialnetwork.vocab.courses.lessonDescription') }}</h4>
<p>{{ lesson.description }}</p>
</div>
<!-- Grammatik-Erklärungen -->
<div v-if="grammarExplanations && grammarExplanations.length > 0" class="grammar-explanations">
<h4>{{ $t('socialnetwork.vocab.courses.grammarExplanations') }}</h4>
<div v-for="(explanation, index) in grammarExplanations" :key="index" class="grammar-explanation-item">
<strong>{{ explanation.title }}</strong>
<p>{{ explanation.text }}</p>
<div v-if="lessonDidactics.learningGoals.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.learningGoals') }}</h4>
<ul class="didactic-list">
<li v-for="(goal, index) in lessonDidactics.learningGoals" :key="'goal-' + index">{{ goal }}</li>
</ul>
</div>
<div v-if="lessonDidactics.corePatterns.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.corePatterns') }}</h4>
<div class="pattern-list">
<div v-for="(pattern, index) in lessonDidactics.corePatterns" :key="'pattern-' + index" class="pattern-item">
{{ pattern }}
</div>
</div>
</div>
<div v-if="lessonDidactics.grammarFocus.length > 0" class="grammar-explanations didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.grammarExplanations') }}</h4>
<div v-for="(explanation, index) in lessonDidactics.grammarFocus" :key="'grammar-' + index" class="grammar-explanation-item">
<strong>{{ explanation.title || $t('socialnetwork.vocab.courses.grammarImpulse') }}</strong>
<p>{{ explanation.text }}</p>
<p v-if="explanation.example" class="grammar-example">{{ explanation.example }}</p>
</div>
</div>
<div v-if="lessonDidactics.speakingPrompts.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.speakingTasks') }}</h4>
<div v-for="(prompt, index) in lessonDidactics.speakingPrompts" :key="'speaking-' + index" class="speaking-prompt-item">
<strong>{{ prompt.title || $t('socialnetwork.vocab.courses.speakingPrompt') }}</strong>
<p>{{ prompt.prompt }}</p>
<p v-if="prompt.cue" class="speaking-cue">{{ prompt.cue }}</p>
</div>
</div>
<div v-if="lessonDidactics.practicalTasks.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.practicalTasks') }}</h4>
<div v-for="(task, index) in lessonDidactics.practicalTasks" :key="'task-' + index" class="practical-task-item">
<strong>{{ task.title }}</strong>
<p>{{ task.text }}</p>
</div>
</div>
<div v-if="lesson && lesson.culturalNotes" class="cultural-notes didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.culturalNotes') }}</h4>
<p>{{ lesson.culturalNotes }}</p>
</div>
</div>
@@ -253,6 +307,94 @@
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'sentence_building'" class="sentence-building-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<div v-if="getQuestionData(exercise)?.tokens?.length" class="token-list">
<span v-for="(token, index) in getQuestionData(exercise).tokens" :key="index" class="token-chip">{{ token }}</span>
</div>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.buildSentencePlaceholder')"
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].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelSentence') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'dialog_completion'" class="dialog-completion-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<div v-if="getQuestionData(exercise)?.dialog" class="dialog-snippet">
<p v-for="(line, index) in getQuestionData(exercise).dialog" :key="index">{{ line }}</p>
</div>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.completeDialogPlaceholder')"
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].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelDialogLine') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'situational_response'" class="situational-response-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<textarea
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.situationalResponsePlaceholder')"
class="response-textarea"
/>
<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].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelResponse') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].alternatives && exerciseResults[exercise.id].alternatives.length > 0" class="alternatives">
{{ $t('socialnetwork.vocab.courses.keywords') }}: {{ exerciseResults[exercise.id].alternatives.join(', ') }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'pattern_drill'" class="pattern-drill-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<p v-if="getQuestionData(exercise)?.pattern" class="pattern-drill-hint">
{{ $t('socialnetwork.vocab.courses.patternPrompt') }}: {{ getQuestionData(exercise).pattern }}
</p>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.patternDrillPlaceholder')"
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].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelPattern') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<!-- Reading Aloud Übung -->
<div v-else-if="getExerciseType(exercise) === 'reading_aloud'" class="reading-aloud-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
@@ -543,6 +685,15 @@ export default {
console.error('Fehler in importantVocab computed property:', e);
return [];
}
},
lessonDidactics() {
return this.lesson?.didactics || {
learningGoals: [],
corePatterns: [],
grammarFocus: [],
speakingPrompts: [],
practicalTasks: []
};
}
},
watch: {
@@ -787,10 +938,31 @@ export default {
5: 'conjugation',
6: 'declension',
7: 'reading_aloud',
8: 'speaking_from_memory'
8: 'speaking_from_memory',
9: 'dialog_completion',
10: 'situational_response',
11: 'pattern_drill'
};
return typeMap[exercise.exerciseTypeId] || 'unknown';
},
getLessonTypeLabel(lessonType) {
const labels = {
vocab: this.$t('socialnetwork.vocab.courses.lessonTypeVocab'),
grammar: this.$t('socialnetwork.vocab.courses.lessonTypeGrammar'),
conversation: this.$t('socialnetwork.vocab.courses.lessonTypeConversation'),
culture: this.$t('socialnetwork.vocab.courses.lessonTypeCulture'),
review: this.$t('socialnetwork.vocab.courses.lessonTypeReview'),
vocab_review: this.$t('socialnetwork.vocab.courses.lessonTypeReview')
};
return labels[lessonType] || lessonType || this.$t('socialnetwork.vocab.courses.lessonTypeVocab');
},
formatTargetMinutes(targetMinutes) {
const minutes = Number(targetMinutes);
if (!minutes) {
return this.$t('socialnetwork.vocab.courses.durationFlexible');
}
return this.$t('socialnetwork.vocab.courses.durationMinutes', { minutes });
},
getQuestionData(exercise) {
if (!exercise.questionData) return null;
return typeof exercise.questionData === 'string'
@@ -861,9 +1033,11 @@ export default {
} else if (exerciseType === 'multiple_choice') {
// Multiple Choice: Index als Zahl
answer = Number(answer);
} else if (exerciseType === 'transformation') {
} else if (exerciseType === 'transformation' || exerciseType === 'sentence_building' || exerciseType === 'dialog_completion' || exerciseType === 'pattern_drill') {
// Transformation: String
answer = String(answer || '').trim();
} else if (exerciseType === 'situational_response') {
answer = String(answer || '').trim();
} else if (exerciseType === 'reading_aloud' || exerciseType === 'speaking_from_memory') {
// Reading Aloud / Speaking From Memory: Verwende erkannten Text
answer = this.recognizedText[exerciseId] || String(answer || '').trim();
@@ -1453,6 +1627,126 @@ export default {
margin-bottom: 20px;
}
.lesson-overview-card {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 20px;
margin-bottom: 20px;
background: linear-gradient(135deg, #fff8eb 0%, #f7efe2 100%);
border: 1px solid rgba(160, 120, 40, 0.18);
border-radius: 12px;
}
.lesson-overview-text {
margin: 8px 0 0;
color: #5b4b2f;
}
.lesson-meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(130px, 1fr));
gap: 12px;
min-width: 360px;
}
.lesson-meta-item {
padding: 12px 14px;
background: rgba(255, 255, 255, 0.72);
border-radius: 10px;
}
.lesson-meta-label {
display: block;
margin-bottom: 6px;
font-size: 0.82rem;
color: #7a6848;
}
.learn-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
}
.didactic-card,
.lesson-description-box,
.cultural-notes {
padding: 18px;
background: #f8f9fa;
border-radius: 12px;
border: 1px solid #e7e7e7;
}
.didactic-list {
margin: 0;
padding-left: 20px;
}
.didactic-list li + li {
margin-top: 8px;
}
.pattern-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.pattern-item {
padding: 12px 14px;
border-left: 4px solid #d2831f;
background: #fff;
border-radius: 8px;
}
.grammar-example,
.speaking-cue,
.pattern-drill-hint {
margin-top: 8px;
color: #66553a;
font-style: italic;
}
.speaking-prompt-item + .speaking-prompt-item,
.practical-task-item + .practical-task-item {
margin-top: 14px;
}
.token-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 14px 0;
}
.token-chip {
display: inline-flex;
align-items: center;
padding: 6px 12px;
background: #eef3ff;
border: 1px solid #cfdbff;
border-radius: 999px;
font-size: 0.95rem;
}
.dialog-snippet {
margin: 14px 0;
padding: 14px;
background: #fff;
border-radius: 10px;
border: 1px solid #e6e6e6;
}
.response-textarea {
width: 100%;
min-height: 120px;
padding: 12px;
border: 1px solid #d0d0d0;
border-radius: 8px;
resize: vertical;
}
.btn-back {
padding: 8px 16px;
border: 1px solid #ddd;
@@ -2023,7 +2317,11 @@ export default {
/* Reading Aloud & Speaking From Memory Styles */
.reading-aloud-exercise,
.speaking-from-memory-exercise {
.speaking-from-memory-exercise,
.sentence-building-exercise,
.dialog-completion-exercise,
.situational-response-exercise,
.pattern-drill-exercise {
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
@@ -2229,4 +2527,15 @@ export default {
.dialog-button:hover {
background: #0056b3;
}
@media (max-width: 900px) {
.lesson-overview-card {
flex-direction: column;
}
.lesson-meta-grid {
grid-template-columns: 1fr;
min-width: 0;
}
}
</style>