feat: paginate/search words and improve review flow
This commit is contained in:
@@ -100,8 +100,9 @@ func (h *WordHandler) SubmitReview(c *gin.Context) {
|
||||
func (h *WordHandler) GetWords(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
query := strings.TrimSpace(c.DefaultQuery("q", ""))
|
||||
|
||||
words, total, err := h.wordService.GetAllWords(limit, offset)
|
||||
words, total, err := h.wordService.GetAllWords(limit, offset, query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -26,11 +26,20 @@ func (r *WordRepository) Create(word *model.Word) error {
|
||||
return r.db.Create(word).Error
|
||||
}
|
||||
|
||||
func (r *WordRepository) List(limit, offset int) ([]model.Word, int64, error) {
|
||||
func (r *WordRepository) List(limit, offset int, query string) ([]model.Word, int64, error) {
|
||||
var words []model.Word
|
||||
var total int64
|
||||
r.db.Model(&model.Word{}).Count(&total)
|
||||
if err := r.db.Limit(limit).Offset(offset).Order("created_at DESC").Find(&words).Error; err != nil {
|
||||
|
||||
db := r.db.Model(&model.Word{})
|
||||
if query != "" {
|
||||
like := "%" + query + "%"
|
||||
db = db.Where("word LIKE ? OR definition LIKE ?", like, like)
|
||||
}
|
||||
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Limit(limit).Offset(offset).Order("created_at DESC").Find(&words).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return words, total, nil
|
||||
|
||||
@@ -412,8 +412,8 @@ func (s *WordService) SubmitReviewAnswer(userID int64, req request.ReviewAnswerR
|
||||
}
|
||||
|
||||
// 获取所有单词
|
||||
func (s *WordService) GetAllWords(limit, offset int) ([]model.Word, int64, error) {
|
||||
return s.wordRepo.List(limit, offset)
|
||||
func (s *WordService) GetAllWords(limit, offset int, query string) ([]model.Word, int64, error) {
|
||||
return s.wordRepo.List(limit, offset, strings.TrimSpace(query))
|
||||
}
|
||||
|
||||
// 获取记忆统计
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function addWord(word: string) {
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getWords(params: { limit?: number; offset?: number } = {}) {
|
||||
export async function getWords(params: { limit?: number; offset?: number; q?: string } = {}) {
|
||||
const res = await http.get<{ data: Word[]; total: number }>('/words', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -29,16 +29,36 @@
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<el-input v-model="answer" placeholder="输入答案并回车" @keyup.enter="submit" />
|
||||
<div style="margin-top:12px; display:flex; gap:12px">
|
||||
<el-button type="primary" :loading="loading" @click="submit">提交</el-button>
|
||||
<el-input
|
||||
v-model="answer"
|
||||
placeholder="输入答案并回车"
|
||||
:disabled="loading || submitted"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
|
||||
<div style="margin-top:12px; display:flex; gap:12px; flex-wrap:wrap;">
|
||||
<el-button type="primary" :loading="loading" :disabled="submitted" @click="submit">提交</el-button>
|
||||
<el-button type="success" :disabled="!submitted" @click="nextOne">下一题</el-button>
|
||||
<el-button @click="loadOne" :disabled="loading">换一个</el-button>
|
||||
</div>
|
||||
|
||||
<el-result v-if="result" :icon="result.correct ? 'success' : 'error'" :title="result.correct ? '正确' : '不对'" style="margin-top:16px">
|
||||
<el-alert
|
||||
v-if="result && !result.correct"
|
||||
type="error"
|
||||
show-icon
|
||||
style="margin-top:12px"
|
||||
:title="`答案不正确:你的答案「${result.answer}」,正确答案「${result.correct_ans || '-'}」`"
|
||||
/>
|
||||
|
||||
<el-result
|
||||
v-if="result"
|
||||
:icon="result.correct ? 'success' : 'error'"
|
||||
:title="result.correct ? '回答正确' : '回答错误'"
|
||||
style="margin-top:16px"
|
||||
>
|
||||
<template #sub-title>
|
||||
<div>单词:{{ result.word.word }} / 释义:{{ result.word.definition }}</div>
|
||||
<div v-if="!result.correct">你的答案:{{ result.answer }};正确:{{ result.correct_ans }}</div>
|
||||
<div v-if="!result.correct">请点击“下一题”继续复习。</div>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
@@ -53,10 +73,11 @@ import { audioUrl, getReviewWords, submitReview } from '../services/api'
|
||||
import type { MemoryRecord, ReviewMode, ReviewResult } from '../services/api'
|
||||
|
||||
const mode = ref<ReviewMode>('spelling')
|
||||
const record = ref<MemoryRecord | null>(null) // MemoryRecord (含 word)
|
||||
const record = ref<MemoryRecord | null>(null)
|
||||
const answer = ref('')
|
||||
const loading = ref(false)
|
||||
const result = ref<ReviewResult | null>(null)
|
||||
const submitted = ref(false)
|
||||
|
||||
const modeHint = computed(() => {
|
||||
if (mode.value === 'spelling') return '听读音,拼写单词'
|
||||
@@ -66,12 +87,17 @@ const modeHint = computed(() => {
|
||||
|
||||
async function loadOne() {
|
||||
result.value = null
|
||||
submitted.value = false
|
||||
answer.value = ''
|
||||
const res = await getReviewWords({ mode: mode.value, limit: 1 })
|
||||
const res = await getReviewWords({ mode: mode.value, limit: 1 })
|
||||
const arr = (res as any).data ?? (res as any)
|
||||
record.value = Array.isArray(arr) && arr.length ? (arr[0] as MemoryRecord) : null
|
||||
}
|
||||
|
||||
function nextOne() {
|
||||
loadOne().catch(console.error)
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (!record.value?.word?.word) return
|
||||
const a = new Audio(audioUrl(record.value.word.word, 'uk'))
|
||||
@@ -79,12 +105,14 @@ function play() {
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!record.value) return
|
||||
if (!record.value || submitted.value) return
|
||||
if (!answer.value.trim()) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await submitReview({ recordId: record.value.id, answer: answer.value, mode: mode.value })
|
||||
result.value = (res as any).data ?? (res as any)
|
||||
submitted.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -1,32 +1,82 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>单词列表</template>
|
||||
<el-input v-model="kw" placeholder="搜索单词" style="max-width:280px;margin-bottom:12px" />
|
||||
<el-table :data="filtered" border>
|
||||
|
||||
<div style="display:flex; gap:12px; margin-bottom:12px; align-items:center;">
|
||||
<el-input
|
||||
v-model="kw"
|
||||
placeholder="搜索单词/释义"
|
||||
clearable
|
||||
style="max-width:320px"
|
||||
@keyup.enter="onSearch"
|
||||
@clear="onSearch"
|
||||
/>
|
||||
<el-button type="primary" @click="onSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" border v-loading="loading">
|
||||
<el-table-column prop="word" label="单词" width="180" />
|
||||
<el-table-column prop="part_of_speech" label="词性" width="120" />
|
||||
<el-table-column prop="definition" label="释义" />
|
||||
</el-table>
|
||||
|
||||
<div style="margin-top:12px; display:flex; justify-content:flex-end;">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="total"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@current-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getWords } from '../services/api'
|
||||
|
||||
const kw = ref('')
|
||||
import type { Word } from '../services/api'
|
||||
|
||||
const kw = ref('')
|
||||
const rows = ref<Word[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = kw.value.trim().toLowerCase()
|
||||
if (!q) return rows.value
|
||||
return rows.value.filter(r => (r.word || '').toLowerCase().includes(q))
|
||||
})
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await getWords({ limit: 200, offset: 0 })
|
||||
rows.value = res.data || []
|
||||
async function fetchWords() {
|
||||
loading.value = true
|
||||
try {
|
||||
const offset = (currentPage.value - 1) * pageSize.value
|
||||
const res = await getWords({ limit: pageSize.value, offset, q: kw.value.trim() || undefined })
|
||||
rows.value = res.data || []
|
||||
total.value = res.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
currentPage.value = 1
|
||||
fetchWords().catch(console.error)
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
currentPage.value = page
|
||||
fetchWords().catch(console.error)
|
||||
}
|
||||
|
||||
function onSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
fetchWords().catch(console.error)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchWords().catch(console.error)
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user