Add memory-graph practice flow with result coloring on the canvas.

Users can start practice from the word library graph, see correct/wrong node colors after sessions, and use an improved graph canvas with better hover and layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-04 15:19:40 -07:00
parent 5dd29e8930
commit 59aeb9aed3
8 changed files with 891 additions and 146 deletions
+129 -44
View File
@@ -1,10 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { MemoryGraphLink, MemoryGraphNode } from '../api/request' import type { MemoryGraphLink, MemoryGraphNode } from '../api/request'
import type { PracticeResult } from '../utils/practiceGraphSession'
const props = defineProps<{ const props = defineProps<{
nodes: MemoryGraphNode[] nodes: MemoryGraphNode[]
links: MemoryGraphLink[] links: MemoryGraphLink[]
/** 练习结果着色:错=红,对=绿,未练=原状态色 */
practiceResults?: Record<string, PracticeResult>
/** 全部练完后才给连线着色 */
sessionComplete?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -15,9 +20,21 @@ const canvasRef = ref<HTMLCanvasElement | null>(null)
const wrapRef = ref<HTMLDivElement | null>(null) const wrapRef = ref<HTMLDivElement | null>(null)
const selectedId = ref<string | null>(null) const selectedId = ref<string | null>(null)
const cursorStyle = ref('default') const cursorStyle = ref('default')
const hoverNode = ref<MemoryGraphNode | null>(null) const hoverId = ref<string | null>(null)
const tooltipPos = ref({ x: 0, y: 0 }) const tooltipPos = ref({ x: 0, y: 0 })
const hoverTip = computed(() => {
const id = hoverId.value
if (!id) return null
const node = props.nodes.find((n) => n.id === id)
if (!node) return null
return {
label: node.label,
status: node.status,
mastery: node.mastery,
}
})
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
new: '新词', new: '新词',
learning: '学习中', learning: '学习中',
@@ -50,6 +67,37 @@ const statusColor: Record<string, string> = {
weak: '#ef4444', weak: '#ef4444',
} }
const COLOR_CORRECT = '#22c55e'
const COLOR_WRONG = '#ef4444'
function nodeFillColor(n: MemoryGraphNode): string {
const pr = props.practiceResults?.[n.id]
if (pr === 'wrong') return COLOR_WRONG
if (pr === 'correct') return COLOR_CORRECT
return statusColor[n.status] || '#64748b'
}
function linkStrokeStyle(l: MemoryGraphLink): { color: string; width: number; dash: number[] } {
if (props.sessionComplete && props.practiceResults) {
const a = props.practiceResults[l.source]
const b = props.practiceResults[l.target]
if (a === 'wrong' || b === 'wrong') {
return { color: 'rgba(239,68,68,0.65)', width: 2, dash: [] }
}
if (a === 'correct' && b === 'correct') {
return { color: 'rgba(34,197,94,0.55)', width: 1.5, dash: [] }
}
return { color: 'rgba(148,163,184,0.35)', width: 1, dash: [3, 3] }
}
if (l.kind === 'co_review') {
return { color: 'rgba(79,110,247,0.45)', width: 1.5, dash: [] }
}
if (l.kind === 'similar') {
return { color: 'rgba(245,158,11,0.4)', width: 1, dash: [4, 4] }
}
return { color: 'rgba(148,163,184,0.3)', width: 1, dash: [2, 3] }
}
function nodeRadius(n: SimNode) { function nodeRadius(n: SimNode) {
return 6 + n.size * 0.25 return 6 + n.size * 0.25
} }
@@ -73,7 +121,7 @@ function pointerPos(e: PointerEvent) {
function hitNode(x: number, y: number): SimNode | null { function hitNode(x: number, y: number): SimNode | null {
for (let i = simNodes.length - 1; i >= 0; i--) { for (let i = simNodes.length - 1; i >= 0; i--) {
const n = simNodes[i] const n = simNodes[i]
const r = nodeRadius(n) + 6 const r = nodeRadius(n) + 10
if ((x - n.x) ** 2 + (y - n.y) ** 2 <= r * r) return n if ((x - n.x) ** 2 + (y - n.y) ** 2 <= r * r) return n
} }
return null return null
@@ -179,26 +227,17 @@ function draw() {
ctx.beginPath() ctx.beginPath()
ctx.moveTo(a.x, a.y) ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y) ctx.lineTo(b.x, b.y)
if (l.kind === 'co_review') { const style = linkStrokeStyle(l)
ctx.strokeStyle = 'rgba(79,110,247,0.45)' ctx.strokeStyle = style.color
ctx.lineWidth = 1.5 ctx.lineWidth = style.width
ctx.setLineDash([]) ctx.setLineDash(style.dash)
} else if (l.kind === 'similar') {
ctx.strokeStyle = 'rgba(245,158,11,0.4)'
ctx.lineWidth = 1
ctx.setLineDash([4, 4])
} else {
ctx.strokeStyle = 'rgba(148,163,184,0.3)'
ctx.lineWidth = 1
ctx.setLineDash([2, 3])
}
ctx.stroke() ctx.stroke()
ctx.setLineDash([]) ctx.setLineDash([])
} }
for (const n of simNodes) { for (const n of simNodes) {
const r = nodeRadius(n) const r = nodeRadius(n)
const color = statusColor[n.status] || '#64748b' const color = nodeFillColor(n)
ctx.beginPath() ctx.beginPath()
ctx.arc(n.x, n.y, r, 0, Math.PI * 2) ctx.arc(n.x, n.y, r, 0, Math.PI * 2)
ctx.fillStyle = n.id === selectedId.value ? color : color + 'cc' ctx.fillStyle = n.id === selectedId.value ? color : color + 'cc'
@@ -226,9 +265,10 @@ function loop() {
function resize() { function resize() {
const canvas = canvasRef.value const canvas = canvasRef.value
if (!canvas?.parentElement) return const wrap = wrapRef.value
width = canvas.parentElement.clientWidth if (!canvas || !wrap) return
height = 360 width = wrap.clientWidth
height = Math.max(280, wrap.clientHeight - 36)
canvas.width = width * devicePixelRatio canvas.width = width * devicePixelRatio
canvas.height = height * devicePixelRatio canvas.height = height * devicePixelRatio
canvas.style.width = `${width}px` canvas.style.width = `${width}px`
@@ -237,12 +277,46 @@ function resize() {
if (ctx) ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0) if (ctx) ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
} }
function updateHover(clientX: number, clientY: number) {
if (draggingId) return
const canvas = canvasRef.value
const wrap = wrapRef.value
if (!canvas || !wrap) return
const canvasRect = canvas.getBoundingClientRect()
const inside =
clientX >= canvasRect.left &&
clientX <= canvasRect.right &&
clientY >= canvasRect.top &&
clientY <= canvasRect.bottom
if (!inside) {
hoverId.value = null
return
}
const x = clientX - canvasRect.left
const y = clientY - canvasRect.top
const hit = hitNode(x, y)
cursorStyle.value = hit ? 'grab' : 'default'
if (hit) {
hoverId.value = hit.id
const wrapRect = wrap.getBoundingClientRect()
tooltipPos.value = {
x: Math.min(clientX - wrapRect.left + 12, wrapRect.width - 220),
y: Math.max(clientY - wrapRect.top - 12, 8),
}
} else {
hoverId.value = null
}
}
function onPointerDown(e: PointerEvent) { function onPointerDown(e: PointerEvent) {
const { x, y } = pointerPos(e) const { x, y } = pointerPos(e)
const hit = hitNode(x, y) const hit = hitNode(x, y)
if (!hit) return if (!hit) return
hoverNode.value = null
draggingId = hit.id draggingId = hit.id
dragOffsetX = x - hit.x dragOffsetX = x - hit.x
dragOffsetY = y - hit.y dragOffsetY = y - hit.y
@@ -263,6 +337,7 @@ function onPointerMove(e: PointerEvent) {
const n = simNodes.find((node) => node.id === draggingId) const n = simNodes.find((node) => node.id === draggingId)
if (n) { if (n) {
pointerMoved = true pointerMoved = true
hoverId.value = null
const c = clampPos(x - dragOffsetX, y - dragOffsetY) const c = clampPos(x - dragOffsetX, y - dragOffsetY)
n.x = c.x n.x = c.x
n.y = c.y n.y = c.y
@@ -273,21 +348,7 @@ function onPointerMove(e: PointerEvent) {
return return
} }
const hit = hitNode(x, y) updateHover(e.clientX, e.clientY)
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) { function onPointerUp(e: PointerEvent) {
@@ -302,6 +363,7 @@ function onPointerUp(e: PointerEvent) {
} }
pointerMoved = false pointerMoved = false
cursorStyle.value = 'default' cursorStyle.value = 'default'
updateHover(e.clientX, e.clientY)
} }
function onResize() { function onResize() {
@@ -309,13 +371,24 @@ function onResize() {
if (!draggingId) initSim() if (!draggingId) initSim()
} }
function onWrapMouseMove(e: MouseEvent) {
updateHover(e.clientX, e.clientY)
}
function onWrapMouseLeave() {
if (!draggingId) hoverId.value = null
}
onMounted(() => { onMounted(() => {
const canvas = canvasRef.value const canvas = canvasRef.value
if (!canvas) return const wrap = wrapRef.value
if (!canvas || !wrap) return
resize() resize()
initSim() initSim()
loop() loop()
window.addEventListener('resize', onResize) window.addEventListener('resize', onResize)
wrap.addEventListener('mousemove', onWrapMouseMove)
wrap.addEventListener('mouseleave', onWrapMouseLeave)
canvas.addEventListener('pointerdown', onPointerDown) canvas.addEventListener('pointerdown', onPointerDown)
canvas.addEventListener('pointermove', onPointerMove) canvas.addEventListener('pointermove', onPointerMove)
canvas.addEventListener('pointerup', onPointerUp) canvas.addEventListener('pointerup', onPointerUp)
@@ -323,7 +396,7 @@ onMounted(() => {
}) })
watch( watch(
() => [props.nodes, props.links], () => [props.nodes, props.links, props.practiceResults, props.sessionComplete],
() => { () => {
resize() resize()
draggingId = null draggingId = null
@@ -336,6 +409,9 @@ onBeforeUnmount(() => {
cancelAnimationFrame(animId) cancelAnimationFrame(animId)
window.removeEventListener('resize', onResize) window.removeEventListener('resize', onResize)
const canvas = canvasRef.value const canvas = canvasRef.value
const wrap = wrapRef.value
wrap?.removeEventListener('mousemove', onWrapMouseMove)
wrap?.removeEventListener('mouseleave', onWrapMouseLeave)
if (!canvas) return if (!canvas) return
canvas.removeEventListener('pointerdown', onPointerDown) canvas.removeEventListener('pointerdown', onPointerDown)
canvas.removeEventListener('pointermove', onPointerMove) canvas.removeEventListener('pointermove', onPointerMove)
@@ -352,16 +428,17 @@ onBeforeUnmount(() => {
:style="{ cursor: cursorStyle }" :style="{ cursor: cursorStyle }"
/> />
<div <div
v-if="hoverNode" v-if="hoverTip"
class="graph-tooltip" class="graph-tooltip"
:style="{ left: `${tooltipPos.x}px`, top: `${tooltipPos.y}px` }" :style="{ left: `${tooltipPos.x}px`, top: `${tooltipPos.y}px` }"
> >
<div class="tip-title">{{ hoverNode.label }} {{ hoverNode.zh }}</div> <div class="tip-title">{{ hoverTip.label }}</div>
<div class="tip-meta"> <div class="tip-meta">
{{ statusLabel[hoverNode.status] || hoverNode.status }} · 掌握 {{ hoverNode.mastery }}% {{ statusLabel[hoverTip.status] || hoverTip.status }} · 掌握 {{ hoverTip.mastery }}%
</div> </div>
<div class="tip-hint">点击查看释义</div>
</div> </div>
<p class="graph-hint">悬停预览 · 点击选中 · 拖动调整节点位置</p> <p class="graph-hint">悬停看状态 · 点击查看释义 · 拖动调整节点</p>
</div> </div>
</template> </template>
@@ -374,10 +451,13 @@ onBeforeUnmount(() => {
background: #fafbff; background: #fafbff;
overflow: hidden; overflow: hidden;
} }
.graph-wrap:has(.graph-tooltip) {
overflow: visible;
}
.graph-tooltip { .graph-tooltip {
position: absolute; position: absolute;
z-index: 5; z-index: 20;
max-width: 200px; max-width: 240px;
padding: 8px 10px; padding: 8px 10px;
border-radius: 8px; border-radius: 8px;
background: rgba(30, 41, 59, 0.92); background: rgba(30, 41, 59, 0.92);
@@ -394,6 +474,11 @@ onBeforeUnmount(() => {
opacity: 0.85; opacity: 0.85;
font-size: 11px; font-size: 11px;
} }
.tip-hint {
margin-top: 4px;
font-size: 10px;
opacity: 0.7;
}
.graph-canvas { .graph-canvas {
display: block; display: block;
touch-action: none; touch-action: none;
+1 -1
View File
@@ -27,7 +27,7 @@ function passesNodeFilter(
return n.label.toLowerCase().includes(q) || n.zh.toLowerCase().includes(q) return n.label.toLowerCase().includes(q) || n.zh.toLowerCase().includes(q)
} }
function buildFocusNodes( export function buildFocusNodes(
viz: MemoryVisualization, viz: MemoryVisualization,
centerId: string, centerId: string,
statusFilter: string, statusFilter: string,
+374
View File
@@ -0,0 +1,374 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import QuizCard from '../components/QuizCard.vue'
import SpellCard from '../components/SpellCard.vue'
import WordGraphCanvas from '../components/WordGraphCanvas.vue'
import { api, type MemoryGraphLink, type MemoryGraphNode, type QuizQuestion, type Word } from '../api/request'
import { buildPracticeQuestion, type PracticeMode } from '../utils/buildPracticeQuestion'
import {
buildPracticeGraphLinks,
buildPracticeGraphNodes,
resolvePracticeDisplayIds,
} from '../utils/practiceGraphDisplay'
import {
getGraphPracticeDisplayIds,
getGraphPracticeWordIds,
isPracticeSessionComplete,
loadPracticeResults,
savePracticeResult,
type PracticeResult,
} from '../utils/practiceGraphSession'
import { submitQuizAnswer } from '../composables/useQuizSession'
import { useQuizTimer } from '../composables/useQuizTimer'
const router = useRouter()
const { startQuestionTimer, consumeDurationSeconds } = useQuizTimer()
const modes: { key: PracticeMode; label: string }[] = [
{ key: 'en_to_zh', label: '看英文选中文' },
{ key: 'zh_to_en', label: '看中文选英文' },
{ key: 'spell', label: '拼写' },
]
const loading = ref(true)
const wordPool = ref<Word[]>([])
const words = ref<Word[]>([])
const graphNodes = ref<MemoryGraphNode[]>([])
const graphLinks = ref<MemoryGraphLink[]>([])
const wordIds = ref<string[]>([])
const practiceResults = ref<Record<string, PracticeResult>>({})
const practiceMode = ref<PracticeMode>('zh_to_en')
const queueIndex = ref(0)
const selected = ref('')
const submittedAnswer = ref('')
const showResult = ref(false)
const isCorrect = ref(false)
const spellCardRef = ref<InstanceType<typeof SpellCard> | null>(null)
const sessionComplete = computed(() =>
isPracticeSessionComplete(wordIds.value, practiceResults.value)
)
const pendingWordIds = computed(() =>
wordIds.value.filter((id) => practiceResults.value[id] === 'pending')
)
const currentWordId = computed(() => {
const pending = pendingWordIds.value
if (pending.length) return pending[queueIndex.value % pending.length]
return wordIds.value[queueIndex.value % Math.max(wordIds.value.length, 1)]
})
const currentWord = computed(() =>
words.value.find((w) => String(w.id) === currentWordId.value)
)
const currentQuestion = computed((): QuizQuestion | null => {
const w = currentWord.value
if (!w || wordPool.value.length < 4) return null
return buildPracticeQuestion(w, wordPool.value, practiceMode.value)
})
const progressText = computed(() => {
const done = wordIds.value.filter(
(id) => practiceResults.value[id] === 'correct' || practiceResults.value[id] === 'wrong'
).length
return `${done} / ${wordIds.value.length} 词已练习`
})
function syncResultsFromStorage() {
practiceResults.value = { ...loadPracticeResults() }
}
async function loadData() {
loading.value = true
try {
wordIds.value = getGraphPracticeWordIds()
if (!wordIds.value.length) {
router.replace('/words')
return
}
syncResultsFromStorage()
const practiceSet = new Set(wordIds.value)
const [wordsRes, vizRes] = await Promise.all([api.listWords(), api.memoryViz()])
const pool = wordsRes.data
wordPool.value = pool
words.value = pool.filter((w) => practiceSet.has(String(w.id)))
const displayIds = resolvePracticeDisplayIds(
wordIds.value,
getGraphPracticeDisplayIds(),
vizRes.data
)
graphNodes.value = buildPracticeGraphNodes(
displayIds,
vizRes.data.graph.nodes,
pool
)
graphLinks.value = buildPracticeGraphLinks(displayIds, vizRes.data.graph.links)
if (pool.length < 4) {
router.replace('/words')
return
}
} finally {
loading.value = false
resetQuestion()
}
}
function resetQuestion() {
selected.value = ''
submittedAnswer.value = ''
showResult.value = false
isCorrect.value = false
startQuestionTimer()
}
function onGraphSelect(id: string) {
const idx = pendingWordIds.value.indexOf(id)
if (idx >= 0) queueIndex.value = idx
else {
const i = wordIds.value.indexOf(id)
if (i >= 0) queueIndex.value = i
}
resetQuestion()
}
async function submitAnswer(answer: string) {
const q = currentQuestion.value
if (!q || showResult.value) return
submittedAnswer.value = answer
const result = await submitQuizAnswer('daily', {
word_id: q.word_id,
question_type: q.question_type,
user_answer: answer,
correct_answer: q.correct_answer,
duration_seconds: consumeDurationSeconds(),
})
const ok =
result.ok && result.is_correct !== undefined
? result.is_correct
: q.question_type === 'spell'
? answer.toLowerCase() === q.correct_answer.toLowerCase()
: answer === q.correct_answer
isCorrect.value = ok
savePracticeResult(String(q.word_id), ok ? 'correct' : 'wrong')
syncResultsFromStorage()
showResult.value = true
}
function onSelect(answer: string) {
selected.value = answer
return submitAnswer(answer)
}
function nextQuestion() {
if (sessionComplete.value) return
const pending = pendingWordIds.value
if (pending.length > 1) {
queueIndex.value = (queueIndex.value + 1) % pending.length
}
resetQuestion()
}
function switchMode(mode: PracticeMode) {
practiceMode.value = mode
resetQuestion()
}
function finishAndBack() {
router.push({ path: '/words', query: { view: 'memory' } })
}
onMounted(loadData)
watch(practiceMode, () => {
if (!loading.value) resetQuestion()
})
</script>
<template>
<div class="practice-page">
<header class="practice-header">
<button type="button" class="btn-back" @click="finishAndBack"> 返回词库</button>
<h1 class="practice-title">关联练习</h1>
<span class="practice-progress">{{ progressText }}</span>
</header>
<p v-if="loading" class="loading-hint">加载中...</p>
<template v-else>
<section class="graph-section card">
<p class="section-hint">
{{ sessionComplete ? '已全部完成 · 节点与连线已按对错着色' : '答对变绿 · 答错变红 · 全部完成后连线变色' }}
</p>
<WordGraphCanvas
v-if="graphNodes.length"
class="practice-graph"
:nodes="graphNodes"
:links="graphLinks"
:practice-results="practiceResults"
:session-complete="sessionComplete"
@select="onGraphSelect"
/>
</section>
<section class="quiz-section card">
<div v-if="sessionComplete" class="done-banner">
<p>本轮练习已完成 🎉</p>
<button type="button" class="btn btn-primary" @click="finishAndBack">返回记忆曲线</button>
</div>
<template v-else>
<div class="mode-tabs">
<button
v-for="m in modes"
:key="m.key"
type="button"
:class="['mode-tab', { active: practiceMode === m.key }]"
@click="switchMode(m.key)"
>
{{ m.label }}
</button>
</div>
<template v-if="currentQuestion && practiceMode !== 'spell'">
<QuizCard
:question="currentQuestion"
:index="queueIndex"
:total="pendingWordIds.length || wordIds.length"
:selected="selected"
:show-result="showResult"
:is-correct="isCorrect"
@select="onSelect"
/>
<button
v-if="showResult"
class="btn btn-primary next-btn"
@click="nextQuestion"
>
下一题
</button>
</template>
<template v-else-if="currentQuestion && practiceMode === 'spell'">
<SpellCard
ref="spellCardRef"
:question="currentQuestion"
:index="queueIndex"
:total="pendingWordIds.length || wordIds.length"
:show-result="showResult"
:is-correct="isCorrect"
:submitted-answer="submittedAnswer"
@submit="onSelect"
/>
<button
v-if="showResult"
class="btn btn-primary next-btn"
@click="nextQuestion"
>
下一题
</button>
</template>
<p v-else class="loading-hint">词库不足无法出题</p>
</template>
</section>
</template>
</div>
</template>
<style scoped>
.practice-page {
display: flex;
flex-direction: column;
min-height: 100%;
padding-bottom: 24px;
}
.practice-header {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.btn-back {
border: none;
background: none;
color: var(--primary);
font-size: 14px;
cursor: pointer;
padding: 0;
}
.practice-title {
font-size: 18px;
margin: 0;
flex: 1;
}
.practice-progress {
font-size: 13px;
color: var(--muted);
}
.graph-section {
flex: 0 0 auto;
margin-bottom: 12px;
padding: 12px;
}
.section-hint {
font-size: 12px;
color: var(--muted);
margin: 0 0 8px;
}
.practice-graph :deep(.graph-wrap) {
min-height: 38vh;
}
.practice-graph :deep(.graph-canvas) {
min-height: 34vh;
}
.quiz-section {
flex: 1;
padding: 12px;
}
.mode-tabs {
display: flex;
gap: 6px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.mode-tab {
flex: 1;
min-width: 90px;
padding: 8px 6px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
font-size: 12px;
cursor: pointer;
}
.mode-tab.active {
border-color: var(--primary);
background: rgba(79, 110, 247, 0.12);
color: var(--primary);
font-weight: 600;
}
.next-btn {
width: 100%;
margin-top: 12px;
}
.done-banner {
text-align: center;
padding: 24px 12px;
}
.done-banner p {
margin-bottom: 16px;
font-size: 16px;
}
.loading-hint {
text-align: center;
color: var(--muted);
padding: 20px;
}
</style>
+165 -101
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue' import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import WordCard from '../components/WordCard.vue' import WordCard from '../components/WordCard.vue'
const MemoryCurveChart = defineAsyncComponent( const MemoryCurveChart = defineAsyncComponent(
@@ -18,10 +18,33 @@ import {
type WordMemoryDetail, type WordMemoryDetail,
} from '../api/request' } from '../api/request'
import { formatTrainSeconds } from '../composables/useQuizTimer' import { formatTrainSeconds } from '../composables/useQuizTimer'
import { LINK_KIND_META, useGraphFilter, type LinkKind } from '../composables/useGraphFilter' import {
import { setPracticeFocusWordId } from '../utils/practiceFocus' buildFocusNodes,
LINK_KIND_META,
useGraphFilter,
type LinkKind,
} from '../composables/useGraphFilter'
import {
getGraphPracticeWordIds,
isPracticeSessionComplete,
loadPracticeResults,
startGraphPractice,
type PracticeResult,
} from '../utils/practiceGraphSession'
const router = useRouter() const router = useRouter()
const route = useRoute()
const graphPracticeResults = ref<Record<string, PracticeResult>>(loadPracticeResults())
const graphSessionComplete = computed(() => {
const ids = getGraphPracticeWordIds()
if (!ids.length) return false
return isPracticeSessionComplete(ids, graphPracticeResults.value)
})
function refreshGraphPracticeResults() {
graphPracticeResults.value = loadPracticeResults()
}
const viewTabs = [ const viewTabs = [
{ key: 'list', label: '单词列表' }, { key: 'list', label: '单词列表' },
@@ -46,6 +69,7 @@ const loading = ref(true)
const vizLoading = ref(false) const vizLoading = ref(false)
const viz = ref<MemoryVisualization | null>(null) const viz = ref<MemoryVisualization | null>(null)
const selectedWord = ref<MemoryWordSummary | null>(null) const selectedWord = ref<MemoryWordSummary | null>(null)
const detailExpanded = ref(true)
const wordMemory = ref<WordMemoryDetail | null>(null) const wordMemory = ref<WordMemoryDetail | null>(null)
const wordMemoryLoading = ref(false) const wordMemoryLoading = ref(false)
@@ -53,6 +77,7 @@ const {
graphStatusFilter, graphStatusFilter,
graphSearch, graphSearch,
graphViewMode, graphViewMode,
focusCenterId,
linkFilters, linkFilters,
filteredGraphNodes, filteredGraphNodes,
filteredGraphLinks, filteredGraphLinks,
@@ -125,7 +150,6 @@ async function loadViz() {
viz.value = data viz.value = data
selectedWord.value = data.words[0] ?? null selectedWord.value = data.words[0] ?? null
if (data.words[0]) { if (data.words[0]) {
setFocusCenter(String(data.words[0].id))
loadWordMemory(data.words[0].id) loadWordMemory(data.words[0].id)
} }
} finally { } finally {
@@ -156,15 +180,47 @@ function onGraphSelect(id: string) {
const w = viz.value?.words.find((x) => String(x.id) === id) const w = viz.value?.words.find((x) => String(x.id) === id)
if (w) { if (w) {
selectedWord.value = w selectedWord.value = w
setFocusCenter(id)
loadWordMemory(w.id) loadWordMemory(w.id)
} }
} }
function goPracticeSpell() { function goGraphPractice(wordIds?: string[]) {
const ids = wordIds ?? filteredGraphNodes.value.map((n) => n.id)
if (!ids.length) return
const total = viz.value?.words.length ?? words.value.length
if (total < 4) {
alert('词库至少 4 个单词才能进行选择题练习')
return
}
startGraphPractice(ids, ids)
router.push('/graph-practice')
}
function graphIdsForPracticeContext(centerId: string): string[] {
if (!viz.value) return [centerId]
if (graphViewMode.value === 'focus' && focusCenterId.value) {
return filteredGraphNodes.value.map((n) => n.id)
}
return buildFocusNodes(
viz.value,
centerId,
graphStatusFilter.value,
graphSearch.value,
linkFilters.value
).map((n) => n.id)
}
function goPracticeSelectedWord() {
if (!selectedWord.value) return if (!selectedWord.value) return
setPracticeFocusWordId(selectedWord.value.id) const centerId = String(selectedWord.value.id)
router.push('/spell') const graphIds = graphIdsForPracticeContext(centerId)
const total = viz.value?.words.length ?? words.value.length
if (total < 4) {
alert('词库至少 4 个单词才能进行选择题练习')
return
}
startGraphPractice([centerId], graphIds)
router.push('/graph-practice')
} }
watch([graphStatusFilter, graphSearch, linkFilters], () => { watch([graphStatusFilter, graphSearch, linkFilters], () => {
@@ -191,9 +247,24 @@ watch(activeTab, () => {
watch(activeView, (v) => { watch(activeView, (v) => {
if (v === 'list') loadWords() if (v === 'list') loadWords()
else loadViz() else {
refreshGraphPracticeResults()
loadViz()
}
}) })
watch(
() => route.query.view,
(v) => {
if (v === 'memory') {
activeView.value = 'memory'
refreshGraphPracticeResults()
if (!viz.value && !vizLoading.value) loadViz()
}
},
{ immediate: true }
)
onMounted(() => { onMounted(() => {
loadWords() loadWords()
}) })
@@ -322,6 +393,14 @@ onMounted(() => {
<div class="graph-mode-row"> <div class="graph-mode-row">
<p class="graph-stats">{{ graphStatsText }}</p> <p class="graph-stats">{{ graphStatsText }}</p>
<div class="graph-mode-actions"> <div class="graph-mode-actions">
<button
v-if="filteredGraphNodes.length"
type="button"
class="btn-mini primary"
@click="goGraphPractice()"
>
开始练习
</button>
<button <button
v-if="graphViewMode === 'focus'" v-if="graphViewMode === 'focus'"
type="button" type="button"
@@ -341,48 +420,60 @@ onMounted(() => {
</div> </div>
</div> </div>
<div class="memory-graph-row"> <p v-if="Object.keys(graphPracticeResults).length" class="practice-color-hint">
<div class="graph-panel"> {{ graphSessionComplete ? '上次练习已全部完成 · 节点与连线按对错着色' : '图中保留上次练习进度 · 答对绿答错红' }}
<WordGraphCanvas </p>
v-if="filteredGraphNodes.length"
:nodes="filteredGraphNodes"
:links="filteredGraphLinks"
@select="onGraphSelect"
/>
<p v-else class="curve-loading">当前筛选无匹配单词请调整条件</p>
<div class="graph-legend"> <WordGraphCanvas
<span v-for="(meta, kind) in LINK_KIND_META" :key="kind" class="legend-item"> v-if="filteredGraphNodes.length"
<span class="link-swatch" :style="{ background: meta.color }" /> :nodes="filteredGraphNodes"
{{ meta.label }}{{ meta.desc }} :links="filteredGraphLinks"
</span> :practice-results="graphPracticeResults"
</div> :session-complete="graphSessionComplete"
@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>
<div class="detail-header-actions">
<button type="button" class="btn-mini primary" @click="goPracticeSelectedWord">
练这个词
</button>
<button type="button" class="detail-toggle" @click="detailExpanded = !detailExpanded">
{{ detailExpanded ? '收起' : '展开' }}
</button>
</div> </div>
</div>
<aside v-if="selectedWord" class="detail-panel card"> <div v-show="detailExpanded" class="detail-expanded">
<h3 class="detail-word-title">{{ selectedWord.en }}</h3> <div class="detail-meta">
<p class="detail-word-zh">{{ selectedWord.zh }}</p> <span>进入词库{{ selectedWord.entered_at.replace('T', ' ').slice(0, 16) }}</span>
<div class="detail-actions"> <span>训练 {{ selectedWord.train_count }} · 累计 {{ formatTrainSeconds(selectedWord.total_train_seconds) }}</span>
<button type="button" class="btn btn-primary btn-sm" @click="goPracticeSpell"> <span>答对 {{ selectedWord.correct_count }} · 答错 {{ selectedWord.wrong_count }}</span>
练这个词 <span>掌握率 {{ selectedWord.mastery_score }}%</span>
</button> <span>当前记忆保留 {{ selectedWord.retention_now }}%</span>
</div> <span>7 日后预测 {{ selectedWord.risk_7d }}%</span>
<div class="detail-meta"> </div>
<span>进入词库{{ selectedWord.entered_at.replace('T', ' ').slice(0, 16) }}</span> <p v-if="wordMemoryLoading" class="curve-loading">加载该词记忆曲线...</p>
<span>训练 {{ selectedWord.train_count }} · {{ formatTrainSeconds(selectedWord.total_train_seconds) }}</span> <template v-else-if="wordCurvePoints.length">
<span>答对 {{ selectedWord.correct_count }} · 答错 {{ selectedWord.wrong_count }}</span> <h4 class="word-curve-title">该单词记忆曲线</h4>
<span>掌握率 {{ selectedWord.mastery_score }}% · 保留 {{ selectedWord.retention_now }}%</span> <p class="section-desc">每次训练后更新 · 点与答题记录对应</p>
</div> <MemoryCurveChart
<p v-if="wordMemoryLoading" class="curve-loading">加载曲线...</p> :curve-points="wordCurvePoints"
<template v-else-if="wordCurvePoints.length"> :future-risk="wordMemory?.future_risk ?? []"
<h4 class="word-curve-title">记忆曲线</h4> />
<MemoryCurveChart </template>
:curve-points="wordCurvePoints" <p v-else class="curve-loading">暂无训练记录完成每日训练或拼写练习后生成曲线</p>
:future-risk="wordMemory?.future_risk ?? []"
/>
</template>
<p v-else class="curve-loading">暂无训练记录</p>
</aside>
</div> </div>
</div> </div>
</template> </template>
@@ -507,6 +598,11 @@ onMounted(() => {
margin: 0; margin: 0;
flex: 1; flex: 1;
} }
.practice-color-hint {
font-size: 12px;
color: var(--muted);
margin: 0 0 8px;
}
.graph-mode-actions { .graph-mode-actions {
flex-shrink: 0; flex-shrink: 0;
} }
@@ -519,62 +615,15 @@ onMounted(() => {
color: var(--primary); color: var(--primary);
cursor: pointer; cursor: pointer;
} }
.memory-graph-row { .btn-mini.primary {
display: flex; border-color: var(--primary);
flex-direction: column; background: rgba(79, 110, 247, 0.1);
gap: 12px;
} }
@media (min-width: 720px) { .detail-header-actions {
.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; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 8px;
flex-shrink: 0;
} }
.word-detail { .word-detail {
margin-top: 12px; margin-top: 12px;
@@ -622,6 +671,21 @@ onMounted(() => {
margin: 14px 0 4px; margin: 14px 0 4px;
color: #1e293b; color: #1e293b;
} }
.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;
}
.curve-loading { .curve-loading {
font-size: 13px; font-size: 13px;
color: var(--muted); color: var(--muted);
+5
View File
@@ -16,6 +16,11 @@ const router = createRouter({
{ path: 'words', name: 'WordLibrary', component: () => import('../pages/WordLibrary.vue') }, { path: 'words', name: 'WordLibrary', component: () => import('../pages/WordLibrary.vue') },
{ path: 'quiz', name: 'DailyQuiz', component: () => import('../pages/DailyQuiz.vue') }, { path: 'quiz', name: 'DailyQuiz', component: () => import('../pages/DailyQuiz.vue') },
{ path: 'spell', name: 'SpellQuiz', component: () => import('../pages/SpellQuiz.vue') }, { path: 'spell', name: 'SpellQuiz', component: () => import('../pages/SpellQuiz.vue') },
{
path: 'graph-practice',
name: 'GraphPractice',
component: () => import('../pages/GraphPractice.vue'),
},
{ path: 'settings', name: 'Settings', component: () => import('../pages/Settings.vue') }, { path: 'settings', name: 'Settings', component: () => import('../pages/Settings.vue') },
], ],
}, },
@@ -0,0 +1,72 @@
import type { QuizOption, QuizQuestion, Word } from '../api/request'
import { spellQuestionFromWord } from './spellQuestion'
function enText(w: Word) {
return w.source_lang === 'en' ? w.source_text : w.target_text
}
function zhText(w: Word) {
return w.source_lang === 'zh' ? w.source_text : w.target_text
}
function shuffle<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
export type PracticeMode = 'en_to_zh' | 'zh_to_en' | 'spell'
export function buildPracticeQuestion(
word: Word,
pool: Word[],
mode: PracticeMode
): QuizQuestion {
if (mode === 'spell') {
return spellQuestionFromWord(word)
}
const others = pool.filter((w) => w.id !== word.id)
if (mode === 'en_to_zh') {
const prompt = enText(word)
const correct = zhText(word)
const distractors = shuffle(
others.map((w) => zhText(w)).filter((t) => t !== correct)
).slice(0, 3)
const optionsText = shuffle([correct, ...distractors])
const labels = ['A', 'B', 'C', 'D']
const options: QuizOption[] = optionsText.slice(0, 4).map((text, i) => ({
label: labels[i],
text,
}))
return {
word_id: word.id,
question_type: 'en_to_zh',
prompt,
options,
correct_answer: correct,
}
}
const prompt = zhText(word)
const correct = enText(word)
const distractors = shuffle(
others.map((w) => enText(w)).filter((t) => t !== correct)
).slice(0, 3)
const optionsText = shuffle([correct, ...distractors])
const labels = ['A', 'B', 'C', 'D']
const options: QuizOption[] = optionsText.slice(0, 4).map((text, i) => ({
label: labels[i],
text,
}))
return {
word_id: word.id,
question_type: 'zh_to_en',
prompt,
options,
correct_answer: correct,
}
}
@@ -0,0 +1,75 @@
import type { MemoryGraphLink, MemoryGraphNode, MemoryVisualization, Word } from '../api/request'
import { buildFocusNodes } from '../composables/useGraphFilter'
import type { LinkKind } from '../composables/useGraphFilter'
const DEFAULT_LINK_FILTERS: Record<LinkKind, boolean> = {
co_review: true,
status: true,
similar: true,
}
function enText(w: Word) {
return w.source_lang === 'en' ? w.source_text : w.target_text
}
function zhText(w: Word) {
return w.source_lang === 'zh' ? w.source_text : w.target_text
}
/** 单词练习时展开为聚焦子图;多词练习则展示全部待练词 */
export function resolvePracticeDisplayIds(
practiceIds: string[],
storedDisplayIds: string[],
viz: MemoryVisualization
): string[] {
if (practiceIds.length > 1) {
const coversAll =
storedDisplayIds.length >= practiceIds.length &&
practiceIds.every((id) => storedDisplayIds.includes(id))
return coversAll ? storedDisplayIds : practiceIds
}
if (practiceIds.length !== 1) return practiceIds
const center = practiceIds[0]
if (storedDisplayIds.length > 1) return storedDisplayIds
return buildFocusNodes(viz, center, '', '', DEFAULT_LINK_FILTERS).map((n) => n.id)
}
export function buildPracticeGraphNodes(
displayIds: string[],
vizNodes: MemoryGraphNode[],
words: Word[]
): MemoryGraphNode[] {
const byId = new Map(vizNodes.map((n) => [n.id, n]))
const out: MemoryGraphNode[] = []
for (const id of displayIds) {
const existing = byId.get(id)
if (existing) {
out.push(existing)
continue
}
const w = words.find((x) => String(x.id) === id)
if (!w) continue
out.push({
id,
label: enText(w).slice(0, 16),
zh: zhText(w).slice(0, 8),
status: w.status,
mastery: w.mastery_score,
entered_at: w.entered_at || w.created_at,
size: 8 + Math.min(w.correct_count + w.wrong_count, 20),
})
}
return out
}
export function buildPracticeGraphLinks(
displayIds: string[],
vizLinks: MemoryGraphLink[]
): MemoryGraphLink[] {
const ids = new Set(displayIds)
return vizLinks.filter((l) => ids.has(l.source) && ids.has(l.target))
}
@@ -0,0 +1,70 @@
export type PracticeResult = 'pending' | 'correct' | 'wrong'
const IDS_KEY = 'wordloop_practice_word_ids'
const GRAPH_IDS_KEY = 'wordloop_practice_graph_ids'
const RESULTS_KEY = 'wordloop_graph_practice_results'
/** @param graphNodeIds 图上展示的节点(可多于待练词,例如单词练习时展示关联子图) */
export function startGraphPractice(wordIds: string[], graphNodeIds?: string[]) {
sessionStorage.setItem(IDS_KEY, JSON.stringify(wordIds))
const display = graphNodeIds?.length ? graphNodeIds : wordIds
sessionStorage.setItem(GRAPH_IDS_KEY, JSON.stringify(display))
const results: Record<string, PracticeResult> = {}
for (const id of wordIds) results[id] = 'pending'
sessionStorage.setItem(RESULTS_KEY, JSON.stringify(results))
}
export function getGraphPracticeDisplayIds(): string[] {
try {
const raw = sessionStorage.getItem(GRAPH_IDS_KEY)
if (!raw) return getGraphPracticeWordIds()
const ids = JSON.parse(raw) as string[]
return Array.isArray(ids) && ids.length ? ids : getGraphPracticeWordIds()
} catch {
return getGraphPracticeWordIds()
}
}
export function getGraphPracticeWordIds(): string[] {
try {
const raw = sessionStorage.getItem(IDS_KEY)
if (!raw) return []
const ids = JSON.parse(raw) as string[]
return Array.isArray(ids) ? ids : []
} catch {
return []
}
}
export function loadPracticeResults(): Record<string, PracticeResult> {
try {
const raw = sessionStorage.getItem(RESULTS_KEY)
if (!raw) return {}
return JSON.parse(raw) as Record<string, PracticeResult>
} catch {
return {}
}
}
export function savePracticeResult(wordId: string, result: 'correct' | 'wrong') {
const all = loadPracticeResults()
all[wordId] = result
sessionStorage.setItem(RESULTS_KEY, JSON.stringify(all))
}
export function isPracticeSessionComplete(
wordIds: string[],
results: Record<string, PracticeResult>
): boolean {
if (!wordIds.length) return false
return wordIds.every((id) => {
const r = results[id]
return r === 'correct' || r === 'wrong'
})
}
export function clearGraphPracticeSession() {
sessionStorage.removeItem(IDS_KEY)
sessionStorage.removeItem(GRAPH_IDS_KEY)
sessionStorage.removeItem(RESULTS_KEY)
}