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:
224
frontend/src/views/settings/LanguageAssistantView.vue
Normal file
224
frontend/src/views/settings/LanguageAssistantView.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="language-assistant-settings">
|
||||
<section class="language-assistant-settings__hero surface-card">
|
||||
<span class="language-assistant-settings__eyebrow">{{ $t('settings.languageAssistant.eyebrow') }}</span>
|
||||
<h2>{{ $t('settings.languageAssistant.title') }}</h2>
|
||||
<p class="language-assistant-settings__intro">{{ $t('settings.languageAssistant.intro') }}</p>
|
||||
<ul class="language-assistant-settings__links">
|
||||
<li>
|
||||
<a href="https://platform.openai.com/signup" target="_blank" rel="noopener noreferrer">
|
||||
{{ $t('settings.languageAssistant.linkSignup') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer">
|
||||
{{ $t('settings.languageAssistant.linkApiKeys') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="loadError" class="surface-card language-assistant-settings__panel">
|
||||
<p class="form-error">{{ loadError }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else class="language-assistant-settings__panel surface-card">
|
||||
<label class="language-assistant-settings__toggle">
|
||||
<input type="checkbox" v-model="form.enabled" />
|
||||
<span>{{ $t('settings.languageAssistant.enabled') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="language-assistant-settings__grid">
|
||||
<label class="language-assistant-settings__field">
|
||||
<span>{{ $t('settings.languageAssistant.baseUrl') }}</span>
|
||||
<input
|
||||
v-model="form.baseUrl"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
:placeholder="$t('settings.languageAssistant.baseUrlPlaceholder')"
|
||||
/>
|
||||
</label>
|
||||
<label class="language-assistant-settings__field">
|
||||
<span>{{ $t('settings.languageAssistant.model') }}</span>
|
||||
<input v-model="form.model" type="text" autocomplete="off" placeholder="gpt-4o-mini" />
|
||||
</label>
|
||||
<label class="language-assistant-settings__field language-assistant-settings__field--full">
|
||||
<span>{{ $t('settings.languageAssistant.apiKey') }}</span>
|
||||
<input
|
||||
v-model="form.apiKey"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="apiKeyPlaceholder"
|
||||
/>
|
||||
<span class="language-assistant-settings__hint">{{ $t('settings.languageAssistant.apiKeyHint') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="language-assistant-settings__toggle">
|
||||
<input type="checkbox" v-model="form.clearKey" />
|
||||
<span>{{ $t('settings.languageAssistant.clearKey') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="language-assistant-settings__actions">
|
||||
<button type="button" :disabled="saving" @click="save">{{ $t('settings.languageAssistant.save') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { showApiError, showError, showSuccess } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: 'LanguageAssistantSettingsView',
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
enabled: true,
|
||||
baseUrl: '',
|
||||
model: 'gpt-4o-mini',
|
||||
apiKey: '',
|
||||
clearKey: false
|
||||
},
|
||||
hasKey: false,
|
||||
keyLast4: null,
|
||||
saving: false,
|
||||
loadError: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['user']),
|
||||
apiKeyPlaceholder() {
|
||||
if (this.form.clearKey) {
|
||||
return this.$t('settings.languageAssistant.apiKeyPlaceholderClear');
|
||||
}
|
||||
if (this.hasKey) {
|
||||
return this.$t('settings.languageAssistant.apiKeyPlaceholderHasKey', {
|
||||
last4: this.keyLast4 || '••••'
|
||||
});
|
||||
}
|
||||
return this.$t('settings.languageAssistant.apiKeyPlaceholderNew');
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.load();
|
||||
},
|
||||
methods: {
|
||||
async load() {
|
||||
this.loadError = null;
|
||||
try {
|
||||
const { data } = await apiClient.get('/api/settings/llm');
|
||||
this.form.enabled = data.enabled !== false;
|
||||
this.form.baseUrl = data.baseUrl || '';
|
||||
this.form.model = data.model || 'gpt-4o-mini';
|
||||
this.hasKey = data.hasKey;
|
||||
this.keyLast4 = data.keyLast4;
|
||||
this.form.apiKey = '';
|
||||
this.form.clearKey = false;
|
||||
} catch (e) {
|
||||
this.loadError = e.response?.data?.error || e.message || 'Error';
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
if (this.form.clearKey && !window.confirm(this.$t('settings.languageAssistant.confirmClear'))) {
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
try {
|
||||
await apiClient.post('/api/settings/llm', {
|
||||
enabled: this.form.enabled,
|
||||
baseUrl: this.form.baseUrl,
|
||||
model: this.form.model,
|
||||
apiKey: this.form.apiKey,
|
||||
clearKey: this.form.clearKey
|
||||
});
|
||||
showSuccess(this, this.$t('settings.languageAssistant.saved'));
|
||||
this.form.apiKey = '';
|
||||
this.form.clearKey = false;
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
showApiError(this, e, () => showError(this, this.$t('settings.languageAssistant.saveError')));
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.language-assistant-settings__hero {
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.language-assistant-settings__eyebrow {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.75;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.language-assistant-settings__intro {
|
||||
margin: 0.5rem 0 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.language-assistant-settings__links {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.language-assistant-settings__links a {
|
||||
color: var(--color-link, #3b82f6);
|
||||
}
|
||||
.language-assistant-settings__panel {
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.language-assistant-settings__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.language-assistant-settings__grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.language-assistant-settings__field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
.language-assistant-settings__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.language-assistant-settings__field span:first-child {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.language-assistant-settings__field input {
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border, #ccc);
|
||||
background: var(--color-input-bg, #fff);
|
||||
color: inherit;
|
||||
}
|
||||
.language-assistant-settings__hint {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.language-assistant-settings__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.language-assistant-settings__actions {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.form-error {
|
||||
color: #c62828;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user