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