feat(vocab): add language and course dictionary endpoints and UI components
All checks were successful
Deploy to production / deploy (push) Successful in 2m53s
All checks were successful
Deploy to production / deploy (push) Successful in 2m53s
- Implemented `getLanguageDictionary` and `getCourseDictionary` methods in the VocabService to retrieve vocabulary entries filtered by search terms. - Updated VocabController and vocabRouter to include new routes for accessing language and course dictionaries. - Enhanced frontend components to navigate to the new dictionary views, including buttons in VocabCourseView and VocabLanguageView. - Added localization entries for the dictionary feature in multiple languages, ensuring a consistent user experience across the platform.
This commit is contained in:
@@ -18,6 +18,9 @@ class VocabController {
|
||||
this.createChapter = this._wrapWithUser((userId, req) => this.service.createChapter(userId, req.params.languageId, req.body), { successStatus: 201 });
|
||||
this.listLanguageVocabs = this._wrapWithUser((userId, req) => this.service.listLanguageVocabs(userId, req.params.languageId));
|
||||
this.searchVocabs = this._wrapWithUser((userId, req) => this.service.searchVocabs(userId, req.params.languageId, req.query));
|
||||
this.getLanguageDictionary = this._wrapWithUser((userId, req) =>
|
||||
this.service.getLanguageDictionary(userId, req.params.languageId, req.query)
|
||||
);
|
||||
|
||||
this.getChapter = this._wrapWithUser((userId, req) => this.service.getChapter(userId, req.params.chapterId));
|
||||
this.listChapterVocabs = this._wrapWithUser((userId, req) => this.service.listChapterVocabs(userId, req.params.chapterId));
|
||||
@@ -31,6 +34,9 @@ class VocabController {
|
||||
this.getCompletedLessonVocabPool = this._wrapWithUser((userId, req) =>
|
||||
this.service.getCompletedLessonVocabPool(userId, req.params.courseId, req.query.untilLessonId)
|
||||
);
|
||||
this.getCourseDictionary = this._wrapWithUser((userId, req) =>
|
||||
this.service.getCourseDictionary(userId, req.params.courseId, req.query)
|
||||
);
|
||||
this.getVocabDistractorPool = this._wrapWithUser((userId, req) =>
|
||||
this.service.getVocabDistractorPool(userId, req.params.courseId, req.query.beforeLessonId)
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ router.get('/languages/:languageId/chapters', vocabController.listChapters);
|
||||
router.post('/languages/:languageId/chapters', vocabController.createChapter);
|
||||
router.get('/languages/:languageId/vocabs', vocabController.listLanguageVocabs);
|
||||
router.get('/languages/:languageId/search', vocabController.searchVocabs);
|
||||
router.get('/languages/:languageId/dictionary', vocabController.getLanguageDictionary);
|
||||
|
||||
router.get('/chapters/:chapterId', vocabController.getChapter);
|
||||
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
|
||||
@@ -32,6 +33,7 @@ router.get('/courses', vocabController.getCourses);
|
||||
router.get('/courses/my', vocabController.getMyCourses);
|
||||
router.post('/courses/find-by-code', vocabController.getCourseByShareCode);
|
||||
router.get('/courses/:courseId/completed-lesson-vocabs', vocabController.getCompletedLessonVocabPool);
|
||||
router.get('/courses/:courseId/dictionary', vocabController.getCourseDictionary);
|
||||
router.get('/courses/:courseId/distractor-pool', vocabController.getVocabDistractorPool);
|
||||
router.get('/courses/:courseId', vocabController.getCourse);
|
||||
router.put('/courses/:courseId', vocabController.updateCourse);
|
||||
|
||||
@@ -1605,6 +1605,64 @@ export default class VocabService {
|
||||
return { languageId: access.id, results: rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wörterbuch: alle Vokabeln einer Trainer-Sprache (Kapitel), optional gefiltert.
|
||||
* Ein Suchbegriff durchsucht Lern- und Referenzspalte (Teilstrings, ILIKE).
|
||||
*/
|
||||
async getLanguageDictionary(hashedUserId, languageId, { q } = {}) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const access = await this._getLanguageAccess(user.id, languageId);
|
||||
const term = typeof q === 'string' ? q.trim() : '';
|
||||
const like = term ? `%${term}%` : null;
|
||||
|
||||
const rows = await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
cl.id,
|
||||
c.id AS "chapterId",
|
||||
c.title AS "chapterTitle",
|
||||
l1.text AS "learning",
|
||||
l2.text AS "reference"
|
||||
FROM community.vocab_chapter_lexeme cl
|
||||
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
|
||||
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
|
||||
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
|
||||
WHERE c.language_id = :languageId
|
||||
${like ? 'AND (l1.text ILIKE :like OR l2.text ILIKE :like)' : ''}
|
||||
ORDER BY c.title ASC, l1.text ASC, l2.text ASC
|
||||
LIMIT 20000
|
||||
`,
|
||||
{
|
||||
replacements: like ? { languageId: access.id, like } : { languageId: access.id },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
}
|
||||
);
|
||||
|
||||
return { languageId: access.id, results: rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wörterbuch: aus abgeschlossenen Kurslektionen extrahierte Paare, optional gefiltert (Teilstring in beiden Spalten).
|
||||
*/
|
||||
async getCourseDictionary(hashedUserId, courseId, { q } = {}) {
|
||||
const pool = await this.getCompletedLessonVocabPool(hashedUserId, courseId);
|
||||
const term = typeof q === 'string' ? q.trim().toLowerCase() : '';
|
||||
let vocabs = pool.vocabs || [];
|
||||
if (term) {
|
||||
vocabs = vocabs.filter((entry) => {
|
||||
const l = String(entry.learning || '').toLowerCase();
|
||||
const r = String(entry.reference || '').toLowerCase();
|
||||
return l.includes(term) || r.includes(term);
|
||||
});
|
||||
}
|
||||
vocabs.sort((a, b) => {
|
||||
const refCmp = String(a.reference || '').localeCompare(String(b.reference || ''), undefined, { sensitivity: 'base' });
|
||||
if (refCmp !== 0) return refCmp;
|
||||
return String(a.learning || '').localeCompare(String(b.learning || ''), undefined, { sensitivity: 'base' });
|
||||
});
|
||||
return { courseId: pool.courseId, results: vocabs };
|
||||
}
|
||||
|
||||
async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const ch = await this._getChapterAccess(user.id, chapterId);
|
||||
|
||||
@@ -41,8 +41,10 @@ const TITLE_MAP = {
|
||||
VocabNewLanguage: 'sectionBar.titles.newLanguage',
|
||||
VocabSubscribe: 'sectionBar.titles.subscribeLanguage',
|
||||
VocabLanguage: 'sectionBar.titles.language',
|
||||
VocabLanguageDictionary: 'sectionBar.titles.vocabDictionary',
|
||||
VocabChapter: 'sectionBar.titles.chapter',
|
||||
VocabCourses: 'sectionBar.titles.courses',
|
||||
VocabCourseDictionary: 'sectionBar.titles.vocabDictionary',
|
||||
VocabCourse: 'sectionBar.titles.course',
|
||||
VocabLesson: 'sectionBar.titles.lesson',
|
||||
FalukantCreate: 'sectionBar.titles.createCharacter',
|
||||
@@ -102,6 +104,14 @@ export default {
|
||||
backTarget() {
|
||||
const params = this.$route?.params || {};
|
||||
|
||||
if (this.routePath.endsWith('/dictionary') && params.courseId) {
|
||||
return `/socialnetwork/vocab/courses/${params.courseId}`;
|
||||
}
|
||||
|
||||
if (this.routePath.endsWith('/dictionary') && params.languageId) {
|
||||
return `/socialnetwork/vocab/${params.languageId}`;
|
||||
}
|
||||
|
||||
if (this.routePath.startsWith('/socialnetwork/vocab/courses/') && params.lessonId && params.courseId) {
|
||||
return `/socialnetwork/vocab/courses/${params.courseId}`;
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
"subscribeLanguage": "Mag-subscribe sa pinulongan",
|
||||
"language": "Pinulongan",
|
||||
"chapter": "Kapitulo",
|
||||
"vocabDictionary": "Dictionary",
|
||||
"courses": "Mga kurso",
|
||||
"course": "Kurso",
|
||||
"lesson": "Leksiyon",
|
||||
|
||||
@@ -785,6 +785,19 @@
|
||||
"search": "Pangita",
|
||||
"noResults": "Walay results.",
|
||||
"error": "Pangita failed."
|
||||
},
|
||||
"dictionary": {
|
||||
"open": "Dictionary",
|
||||
"kicker": "Tan-awa",
|
||||
"intro": "Tanang termino gikan sa imong mga kapitulo. Usa ka field para sa partial match sa duha ka pinulongan.",
|
||||
"searchLabel": "Pangita (duha ka pinulongan)",
|
||||
"searchPlaceholder": "Part sa pulong sa bisan unsang pinulongan …",
|
||||
"empty": "Walay entry.",
|
||||
"loadError": "Dili ma-load ang dictionary.",
|
||||
"notFound": "Walay access o wala makita.",
|
||||
"languageTitle": "Dictionary: {name}",
|
||||
"courseTitle": "Dictionary sa kurso: {name}",
|
||||
"courseLearningColumn": "Pagkat-on nga pinulongan (kurso)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
"subscribeLanguage": "Sprache abonnieren",
|
||||
"language": "Sprache",
|
||||
"chapter": "Kapitel",
|
||||
"vocabDictionary": "Wörterbuch",
|
||||
"courses": "Kurse",
|
||||
"course": "Kurs",
|
||||
"lesson": "Lektion",
|
||||
|
||||
@@ -468,6 +468,19 @@
|
||||
"noResults": "Keine Treffer.",
|
||||
"error": "Suche fehlgeschlagen."
|
||||
},
|
||||
"dictionary": {
|
||||
"open": "Wörterbuch",
|
||||
"kicker": "Nachschlagen",
|
||||
"intro": "Alle Begriffe aus deinen Kapiteln. Ein Suchfeld filtert in beiden Sprachen nach Teilwörtern.",
|
||||
"searchLabel": "Suche (beide Sprachen)",
|
||||
"searchPlaceholder": "Teilwort in Lern- oder Muttersprache …",
|
||||
"empty": "Keine Einträge.",
|
||||
"loadError": "Wörterbuch konnte nicht geladen werden.",
|
||||
"notFound": "Kein Zugriff oder nicht gefunden.",
|
||||
"languageTitle": "Wörterbuch: {name}",
|
||||
"courseTitle": "Kurs-Wörterbuch: {name}",
|
||||
"courseLearningColumn": "Lernsprache (Kurs)"
|
||||
},
|
||||
"courses": {
|
||||
"title": "Sprachlernkurse",
|
||||
"create": "Kurs erstellen",
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
"subscribeLanguage": "Subscribe to language",
|
||||
"language": "Language",
|
||||
"chapter": "Chapter",
|
||||
"vocabDictionary": "Dictionary",
|
||||
"courses": "Courses",
|
||||
"course": "Course",
|
||||
"lesson": "Lesson",
|
||||
|
||||
@@ -468,6 +468,19 @@
|
||||
"noResults": "No results.",
|
||||
"error": "Search failed."
|
||||
},
|
||||
"dictionary": {
|
||||
"open": "Dictionary",
|
||||
"kicker": "Lookup",
|
||||
"intro": "All terms from your chapters. One field filters both languages by partial matches.",
|
||||
"searchLabel": "Search (both languages)",
|
||||
"searchPlaceholder": "Part of a word in either language…",
|
||||
"empty": "No entries.",
|
||||
"loadError": "Could not load the dictionary.",
|
||||
"notFound": "No access or not found.",
|
||||
"languageTitle": "Dictionary: {name}",
|
||||
"courseTitle": "Course dictionary: {name}",
|
||||
"courseLearningColumn": "Learning language (course)"
|
||||
},
|
||||
"courses": {
|
||||
"title": "Language Learning Courses",
|
||||
"create": "Create Course",
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
"subscribeLanguage": "Suscribirse al idioma",
|
||||
"language": "Idioma",
|
||||
"chapter": "Capítulo",
|
||||
"vocabDictionary": "Diccionario",
|
||||
"courses": "Cursos",
|
||||
"course": "Curso",
|
||||
"lesson": "Lección",
|
||||
|
||||
@@ -466,6 +466,19 @@
|
||||
"noResults": "Sin resultados.",
|
||||
"error": "La búsqueda ha fallado."
|
||||
},
|
||||
"dictionary": {
|
||||
"open": "Diccionario",
|
||||
"kicker": "Consulta",
|
||||
"intro": "Todos los términos de tus capítulos. Un solo campo filtra en ambos idiomas por coincidencias parciales.",
|
||||
"searchLabel": "Búsqueda (ambos idiomas)",
|
||||
"searchPlaceholder": "Parte de una palabra en cualquier idioma…",
|
||||
"empty": "Sin entradas.",
|
||||
"loadError": "No se pudo cargar el diccionario.",
|
||||
"notFound": "Sin acceso o no encontrado.",
|
||||
"languageTitle": "Diccionario: {name}",
|
||||
"courseTitle": "Diccionario del curso: {name}",
|
||||
"courseLearningColumn": "Idioma de aprendizaje (curso)"
|
||||
},
|
||||
"courses": {
|
||||
"title": "Cursos de idiomas",
|
||||
"create": "Crear curso",
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
"subscribeLanguage": "Abonnez-vous à la langue",
|
||||
"language": "Langue",
|
||||
"chapter": "Chapitre",
|
||||
"vocabDictionary": "Dictionnaire",
|
||||
"courses": "Cours",
|
||||
"course": "Kurs",
|
||||
"lesson": "Lektion",
|
||||
|
||||
@@ -466,6 +466,19 @@
|
||||
"noResults": "Aucun coup sûr.",
|
||||
"error": "La recherche a échoué."
|
||||
},
|
||||
"dictionary": {
|
||||
"open": "Dictionnaire",
|
||||
"kicker": "Consulter",
|
||||
"intro": "Tous les termes de vos chapitres. Un seul champ filtre les deux langues par correspondances partielles.",
|
||||
"searchLabel": "Recherche (les deux langues)",
|
||||
"searchPlaceholder": "Fragment dans l’une ou l’autre langue…",
|
||||
"empty": "Aucune entrée.",
|
||||
"loadError": "Impossible de charger le dictionnaire.",
|
||||
"notFound": "Pas d’accès ou introuvable.",
|
||||
"languageTitle": "Dictionnaire : {name}",
|
||||
"courseTitle": "Dictionnaire du cours : {name}",
|
||||
"courseLearningColumn": "Langue apprise (cours)"
|
||||
},
|
||||
"courses": {
|
||||
"title": "Cours d'apprentissage des langues",
|
||||
"create": "Créer un cours",
|
||||
|
||||
@@ -14,6 +14,7 @@ const VocabCourseListView = () => import('../views/social/VocabCourseListView.vu
|
||||
const VocabCourseView = () => import('../views/social/VocabCourseView.vue');
|
||||
const VocabLessonView = () => import('../views/social/VocabLessonView.vue');
|
||||
const VocabLessonReviewView = () => import('../views/social/VocabLessonReviewView.vue');
|
||||
const VocabDictionaryView = () => import('../views/social/VocabDictionaryView.vue');
|
||||
const EroticAccessView = () => import('../views/social/EroticAccessView.vue');
|
||||
const EroticPicturesView = () => import('../views/social/EroticPicturesView.vue');
|
||||
const EroticVideosView = () => import('../views/social/EroticVideosView.vue');
|
||||
@@ -97,18 +98,6 @@ const socialRoutes = [
|
||||
component: VocabSubscribeView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/socialnetwork/vocab/:languageId',
|
||||
name: 'VocabLanguage',
|
||||
component: VocabLanguageView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/socialnetwork/vocab/:languageId/chapters/:chapterId',
|
||||
name: 'VocabChapter',
|
||||
component: VocabChapterView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/socialnetwork/vocab/courses',
|
||||
name: 'VocabCourses',
|
||||
@@ -116,10 +105,9 @@ const socialRoutes = [
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/socialnetwork/vocab/courses/:courseId',
|
||||
name: 'VocabCourse',
|
||||
component: VocabCourseView,
|
||||
props: true,
|
||||
path: '/socialnetwork/vocab/courses/:courseId/dictionary',
|
||||
name: 'VocabCourseDictionary',
|
||||
component: VocabDictionaryView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
@@ -136,6 +124,31 @@ const socialRoutes = [
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/socialnetwork/vocab/courses/:courseId',
|
||||
name: 'VocabCourse',
|
||||
component: VocabCourseView,
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/socialnetwork/vocab/:languageId/dictionary',
|
||||
name: 'VocabLanguageDictionary',
|
||||
component: VocabDictionaryView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/socialnetwork/vocab/:languageId/chapters/:chapterId',
|
||||
name: 'VocabChapter',
|
||||
component: VocabChapterView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/socialnetwork/vocab/:languageId',
|
||||
name: 'VocabLanguage',
|
||||
component: VocabLanguageView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
];
|
||||
|
||||
export default socialRoutes;
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
<span v-if="course.shareCode && isOwner" class="share-code">
|
||||
{{ $t('socialnetwork.vocab.courses.shareCode') }}: <code>{{ course.shareCode }}</code>
|
||||
</span>
|
||||
<button type="button" class="course-dictionary-link" @click="goCourseDictionary">
|
||||
{{ $t('socialnetwork.vocab.dictionary.open') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="surface-card course-assistant">
|
||||
@@ -691,6 +694,9 @@ export default {
|
||||
showApiError(this, e, this.$t('socialnetwork.vocab.courses.deleteLessonError'));
|
||||
}
|
||||
},
|
||||
goCourseDictionary() {
|
||||
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/dictionary`);
|
||||
},
|
||||
openLesson(lessonId) {
|
||||
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}`);
|
||||
},
|
||||
@@ -826,6 +832,11 @@ export default {
|
||||
color: #666;
|
||||
flex-wrap: wrap;
|
||||
padding: 16px 18px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.course-dictionary-link {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.course-assistant {
|
||||
|
||||
253
frontend/src/views/social/VocabDictionaryView.vue
Normal file
253
frontend/src/views/social/VocabDictionaryView.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="vocab-dictionary-view">
|
||||
<section class="dictionary-hero surface-card">
|
||||
<div v-if="metaLoading">{{ $t('general.loading') }}</div>
|
||||
<div v-else-if="!contextTitle">{{ $t('socialnetwork.vocab.dictionary.notFound') }}</div>
|
||||
<div v-else>
|
||||
<span class="dictionary-kicker">{{ $t('socialnetwork.vocab.dictionary.kicker') }}</span>
|
||||
<h2>{{ pageHeading }}</h2>
|
||||
<p>{{ $t('socialnetwork.vocab.dictionary.intro') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="contextTitle" class="dictionary-toolbar surface-card">
|
||||
<label class="dictionary-search">
|
||||
<span class="dictionary-search__label">{{ $t('socialnetwork.vocab.dictionary.searchLabel') }}</span>
|
||||
<input
|
||||
v-model="q"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
:placeholder="$t('socialnetwork.vocab.dictionary.searchPlaceholder')"
|
||||
@input="scheduleLoad"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="dictionary-body surface-card">
|
||||
<div v-if="listLoading" class="dictionary-state">{{ $t('general.loading') }}</div>
|
||||
<div v-else-if="error" class="dictionary-state dictionary-state--error">{{ error }}</div>
|
||||
<div v-else-if="results.length === 0" class="dictionary-state">{{ $t('socialnetwork.vocab.dictionary.empty') }}</div>
|
||||
<table v-else class="dictionary-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('socialnetwork.vocab.search.motherTongue') }}</th>
|
||||
<th>{{ learningColumnLabel }}</th>
|
||||
<th v-if="!isCourseMode">{{ $t('socialnetwork.vocab.chapters') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in results" :key="row.id != null ? `id-${row.id}` : `r-${idx}-${row.learning}-${row.reference}`">
|
||||
<td>{{ row.reference }}</td>
|
||||
<td>{{ row.learning }}</td>
|
||||
<td v-if="!isCourseMode">{{ row.chapterTitle }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import apiClient from '@/utils/axios.js';
|
||||
|
||||
export default {
|
||||
name: 'VocabDictionaryView',
|
||||
data() {
|
||||
return {
|
||||
metaLoading: false,
|
||||
listLoading: false,
|
||||
contextTitle: '',
|
||||
languageName: '',
|
||||
q: '',
|
||||
results: [],
|
||||
error: '',
|
||||
debounceId: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isCourseMode() {
|
||||
return Boolean(this.$route.params.courseId);
|
||||
},
|
||||
pageHeading() {
|
||||
if (!this.contextTitle) return '';
|
||||
if (this.isCourseMode) {
|
||||
return this.$t('socialnetwork.vocab.dictionary.courseTitle', { name: this.contextTitle });
|
||||
}
|
||||
return this.$t('socialnetwork.vocab.dictionary.languageTitle', { name: this.contextTitle });
|
||||
},
|
||||
learningColumnLabel() {
|
||||
if (this.isCourseMode) {
|
||||
return this.$t('socialnetwork.vocab.dictionary.courseLearningColumn');
|
||||
}
|
||||
return this.languageName || this.$t('socialnetwork.vocab.search.learningLanguage');
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'$route.fullPath'() {
|
||||
this.bootstrap();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.bootstrap();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.debounceId) {
|
||||
clearTimeout(this.debounceId);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async bootstrap() {
|
||||
this.q = '';
|
||||
this.results = [];
|
||||
this.error = '';
|
||||
await this.loadContext();
|
||||
if (!this.contextTitle) {
|
||||
return;
|
||||
}
|
||||
await this.loadList();
|
||||
},
|
||||
scheduleLoad() {
|
||||
if (this.debounceId) {
|
||||
clearTimeout(this.debounceId);
|
||||
}
|
||||
this.debounceId = setTimeout(() => {
|
||||
this.debounceId = null;
|
||||
this.loadList();
|
||||
}, 320);
|
||||
},
|
||||
async loadContext() {
|
||||
this.metaLoading = true;
|
||||
this.contextTitle = '';
|
||||
this.languageName = '';
|
||||
try {
|
||||
if (this.isCourseMode) {
|
||||
const { data } = await apiClient.get(`/api/vocab/courses/${this.$route.params.courseId}`);
|
||||
this.contextTitle = data?.title || '';
|
||||
} else {
|
||||
const { data } = await apiClient.get(`/api/vocab/languages/${this.$route.params.languageId}`);
|
||||
this.contextTitle = data?.name || '';
|
||||
this.languageName = data?.name || '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Vocab dictionary context load failed:', e);
|
||||
this.contextTitle = '';
|
||||
} finally {
|
||||
this.metaLoading = false;
|
||||
}
|
||||
},
|
||||
async loadList() {
|
||||
if (this.metaLoading) {
|
||||
return;
|
||||
}
|
||||
const hasScope = this.isCourseMode
|
||||
? Boolean(this.$route.params.courseId)
|
||||
: Boolean(this.$route.params.languageId);
|
||||
if (!hasScope) {
|
||||
return;
|
||||
}
|
||||
this.listLoading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const params = { q: this.q.trim() };
|
||||
if (this.isCourseMode) {
|
||||
const { data } = await apiClient.get(`/api/vocab/courses/${this.$route.params.courseId}/dictionary`, { params });
|
||||
this.results = data?.results || [];
|
||||
} else {
|
||||
const { data } = await apiClient.get(`/api/vocab/languages/${this.$route.params.languageId}/dictionary`, { params });
|
||||
this.results = data?.results || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Vocab dictionary list failed:', e);
|
||||
this.results = [];
|
||||
this.error = this.$t('socialnetwork.vocab.dictionary.loadError');
|
||||
} finally {
|
||||
this.listLoading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vocab-dictionary-view {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.dictionary-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dictionary-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.dictionary-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dictionary-toolbar {
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dictionary-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.dictionary-search__label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dictionary-search input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dictionary-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.dictionary-state {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dictionary-state--error {
|
||||
color: var(--color-danger, #b42318);
|
||||
}
|
||||
|
||||
.dictionary-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.dictionary-table th,
|
||||
.dictionary-table td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.dictionary-table th {
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -24,6 +24,7 @@
|
||||
<div class="row row--actions">
|
||||
<button @click="goSubscribe">{{ $t('socialnetwork.vocab.subscribeByCode') }}</button>
|
||||
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
|
||||
<button type="button" @click="goDictionary">{{ $t('socialnetwork.vocab.dictionary.open') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@@ -85,6 +86,9 @@ export default {
|
||||
languageName: this.language?.name || '',
|
||||
});
|
||||
},
|
||||
goDictionary() {
|
||||
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}/dictionary`);
|
||||
},
|
||||
openChapter(chapterId) {
|
||||
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}/chapters/${chapterId}`);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user