Add spell practice, memory analytics, auth persistence, and word library UX.

Includes per-word training stats and curves, quiz session auto-save, remember-login,
paginated word list with floating page arrows, and Obsidian-style relationship graph baseline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-04 14:57:39 -07:00
parent bd7635986a
commit 68c5efe573
30 changed files with 2560 additions and 63 deletions
+421 -20
View File
@@ -1,7 +1,27 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
import WordCard from '../components/WordCard.vue'
import { api, type Word } from '../api/request'
const MemoryCurveChart = defineAsyncComponent(
() => import('../components/MemoryCurveChart.vue')
)
const WordGraphCanvas = defineAsyncComponent(
() => import('../components/WordGraphCanvas.vue')
)
import {
api,
type MemoryCurvePoint,
type MemoryVisualization,
type MemoryWordSummary,
type Word,
type WordMemoryDetail,
} from '../api/request'
import { formatTrainSeconds } from '../composables/useQuizTimer'
const viewTabs = [
{ key: 'list', label: '单词列表' },
{ key: 'memory', label: '记忆曲线' },
]
const tabs = [
{ key: '', label: '全部' },
@@ -11,52 +31,433 @@ const tabs = [
{ key: 'weak', label: '易错词' },
]
const PAGE_SIZE = 20
const activeView = ref('list')
const activeTab = ref('')
const words = ref<Word[]>([])
const currentPage = ref(1)
const loading = ref(true)
const vizLoading = ref(false)
const viz = ref<MemoryVisualization | null>(null)
const selectedWord = ref<MemoryWordSummary | null>(null)
const detailExpanded = ref(true)
const wordMemory = ref<WordMemoryDetail | null>(null)
const wordMemoryLoading = ref(false)
const wordCurvePoints = computed((): MemoryCurvePoint[] => {
if (!wordMemory.value?.curve_points.length) return []
return wordMemory.value.curve_points.map((p, i) => ({
day_index: i,
date: p.date,
forgetting: p.forgetting,
mastery: p.mastery,
risk: p.risk,
}))
})
const totalPages = computed(() =>
Math.max(1, Math.ceil(words.value.length / PAGE_SIZE))
)
const paginatedWords = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE
return words.value.slice(start, start + PAGE_SIZE)
})
const pageSummary = computed(() => {
if (!words.value.length) return ''
const start = (currentPage.value - 1) * PAGE_SIZE + 1
const end = Math.min(currentPage.value * PAGE_SIZE, words.value.length)
return `${currentPage.value} / ${totalPages.value} 页,显示 ${start}${end},共 ${words.value.length}`
})
function clampPage() {
if (currentPage.value > totalPages.value) {
currentPage.value = totalPages.value
}
if (currentPage.value < 1) {
currentPage.value = 1
}
}
function goToPage(page: number) {
currentPage.value = Math.min(Math.max(1, page), totalPages.value)
}
function goToPageAndScroll(page: number) {
goToPage(page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function loadWords() {
loading.value = true
try {
const { data } = await api.listWords(activeTab.value || undefined)
words.value = data
clampPage()
} finally {
loading.value = false
}
}
async function loadViz() {
vizLoading.value = true
try {
const { data } = await api.memoryViz()
viz.value = data
selectedWord.value = data.words[0] ?? null
detailExpanded.value = true
if (data.words[0]) loadWordMemory(data.words[0].id)
} finally {
vizLoading.value = false
}
}
async function handleDelete(id: number) {
if (!confirm('确定删除这个单词?')) return
await api.deleteWord(id)
await loadWords()
if (activeView.value === 'memory') await loadViz()
}
watch(activeTab, loadWords)
onMounted(loadWords)
async function loadWordMemory(wordId: number) {
wordMemoryLoading.value = true
try {
const { data } = await api.wordMemory(wordId)
wordMemory.value = data
} catch {
wordMemory.value = null
} finally {
wordMemoryLoading.value = false
}
}
function onGraphSelect(id: string) {
const w = viz.value?.words.find((x) => String(x.id) === id)
if (w) {
selectedWord.value = w
detailExpanded.value = true
loadWordMemory(w.id)
}
}
watch(selectedWord, (w) => {
if (w && activeView.value === 'memory') loadWordMemory(w.id)
})
watch(activeTab, () => {
currentPage.value = 1
if (activeView.value === 'list') loadWords()
})
watch(activeView, (v) => {
if (v === 'list') loadWords()
else loadViz()
})
onMounted(() => {
loadWords()
})
</script>
<template>
<div class="page">
<h1 class="page-title">单词库</h1>
<div class="tabs">
<div class="view-tabs">
<button
v-for="t in tabs"
:key="t.key"
:class="['tab', { active: activeTab === t.key }]"
@click="activeTab = t.key"
v-for="v in viewTabs"
:key="v.key"
:class="['view-tab', { active: activeView === v.key }]"
@click="activeView = v.key"
>
{{ t.label }}
{{ v.label }}
</button>
</div>
<p v-if="loading" style="color: var(--muted)">加载中...</p>
<p v-else-if="words.length === 0" style="color: var(--muted); text-align: center">
暂无单词去翻译页添加吧
</p>
<WordCard
v-for="w in words"
:key="w.id"
:word="w"
@delete="handleDelete"
/>
<template v-if="activeView === 'list'">
<div class="tabs">
<button
v-for="t in tabs"
:key="t.key"
:class="['tab', { active: activeTab === t.key }]"
@click="activeTab = t.key"
>
{{ t.label }}
</button>
</div>
<p v-if="loading" style="color: var(--muted)">加载中...</p>
<p v-else-if="words.length === 0" style="color: var(--muted); text-align: center">
暂无单词去翻译页添加吧
</p>
<template v-else>
<Teleport to="body">
<button
v-if="totalPages > 1"
type="button"
class="float-page-arrow prev"
:disabled="currentPage <= 1"
aria-label="上一页"
@click="goToPageAndScroll(currentPage - 1)"
>
</button>
<button
v-if="totalPages > 1"
type="button"
class="float-page-arrow next"
:disabled="currentPage >= totalPages"
aria-label="下一页"
@click="goToPageAndScroll(currentPage + 1)"
>
</button>
</Teleport>
<p class="page-summary">{{ pageSummary }}</p>
<WordCard v-for="w in paginatedWords" :key="w.id" :word="w" @delete="handleDelete" />
<div v-if="totalPages > 1" class="pagination">
<button
type="button"
class="page-btn"
:disabled="currentPage <= 1"
@click="goToPageAndScroll(currentPage - 1)"
>
上一页
</button>
<span class="page-indicator">{{ currentPage }} / {{ totalPages }}</span>
<button
type="button"
class="page-btn"
:disabled="currentPage >= totalPages"
@click="goToPageAndScroll(currentPage + 1)"
>
下一页
</button>
</div>
</template>
</template>
<template v-else>
<p v-if="vizLoading" style="color: var(--muted)">加载记忆数据...</p>
<template v-else-if="viz && viz.graph.nodes.length">
<div class="card chart-card">
<h3 class="section-title">记忆曲线</h3>
<p class="section-desc">
红线遗忘曲线 · 绿线熟练曲线 · 橙虚线可能遗忘(历史) · 紫点线未来预测
</p>
<MemoryCurveChart :curve-points="viz.curve_points" :future-risk="viz.future_risk" />
</div>
<div class="card graph-card">
<h3 class="section-title">单词关系图</h3>
<p class="section-desc">类似 Obsidian 的力导向图展示词与词之间的记忆关联</p>
<WordGraphCanvas
:nodes="viz.graph.nodes"
:links="viz.graph.links"
@select="onGraphSelect"
/>
</div>
<div v-if="selectedWord" class="card word-detail">
<div class="detail-header">
<h3 class="detail-word-title">{{ selectedWord.en }} {{ selectedWord.zh }}</h3>
<button type="button" class="detail-toggle" @click="detailExpanded = !detailExpanded">
{{ detailExpanded ? '收起' : '展开' }}
</button>
</div>
<div v-show="detailExpanded" class="detail-expanded">
<div class="detail-meta">
<span>进入词库{{ selectedWord.entered_at.replace('T', ' ').slice(0, 16) }}</span>
<span>训练 {{ selectedWord.train_count }} · 累计 {{ formatTrainSeconds(selectedWord.total_train_seconds) }}</span>
<span>答对 {{ selectedWord.correct_count }} · 答错 {{ selectedWord.wrong_count }}</span>
<span>掌握率 {{ selectedWord.mastery_score }}%</span>
<span>当前记忆保留 {{ selectedWord.retention_now }}%</span>
<span>7 日后预测 {{ selectedWord.risk_7d }}%</span>
</div>
<p v-if="wordMemoryLoading" class="curve-loading">加载该词记忆曲线...</p>
<template v-else-if="wordCurvePoints.length">
<h4 class="word-curve-title">该单词记忆曲线</h4>
<p class="section-desc">每次训练后更新 · 点与答题记录对应</p>
<MemoryCurveChart
:curve-points="wordCurvePoints"
:future-risk="wordMemory?.future_risk ?? []"
/>
</template>
<p v-else class="curve-loading">暂无训练记录完成每日训练或拼写练习后生成曲线</p>
</div>
</div>
</template>
<p v-else style="color: var(--muted); text-align: center">暂无单词无法生成记忆曲线</p>
</template>
</div>
</template>
<style scoped>
.view-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.view-tab {
flex: 1;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
font-size: 14px;
cursor: pointer;
}
.view-tab.active {
border-color: var(--primary);
background: rgba(79, 110, 247, 0.1);
color: var(--primary);
font-weight: 600;
}
.page-summary {
font-size: 13px;
color: var(--muted);
margin: 0 0 12px;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
padding-top: 12px;
}
.page-btn {
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
font-size: 14px;
color: var(--primary);
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
color: var(--muted);
}
.page-indicator {
font-size: 14px;
color: var(--muted);
min-width: 72px;
text-align: center;
}
.section-title {
font-size: 16px;
margin: 0 0 6px;
}
.section-desc {
font-size: 12px;
color: var(--muted);
margin: 0 0 12px;
}
.chart-card,
.graph-card {
margin-bottom: 16px;
}
.word-detail {
margin-top: 12px;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.detail-word-title {
font-size: 17px;
font-weight: 600;
margin: 0;
flex: 1;
min-width: 0;
}
.detail-toggle {
flex-shrink: 0;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
font-size: 13px;
color: var(--primary);
cursor: pointer;
}
.detail-toggle:hover {
background: rgba(79, 110, 247, 0.08);
}
.detail-expanded {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.detail-meta {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: var(--muted);
}
.word-curve-title {
font-size: 14px;
margin: 14px 0 4px;
color: #1e293b;
}
.curve-loading {
font-size: 13px;
color: var(--muted);
margin: 12px 0;
}
</style>
<style>
/* 悬浮翻页箭头挂到 body,避免被页面容器裁剪 */
.float-page-arrow {
position: fixed;
top: calc(50% - 28px);
z-index: 100;
width: 44px;
height: 52px;
border: 1px solid var(--border, #e2e8f0);
border-radius: 12px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.12);
font-size: 28px;
line-height: 1;
color: var(--primary, #4f6ef7);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
backdrop-filter: blur(6px);
-webkit-tap-highlight-color: transparent;
}
.float-page-arrow.prev {
left: max(6px, env(safe-area-inset-left));
}
.float-page-arrow.next {
right: max(6px, env(safe-area-inset-right));
}
.float-page-arrow:hover:not(:disabled) {
background: #fff;
border-color: var(--primary, #4f6ef7);
}
.float-page-arrow:disabled {
opacity: 0.35;
cursor: not-allowed;
box-shadow: none;
}
@media (min-width: 480px) {
.float-page-arrow.prev {
left: 12px;
}
.float-page-arrow.next {
right: 12px;
}
}
</style>