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>
This commit is contained in:
John
2026-06-04 15:00:45 -07:00
parent 22ed0fa95d
commit 5dd29e8930
7 changed files with 339 additions and 56 deletions
+143 -48
View File
@@ -1,5 +1,6 @@
<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(
@@ -18,6 +19,9 @@ import {
} 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: '单词列表' },
@@ -42,18 +46,20 @@ 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 {
graphStatusFilter,
graphSearch,
graphViewMode,
linkFilters,
filteredGraphNodes,
filteredGraphLinks,
graphStatsText,
isNodeVisible,
setFocusCenter,
exitFocusMode,
} = useGraphFilter(viz)
const wordCurvePoints = computed((): MemoryCurvePoint[] => {
@@ -118,8 +124,10 @@ async function loadViz() {
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)
if (data.words[0]) {
setFocusCenter(String(data.words[0].id))
loadWordMemory(data.words[0].id)
}
} finally {
vizLoading.value = false
}
@@ -148,11 +156,17 @@ function onGraphSelect(id: string) {
const w = viz.value?.words.find((x) => String(x.id) === id)
if (w) {
selectedWord.value = w
detailExpanded.value = true
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]
@@ -305,51 +319,70 @@ onMounted(() => {
</label>
</div>
<p class="graph-stats">{{ graphStatsText }}</p>
<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>
<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 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>
<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 ?? []"
</div>
<div class="memory-graph-row">
<div class="graph-panel">
<WordGraphCanvas
v-if="filteredGraphNodes.length"
:nodes="filteredGraphNodes"
:links="filteredGraphLinks"
@select="onGraphSelect"
/>
</template>
<p v-else class="curve-loading">暂无训练记录完成每日训练或拼写练习后生成曲线</p>
<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>
@@ -461,10 +494,72 @@ onMounted(() => {
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 0 10px;
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;