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:
@@ -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