feat: paginate/search words and improve review flow

This commit is contained in:
2026-02-27 13:15:37 +08:00
parent 906c5c37ba
commit a62c2a3aa1
6 changed files with 116 additions and 28 deletions

View File

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

View File

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

View File

@@ -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))
}
// 获取记忆统计

View File

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

View File

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

View File

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