- Implemented keydown event listener to manage Enter key functionality for navigating questions and submitting answers. - Enhanced user experience by allowing Enter to trigger actions based on the current dialog state, improving accessibility during practice sessions. - Ensured proper cleanup of event listeners when closing the dialog to prevent memory leaks.
535 lines
15 KiB
Vue
535 lines
15 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" @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="!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';
|
|
|
|
export default {
|
|
name: 'VocabPracticeDialog',
|
|
components: { DialogWidget },
|
|
data() {
|
|
return {
|
|
openParams: null, // { languageId, chapterId }
|
|
onClose: null,
|
|
loading: false,
|
|
allVocabs: false,
|
|
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;
|
|
},
|
|
showSkipButton() {
|
|
return !this.answered;
|
|
},
|
|
},
|
|
methods: {
|
|
open({ languageId, chapterId, onClose = null }) {
|
|
if (this.autoAdvanceTimer) {
|
|
clearTimeout(this.autoAdvanceTimer);
|
|
this.autoAdvanceTimer = null;
|
|
}
|
|
this.openParams = { languageId, chapterId };
|
|
this.onClose = typeof onClose === 'function' ? onClose : 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) {
|
|
return String(s || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\s+/g, ' ');
|
|
},
|
|
resetQuestion() {
|
|
this.current = null;
|
|
this.direction = 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;
|
|
this.loading = true;
|
|
try {
|
|
let res;
|
|
if (this.allVocabs) {
|
|
res = await apiClient.get(`/api/vocab/languages/${this.openParams.languageId}/vocabs`);
|
|
this.pool = res.data?.vocabs || [];
|
|
} else {
|
|
res = await apiClient.get(`/api/vocab/chapters/${this.openParams.chapterId}/vocabs`);
|
|
this.pool = 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 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
|
|
}
|
|
},
|
|
markResult(isCorrect) {
|
|
this.answered = true;
|
|
this.lastCorrect = isCorrect;
|
|
if (isCorrect) this.correctCount += 1;
|
|
else this.wrongCount += 1;
|
|
|
|
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);
|
|
},
|
|
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) {
|
|
// 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.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;
|
|
}
|
|
.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>
|
|
|
|
|