All checks were successful
Deploy to production / deploy (push) Successful in 2m47s
- Enhanced the `normalize` method to trim trailing punctuation marks (.,!?;:) from input strings, ensuring cleaner and more consistent user input. - Added logic to replace multiple spaces with a single space, improving the overall formatting of normalized strings.
361 lines
12 KiB
Vue
361 lines
12 KiB
Vue
<template>
|
|
<div class="vocab-review-view">
|
|
<section class="surface-card review-header">
|
|
<button type="button" class="button-secondary" @click="backToCourse">{{ $t('general.back') }}</button>
|
|
<div>
|
|
<h2>{{ $t('socialnetwork.vocab.courses.quickReviewTitle') }}</h2>
|
|
<p v-if="lesson">{{ lesson.title }} (#{{ lesson.lessonNumber }})</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="loading" class="surface-card review-state">{{ $t('general.loading') }}</section>
|
|
<section v-else-if="!lesson" class="surface-card review-state">{{ $t('socialnetwork.vocab.notFound') }}</section>
|
|
<section v-else class="surface-card review-body">
|
|
<p class="review-intro">
|
|
{{ $t('socialnetwork.vocab.courses.quickReviewIntro', { count: reviewQueue.length }) }}
|
|
</p>
|
|
|
|
<div v-if="reviewDone" class="review-done">
|
|
<h3>{{ $t('socialnetwork.vocab.courses.quickReviewDoneTitle') }}</h3>
|
|
<p>{{ $t('socialnetwork.vocab.courses.quickReviewDoneScore', { correct: correctCount, total: reviewQueue.length }) }}</p>
|
|
<button type="button" @click="backToCourse">{{ $t('socialnetwork.vocab.courses.quickReviewBackToCourse') }}</button>
|
|
</div>
|
|
|
|
<div v-else-if="currentItem" class="review-card">
|
|
<p class="review-progress">{{ $t('socialnetwork.vocab.courses.quickReviewProgress', { current: currentIndex + 1, total: reviewQueue.length }) }}</p>
|
|
<p class="review-question">{{ currentPrompt }}</p>
|
|
|
|
<div v-if="mode === 'multiple_choice'" class="review-options">
|
|
<label v-for="(opt, idx) in currentOptions" :key="idx" class="review-option">
|
|
<input v-model="selectedOption" type="radio" :value="opt" />
|
|
<span>{{ opt }}</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div v-else class="review-input">
|
|
<input
|
|
v-model="typedAnswer"
|
|
type="text"
|
|
:placeholder="$t('socialnetwork.vocab.courses.enterAnswer')"
|
|
@keyup.enter="checkCurrent"
|
|
/>
|
|
</div>
|
|
|
|
<div class="review-actions">
|
|
<button
|
|
type="button"
|
|
:disabled="submitDisabled"
|
|
@click="checkCurrent"
|
|
>
|
|
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
|
|
</button>
|
|
<button
|
|
v-if="needsFeedbackAck"
|
|
type="button"
|
|
class="button-secondary"
|
|
@click="advanceAfterFeedback"
|
|
>
|
|
{{ $t('socialnetwork.vocab.courses.quickReviewAcknowledge') }}
|
|
</button>
|
|
</div>
|
|
|
|
<p v-if="feedback" class="review-feedback" :class="feedbackCorrect ? 'ok' : 'bad'">
|
|
{{ feedback }}
|
|
</p>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import apiClient from '@/utils/axios.js';
|
|
|
|
export default {
|
|
name: 'VocabLessonReviewView',
|
|
props: {
|
|
courseId: { type: String, required: true },
|
|
lessonId: { type: String, required: true }
|
|
},
|
|
data() {
|
|
return {
|
|
loading: false,
|
|
lesson: null,
|
|
reviewQueue: [],
|
|
currentIndex: 0,
|
|
mode: 'multiple_choice',
|
|
currentOptions: [],
|
|
selectedOption: '',
|
|
typedAnswer: '',
|
|
feedback: '',
|
|
feedbackCorrect: false,
|
|
needsFeedbackAck: false,
|
|
correctCount: 0,
|
|
reviewDone: false,
|
|
weakVocabMap: {}
|
|
};
|
|
},
|
|
computed: {
|
|
currentItem() {
|
|
return this.reviewQueue[this.currentIndex] || null;
|
|
},
|
|
currentPrompt() {
|
|
if (!this.currentItem) return '';
|
|
return this.mode === 'multiple_choice'
|
|
? this.$t('socialnetwork.vocab.courses.quickReviewPromptMeaning', { term: this.currentItem.target })
|
|
: this.$t('socialnetwork.vocab.courses.quickReviewPromptTarget', { term: this.currentItem.gloss });
|
|
},
|
|
submitDisabled() {
|
|
if (!this.currentItem) return true;
|
|
return this.mode === 'multiple_choice' ? !this.selectedOption : !this.typedAnswer.trim();
|
|
}
|
|
},
|
|
methods: {
|
|
normalize(s) {
|
|
const base = String(s || '').trim().toLowerCase();
|
|
// Satzzeichen am Ende ignorieren (Punkt, Fragezeichen, Ausrufezeichen, Komma, Strichpunkt, Doppelpunkt)
|
|
return base.replace(/[.,!?;:]+$/g, '').replace(/\s+/g, ' ');
|
|
},
|
|
getItemKey(item) {
|
|
return `${String(item?.gloss || '').trim()}|${String(item?.target || '').trim()}`;
|
|
},
|
|
parseCorePatterns() {
|
|
const raw = this.lesson?.didactics?.corePatterns || [];
|
|
const out = [];
|
|
raw.forEach((entry) => {
|
|
if (entry && typeof entry === 'object' && entry.target) {
|
|
out.push({ target: String(entry.target).trim(), gloss: String(entry.gloss || '').trim() });
|
|
return;
|
|
}
|
|
const s = String(entry || '').trim();
|
|
if (!s) return;
|
|
const split = s.indexOf('|');
|
|
if (split >= 0) {
|
|
out.push({ target: s.slice(0, split).trim(), gloss: s.slice(split + 1).trim() });
|
|
} else {
|
|
out.push({ target: s, gloss: '' });
|
|
}
|
|
});
|
|
return out.filter((x) => x.target);
|
|
},
|
|
buildQueue() {
|
|
const fromPatterns = this.parseCorePatterns();
|
|
const q = [];
|
|
const seen = new Set();
|
|
fromPatterns.forEach((item) => {
|
|
const key = `${this.normalize(item.target)}|${this.normalize(item.gloss)}`;
|
|
if (!seen.has(key) && item.target) {
|
|
seen.add(key);
|
|
q.push(item);
|
|
}
|
|
});
|
|
return q.slice(0, 10);
|
|
},
|
|
randomOtherGlosses(correctGloss) {
|
|
const pool = this.reviewQueue
|
|
.map((x) => x.gloss)
|
|
.filter((g) => g && this.normalize(g) !== this.normalize(correctGloss));
|
|
const out = [];
|
|
while (pool.length && out.length < 3) {
|
|
const idx = Math.floor(Math.random() * pool.length);
|
|
out.push(pool[idx]);
|
|
pool.splice(idx, 1);
|
|
}
|
|
return out;
|
|
},
|
|
setupCurrent() {
|
|
this.feedback = '';
|
|
this.selectedOption = '';
|
|
this.typedAnswer = '';
|
|
this.needsFeedbackAck = false;
|
|
if (!this.currentItem) return;
|
|
this.mode = this.currentItem.gloss ? 'multiple_choice' : 'typing';
|
|
if (this.mode === 'multiple_choice') {
|
|
const opts = [this.currentItem.gloss, ...this.randomOtherGlosses(this.currentItem.gloss)];
|
|
this.currentOptions = opts.sort(() => Math.random() - 0.5);
|
|
} else {
|
|
this.currentOptions = [];
|
|
}
|
|
},
|
|
async checkCurrent() {
|
|
if (!this.currentItem) return;
|
|
const isCorrect = this.mode === 'multiple_choice'
|
|
? this.normalize(this.selectedOption) === this.normalize(this.currentItem.gloss)
|
|
: this.normalize(this.typedAnswer) === this.normalize(this.currentItem.target);
|
|
|
|
this.feedbackCorrect = isCorrect;
|
|
if (isCorrect) {
|
|
this.correctCount += 1;
|
|
this.feedback = this.$t('socialnetwork.vocab.courses.correct');
|
|
this.needsFeedbackAck = false;
|
|
window.setTimeout(() => {
|
|
this.advanceAfterFeedback();
|
|
}, 550);
|
|
} 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.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;
|
|
}
|
|
},
|
|
async advanceAfterFeedback() {
|
|
this.currentIndex += 1;
|
|
if (this.currentIndex >= this.reviewQueue.length) {
|
|
await this.finishReview();
|
|
return;
|
|
}
|
|
this.setupCurrent();
|
|
},
|
|
async finishReview() {
|
|
this.reviewDone = true;
|
|
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`, {
|
|
completed: true,
|
|
score: 100,
|
|
timeSpentMinutes: 1,
|
|
lessonState: {
|
|
reviewWeakVocab: mergedWeak
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error('Review-Fortschritt konnte nicht gespeichert werden:', e);
|
|
}
|
|
},
|
|
backToCourse() {
|
|
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}`);
|
|
},
|
|
async load() {
|
|
this.loading = true;
|
|
try {
|
|
const { data } = await apiClient.get(`/api/vocab/lessons/${this.lessonId}`);
|
|
this.lesson = data;
|
|
this.reviewQueue = this.buildQueue();
|
|
if (this.reviewQueue.length === 0) {
|
|
this.reviewDone = true;
|
|
} else {
|
|
this.setupCurrent();
|
|
}
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
},
|
|
mounted() {
|
|
this.load();
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.vocab-review-view {
|
|
max-width: var(--content-max-width);
|
|
margin: 0 auto;
|
|
}
|
|
.review-header {
|
|
display: flex;
|
|
gap: 14px;
|
|
align-items: center;
|
|
margin-bottom: 14px;
|
|
}
|
|
.review-state,
|
|
.review-body {
|
|
padding: 18px;
|
|
}
|
|
.review-intro {
|
|
margin-top: 0;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
.review-card {
|
|
margin-top: 8px;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 10px;
|
|
padding: 14px;
|
|
background: #fff;
|
|
}
|
|
.review-progress {
|
|
margin: 0 0 8px;
|
|
font-size: 0.85rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.review-question {
|
|
margin: 0 0 12px;
|
|
font-size: 1.05rem;
|
|
font-weight: 600;
|
|
}
|
|
.review-options {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
.review-option {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
.review-input input {
|
|
width: 100%;
|
|
padding: 8px 10px;
|
|
}
|
|
.review-actions {
|
|
margin-top: 10px;
|
|
}
|
|
.review-feedback {
|
|
margin-top: 10px;
|
|
font-weight: 600;
|
|
}
|
|
.review-feedback.ok { color: #2f6b3d; }
|
|
.review-feedback.bad { color: #9c3e3e; }
|
|
.review-done {
|
|
padding: 12px;
|
|
border-radius: 10px;
|
|
background: rgba(68, 138, 86, 0.12);
|
|
}
|
|
</style>
|