feat(vocab): enhance feedback acknowledgment and localization in vocabulary lessons
All checks were successful
Deploy to production / deploy (push) Successful in 2m41s
All checks were successful
Deploy to production / deploy (push) Successful in 2m41s
- Added acknowledgment messages for exercise reinforcement in German, English, and Spanish localization files to improve user guidance. - Updated VocabLessonView to include a feedback acknowledgment button, enhancing user interaction after answering questions. - Implemented logic to track feedback acknowledgment state, improving the flow of lesson reviews and user experience.
This commit is contained in:
@@ -523,6 +523,8 @@
|
|||||||
"exerciseWrongTitle": "Noch nicht richtig",
|
"exerciseWrongTitle": "Noch nicht richtig",
|
||||||
"exerciseReinforcementGoPractice": "Zum Üben wechseln",
|
"exerciseReinforcementGoPractice": "Zum Üben wechseln",
|
||||||
"exerciseReinforcementStay": "Bei der Prüfung bleiben",
|
"exerciseReinforcementStay": "Bei der Prüfung bleiben",
|
||||||
|
"exerciseReinforcementGoPracticeAck": "Gelesen, zum Üben wechseln",
|
||||||
|
"exerciseReinforcementStayAck": "Gelesen, bei der Prüfung bleiben",
|
||||||
"exerciseStatusOpen": "Offen",
|
"exerciseStatusOpen": "Offen",
|
||||||
"exerciseStatusCorrect": "Erledigt",
|
"exerciseStatusCorrect": "Erledigt",
|
||||||
"exerciseStatusRetry": "Nochmal prüfen",
|
"exerciseStatusRetry": "Nochmal prüfen",
|
||||||
|
|||||||
@@ -523,6 +523,8 @@
|
|||||||
"exerciseWrongTitle": "Not quite right",
|
"exerciseWrongTitle": "Not quite right",
|
||||||
"exerciseReinforcementGoPractice": "Go to practice",
|
"exerciseReinforcementGoPractice": "Go to practice",
|
||||||
"exerciseReinforcementStay": "Stay on the test",
|
"exerciseReinforcementStay": "Stay on the test",
|
||||||
|
"exerciseReinforcementGoPracticeAck": "Read, go to practice",
|
||||||
|
"exerciseReinforcementStayAck": "Read, stay on the test",
|
||||||
"exerciseStatusOpen": "Open",
|
"exerciseStatusOpen": "Open",
|
||||||
"exerciseStatusCorrect": "Done",
|
"exerciseStatusCorrect": "Done",
|
||||||
"exerciseStatusRetry": "Try again",
|
"exerciseStatusRetry": "Try again",
|
||||||
|
|||||||
@@ -521,6 +521,8 @@
|
|||||||
"exerciseWrongTitle": "Aún no es correcto",
|
"exerciseWrongTitle": "Aún no es correcto",
|
||||||
"exerciseReinforcementGoPractice": "Ir a practicar",
|
"exerciseReinforcementGoPractice": "Ir a practicar",
|
||||||
"exerciseReinforcementStay": "Seguir en la prueba",
|
"exerciseReinforcementStay": "Seguir en la prueba",
|
||||||
|
"exerciseReinforcementGoPracticeAck": "Leído, ir a practicar",
|
||||||
|
"exerciseReinforcementStayAck": "Leído, seguir en la prueba",
|
||||||
"exerciseStatusOpen": "Pendiente",
|
"exerciseStatusOpen": "Pendiente",
|
||||||
"exerciseStatusCorrect": "Hecha",
|
"exerciseStatusCorrect": "Hecha",
|
||||||
"exerciseStatusRetry": "Revisar otra vez",
|
"exerciseStatusRetry": "Revisar otra vez",
|
||||||
|
|||||||
@@ -49,6 +49,14 @@
|
|||||||
>
|
>
|
||||||
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
|
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="needsFeedbackAck"
|
||||||
|
type="button"
|
||||||
|
class="button-secondary"
|
||||||
|
@click="advanceAfterFeedback"
|
||||||
|
>
|
||||||
|
Gelesen, weiter
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="feedback" class="review-feedback" :class="feedbackCorrect ? 'ok' : 'bad'">
|
<p v-if="feedback" class="review-feedback" :class="feedbackCorrect ? 'ok' : 'bad'">
|
||||||
@@ -80,8 +88,10 @@ export default {
|
|||||||
typedAnswer: '',
|
typedAnswer: '',
|
||||||
feedback: '',
|
feedback: '',
|
||||||
feedbackCorrect: false,
|
feedbackCorrect: false,
|
||||||
|
needsFeedbackAck: false,
|
||||||
correctCount: 0,
|
correctCount: 0,
|
||||||
reviewDone: false
|
reviewDone: false,
|
||||||
|
weakVocabMap: {}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -103,6 +113,9 @@ export default {
|
|||||||
normalize(s) {
|
normalize(s) {
|
||||||
return String(s || '').trim().toLowerCase();
|
return String(s || '').trim().toLowerCase();
|
||||||
},
|
},
|
||||||
|
getItemKey(item) {
|
||||||
|
return `${String(item?.gloss || '').trim()}|${String(item?.target || '').trim()}`;
|
||||||
|
},
|
||||||
parseCorePatterns() {
|
parseCorePatterns() {
|
||||||
const raw = this.lesson?.didactics?.corePatterns || [];
|
const raw = this.lesson?.didactics?.corePatterns || [];
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -151,6 +164,7 @@ export default {
|
|||||||
this.feedback = '';
|
this.feedback = '';
|
||||||
this.selectedOption = '';
|
this.selectedOption = '';
|
||||||
this.typedAnswer = '';
|
this.typedAnswer = '';
|
||||||
|
this.needsFeedbackAck = false;
|
||||||
if (!this.currentItem) return;
|
if (!this.currentItem) return;
|
||||||
this.mode = this.currentItem.gloss ? 'multiple_choice' : 'typing';
|
this.mode = this.currentItem.gloss ? 'multiple_choice' : 'typing';
|
||||||
if (this.mode === 'multiple_choice') {
|
if (this.mode === 'multiple_choice') {
|
||||||
@@ -170,26 +184,85 @@ export default {
|
|||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
this.correctCount += 1;
|
this.correctCount += 1;
|
||||||
this.feedback = this.$t('socialnetwork.vocab.courses.correct');
|
this.feedback = this.$t('socialnetwork.vocab.courses.correct');
|
||||||
|
this.needsFeedbackAck = false;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.advanceAfterFeedback();
|
||||||
|
}, 550);
|
||||||
} else {
|
} else {
|
||||||
this.feedback = `${this.$t('socialnetwork.vocab.courses.wrong')} - ${this.$t('socialnetwork.vocab.courses.correctAnswer')}: ${this.mode === 'multiple_choice' ? this.currentItem.gloss : this.currentItem.target}`;
|
this.feedback = `${this.$t('socialnetwork.vocab.courses.wrong')} - ${this.$t('socialnetwork.vocab.courses.correctAnswer')}: ${this.mode === 'multiple_choice' ? this.currentItem.gloss : this.currentItem.target}`;
|
||||||
|
this.needsFeedbackAck = true;
|
||||||
|
const key = this.getItemKey(this.currentItem);
|
||||||
|
const existing = this.weakVocabMap[key] || {
|
||||||
|
learning: this.currentItem.gloss || '',
|
||||||
|
reference: this.currentItem.target || '',
|
||||||
|
wrongCount: 0,
|
||||||
|
lastWrongAt: ''
|
||||||
|
};
|
||||||
|
existing.wrongCount += 1;
|
||||||
|
existing.lastWrongAt = new Date().toISOString();
|
||||||
|
this.weakVocabMap[key] = existing;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
window.setTimeout(async () => {
|
async advanceAfterFeedback() {
|
||||||
this.currentIndex += 1;
|
this.currentIndex += 1;
|
||||||
if (this.currentIndex >= this.reviewQueue.length) {
|
if (this.currentIndex >= this.reviewQueue.length) {
|
||||||
await this.finishReview();
|
await this.finishReview();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setupCurrent();
|
this.setupCurrent();
|
||||||
}, 500);
|
|
||||||
},
|
},
|
||||||
async finishReview() {
|
async finishReview() {
|
||||||
this.reviewDone = true;
|
this.reviewDone = true;
|
||||||
try {
|
try {
|
||||||
|
let mergedWeak = Object.values(this.weakVocabMap);
|
||||||
|
try {
|
||||||
|
const { data: progressList } = await apiClient.get(`/api/vocab/courses/${this.courseId}/progress`);
|
||||||
|
const existingProgress = Array.isArray(progressList)
|
||||||
|
? progressList.find((p) => Number(p.lessonId) === Number(this.lessonId))
|
||||||
|
: null;
|
||||||
|
const existingWeak = Array.isArray(existingProgress?.lessonState?.reviewWeakVocab)
|
||||||
|
? existingProgress.lessonState.reviewWeakVocab
|
||||||
|
: [];
|
||||||
|
const map = new Map();
|
||||||
|
existingWeak.forEach((entry) => {
|
||||||
|
const key = `${String(entry?.learning || '').trim()}|${String(entry?.reference || '').trim()}`;
|
||||||
|
if (!key) return;
|
||||||
|
map.set(key, {
|
||||||
|
learning: String(entry?.learning || '').trim(),
|
||||||
|
reference: String(entry?.reference || '').trim(),
|
||||||
|
wrongCount: Math.max(0, Number(entry?.wrongCount) || 0),
|
||||||
|
lastWrongAt: String(entry?.lastWrongAt || '')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
mergedWeak.forEach((entry) => {
|
||||||
|
const key = `${String(entry?.learning || '').trim()}|${String(entry?.reference || '').trim()}`;
|
||||||
|
if (!key) return;
|
||||||
|
const prev = map.get(key);
|
||||||
|
if (!prev) {
|
||||||
|
map.set(key, entry);
|
||||||
|
} else {
|
||||||
|
map.set(key, {
|
||||||
|
learning: prev.learning || entry.learning,
|
||||||
|
reference: prev.reference || entry.reference,
|
||||||
|
wrongCount: Math.max(0, Number(prev.wrongCount) || 0) + Math.max(0, Number(entry.wrongCount) || 0),
|
||||||
|
lastWrongAt: entry.lastWrongAt || prev.lastWrongAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mergedWeak = Array.from(map.values())
|
||||||
|
.filter((entry) => entry.learning && entry.reference)
|
||||||
|
.sort((a, b) => (b.wrongCount || 0) - (a.wrongCount || 0))
|
||||||
|
.slice(0, 40);
|
||||||
|
} catch (mergeErr) {
|
||||||
|
console.warn('Konnte bestehende Review-Schwachstellen nicht laden:', mergeErr);
|
||||||
|
}
|
||||||
await apiClient.put(`/api/vocab/lessons/${this.lessonId}/progress`, {
|
await apiClient.put(`/api/vocab/lessons/${this.lessonId}/progress`, {
|
||||||
completed: true,
|
completed: true,
|
||||||
score: 100,
|
score: 100,
|
||||||
timeSpentMinutes: 1
|
timeSpentMinutes: 1,
|
||||||
|
lessonState: {
|
||||||
|
reviewWeakVocab: mergedWeak
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Review-Fortschritt konnte nicht gespeichert werden:', e);
|
console.error('Review-Fortschritt konnte nicht gespeichert werden:', e);
|
||||||
|
|||||||
@@ -902,11 +902,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nach falscher Kapitel-Antwort: zuerst Lösung, dann optional zum Üben -->
|
<!-- Nach falscher Kapitel-Antwort: zuerst Lösung, dann optional zum Üben -->
|
||||||
<div v-if="showExerciseReinforcementDialog" class="dialog-overlay" @click.self="closeExerciseReinforcementDialog">
|
<div v-if="showExerciseReinforcementDialog" class="dialog-overlay">
|
||||||
<div class="dialog" style="width: 440px; height: auto;">
|
<div class="dialog" style="width: 440px; height: auto;">
|
||||||
<div class="dialog-header">
|
<div class="dialog-header">
|
||||||
<span class="dialog-title">{{ $t('socialnetwork.vocab.courses.exerciseWrongTitle') }}</span>
|
<span class="dialog-title">{{ $t('socialnetwork.vocab.courses.exerciseWrongTitle') }}</span>
|
||||||
<span class="dialog-close" @click="closeExerciseReinforcementDialog">✖</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
<p v-if="exerciseReinforcementCorrectAnswer" class="exercise-reinforcement-correct">
|
<p v-if="exerciseReinforcementCorrectAnswer" class="exercise-reinforcement-correct">
|
||||||
@@ -917,10 +916,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="dialog-footer dialog-footer--stack">
|
<div class="dialog-footer dialog-footer--stack">
|
||||||
<button type="button" class="dialog-button dialog-button--primary" @click="confirmExerciseReinforcement">
|
<button type="button" class="dialog-button dialog-button--primary" @click="confirmExerciseReinforcement">
|
||||||
{{ $t('socialnetwork.vocab.courses.exerciseReinforcementGoPractice') }}
|
{{ $t('socialnetwork.vocab.courses.exerciseReinforcementGoPracticeAck') }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="dialog-button" @click="closeExerciseReinforcementDialog">
|
<button type="button" class="dialog-button" @click="closeExerciseReinforcementDialog">
|
||||||
{{ $t('socialnetwork.vocab.courses.exerciseReinforcementStay') }}
|
{{ $t('socialnetwork.vocab.courses.exerciseReinforcementStayAck') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1013,6 +1012,8 @@ export default {
|
|||||||
exerciseSequentialIndex: 0,
|
exerciseSequentialIndex: 0,
|
||||||
/** Aus vorherigen Lektionen (MC-Optionen nach Fragentyp Ziel-/Muttersprache) */
|
/** Aus vorherigen Lektionen (MC-Optionen nach Fragentyp Ziel-/Muttersprache) */
|
||||||
distractorPool: { target: [], native: [] },
|
distractorPool: { target: [], native: [] },
|
||||||
|
/** Fortschritt aller Kurslektionen inkl. lessonState für Spezial-Trainer-Boost */
|
||||||
|
courseProgressList: [],
|
||||||
/** { [exerciseId]: { options: string[], useTextAnswer: boolean } } */
|
/** { [exerciseId]: { options: string[], useTextAnswer: boolean } } */
|
||||||
mcRandomizedOptions: {},
|
mcRandomizedOptions: {},
|
||||||
lessonStatePersistenceReady: false,
|
lessonStatePersistenceReady: false,
|
||||||
@@ -2038,6 +2039,7 @@ export default {
|
|||||||
this.vocabTrainerPhase = 'current';
|
this.vocabTrainerPhase = 'current';
|
||||||
this.vocabTrainerCurrentAttempts = 0;
|
this.vocabTrainerCurrentAttempts = 0;
|
||||||
this.vocabTrainerReviewAttempts = 0;
|
this.vocabTrainerReviewAttempts = 0;
|
||||||
|
this.courseProgressList = [];
|
||||||
this.distractorPool = { target: [], native: [] };
|
this.distractorPool = { target: [], native: [] };
|
||||||
this.mcRandomizedOptions = {};
|
this.mcRandomizedOptions = {};
|
||||||
// Reset Flags
|
// Reset Flags
|
||||||
@@ -2059,6 +2061,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const res = await apiClient.get(`/api/vocab/lessons/${this.lessonId}`);
|
const res = await apiClient.get(`/api/vocab/lessons/${this.lessonId}`);
|
||||||
this.lesson = res.data;
|
this.lesson = res.data;
|
||||||
|
await this.loadCourseProgressForBoost();
|
||||||
debugLog('[VocabLessonView] Geladene Lektion:', this.lesson?.id, this.lesson?.title);
|
debugLog('[VocabLessonView] Geladene Lektion:', this.lesson?.id, this.lesson?.title);
|
||||||
if (this.$route.query.assistant) {
|
if (this.$route.query.assistant) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@@ -2095,6 +2098,14 @@ export default {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async loadCourseProgressForBoost() {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get(`/api/vocab/courses/${this.courseId}/progress`);
|
||||||
|
this.courseProgressList = Array.isArray(data) ? data : [];
|
||||||
|
} catch (e) {
|
||||||
|
this.courseProgressList = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
focusAssistantCard() {
|
focusAssistantCard() {
|
||||||
const target = this.$refs.assistantCard;
|
const target = this.$refs.assistantCard;
|
||||||
if (!target || typeof target.scrollIntoView !== 'function') {
|
if (!target || typeof target.scrollIntoView !== 'function') {
|
||||||
@@ -2688,9 +2699,43 @@ export default {
|
|||||||
if (!this.previousVocab || this.previousVocab.length === 0) return [];
|
if (!this.previousVocab || this.previousVocab.length === 0) return [];
|
||||||
const currentKeys = new Set(this.trainableLessonVocab.map(v => this.getVocabKey(v)));
|
const currentKeys = new Set(this.trainableLessonVocab.map(v => this.getVocabKey(v)));
|
||||||
const filtered = this.previousVocab.filter(v => !currentKeys.has(this.getVocabKey(v)));
|
const filtered = this.previousVocab.filter(v => !currentKeys.has(this.getVocabKey(v)));
|
||||||
// Zufällig mischen und auf 40 begrenzen
|
const filteredByKey = new Map(filtered.map((v) => [this.getVocabKey(v), v]));
|
||||||
const shuffled = [...filtered].sort(() => Math.random() - 0.5);
|
|
||||||
return shuffled.slice(0, 40);
|
const weakMap = new Map();
|
||||||
|
(this.courseProgressList || []).forEach((entry) => {
|
||||||
|
const lessonId = Number(entry?.lessonId);
|
||||||
|
if (!Number.isFinite(lessonId) || lessonId === Number(this.lessonId)) return;
|
||||||
|
const weakList = Array.isArray(entry?.lessonState?.reviewWeakVocab)
|
||||||
|
? entry.lessonState.reviewWeakVocab
|
||||||
|
: [];
|
||||||
|
weakList.forEach((w) => {
|
||||||
|
const learning = String(w?.learning || '').trim();
|
||||||
|
const reference = String(w?.reference || '').trim();
|
||||||
|
if (!learning || !reference) return;
|
||||||
|
const key = `${learning}|${reference}`;
|
||||||
|
if (currentKeys.has(key) || !filteredByKey.has(key)) return;
|
||||||
|
const prev = weakMap.get(key) || 0;
|
||||||
|
weakMap.set(key, prev + Math.max(1, Number(w?.wrongCount) || 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const boosted = [];
|
||||||
|
Array.from(weakMap.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 20)
|
||||||
|
.forEach(([key, score]) => {
|
||||||
|
const vocab = filteredByKey.get(key);
|
||||||
|
if (!vocab) return;
|
||||||
|
const weight = Math.min(4, Math.max(1, Math.round(score)));
|
||||||
|
for (let i = 0; i < weight; i++) {
|
||||||
|
boosted.push(vocab);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const boostedKeySet = new Set(Array.from(weakMap.keys()));
|
||||||
|
const rest = filtered.filter((v) => !boostedKeySet.has(this.getVocabKey(v)));
|
||||||
|
const shuffled = [...rest].sort(() => Math.random() - 0.5);
|
||||||
|
return [...boosted, ...shuffled].slice(0, 40);
|
||||||
},
|
},
|
||||||
getVocabKey(vocab) {
|
getVocabKey(vocab) {
|
||||||
return `${vocab.learning}|${vocab.reference}`;
|
return `${vocab.learning}|${vocab.reference}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user