Files
yourpart3/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue
Torsten Schulz (local) 9123bd524a feat(VocabPracticeDialog, VocabCourseView): implement SRS rating feature and enhance user feedback
- Added SRS rating buttons in VocabPracticeDialog to allow users to rate their confidence after answering vocabulary questions.
- Updated methods to handle SRS ratings and integrated them into the review process, improving spaced repetition feedback.
- Enhanced UI with new styles for SRS rating buttons and updated translations for SRS-related terms in multiple languages.
- Modified VocabCourseView to display appropriate introductory text based on SRS due items, improving user guidance.
2026-04-17 09:27:29 +02:00

722 lines
21 KiB
Vue

<template>
<DialogWidget
ref="dialog"
:title="$t('socialnetwork.vocab.practice.title')"
:show-close="false"
:buttons="buttons"
:modal="true"
:isTitleTranslated="false"
width="55em"
height="32em"
name="VocabPracticeDialog"
display="flex"
>
<div class="layout">
<div class="left">
<div class="opts">
<label class="chk">
<input type="checkbox" v-model="allVocabs" :disabled="srsMode" @change="reloadPool" />
{{ $t('socialnetwork.vocab.practice.allVocabs') }}
</label>
<label class="chk">
<input type="checkbox" v-model="simpleMode" @change="onSimpleModeChanged" />
{{ $t('socialnetwork.vocab.practice.simple') }}
</label>
</div>
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="pool.length === 0">
{{ $t('socialnetwork.vocab.practice.noPool') }}
</div>
<div v-else>
<div class="prompt">
<div class="dir">{{ directionLabel }}</div>
<div class="word">{{ currentPrompt }}</div>
</div>
<div v-if="answered" class="feedback" :class="{ ok: lastCorrect, bad: !lastCorrect }">
<div v-if="lastCorrect">{{ $t('socialnetwork.vocab.practice.correct') }}</div>
<div v-else>
{{ $t('socialnetwork.vocab.practice.wrong') }}
<div class="answers">
<div class="answersTitle">{{ $t('socialnetwork.vocab.practice.acceptable') }}</div>
<ul>
<li v-for="a in acceptableAnswers" :key="a">{{ a }}</li>
</ul>
</div>
</div>
</div>
<div v-if="showSrsRatingButtons" class="srs-rating">
<div class="srs-rating__title">{{ $t('socialnetwork.vocab.practice.srsRateTitle') }}</div>
<button
v-for="option in srsRatingOptions"
:key="option.value"
type="button"
class="srs-rating__button"
:class="`srs-rating__button--${option.value}`"
:disabled="locked"
@click="submitSrsRating(option.value)"
>
<strong>{{ option.label }}</strong>
<span>{{ option.hint }}</span>
</button>
</div>
<div v-if="!answered" class="answerArea">
<div v-if="simpleMode" class="choices">
<button
v-for="opt in choiceOptions"
:key="opt"
class="choiceBtn"
:disabled="locked"
@click="submitChoice(opt)"
>
{{ opt }}
</button>
</div>
<div v-else class="typing">
<input
ref="answerInput"
v-model="typedAnswer"
type="text"
:disabled="locked"
@keydown.enter.prevent="submitTyped"
/>
<button :disabled="locked || typedAnswer.trim().length === 0" @click="submitTyped">
{{ $t('socialnetwork.vocab.practice.check') }}
</button>
</div>
</div>
<div class="controls">
<button v-if="showNextButton" @click="next">
{{ $t('socialnetwork.vocab.practice.next') }}
</button>
<button v-else-if="showSkipButton" @click="skip">
{{ $t('socialnetwork.vocab.practice.skip') }}
</button>
</div>
</div>
</div>
<div class="right">
<div class="stat">
<div class="statTitle">{{ $t('socialnetwork.vocab.practice.stats') }}</div>
<div class="statRow">
<span class="k">{{ $t('socialnetwork.vocab.practice.success') }}</span>
<span class="v">{{ correctCount }} ({{ successPercent }}%)</span>
</div>
<div class="statRow">
<span class="k">{{ $t('socialnetwork.vocab.practice.fail') }}</span>
<span class="v">{{ wrongCount }} ({{ failPercent }}%)</span>
</div>
</div>
</div>
</div>
</DialogWidget>
</template>
<script>
import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js';
const PRACTICE_MIN_EXPOSURES = 3;
export default {
name: 'VocabPracticeDialog',
components: { DialogWidget },
data() {
return {
openParams: null, // { languageId, chapterId, lessonId, courseId }
onClose: null,
loading: false,
allVocabs: false,
srsMode: false,
initialPool: null,
simpleMode: false,
pool: [],
// session stats
correctCount: 0,
wrongCount: 0,
perId: {}, // { [id]: { c, w, streak, lastAsked } }
lastIds: [],
// current question
current: null, // { id, learning, reference }
direction: 'L2R', // L2R: learning->reference, R2L: reference->learning
acceptableAnswers: [],
choiceOptions: [],
typedAnswer: '',
answered: false,
lastCorrect: false,
locked: false,
autoAdvanceTimer: null,
};
},
computed: {
buttons() {
return [{ text: this.$t('message.close'), action: this.close }];
},
totalCount() {
return this.correctCount + this.wrongCount;
},
successPercent() {
if (this.totalCount === 0) return 0;
return Math.round((this.correctCount / this.totalCount) * 100);
},
failPercent() {
if (this.totalCount === 0) return 0;
return Math.round((this.wrongCount / this.totalCount) * 100);
},
currentPrompt() {
if (!this.current) return '';
return this.direction === 'L2R' ? this.current.learning : this.current.reference;
},
directionLabel() {
return this.direction === 'L2R'
? this.$t('socialnetwork.vocab.practice.dirLearningToRef')
: this.$t('socialnetwork.vocab.practice.dirRefToLearning');
},
showNextButton() {
// Nur bei falscher Antwort auf "Weiter" warten
return this.answered && !this.lastCorrect && !this.srsMode;
},
showSkipButton() {
return !this.answered;
},
showSrsRatingButtons() {
return this.srsMode && this.answered && !this.locked;
},
srsRatingOptions() {
if (!this.answered) {
return [];
}
if (!this.lastCorrect) {
return [
{
value: 'again',
label: this.$t('socialnetwork.vocab.practice.srsAgain'),
hint: this.$t('socialnetwork.vocab.practice.srsAgainHint')
}
];
}
return [
{
value: 'hard',
label: this.$t('socialnetwork.vocab.practice.srsHard'),
hint: this.$t('socialnetwork.vocab.practice.srsHardHint')
},
{
value: 'good',
label: this.$t('socialnetwork.vocab.practice.srsGood'),
hint: this.$t('socialnetwork.vocab.practice.srsGoodHint')
},
{
value: 'easy',
label: this.$t('socialnetwork.vocab.practice.srsEasy'),
hint: this.$t('socialnetwork.vocab.practice.srsEasyHint')
}
];
},
},
methods: {
open({ languageId, chapterId, lessonId, courseId, initialPool = null, srsMode = false, onClose = null }) {
if (this.autoAdvanceTimer) {
clearTimeout(this.autoAdvanceTimer);
this.autoAdvanceTimer = null;
}
this.openParams = { languageId, chapterId, lessonId, courseId };
this.onClose = typeof onClose === 'function' ? onClose : null;
this.srsMode = Boolean(srsMode);
this.initialPool = Array.isArray(initialPool) ? initialPool : null;
this.allVocabs = false;
this.simpleMode = false;
this.correctCount = 0;
this.wrongCount = 0;
this.perId = {};
this.lastIds = [];
this.pool = [];
this.locked = false;
this.resetQuestion();
this.$refs.dialog.open();
this.$nextTick(() => {
document.addEventListener('keydown', this.handleKeyDown);
});
this.reloadPool();
},
close() {
if (this.autoAdvanceTimer) {
clearTimeout(this.autoAdvanceTimer);
this.autoAdvanceTimer = null;
}
const cb = this.onClose;
this.onClose = null;
document.removeEventListener('keydown', this.handleKeyDown);
this.$refs.dialog.close();
try {
if (cb) cb();
} catch (_) {}
},
handleKeyDown(event) {
// Enter soll bei "Weiter" (falsch beantwortet) funktionieren.
// Im Tippmodus soll Enter weiterhin "Prüfen" auslösen (Input hat eigenen handler).
if (event.key !== 'Enter' && event.keyCode !== 13) return;
if (this.showNextButton) {
event.preventDefault();
this.next();
return;
}
// Falls man im Tippmodus ist und der Fokus NICHT im Input liegt, erlauben wir Enter als "Prüfen".
if (!this.answered && !this.simpleMode && !this.locked) {
const tag = event.target?.tagName?.toLowerCase?.();
if (tag !== 'input' && tag !== 'textarea') {
event.preventDefault();
this.submitTyped();
}
}
},
normalize(s) {
const normalized = String(s || '')
.trim()
.toLowerCase()
.normalize('NFKC')
.replace(/[\p{P}\p{S}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
return normalized.replace(/\s+/g, '');
},
normalizePool(items = []) {
const seen = new Set();
return (Array.isArray(items) ? items : [])
.map((item, index) => {
const learning = String(item?.learning || '').trim();
const reference = String(item?.reference || '').trim();
if (!learning || !reference || this.normalize(learning) === this.normalize(reference)) {
return null;
}
const key = `${this.normalize(learning)}|${this.normalize(reference)}`;
if (seen.has(key)) {
return null;
}
seen.add(key);
return {
...item,
id: item?.id || item?.itemKey || item?.key || `${key}|${index}`,
learning,
reference
};
})
.filter(Boolean);
},
resetQuestion() {
this.current = null;
this.direction = this.openParams?.lessonId ? 'L2R' : (Math.random() < 0.5 ? 'L2R' : 'R2L');
this.acceptableAnswers = [];
this.choiceOptions = [];
this.typedAnswer = '';
this.answered = false;
this.lastCorrect = false;
this.locked = false;
},
onSimpleModeChanged() {
if (this.autoAdvanceTimer) {
clearTimeout(this.autoAdvanceTimer);
this.autoAdvanceTimer = null;
}
this.locked = false;
this.answered = false;
this.lastCorrect = false;
this.typedAnswer = '';
if (!this.pool || this.pool.length === 0) return;
// Wenn wir aktuell keine Frage haben, sofort eine neue ziehen.
if (!this.current) {
this.next();
return;
}
// Aktuelle Frage behalten, nur UI/Antwortmodus neu aufbauen
const prompt = this.currentPrompt;
this.acceptableAnswers = this.getAnswersForPrompt(prompt, this.direction);
if (this.simpleMode) {
this.buildChoices();
} else {
this.choiceOptions = [];
this.$nextTick(() => this.$refs.answerInput?.focus?.());
}
},
async reloadPool() {
if (!this.openParams) return;
if (this.initialPool) {
this.loading = false;
this.pool = this.normalizePool(this.initialPool);
this.next();
return;
}
this.loading = true;
try {
let res;
if (this.openParams.lessonId) {
if (this.allVocabs && this.openParams.courseId) {
res = await apiClient.get(`/api/vocab/courses/${this.openParams.courseId}/completed-lesson-vocabs`, {
params: {
untilLessonId: this.openParams.lessonId
}
});
this.pool = this.normalizePool(res.data?.vocabs || []);
} else {
res = await apiClient.get(`/api/vocab/lessons/${this.openParams.lessonId}/vocab-pool`);
this.pool = this.normalizePool(res.data?.vocabs || []);
}
} else if (this.allVocabs) {
res = await apiClient.get(`/api/vocab/languages/${this.openParams.languageId}/vocabs`);
this.pool = this.normalizePool(res.data?.vocabs || []);
} else {
res = await apiClient.get(`/api/vocab/chapters/${this.openParams.chapterId}/vocabs`);
this.pool = this.normalizePool(res.data?.vocabs || []);
}
} catch (e) {
console.error('Reload pool failed:', e);
this.pool = [];
} finally {
this.loading = false;
this.next();
}
},
getAnswersForPrompt(prompt, direction) {
const p = this.normalize(prompt);
const answers = new Set();
for (const item of this.pool) {
const itemPrompt = direction === 'L2R' ? item.learning : item.reference;
if (this.normalize(itemPrompt) === p) {
const a = direction === 'L2R' ? item.reference : item.learning;
answers.add(a);
}
}
return Array.from(answers);
},
computeWeight(item) {
const st = this.perId[item.id] || { c: 0, w: 0, streak: 0, lastAsked: 0 };
let w = 1;
w += st.w * 2.5;
w *= Math.pow(0.7, st.c);
if (st.streak > 0) {
w *= Math.pow(0.8, st.streak);
} else if (st.streak < 0) {
w *= 1 + Math.min(5, Math.abs(st.streak));
}
if (this.lastIds.includes(item.id)) w *= 0.1;
return Math.max(0.05, Math.min(50, w));
},
pickNextItem() {
const items = this.pool;
if (!items || items.length === 0) return null;
const recent = new Set(this.lastIds);
const underexposed = items
.map((item) => {
const st = this.perId[item.id] || { c: 0, w: 0, streak: 0, lastAsked: 0 };
return {
item,
attempts: (Number(st.c) || 0) + (Number(st.w) || 0),
wrong: Number(st.w) || 0
};
})
.filter((entry) => entry.attempts < (this.srsMode ? 1 : PRACTICE_MIN_EXPOSURES) && !recent.has(entry.item.id))
.sort((a, b) => {
if (a.attempts !== b.attempts) return a.attempts - b.attempts;
if (a.wrong !== b.wrong) return b.wrong - a.wrong;
return String(a.item.id).localeCompare(String(b.item.id));
});
if (underexposed.length > 0) {
return underexposed[0].item;
}
const weights = items.map((it) => this.computeWeight(it));
const sum = weights.reduce((a, b) => a + b, 0);
let r = Math.random() * sum;
for (let i = 0; i < items.length; i++) {
r -= weights[i];
if (r <= 0) return items[i];
}
return items[items.length - 1];
},
buildChoices() {
const prompt = this.currentPrompt;
const acceptable = this.getAnswersForPrompt(prompt, this.direction);
this.acceptableAnswers = acceptable;
const options = new Set();
// 1) mindestens eine richtige Übersetzung
options.add(acceptable[0] || (this.direction === 'L2R' ? this.current.reference : this.current.learning));
// 2) weitere Übersetzungen (Mehrdeutigkeiten) fürs gleiche Wort
for (const a of acceptable) {
if (options.size >= 3) break;
options.add(a);
}
// 3) Distraktoren aus anderen Wörtern
const allAnswers = this.pool.map((it) => (this.direction === 'L2R' ? it.reference : it.learning));
for (let i = 0; i < 50 && options.size < 4; i++) {
const cand = allAnswers[Math.floor(Math.random() * allAnswers.length)];
if (!acceptable.map(this.normalize).includes(this.normalize(cand))) {
options.add(cand);
}
}
const arr = Array.from(options);
// shuffle
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
this.choiceOptions = arr;
},
async playSound(ok) {
try {
const audio = new Audio(ok ? '/sounds/success.mp3' : '/sounds/fail.mp3');
await audio.play();
} catch (_) {
// ignore autoplay issues
}
},
reportSrsReview(isCorrect, rating = null) {
if (!this.current || !this.openParams?.courseId) {
return Promise.resolve();
}
return apiClient.post('/api/vocab/srs/review', {
courseId: this.openParams.courseId || this.current.courseId,
lessonId: this.openParams.lessonId || this.current.lessonId || null,
itemKey: this.current.itemKey || null,
learning: this.current.learning,
reference: this.current.reference,
direction: this.direction,
correct: Boolean(isCorrect),
rating
}).catch((error) => {
console.warn('[VocabPracticeDialog] SRS review could not be saved:', error);
});
},
markResult(isCorrect) {
this.answered = true;
this.lastCorrect = isCorrect;
if (isCorrect) this.correctCount += 1;
else this.wrongCount += 1;
if (!this.srsMode) {
this.reportSrsReview(isCorrect);
}
const id = this.current?.id;
if (!id) return;
const st = this.perId[id] || { c: 0, w: 0, streak: 0, lastAsked: 0 };
if (isCorrect) {
st.c += 1;
st.streak = st.streak >= 0 ? st.streak + 1 : 1;
} else {
st.w += 1;
st.streak = st.streak <= 0 ? st.streak - 1 : -1;
}
st.lastAsked = Date.now();
this.perId[id] = st;
this.lastIds.unshift(id);
this.lastIds = this.lastIds.slice(0, 3);
},
async submitSrsRating(rating) {
if (!this.srsMode || !this.answered || this.locked) {
return;
}
this.locked = true;
await this.reportSrsReview(this.lastCorrect, rating);
this.next();
},
submitChoice(opt) {
if (this.locked) return;
const ok = this.acceptableAnswers.map(this.normalize).includes(this.normalize(opt));
this.markResult(ok);
this.playSound(ok);
if (ok && !this.srsMode) {
// Direkt weiter zur nächsten Frage (kein Klick nötig)
this.locked = true;
this.autoAdvanceTimer = setTimeout(() => {
this.autoAdvanceTimer = null;
this.next();
}, 350);
}
},
submitTyped() {
if (this.locked) return;
const ans = this.normalize(this.typedAnswer);
const ok = this.acceptableAnswers.map(this.normalize).includes(ans);
this.markResult(ok);
this.playSound(ok);
if (ok && !this.srsMode) {
this.locked = true;
this.autoAdvanceTimer = setTimeout(() => {
this.autoAdvanceTimer = null;
this.next();
}, 350);
}
},
skip() {
this.next();
},
next() {
if (this.autoAdvanceTimer) {
clearTimeout(this.autoAdvanceTimer);
this.autoAdvanceTimer = null;
}
if (!this.pool || this.pool.length === 0) {
this.resetQuestion();
return;
}
this.resetQuestion();
this.current = this.pickNextItem();
if (!this.current) return;
const prompt = this.currentPrompt;
this.acceptableAnswers = this.getAnswersForPrompt(prompt, this.direction);
if (this.simpleMode) this.buildChoices();
this.$nextTick(() => {
if (!this.simpleMode) this.$refs.answerInput?.focus?.();
});
},
},
};
</script>
<style scoped>
.layout {
display: flex;
gap: 16px;
height: 100%;
}
.left {
flex: 1;
min-width: 0;
}
.right {
width: 16em;
border-left: 1px solid #ddd;
padding-left: 12px;
}
.opts {
display: flex;
gap: 16px;
margin-bottom: 10px;
}
.chk {
display: inline-flex;
gap: 6px;
align-items: center;
}
.prompt {
padding: 10px;
background: #fff;
border: 1px solid #ccc;
margin-bottom: 10px;
}
.dir {
color: #555;
font-size: 0.9em;
}
.word {
font-size: 1.8em;
font-weight: bold;
}
.typing {
display: flex;
gap: 8px;
align-items: center;
}
.typing input {
flex: 1;
padding: 6px;
}
.choices {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.choiceBtn {
padding: 8px;
}
.controls {
margin-top: 12px;
}
.srs-rating {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
margin: 12px 0;
}
.srs-rating__title {
grid-column: 1 / -1;
font-size: 0.82rem;
font-weight: 700;
color: var(--color-text-secondary, #5f554e);
}
.srs-rating__button {
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
padding: 8px 10px;
border: 1px solid var(--color-border, #d7d0c8);
border-radius: 10px;
background: rgba(255, 255, 255, 0.9);
cursor: pointer;
text-align: left;
}
.srs-rating__button span {
font-size: 0.74rem;
color: var(--color-text-secondary, #6b625b);
}
.srs-rating__button--again {
border-color: rgba(198, 75, 75, 0.45);
}
.srs-rating__button--hard {
border-color: rgba(210, 153, 74, 0.5);
}
.srs-rating__button--good {
border-color: rgba(90, 145, 95, 0.45);
}
.srs-rating__button--easy {
border-color: rgba(78, 139, 188, 0.45);
}
.feedback {
padding: 10px;
border: 1px solid #ccc;
margin-bottom: 10px;
}
.feedback.ok {
background: #e8ffe8;
border-color: #7bbe55;
}
.feedback.bad {
background: #ffecec;
border-color: #d33;
}
.answersTitle {
margin-top: 6px;
font-weight: bold;
}
.statTitle {
font-weight: bold;
margin-bottom: 8px;
}
.statRow {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.k {
color: #333;
}
.v {
font-weight: bold;
}
</style>