5dd29e8930
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>
679 lines
17 KiB
Vue
679 lines
17 KiB
Vue
<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>
|