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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user