feat(vocab): implement user vocab lesson progress reset functionality
All checks were successful
Deploy to production / deploy (push) Successful in 2m59s

- Added `resetUserVocabLessonProgress` method in `AdminController` to allow admins to reset a user's progress for a specific vocab lesson.
- Introduced corresponding route in `adminRouter` for the new reset functionality.
- Enhanced `VocabService` with methods to purge lesson progress for users, ensuring that only the specified lesson's progress is affected.
- Updated UI components in `UsersView` to facilitate the selection of courses and lessons for resetting progress, including confirmation dialogs and loading states.
- Added localization support for the new reset functionality across multiple languages.
- Implemented reset functionality in `VocabLessonView` for users to reset their own lesson progress.
This commit is contained in:
Torsten Schulz (local)
2026-04-02 08:25:56 +02:00
parent 13534498fa
commit c3b2c60362
22 changed files with 517 additions and 24 deletions

View File

@@ -21,6 +21,20 @@
"actions": "Mga aksyon",
"search": "Pangita"
},
"vocabLessonReset": {
"title": "Kurso sa pinulongan: pag-uswag sa leksiyon",
"intro": "Tangtanga ang pag-uswag, mga resulta sa ehersisyo ug natipig nga kahimtang sa usa ka leksiyon lamang (dili ang tibuok kurso). Makita ra ang mga kurso nga makita sa imong admin account (publiko o imoha).",
"loadCourses": "Ikarga ang mga kurso",
"selectCourse": "Kurso",
"selectLesson": "Leksiyon",
"reset": "I-reset ang leksiyon niining user",
"confirm": "Tinuod nga tangtangon ang pag-uswag sa leksiyon nga «{lesson}» ni {username}?",
"success": "Na-reset na ang pag-uswag sa leksiyon.",
"error": "Dili ma-reset.",
"pickUserFirst": "Una pagpili ug user.",
"noCourses": "Walay nakarga nga kurso o walay makita nga kurso.",
"loadingLessons": "Nagkarga sa mga leksiyon …"
},
"rights": {
"add": "Idugang ang katungod",
"select": "Palihog pagpili",

View File

@@ -381,7 +381,11 @@
"lessonReviewHintNextDue": "Sunod nga petsa: {due}.",
"reviewTimeNow": "karon",
"reviewTimeTomorrow": "ugma",
"reviewTimeInDays": "sulod sa {count} ka adlaw"
"reviewTimeInDays": "sulod sa {count} ka adlaw",
"resetLessonProgress": "I-reset ang leksiyon",
"resetLessonProgressConfirm": "I-reset ang pag-uswag niining leksiyona? Mawala ang natipig nga kahimtang, mga resulta sa ehersisyo ug sa trainer. Ang ubang leksiyon dili maapektuhan.",
"resetLessonProgressSuccess": "Na-reset na ang pag-uswag sa leksiyon.",
"resetLessonProgressError": "Dili ma-reset ang leksiyon."
}
}
}

View File

@@ -30,6 +30,20 @@
"actions": "Aktionen",
"search": "Suchen"
},
"vocabLessonReset": {
"title": "Sprachkurs: Lektionsfortschritt",
"intro": "Fortschritt, Übungsergebnisse und gespeicherter Lektionszustand für eine einzelne Lektion löschen (nicht der ganze Kurs). Es werden nur Kurse gelistet, die du als Admin sehen kannst (öffentlich oder eigene).",
"loadCourses": "Kurse laden",
"selectCourse": "Kurs",
"selectLesson": "Lektion",
"reset": "Lektion für diesen Nutzer zurücksetzen",
"confirm": "Fortschritt der Lektion „{lesson}“ für {username} wirklich löschen?",
"success": "Lektionsfortschritt wurde zurückgesetzt.",
"error": "Zurücksetzen fehlgeschlagen.",
"pickUserFirst": "Zuerst einen Benutzer auswählen.",
"noCourses": "Keine Kurse geladen oder keine sichtbaren Kurse.",
"loadingLessons": "Lektionen werden geladen …"
},
"adultVerification": {
"title": "[Admin] - Erotik-Freigaben",
"intro": "Volljährige Nutzer können den Erotikbereich beantragen. Hier werden Anfragen geprüft und freigegeben oder abgelehnt.",

View File

@@ -487,6 +487,10 @@
"score": "Punktzahl",
"review": "Wiederholen",
"start": "Starten",
"resetLessonProgress": "Lektion zurücksetzen",
"resetLessonProgressConfirm": "Fortschritt dieser Lektion zurücksetzen? Gespeicherter Stand, Übungsergebnisse und Trainer-Zustand werden gelöscht. Andere Lektionen bleiben unverändert.",
"resetLessonProgressSuccess": "Die Lektion wurde zurückgesetzt.",
"resetLessonProgressError": "Die Lektion konnte nicht zurückgesetzt werden.",
"noLessons": "Dieser Kurs hat noch keine Lektionen.",
"lessonNumber": "Lektionsnummer",
"chapter": "Kapitel",

View File

@@ -30,6 +30,20 @@
"actions": "Actions",
"search": "Search"
},
"vocabLessonReset": {
"title": "Language course: lesson progress",
"intro": "Delete progress, exercise results and saved lesson state for a single lesson (not the whole course). Only courses you can see as this admin account are listed (public or your own).",
"loadCourses": "Load courses",
"selectCourse": "Course",
"selectLesson": "Lesson",
"reset": "Reset lesson for this user",
"confirm": "Really delete progress for lesson “{lesson}” for {username}?",
"success": "Lesson progress was reset.",
"error": "Reset failed.",
"pickUserFirst": "Select a user first.",
"noCourses": "No courses loaded or no visible courses.",
"loadingLessons": "Loading lessons…"
},
"adultVerification": {
"title": "[Admin] - Erotic approvals",
"intro": "Adult users can request access to the erotic area. Requests can be reviewed, approved or rejected here.",

View File

@@ -487,6 +487,10 @@
"score": "Score",
"review": "Review",
"start": "Start",
"resetLessonProgress": "Reset lesson",
"resetLessonProgressConfirm": "Reset progress for this lesson? Saved state, exercise results, and trainer progress will be cleared. Other lessons stay unchanged.",
"resetLessonProgressSuccess": "Lesson progress was reset.",
"resetLessonProgressError": "Could not reset the lesson.",
"noLessons": "This course has no lessons yet.",
"lessonNumber": "Lesson Number",
"chapter": "Chapter",

View File

@@ -30,6 +30,20 @@
"actions": "Acciones",
"search": "Buscar"
},
"vocabLessonReset": {
"title": "Curso de idiomas: progreso de lección",
"intro": "Elimina el progreso, los resultados de ejercicios y el estado guardado de una sola lección (no todo el curso). Solo se listan cursos visibles para tu cuenta de administración (públicos o propios).",
"loadCourses": "Cargar cursos",
"selectCourse": "Curso",
"selectLesson": "Lección",
"reset": "Restablecer lección para este usuario",
"confirm": "¿Borrar de verdad el progreso de la lección «{lesson}» para {username}?",
"success": "Se restableció el progreso de la lección.",
"error": "No se pudo restablecer.",
"pickUserFirst": "Primero elige un usuario.",
"noCourses": "No hay cursos cargados o no hay cursos visibles.",
"loadingLessons": "Cargando lecciones…"
},
"adultVerification": {
"title": "[Admin] - Aprobaciones eróticas",
"intro": "Los usuarios adultos pueden solicitar acceso al área erótica. Aquí se revisan, aprueban o rechazan las solicitudes.",

View File

@@ -485,6 +485,10 @@
"score": "Puntuación",
"review": "Repasar",
"start": "Empezar",
"resetLessonProgress": "Restablecer lección",
"resetLessonProgressConfirm": "¿Restablecer el progreso de esta lección? Se borrarán el estado guardado, los resultados de ejercicios y el progreso del entrenador. Las demás lecciones no cambian.",
"resetLessonProgressSuccess": "Se restableció el progreso de la lección.",
"resetLessonProgressError": "No se pudo restablecer la lección.",
"noLessons": "Este curso aún no tiene lecciones.",
"lessonNumber": "Número de lección",
"chapter": "Capítulo",

View File

@@ -30,6 +30,56 @@
<button @click="save">{{ $t('common.save') }}</button>
</div>
</section>
<section v-if="selected" class="vocab-reset surface-card">
<h3 class="vocab-reset__title">{{ $t('admin.vocabLessonReset.title') }}</h3>
<p class="vocab-reset__intro">{{ $t('admin.vocabLessonReset.intro') }}</p>
<div class="vocab-reset__row">
<button
type="button"
class="vocab-reset__btn-secondary"
:disabled="loadingVocabCourses"
@click="loadVocabCourses"
>
{{ loadingVocabCourses ? $t('general.loading') : $t('admin.vocabLessonReset.loadCourses') }}
</button>
</div>
<label v-if="vocabCourses.length" class="edit__field">
<span>{{ $t('admin.vocabLessonReset.selectCourse') }}</span>
<select v-model="selectedVocabCourseId" @change="onVocabCourseChange">
<option value="">{{ $t('admin.vocabLessonReset.selectCourse') }}</option>
<option v-for="c in vocabCourses" :key="c.id" :value="String(c.id)">{{ c.title }}</option>
</select>
</label>
<p v-else-if="vocabCoursesAttempted && !loadingVocabCourses" class="vocab-reset__hint">
{{ $t('admin.vocabLessonReset.noCourses') }}
</p>
<label v-if="vocabCourseLessons.length" class="edit__field">
<span>{{ $t('admin.vocabLessonReset.selectLesson') }}</span>
<select v-model="selectedVocabLessonId" :disabled="loadingVocabCourseDetail">
<option value="">
{{ loadingVocabCourseDetail ? $t('admin.vocabLessonReset.loadingLessons') : $t('admin.vocabLessonReset.selectLesson') }}
</option>
<option v-for="l in vocabCourseLessons" :key="l.id" :value="String(l.id)">
{{ l.lessonNumber }}. {{ l.title }}
</option>
</select>
</label>
<div class="vocab-reset__row">
<button
type="button"
:disabled="!selectedVocabLessonId || vocabResetSubmitting || loadingVocabCourseDetail"
@click="adminResetVocabLesson"
>
{{ vocabResetSubmitting ? $t('general.loading') : $t('admin.vocabLessonReset.reset') }}
</button>
</div>
</section>
</div>
</template>
@@ -43,15 +93,31 @@ export default {
data() {
return {
selected: null,
form: { username: '', active: true }
form: { username: '', active: true },
vocabCourses: [],
vocabCoursesAttempted: false,
selectedVocabCourseId: '',
vocabCourseLessons: [],
selectedVocabLessonId: '',
loadingVocabCourses: false,
loadingVocabCourseDetail: false,
vocabResetSubmitting: false
};
},
methods: {
clearVocabResetUi() {
this.vocabCourses = [];
this.vocabCoursesAttempted = false;
this.selectedVocabCourseId = '';
this.vocabCourseLessons = [];
this.selectedVocabLessonId = '';
},
async select(u) {
const res = await apiClient.get(`/api/admin/users/${u.id}`);
this.selected = res.data;
this.form.username = res.data.username;
this.form.active = !!res.data.active;
this.clearVocabResetUi();
},
toggleBlocked(e) {
this.form.active = !e.target.checked;
@@ -63,6 +129,83 @@ export default {
active: this.form.active
});
this.$root?.$refs?.messageDialog?.open?.('tr:common.saved');
},
async loadVocabCourses() {
if (!this.selected) {
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.pickUserFirst'));
return;
}
this.loadingVocabCourses = true;
this.vocabCoursesAttempted = true;
try {
const { data } = await apiClient.get('/api/vocab/courses');
this.vocabCourses = Array.isArray(data) ? data : [];
this.selectedVocabCourseId = '';
this.vocabCourseLessons = [];
this.selectedVocabLessonId = '';
} catch (e) {
console.error('[UsersView] vocab courses:', e);
this.vocabCourses = [];
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error'));
} finally {
this.loadingVocabCourses = false;
}
},
async onVocabCourseChange() {
this.selectedVocabLessonId = '';
this.vocabCourseLessons = [];
if (!this.selectedVocabCourseId) {
return;
}
this.loadingVocabCourseDetail = true;
try {
const { data } = await apiClient.get(`/api/vocab/courses/${this.selectedVocabCourseId}`);
const lessons = data?.lessons || [];
this.vocabCourseLessons = [...lessons].sort((a, b) => {
if (a.weekNumber !== b.weekNumber) {
return (a.weekNumber || 999) - (b.weekNumber || 999);
}
if (a.dayNumber !== b.dayNumber) {
return (a.dayNumber || 999) - (b.dayNumber || 999);
}
return (a.lessonNumber || 0) - (b.lessonNumber || 0);
});
} catch (e) {
console.error('[UsersView] vocab course detail:', e);
this.vocabCourseLessons = [];
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error'));
} finally {
this.loadingVocabCourseDetail = false;
}
},
adminResetVocabLesson() {
if (!this.selected || !this.selectedVocabLessonId || this.vocabResetSubmitting) {
return;
}
const lesson = this.vocabCourseLessons.find((l) => String(l.id) === String(this.selectedVocabLessonId));
const lessonLabel = lesson ? `${lesson.lessonNumber}. ${lesson.title}` : this.selectedVocabLessonId;
const msg = this.$t('admin.vocabLessonReset.confirm', {
lesson: lessonLabel,
username: this.selected.username
});
if (!window.confirm(msg)) {
return;
}
this.runAdminVocabReset();
},
async runAdminVocabReset() {
this.vocabResetSubmitting = true;
try {
await apiClient.post(`/api/admin/users/${this.selected.id}/vocab-lesson-progress/reset`, {
lessonId: Number(this.selectedVocabLessonId)
});
this.$root?.$refs?.messageDialog?.open?.('tr:admin.vocabLessonReset.success');
} catch (e) {
console.error('[UsersView] admin vocab reset:', e);
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error'));
} finally {
this.vocabResetSubmitting = false;
}
}
}
};
@@ -76,7 +219,8 @@ export default {
.admin-users__hero,
.admin-users__search,
.edit {
.edit,
.vocab-reset {
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
@@ -85,7 +229,8 @@ export default {
.admin-users__hero,
.admin-users__search,
.edit {
.edit,
.vocab-reset {
padding: 22px 24px;
}
@@ -113,6 +258,40 @@ export default {
max-width: 560px;
}
.vocab-reset {
display: grid;
gap: 14px;
max-width: 560px;
}
.vocab-reset__title {
margin: 0;
font-size: 1.15rem;
}
.vocab-reset__intro,
.vocab-reset__hint {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.92rem;
line-height: 1.45;
}
.vocab-reset__row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.vocab-reset__btn-secondary {
padding: 8px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 6px);
background: rgba(255, 255, 255, 0.9);
cursor: pointer;
font-weight: 600;
}
.edit__header {
display: flex;
align-items: center;
@@ -141,6 +320,17 @@ export default {
color: var(--color-text-secondary);
}
.edit__field input,
.edit__field select {
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 6px);
background: #fff;
}
.edit__toggle {
display: inline-flex;
align-items: center;
@@ -156,7 +346,8 @@ export default {
@media (max-width: 760px) {
.admin-users__hero,
.admin-users__search,
.edit {
.edit,
.vocab-reset {
padding: 18px;
}
@@ -170,4 +361,3 @@ export default {
}
}
</style>

View File

@@ -5,6 +5,14 @@
<div class="lesson-header">
<button @click="back" class="btn-back">{{ $t('general.back') }}</button>
<h2>{{ lesson.title }}</h2>
<button
type="button"
class="btn-reset-lesson"
:disabled="resettingLessonProgress"
@click="confirmResetLessonProgress"
>
{{ resettingLessonProgress ? $t('general.loading') : $t('socialnetwork.vocab.courses.resetLessonProgress') }}
</button>
</div>
<!-- Tabs für Lernen und Übungen -->
@@ -942,7 +950,8 @@ export default {
lessonStatePersistenceReady: false,
lessonStateSaveTimer: null,
lessonStateSaveInFlight: false,
pendingLessonStatePayload: null
pendingLessonStatePayload: null,
resettingLessonProgress: false
};
},
computed: {
@@ -1373,6 +1382,44 @@ export default {
const userId = this.user?.id || 'guest';
return `vocab-lesson-state:${LESSON_STATE_VERSION}:${userId}:${this.courseId}:${this.lessonId}`;
},
clearLocalLessonStateCache() {
const storageKey = this.getLessonStateStorageKey();
if (!storageKey) {
return;
}
try {
window.localStorage.removeItem(storageKey);
} catch (error) {
console.warn('[VocabLessonView] Lokaler Lektions-Cache konnte nicht gelöscht werden:', error);
}
},
confirmResetLessonProgress() {
if (!this.lessonId || this.resettingLessonProgress) {
return;
}
if (!window.confirm(this.$t('socialnetwork.vocab.courses.resetLessonProgressConfirm'))) {
return;
}
this.resetLessonProgressOnServer();
},
async resetLessonProgressOnServer() {
if (!this.lessonId || this.resettingLessonProgress) {
return;
}
this.resettingLessonProgress = true;
try {
await apiClient.delete(`/api/vocab/lessons/${this.lessonId}/progress`);
this.clearLocalLessonStateCache();
this.$root?.$refs?.messageDialog?.open?.('tr:socialnetwork.vocab.courses.resetLessonProgressSuccess');
await this.loadLesson();
} catch (e) {
console.error('[VocabLessonView] Lektion zurücksetzen fehlgeschlagen:', e);
const msg = e?.response?.data?.error || this.$t('socialnetwork.vocab.courses.resetLessonProgressError');
this.$root?.$refs?.messageDialog?.open?.(msg);
} finally {
this.resettingLessonProgress = false;
}
},
buildPersistedLessonState() {
return {
version: LESSON_STATE_VERSION,
@@ -3104,6 +3151,29 @@ export default {
margin-bottom: 20px;
}
.lesson-header h2 {
flex: 1;
min-width: 0;
margin: 0;
}
.btn-reset-lesson {
flex-shrink: 0;
padding: 8px 14px;
border: 1px solid rgba(140, 90, 60, 0.35);
border-radius: 4px;
background: rgba(255, 248, 240, 0.95);
color: #6b4420;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
}
.btn-reset-lesson:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.lesson-overview-card {
display: flex;
flex-direction: column;