feat(vocab): implement user vocab lesson progress reset functionality
All checks were successful
Deploy to production / deploy (push) Successful in 2m59s
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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user