Files
wordloop/frontend/src/pages/WordLibrary.vue
T
John 5dd29e8930 Add graph focus mode, hover tooltips, side panel, and targeted spell practice.
Clicking a node focuses its neighbor subgraph; side panel shows word stats and curve;
「练这个词」 prepends a spell question for the selected word.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 15:00:45 -07:00

679 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import WordCard from '../components/WordCard.vue'
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'
import { LINK_KIND_META, useGraphFilter, type LinkKind } from '../composables/useGraphFilter'
import { setPracticeFocusWordId } from '../utils/practiceFocus'
const router = useRouter()
const viewTabs = [
{ key: 'list', label: '单词列表' },
{ key: 'memory', label: '记忆曲线' },
]
const tabs = [
{ key: '', label: '全部' },
{ key: 'new', label: '新词' },
{ key: 'learning', label: '学习中' },
{ key: 'mastered', label: '已掌握' },
{ 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 wordMemory = ref<WordMemoryDetail | null>(null)
const wordMemoryLoading = ref(false)
const {
graphStatusFilter,
graphSearch,
graphViewMode,
linkFilters,
filteredGraphNodes,
filteredGraphLinks,
graphStatsText,
isNodeVisible,
setFocusCenter,
exitFocusMode,
} = useGraphFilter(viz)
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
if (data.words[0]) {
setFocusCenter(String(data.words[0].id))
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()
}
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
setFocusCenter(id)
loadWordMemory(w.id)
}
}
function goPracticeSpell() {
if (!selectedWord.value) return
setPracticeFocusWordId(selectedWord.value.id)
router.push('/spell')
}
watch([graphStatusFilter, graphSearch, linkFilters], () => {
if (selectedWord.value && !isNodeVisible(String(selectedWord.value.id))) {
const first = filteredGraphNodes.value[0]
if (first) {
const w = viz.value?.words.find((x) => String(x.id) === first.id)
if (w) {
selectedWord.value = w
loadWordMemory(w.id)
}
}
}
}, { deep: true })
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="view-tabs">
<button
v-for="v in viewTabs"
:key="v.key"
:class="['view-tab', { active: activeView === v.key }]"
@click="activeView = v.key"
>
{{ v.label }}
</button>
</div>
<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">筛选后展示子图减轻卡顿并突出记忆关联</p>
<div class="graph-toolbar">
<input
v-model="graphSearch"
class="graph-search input"
type="search"
placeholder="搜索英文或中文"
/>
<select v-model="graphStatusFilter" class="graph-select">
<option value="">全部状态</option>
<option value="new">新词</option>
<option value="learning">学习中</option>
<option value="mastered">已掌握</option>
<option value="weak">易错词</option>
</select>
</div>
<div class="link-filters">
<label
v-for="(meta, kind) in LINK_KIND_META"
:key="kind"
class="link-filter-item"
>
<input v-model="linkFilters[kind as LinkKind]" type="checkbox" />
<span class="link-swatch" :style="{ background: meta.color }" />
{{ meta.label }}
</label>
</div>
<div class="graph-mode-row">
<p class="graph-stats">{{ graphStatsText }}</p>
<div class="graph-mode-actions">
<button
v-if="graphViewMode === 'focus'"
type="button"
class="btn-mini"
@click="exitFocusMode"
>
退出聚焦
</button>
<button
v-else-if="selectedWord"
type="button"
class="btn-mini"
@click="setFocusCenter(String(selectedWord.id))"
>
聚焦此词
</button>
</div>
</div>
<div class="memory-graph-row">
<div class="graph-panel">
<WordGraphCanvas
v-if="filteredGraphNodes.length"
:nodes="filteredGraphNodes"
:links="filteredGraphLinks"
@select="onGraphSelect"
/>
<p v-else class="curve-loading">当前筛选无匹配单词请调整条件</p>
<div class="graph-legend">
<span v-for="(meta, kind) in LINK_KIND_META" :key="kind" class="legend-item">
<span class="link-swatch" :style="{ background: meta.color }" />
{{ meta.label }}{{ meta.desc }}
</span>
</div>
</div>
<aside v-if="selectedWord" class="detail-panel card">
<h3 class="detail-word-title">{{ selectedWord.en }}</h3>
<p class="detail-word-zh">{{ selectedWord.zh }}</p>
<div class="detail-actions">
<button type="button" class="btn btn-primary btn-sm" @click="goPracticeSpell">
练这个词
</button>
</div>
<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 }}% · 保留 {{ selectedWord.retention_now }}%</span>
</div>
<p v-if="wordMemoryLoading" class="curve-loading">加载曲线...</p>
<template v-else-if="wordCurvePoints.length">
<h4 class="word-curve-title">记忆曲线</h4>
<MemoryCurveChart
:curve-points="wordCurvePoints"
:future-risk="wordMemory?.future_risk ?? []"
/>
</template>
<p v-else class="curve-loading">暂无训练记录</p>
</aside>
</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;
}
.graph-toolbar {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.graph-search {
flex: 1;
min-width: 0;
}
.graph-select {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 14px;
background: #fff;
}
.link-filters {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
margin-bottom: 8px;
}
.link-filter-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--muted);
cursor: pointer;
}
.link-swatch {
width: 10px;
height: 10px;
border-radius: 2px;
flex-shrink: 0;
}
.graph-mode-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.graph-stats {
font-size: 12px;
color: var(--muted);
margin: 0;
flex: 1;
}
.graph-mode-actions {
flex-shrink: 0;
}
.btn-mini {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
font-size: 12px;
color: var(--primary);
cursor: pointer;
}
.memory-graph-row {
display: flex;
flex-direction: column;
gap: 12px;
}
@media (min-width: 720px) {
.memory-graph-row {
flex-direction: row;
align-items: flex-start;
}
.graph-panel {
flex: 1;
min-width: 0;
}
.detail-panel {
width: 280px;
flex-shrink: 0;
margin-top: 0 !important;
}
}
.detail-panel {
margin-top: 0;
padding: 14px;
}
.detail-word-title {
font-size: 18px;
font-weight: 700;
margin: 0;
}
.detail-word-zh {
font-size: 15px;
color: var(--muted);
margin: 4px 0 10px;
}
.detail-actions {
margin-bottom: 12px;
}
.btn-sm {
width: 100%;
padding: 10px;
font-size: 14px;
}
.graph-legend {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.legend-item {
font-size: 11px;
color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
}
.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>