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) {
|
func (h *WordHandler) GetWords(c *gin.Context) {
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -26,11 +26,20 @@ func (r *WordRepository) Create(word *model.Word) error {
|
|||||||
return r.db.Create(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 words []model.Word
|
||||||
var total int64
|
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 nil, 0, err
|
||||||
}
|
}
|
||||||
return words, total, nil
|
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) {
|
func (s *WordService) GetAllWords(limit, offset int, query string) ([]model.Word, int64, error) {
|
||||||
return s.wordRepo.List(limit, offset)
|
return s.wordRepo.List(limit, offset, strings.TrimSpace(query))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取记忆统计
|
// 获取记忆统计
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export async function addWord(word: string) {
|
|||||||
return res.data
|
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 })
|
const res = await http.get<{ data: Word[]; total: number }>('/words', { params })
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,16 +29,36 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-input v-model="answer" placeholder="输入答案并回车" @keyup.enter="submit" />
|
<el-input
|
||||||
<div style="margin-top:12px; display:flex; gap:12px">
|
v-model="answer"
|
||||||
<el-button type="primary" :loading="loading" @click="submit">提交</el-button>
|
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>
|
<el-button @click="loadOne" :disabled="loading">换一个</el-button>
|
||||||
</div>
|
</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>
|
<template #sub-title>
|
||||||
<div>单词:{{ result.word.word }} / 释义:{{ result.word.definition }}</div>
|
<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>
|
</template>
|
||||||
</el-result>
|
</el-result>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,10 +73,11 @@ import { audioUrl, getReviewWords, submitReview } from '../services/api'
|
|||||||
import type { MemoryRecord, ReviewMode, ReviewResult } from '../services/api'
|
import type { MemoryRecord, ReviewMode, ReviewResult } from '../services/api'
|
||||||
|
|
||||||
const mode = ref<ReviewMode>('spelling')
|
const mode = ref<ReviewMode>('spelling')
|
||||||
const record = ref<MemoryRecord | null>(null) // MemoryRecord (含 word)
|
const record = ref<MemoryRecord | null>(null)
|
||||||
const answer = ref('')
|
const answer = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const result = ref<ReviewResult | null>(null)
|
const result = ref<ReviewResult | null>(null)
|
||||||
|
const submitted = ref(false)
|
||||||
|
|
||||||
const modeHint = computed(() => {
|
const modeHint = computed(() => {
|
||||||
if (mode.value === 'spelling') return '听读音,拼写单词'
|
if (mode.value === 'spelling') return '听读音,拼写单词'
|
||||||
@@ -66,12 +87,17 @@ const modeHint = computed(() => {
|
|||||||
|
|
||||||
async function loadOne() {
|
async function loadOne() {
|
||||||
result.value = null
|
result.value = null
|
||||||
|
submitted.value = false
|
||||||
answer.value = ''
|
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)
|
const arr = (res as any).data ?? (res as any)
|
||||||
record.value = Array.isArray(arr) && arr.length ? (arr[0] as MemoryRecord) : null
|
record.value = Array.isArray(arr) && arr.length ? (arr[0] as MemoryRecord) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextOne() {
|
||||||
|
loadOne().catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
function play() {
|
function play() {
|
||||||
if (!record.value?.word?.word) return
|
if (!record.value?.word?.word) return
|
||||||
const a = new Audio(audioUrl(record.value.word.word, 'uk'))
|
const a = new Audio(audioUrl(record.value.word.word, 'uk'))
|
||||||
@@ -79,12 +105,14 @@ function play() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!record.value) return
|
if (!record.value || submitted.value) return
|
||||||
if (!answer.value.trim()) return
|
if (!answer.value.trim()) return
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await submitReview({ recordId: record.value.id, answer: answer.value, mode: mode.value })
|
const res = await submitReview({ recordId: record.value.id, answer: answer.value, mode: mode.value })
|
||||||
result.value = (res as any).data ?? (res as any)
|
result.value = (res as any).data ?? (res as any)
|
||||||
|
submitted.value = true
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,82 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-card>
|
<el-card>
|
||||||
<template #header>单词列表</template>
|
<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="word" label="单词" width="180" />
|
||||||
<el-table-column prop="part_of_speech" label="词性" width="120" />
|
<el-table-column prop="part_of_speech" label="词性" width="120" />
|
||||||
<el-table-column prop="definition" label="释义" />
|
<el-table-column prop="definition" label="释义" />
|
||||||
</el-table>
|
</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>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { getWords } from '../services/api'
|
import { getWords } from '../services/api'
|
||||||
|
|
||||||
const kw = ref('')
|
|
||||||
import type { Word } from '../services/api'
|
import type { Word } from '../services/api'
|
||||||
|
|
||||||
|
const kw = ref('')
|
||||||
const rows = ref<Word[]>([])
|
const rows = ref<Word[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
const filtered = computed(() => {
|
const currentPage = ref(1)
|
||||||
const q = kw.value.trim().toLowerCase()
|
const pageSize = ref(20)
|
||||||
if (!q) return rows.value
|
|
||||||
return rows.value.filter(r => (r.word || '').toLowerCase().includes(q))
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
async function fetchWords() {
|
||||||
const res = await getWords({ limit: 200, offset: 0 })
|
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 || []
|
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>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user