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
+1
View File
@@ -182,6 +182,7 @@ export const api = {
createWord: (data: Partial<Word>) => request.post<Word>('/words', data),
listWords: (status?: string) =>
request.get<Word[]>('/words', { params: status ? { status } : {} }),
getWord: (id: number) => request.get<Word>(`/words/${id}`),
memoryViz: () => request.get<MemoryVisualization>('/words/memory-viz'),
wordMemory: (id: number) => request.get<WordMemoryDetail>(`/words/${id}/memory`),
deleteWord: (id: number) => request.delete(`/words/${id}`),
+59 -3
View File
@@ -12,8 +12,18 @@ const emit = defineEmits<{
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const wrapRef = ref<HTMLDivElement | null>(null)
const selectedId = ref<string | null>(null)
const cursorStyle = ref('default')
const hoverNode = ref<MemoryGraphNode | null>(null)
const tooltipPos = ref({ x: 0, y: 0 })
const statusLabel: Record<string, string> = {
new: '新词',
learning: '学习中',
mastered: '已掌握',
weak: '易错词',
}
interface SimNode extends MemoryGraphNode {
x: number
@@ -232,6 +242,7 @@ function onPointerDown(e: PointerEvent) {
const hit = hitNode(x, y)
if (!hit) return
hoverNode.value = null
draggingId = hit.id
dragOffsetX = x - hit.x
dragOffsetY = y - hit.y
@@ -262,7 +273,21 @@ function onPointerMove(e: PointerEvent) {
return
}
cursorStyle.value = hitNode(x, y) ? 'grab' : 'default'
const hit = hitNode(x, y)
cursorStyle.value = hit ? 'grab' : 'default'
if (hit) {
hoverNode.value = hit
const wrap = wrapRef.value
if (wrap) {
const rect = wrap.getBoundingClientRect()
tooltipPos.value = {
x: Math.min(e.clientX - rect.left + 12, rect.width - 160),
y: Math.max(e.clientY - rect.top - 8, 8),
}
}
} else {
hoverNode.value = null
}
}
function onPointerUp(e: PointerEvent) {
@@ -320,24 +345,55 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="graph-wrap">
<div ref="wrapRef" class="graph-wrap">
<canvas
ref="canvasRef"
class="graph-canvas"
:style="{ cursor: cursorStyle }"
/>
<p class="graph-hint">拖动节点调整位置画布固定· 点击节点查看单词</p>
<div
v-if="hoverNode"
class="graph-tooltip"
:style="{ left: `${tooltipPos.x}px`, top: `${tooltipPos.y}px` }"
>
<div class="tip-title">{{ hoverNode.label }} {{ hoverNode.zh }}</div>
<div class="tip-meta">
{{ statusLabel[hoverNode.status] || hoverNode.status }} · 掌握 {{ hoverNode.mastery }}%
</div>
</div>
<p class="graph-hint">悬停预览 · 点击选中 · 拖动调整节点位置</p>
</div>
</template>
<style scoped>
.graph-wrap {
position: relative;
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fafbff;
overflow: hidden;
}
.graph-tooltip {
position: absolute;
z-index: 5;
max-width: 200px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(30, 41, 59, 0.92);
color: #fff;
font-size: 12px;
pointer-events: none;
line-height: 1.4;
}
.tip-title {
font-weight: 600;
margin-bottom: 2px;
}
.tip-meta {
opacity: 0.85;
font-size: 11px;
}
.graph-canvas {
display: block;
touch-action: none;
+93 -4
View File
@@ -2,8 +2,10 @@ import { computed, ref, type Ref } from 'vue'
import type { MemoryGraphLink, MemoryGraphNode, MemoryVisualization } from '../api/request'
export const MAX_GRAPH_NODES = 40
export const MAX_FOCUS_NODES = 28
export type LinkKind = 'co_review' | 'status' | 'similar'
export type GraphViewMode = 'browse' | 'focus'
export const LINK_KIND_META: Record<
LinkKind,
@@ -14,16 +16,79 @@ export const LINK_KIND_META: Record<
similar: { label: '词形相近', color: '#f59e0b', desc: '英文拼写相近' },
}
function passesNodeFilter(
n: MemoryGraphNode,
statusFilter: string,
search: string
): boolean {
if (statusFilter && n.status !== statusFilter) return false
const q = search.trim().toLowerCase()
if (!q) return true
return n.label.toLowerCase().includes(q) || n.zh.toLowerCase().includes(q)
}
function buildFocusNodes(
viz: MemoryVisualization,
centerId: string,
statusFilter: string,
search: string,
linkFilters: Record<LinkKind, boolean>
): MemoryGraphNode[] {
const nodeMap = new Map(viz.graph.nodes.map((n) => [n.id, n]))
const center = nodeMap.get(centerId)
if (!center) return []
const ids = new Set<string>([centerId])
for (const l of viz.graph.links) {
const kind = l.kind as LinkKind
if (!linkFilters[kind]) continue
if (l.source === centerId) ids.add(l.target)
if (l.target === centerId) ids.add(l.source)
}
let nodes = [...ids]
.map((id) => nodeMap.get(id)!)
.filter(Boolean)
.filter((n) => passesNodeFilter(n, statusFilter, search))
if (!nodes.some((n) => n.id === centerId)) {
nodes = [center, ...nodes]
}
if (nodes.length > MAX_FOCUS_NODES) {
const centerNode = nodes.find((n) => n.id === centerId)!
const rest = nodes
.filter((n) => n.id !== centerId)
.sort((a, b) => a.mastery - b.mastery)
.slice(0, MAX_FOCUS_NODES - 1)
nodes = [centerNode, ...rest]
}
return nodes
}
export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
const graphStatusFilter = ref('')
const graphSearch = ref('')
const graphViewMode = ref<GraphViewMode>('browse')
const focusCenterId = ref<string | null>(null)
const linkFilters = ref<Record<LinkKind, boolean>>({
co_review: true,
status: true,
similar: true,
})
const matchedBeforeLimit = computed(() => {
function setFocusCenter(id: string) {
focusCenterId.value = id
graphViewMode.value = 'focus'
}
function exitFocusMode() {
focusCenterId.value = null
graphViewMode.value = 'browse'
}
const browseMatchedCount = computed(() => {
if (!viz.value) return 0
let nodes = viz.value.graph.nodes
if (graphStatusFilter.value) {
@@ -38,7 +103,7 @@ export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
return nodes.length
})
const filteredGraphNodes = computed((): MemoryGraphNode[] => {
const browseNodes = computed((): MemoryGraphNode[] => {
if (!viz.value) return []
let nodes = [...viz.value.graph.nodes]
if (graphStatusFilter.value) {
@@ -63,8 +128,23 @@ export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
.slice(0, MAX_GRAPH_NODES)
})
const filteredGraphNodes = computed((): MemoryGraphNode[] => {
if (!viz.value) return []
if (graphViewMode.value === 'focus' && focusCenterId.value) {
return buildFocusNodes(
viz.value,
focusCenterId.value,
graphStatusFilter.value,
graphSearch.value,
linkFilters.value
)
}
return browseNodes.value
})
const graphTruncated = computed(
() => matchedBeforeLimit.value > MAX_GRAPH_NODES
() =>
graphViewMode.value === 'browse' && browseMatchedCount.value > MAX_GRAPH_NODES
)
const filteredGraphLinks = computed((): MemoryGraphLink[] => {
@@ -81,7 +161,12 @@ export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
const n = filteredGraphNodes.value.length
const e = filteredGraphLinks.value.length
const total = viz.value?.graph.nodes.length ?? 0
let text = `显示 ${n} 个节点、${e} 条连线(全库 ${total} 词)`
if (graphViewMode.value === 'focus' && focusCenterId.value) {
const center = viz.value?.graph.nodes.find((x) => x.id === focusCenterId.value)
const label = center ? `${center.label}` : '当前词'
return `聚焦模式:以「${label}」为中心,显示 ${n} 个关联词、${e} 条连线`
}
let text = `浏览模式:${n} 个节点、${e} 条连线(全库 ${total} 词)`
if (graphTruncated.value) {
text += ` · 已优先展示易错/薄弱词(最多 ${MAX_GRAPH_NODES} 个)`
}
@@ -95,12 +180,16 @@ export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
return {
graphStatusFilter,
graphSearch,
graphViewMode,
focusCenterId,
linkFilters,
filteredGraphNodes,
filteredGraphLinks,
graphTruncated,
graphStatsText,
isNodeVisible,
setFocusCenter,
exitFocusMode,
LINK_KIND_META,
}
}
+16 -1
View File
@@ -10,6 +10,8 @@ import {
type QuizSessionSnapshot,
} from '../composables/useQuizSession'
import { useQuizTimer } from '../composables/useQuizTimer'
import { consumePracticeFocusWordId } from '../utils/practiceFocus'
import { spellQuestionFromWord } from '../utils/spellQuestion'
const { startQuestionTimer, consumeDurationSeconds } = useQuizTimer()
@@ -69,7 +71,20 @@ onMounted(async () => {
clearQuizSession('spell')
const { data } = await api.spellQuiz()
questions.value = data.questions
if (data.questions.length === 0) {
const focusId = consumePracticeFocusWordId()
if (focusId) {
try {
const { data: word } = await api.getWord(focusId)
const focusQ = spellQuestionFromWord(word)
if (!questions.value.some((q) => q.word_id === focusId)) {
questions.value = [focusQ, ...questions.value]
}
currentIndex.value = 0
} catch {
/* 单词不存在则忽略 */
}
}
if (questions.value.length === 0) {
empty.value = true
} else {
persist()
+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;
+13
View File
@@ -0,0 +1,13 @@
const FOCUS_WORD_KEY = 'wordloop_focus_word_id'
export function setPracticeFocusWordId(wordId: number) {
sessionStorage.setItem(FOCUS_WORD_KEY, String(wordId))
}
export function consumePracticeFocusWordId(): number | null {
const raw = sessionStorage.getItem(FOCUS_WORD_KEY)
sessionStorage.removeItem(FOCUS_WORD_KEY)
if (!raw) return null
const id = parseInt(raw, 10)
return Number.isFinite(id) ? id : null
}
+14
View File
@@ -0,0 +1,14 @@
import type { QuizQuestion, Word } from '../api/request'
export function spellQuestionFromWord(word: Word): QuizQuestion {
const zh = word.source_lang === 'zh' ? word.source_text : word.target_text
const en = word.source_lang === 'zh' ? word.target_text : word.source_text
return {
word_id: word.id,
question_type: 'spell',
prompt: zh,
phonetic: word.phonetic,
options: [],
correct_answer: en,
}
}