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">
|
<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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
<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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user