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:
@@ -1,10 +1,15 @@
|
||||
<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 { PracticeResult } from '../utils/practiceGraphSession'
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: MemoryGraphNode[]
|
||||
links: MemoryGraphLink[]
|
||||
/** 练习结果着色:错=红,对=绿,未练=原状态色 */
|
||||
practiceResults?: Record<string, PracticeResult>
|
||||
/** 全部练完后才给连线着色 */
|
||||
sessionComplete?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -15,9 +20,21 @@ 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 hoverId = ref<string | null>(null)
|
||||
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> = {
|
||||
new: '新词',
|
||||
learning: '学习中',
|
||||
@@ -50,6 +67,37 @@ const statusColor: Record<string, string> = {
|
||||
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) {
|
||||
return 6 + n.size * 0.25
|
||||
}
|
||||
@@ -73,7 +121,7 @@ function pointerPos(e: PointerEvent) {
|
||||
function hitNode(x: number, y: number): SimNode | null {
|
||||
for (let i = simNodes.length - 1; i >= 0; 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
|
||||
}
|
||||
return null
|
||||
@@ -179,26 +227,17 @@ function draw() {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(a.x, a.y)
|
||||
ctx.lineTo(b.x, b.y)
|
||||
if (l.kind === 'co_review') {
|
||||
ctx.strokeStyle = 'rgba(79,110,247,0.45)'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.setLineDash([])
|
||||
} 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])
|
||||
}
|
||||
const style = linkStrokeStyle(l)
|
||||
ctx.strokeStyle = style.color
|
||||
ctx.lineWidth = style.width
|
||||
ctx.setLineDash(style.dash)
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
}
|
||||
|
||||
for (const n of simNodes) {
|
||||
const r = nodeRadius(n)
|
||||
const color = statusColor[n.status] || '#64748b'
|
||||
const color = nodeFillColor(n)
|
||||
ctx.beginPath()
|
||||
ctx.arc(n.x, n.y, r, 0, Math.PI * 2)
|
||||
ctx.fillStyle = n.id === selectedId.value ? color : color + 'cc'
|
||||
@@ -226,9 +265,10 @@ function loop() {
|
||||
|
||||
function resize() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas?.parentElement) return
|
||||
width = canvas.parentElement.clientWidth
|
||||
height = 360
|
||||
const wrap = wrapRef.value
|
||||
if (!canvas || !wrap) return
|
||||
width = wrap.clientWidth
|
||||
height = Math.max(280, wrap.clientHeight - 36)
|
||||
canvas.width = width * devicePixelRatio
|
||||
canvas.height = height * devicePixelRatio
|
||||
canvas.style.width = `${width}px`
|
||||
@@ -237,12 +277,46 @@ function resize() {
|
||||
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) {
|
||||
const { x, y } = pointerPos(e)
|
||||
const hit = hitNode(x, y)
|
||||
if (!hit) return
|
||||
|
||||
hoverNode.value = null
|
||||
draggingId = hit.id
|
||||
dragOffsetX = x - hit.x
|
||||
dragOffsetY = y - hit.y
|
||||
@@ -263,6 +337,7 @@ function onPointerMove(e: PointerEvent) {
|
||||
const n = simNodes.find((node) => node.id === draggingId)
|
||||
if (n) {
|
||||
pointerMoved = true
|
||||
hoverId.value = null
|
||||
const c = clampPos(x - dragOffsetX, y - dragOffsetY)
|
||||
n.x = c.x
|
||||
n.y = c.y
|
||||
@@ -273,21 +348,7 @@ function onPointerMove(e: PointerEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
updateHover(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
@@ -302,6 +363,7 @@ function onPointerUp(e: PointerEvent) {
|
||||
}
|
||||
pointerMoved = false
|
||||
cursorStyle.value = 'default'
|
||||
updateHover(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
@@ -309,13 +371,24 @@ function onResize() {
|
||||
if (!draggingId) initSim()
|
||||
}
|
||||
|
||||
function onWrapMouseMove(e: MouseEvent) {
|
||||
updateHover(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
function onWrapMouseLeave() {
|
||||
if (!draggingId) hoverId.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const wrap = wrapRef.value
|
||||
if (!canvas || !wrap) return
|
||||
resize()
|
||||
initSim()
|
||||
loop()
|
||||
window.addEventListener('resize', onResize)
|
||||
wrap.addEventListener('mousemove', onWrapMouseMove)
|
||||
wrap.addEventListener('mouseleave', onWrapMouseLeave)
|
||||
canvas.addEventListener('pointerdown', onPointerDown)
|
||||
canvas.addEventListener('pointermove', onPointerMove)
|
||||
canvas.addEventListener('pointerup', onPointerUp)
|
||||
@@ -323,7 +396,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.nodes, props.links],
|
||||
() => [props.nodes, props.links, props.practiceResults, props.sessionComplete],
|
||||
() => {
|
||||
resize()
|
||||
draggingId = null
|
||||
@@ -336,6 +409,9 @@ onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(animId)
|
||||
window.removeEventListener('resize', onResize)
|
||||
const canvas = canvasRef.value
|
||||
const wrap = wrapRef.value
|
||||
wrap?.removeEventListener('mousemove', onWrapMouseMove)
|
||||
wrap?.removeEventListener('mouseleave', onWrapMouseLeave)
|
||||
if (!canvas) return
|
||||
canvas.removeEventListener('pointerdown', onPointerDown)
|
||||
canvas.removeEventListener('pointermove', onPointerMove)
|
||||
@@ -352,16 +428,17 @@ onBeforeUnmount(() => {
|
||||
:style="{ cursor: cursorStyle }"
|
||||
/>
|
||||
<div
|
||||
v-if="hoverNode"
|
||||
v-if="hoverTip"
|
||||
class="graph-tooltip"
|
||||
: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">
|
||||
{{ statusLabel[hoverNode.status] || hoverNode.status }} · 掌握 {{ hoverNode.mastery }}%
|
||||
{{ statusLabel[hoverTip.status] || hoverTip.status }} · 掌握 {{ hoverTip.mastery }}%
|
||||
</div>
|
||||
<div class="tip-hint">点击查看释义</div>
|
||||
</div>
|
||||
<p class="graph-hint">悬停预览 · 点击选中 · 拖动调整节点位置</p>
|
||||
<p class="graph-hint">悬停看状态 · 点击查看释义 · 拖动调整节点</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -374,10 +451,13 @@ onBeforeUnmount(() => {
|
||||
background: #fafbff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.graph-wrap:has(.graph-tooltip) {
|
||||
overflow: visible;
|
||||
}
|
||||
.graph-tooltip {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
max-width: 200px;
|
||||
z-index: 20;
|
||||
max-width: 240px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(30, 41, 59, 0.92);
|
||||
@@ -394,6 +474,11 @@ onBeforeUnmount(() => {
|
||||
opacity: 0.85;
|
||||
font-size: 11px;
|
||||
}
|
||||
.tip-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.graph-canvas {
|
||||
display: block;
|
||||
touch-action: none;
|
||||
|
||||
@@ -27,7 +27,7 @@ function passesNodeFilter(
|
||||
return n.label.toLowerCase().includes(q) || n.zh.toLowerCase().includes(q)
|
||||
}
|
||||
|
||||
function buildFocusNodes(
|
||||
export function buildFocusNodes(
|
||||
viz: MemoryVisualization,
|
||||
centerId: string,
|
||||
statusFilter: string,
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
const MemoryCurveChart = defineAsyncComponent(
|
||||
@@ -18,10 +18,33 @@ import {
|
||||
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'
|
||||
import {
|
||||
buildFocusNodes,
|
||||
LINK_KIND_META,
|
||||
useGraphFilter,
|
||||
type LinkKind,
|
||||
} from '../composables/useGraphFilter'
|
||||
import {
|
||||
getGraphPracticeWordIds,
|
||||
isPracticeSessionComplete,
|
||||
loadPracticeResults,
|
||||
startGraphPractice,
|
||||
type PracticeResult,
|
||||
} from '../utils/practiceGraphSession'
|
||||
|
||||
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 = [
|
||||
{ key: 'list', label: '单词列表' },
|
||||
@@ -46,6 +69,7 @@ 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)
|
||||
|
||||
@@ -53,6 +77,7 @@ const {
|
||||
graphStatusFilter,
|
||||
graphSearch,
|
||||
graphViewMode,
|
||||
focusCenterId,
|
||||
linkFilters,
|
||||
filteredGraphNodes,
|
||||
filteredGraphLinks,
|
||||
@@ -125,7 +150,6 @@ async function loadViz() {
|
||||
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 {
|
||||
@@ -156,15 +180,47 @@ 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() {
|
||||
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
|
||||
setPracticeFocusWordId(selectedWord.value.id)
|
||||
router.push('/spell')
|
||||
const centerId = String(selectedWord.value.id)
|
||||
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], () => {
|
||||
@@ -191,9 +247,24 @@ watch(activeTab, () => {
|
||||
|
||||
watch(activeView, (v) => {
|
||||
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(() => {
|
||||
loadWords()
|
||||
})
|
||||
@@ -322,6 +393,14 @@ onMounted(() => {
|
||||
<div class="graph-mode-row">
|
||||
<p class="graph-stats">{{ graphStatsText }}</p>
|
||||
<div class="graph-mode-actions">
|
||||
<button
|
||||
v-if="filteredGraphNodes.length"
|
||||
type="button"
|
||||
class="btn-mini primary"
|
||||
@click="goGraphPractice()"
|
||||
>
|
||||
开始练习
|
||||
</button>
|
||||
<button
|
||||
v-if="graphViewMode === 'focus'"
|
||||
type="button"
|
||||
@@ -341,48 +420,60 @@ onMounted(() => {
|
||||
</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>
|
||||
<p v-if="Object.keys(graphPracticeResults).length" class="practice-color-hint">
|
||||
{{ graphSessionComplete ? '上次练习已全部完成 · 节点与连线按对错着色' : '图中保留上次练习进度 · 答对绿、答错红' }}
|
||||
</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>
|
||||
<WordGraphCanvas
|
||||
v-if="filteredGraphNodes.length"
|
||||
:nodes="filteredGraphNodes"
|
||||
:links="filteredGraphLinks"
|
||||
:practice-results="graphPracticeResults"
|
||||
: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>
|
||||
|
||||
<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 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>
|
||||
<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 ?? []"
|
||||
/>
|
||||
</template>
|
||||
<p v-else class="curve-loading">暂无训练记录,完成每日训练或拼写练习后生成曲线</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -507,6 +598,11 @@ onMounted(() => {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.practice-color-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.graph-mode-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -519,62 +615,15 @@ onMounted(() => {
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.memory-graph-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
.btn-mini.primary {
|
||||
border-color: var(--primary);
|
||||
background: rgba(79, 110, 247, 0.1);
|
||||
}
|
||||
@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);
|
||||
.detail-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.word-detail {
|
||||
margin-top: 12px;
|
||||
@@ -622,6 +671,21 @@ onMounted(() => {
|
||||
margin: 14px 0 4px;
|
||||
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 {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -16,6 +16,11 @@ const router = createRouter({
|
||||
{ path: 'words', name: 'WordLibrary', component: () => import('../pages/WordLibrary.vue') },
|
||||
{ path: 'quiz', name: 'DailyQuiz', component: () => import('../pages/DailyQuiz.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') },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user