feat(vocab): add lesson review functionality and update navigation
All checks were successful
Deploy to production / deploy (push) Successful in 2m46s
All checks were successful
Deploy to production / deploy (push) Successful in 2m46s
- Introduced VocabLessonReviewView for reviewing lessons within the vocabulary course structure. - Updated routing in socialRoutes.js to include a new path for lesson reviews, ensuring authenticated access. - Modified VocabCourseView to change the lesson button click behavior to navigate to the review view, enhancing user flow. - Added a new method to handle lesson review navigation, improving the overall user experience in the vocabulary section.
This commit is contained in:
@@ -13,6 +13,7 @@ const VocabChapterView = () => import('../views/social/VocabChapterView.vue');
|
||||
const VocabCourseListView = () => import('../views/social/VocabCourseListView.vue');
|
||||
const VocabCourseView = () => import('../views/social/VocabCourseView.vue');
|
||||
const VocabLessonView = () => import('../views/social/VocabLessonView.vue');
|
||||
const VocabLessonReviewView = () => import('../views/social/VocabLessonReviewView.vue');
|
||||
const EroticAccessView = () => import('../views/social/EroticAccessView.vue');
|
||||
const EroticPicturesView = () => import('../views/social/EroticPicturesView.vue');
|
||||
const EroticVideosView = () => import('../views/social/EroticVideosView.vue');
|
||||
@@ -121,6 +122,13 @@ const socialRoutes = [
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/socialnetwork/vocab/courses/:courseId/lessons/:lessonId/review',
|
||||
name: 'VocabLessonReview',
|
||||
component: VocabLessonReviewView,
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/socialnetwork/vocab/courses/:courseId/lessons/:lessonId',
|
||||
name: 'VocabLesson',
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
:key="`due-${lesson.id}`"
|
||||
type="button"
|
||||
class="course-flow-lesson"
|
||||
@click="openLesson(lesson.id)"
|
||||
@click="openLessonReview(lesson.id)"
|
||||
>
|
||||
<strong>{{ lesson.title }}</strong>
|
||||
<span>{{ formatReviewDue(getLessonProgress(lesson.id, lesson)?.reviewNextDueAt) }}</span>
|
||||
@@ -705,6 +705,10 @@ export default {
|
||||
if (!step?.lesson) {
|
||||
return;
|
||||
}
|
||||
if (step.type === 'review_due') {
|
||||
this.openLessonReview(step.lesson.id);
|
||||
return;
|
||||
}
|
||||
if (step.type === 'practice') {
|
||||
this.openLessonPractice(step.lesson);
|
||||
return;
|
||||
@@ -717,6 +721,9 @@ export default {
|
||||
lessonId: lesson.id
|
||||
});
|
||||
},
|
||||
openLessonReview(lessonId) {
|
||||
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}/review`);
|
||||
},
|
||||
openLessonAssistant(lessonId) {
|
||||
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}?assistant=1`);
|
||||
},
|
||||
|
||||
285
frontend/src/views/social/VocabLessonReviewView.vue
Normal file
285
frontend/src/views/social/VocabLessonReviewView.vue
Normal file
@@ -0,0 +1,285 @@
|
||||
<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>Kurz-Wiederholung</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">
|
||||
Kurze Session mit {{ reviewQueue.length }} Begriffen. Nach Abschluss wird die geplante Wiederholung als erledigt markiert.
|
||||
</p>
|
||||
|
||||
<div v-if="reviewDone" class="review-done">
|
||||
<h3>Geschafft</h3>
|
||||
<p>Richtig: {{ correctCount }} / {{ reviewQueue.length }}</p>
|
||||
<button type="button" @click="backToCourse">Zurück zum Kurs</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentItem" class="review-card">
|
||||
<p class="review-progress">Begriff {{ currentIndex + 1 }} von {{ 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>
|
||||
</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,
|
||||
correctCount: 0,
|
||||
reviewDone: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
currentItem() {
|
||||
return this.reviewQueue[this.currentIndex] || null;
|
||||
},
|
||||
currentPrompt() {
|
||||
if (!this.currentItem) return '';
|
||||
return this.mode === 'multiple_choice'
|
||||
? `Was bedeutet "${this.currentItem.target}"?`
|
||||
: `Tippe auf Zielsprache: "${this.currentItem.gloss}"`;
|
||||
},
|
||||
submitDisabled() {
|
||||
if (!this.currentItem) return true;
|
||||
return this.mode === 'multiple_choice' ? !this.selectedOption : !this.typedAnswer.trim();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
normalize(s) {
|
||||
return String(s || '').trim().toLowerCase();
|
||||
},
|
||||
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 = '';
|
||||
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');
|
||||
} 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}`;
|
||||
}
|
||||
|
||||
window.setTimeout(async () => {
|
||||
this.currentIndex += 1;
|
||||
if (this.currentIndex >= this.reviewQueue.length) {
|
||||
await this.finishReview();
|
||||
return;
|
||||
}
|
||||
this.setupCurrent();
|
||||
}, 500);
|
||||
},
|
||||
async finishReview() {
|
||||
this.reviewDone = true;
|
||||
try {
|
||||
await apiClient.put(`/api/vocab/lessons/${this.lessonId}/progress`, {
|
||||
completed: true,
|
||||
score: 100,
|
||||
timeSpentMinutes: 1
|
||||
});
|
||||
} 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>
|
||||
Reference in New Issue
Block a user