Add spell practice, memory analytics, auth persistence, and word library UX.

Includes per-word training stats and curves, quiz session auto-save, remember-login,
paginated word list with floating page arrows, and Obsidian-style relationship graph baseline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-04 14:57:39 -07:00
parent bd7635986a
commit 68c5efe573
30 changed files with 2560 additions and 63 deletions
@@ -0,0 +1,116 @@
<script setup lang="ts">
import * as echarts from 'echarts'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { MemoryCurvePoint, MemoryFutureRiskPoint } from '../api/request'
const props = defineProps<{
curvePoints: MemoryCurvePoint[]
futureRisk: MemoryFutureRiskPoint[]
}>()
const chartRef = ref<HTMLDivElement | null>(null)
let chart: echarts.ECharts | null = null
function render() {
if (!chartRef.value) return
if (!chart) chart = echarts.init(chartRef.value)
const dates = props.curvePoints.map((p) => p.date)
const futureDates = props.futureRisk.map((p) => p.date)
const allDates = [...dates, ...futureDates.slice(1)]
const forgetting = props.curvePoints.map((p) => p.forgetting)
const mastery = props.curvePoints.map((p) => p.mastery)
const riskHist = props.curvePoints.map((p) => p.risk)
const riskFuture = [
...Array(Math.max(0, dates.length - 1)).fill(null),
props.curvePoints.length ? props.curvePoints[props.curvePoints.length - 1].risk : null,
...props.futureRisk.map((p) => p.risk),
]
chart.setOption({
tooltip: { trigger: 'axis' },
legend: {
data: ['遗忘曲线', '熟练曲线', '可能遗忘(历史)', '可能遗忘(预测)'],
bottom: 0,
textStyle: { fontSize: 11 },
},
grid: { left: 48, right: 16, top: 24, bottom: 56 },
xAxis: {
type: 'category',
data: allDates,
axisLabel: { rotate: 35, fontSize: 10 },
},
yAxis: {
type: 'value',
min: 0,
max: 100,
name: '记忆指数 %',
nameTextStyle: { fontSize: 11 },
},
series: [
{
name: '遗忘曲线',
type: 'line',
smooth: true,
data: [...forgetting, ...Array(futureDates.length).fill(null)],
lineStyle: { color: '#ef4444', width: 2 },
itemStyle: { color: '#ef4444' },
areaStyle: { color: 'rgba(239,68,68,0.08)' },
},
{
name: '熟练曲线',
type: 'line',
smooth: true,
data: [...mastery, ...Array(futureDates.length).fill(null)],
lineStyle: { color: '#22c55e', width: 2 },
itemStyle: { color: '#22c55e' },
},
{
name: '可能遗忘(历史)',
type: 'line',
smooth: true,
data: [...riskHist, ...Array(futureDates.length).fill(null)],
lineStyle: { color: '#f59e0b', width: 2, type: 'dashed' },
itemStyle: { color: '#f59e0b' },
},
{
name: '可能遗忘(预测)',
type: 'line',
smooth: true,
data: riskFuture,
lineStyle: { color: '#a855f7', width: 2, type: 'dotted' },
itemStyle: { color: '#a855f7' },
},
],
})
}
function onResize() {
chart?.resize()
}
onMounted(() => {
render()
window.addEventListener('resize', onResize)
})
watch(() => [props.curvePoints, props.futureRisk], render, { deep: true })
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
chart?.dispose()
chart = null
})
</script>
<template>
<div ref="chartRef" class="memory-chart" />
</template>
<style scoped>
.memory-chart {
width: 100%;
height: 280px;
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { QuizQuestion } from '../api/request'
const props = defineProps<{
question: QuizQuestion
index: number
total: number
showResult?: boolean
isCorrect?: boolean
submittedAnswer?: string
}>()
const emit = defineEmits<{
submit: [answer: string]
}>()
const input = ref('')
watch(
() => props.index,
() => {
input.value = ''
}
)
function onSubmit() {
const answer = input.value.trim()
if (!answer || props.showResult) return
emit('submit', answer)
}
defineExpose({
getDraft: () => input.value.trim(),
})
</script>
<template>
<div class="card quiz-card">
<div class="quiz-progress">{{ index + 1 }} / {{ total }}</div>
<div class="quiz-type">根据中文与读音拼写英文</div>
<div class="quiz-prompt">{{ question.prompt }}</div>
<div v-if="question.phonetic" class="phonetic">{{ question.phonetic }}</div>
<div v-else class="phonetic muted">暂无音标</div>
<input
v-model="input"
class="spell-input"
type="text"
placeholder="输入英文单词"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
:disabled="showResult"
@keydown.enter="onSubmit"
/>
<button
v-if="!showResult"
class="btn btn-primary submit-btn"
:disabled="!input.trim()"
@click="onSubmit"
>
提交
</button>
<div v-if="showResult" class="result" :class="isCorrect ? 'ok' : 'fail'">
{{ isCorrect ? '拼写正确 ' : '拼写错误 ' }}
<template v-if="!isCorrect">
你的答案{{ submittedAnswer }} · 正确答案{{ question.correct_answer }}
</template>
</div>
</div>
</template>
<style scoped>
.quiz-progress {
font-size: 13px;
color: var(--muted);
margin-bottom: 8px;
}
.quiz-type {
font-size: 12px;
color: var(--primary);
margin-bottom: 8px;
}
.quiz-prompt {
font-size: 28px;
font-weight: 700;
text-align: center;
margin: 20px 0 8px;
}
.phonetic {
text-align: center;
font-size: 18px;
color: var(--primary);
margin-bottom: 20px;
}
.phonetic.muted {
color: var(--muted);
font-size: 14px;
}
.spell-input {
width: 100%;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 18px;
text-align: center;
box-sizing: border-box;
}
.spell-input:focus {
outline: none;
border-color: var(--primary);
}
.spell-input:disabled {
background: #f3f4f6;
}
.submit-btn {
width: 100%;
margin-top: 12px;
}
.result {
margin-top: 16px;
padding: 12px;
border-radius: var(--radius);
font-size: 14px;
text-align: center;
line-height: 1.5;
}
.result.ok {
background: #d1fae5;
color: #047857;
}
.result.fail {
background: #fee2e2;
color: #b91c1c;
}
</style>
+16 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Word } from '../api/request'
import { formatTrainSeconds } from '../composables/useQuizTimer'
defineProps<{
word: Word
@@ -23,6 +24,12 @@ function enText(word: Word) {
function zhText(word: Word) {
return word.source_lang === 'zh' ? word.source_text : word.target_text
}
function formatEnteredAt(iso: string) {
if (!iso) return '—'
const d = iso.replace('T', ' ').replace('Z', '')
return d.length >= 16 ? d.slice(0, 16) : d.slice(0, 10)
}
</script>
<template>
@@ -35,11 +42,14 @@ function zhText(word: Word) {
<button class="btn btn-danger" @click="$emit('delete', word.id)">删除</button>
</div>
<div class="word-meta">
<span>训练 {{ word.train_count ?? 0 }} </span>
<span>答对 {{ word.correct_count }}</span>
<span>答错 {{ word.wrong_count }}</span>
<span>连续 {{ word.consecutive_correct_count }}</span>
<span>掌握率 {{ word.mastery_score }}%</span>
<span v-if="word.total_train_seconds">用时 {{ formatTrainSeconds(word.total_train_seconds) }}</span>
</div>
<div class="word-entered">进入词库{{ formatEnteredAt(word.entered_at || word.created_at) }}</div>
<div v-if="word.review_due_date" class="word-due">下次复习{{ word.review_due_date }}</div>
</div>
</template>
@@ -64,9 +74,14 @@ function zhText(word: Word) {
color: var(--muted);
margin-top: 10px;
}
.word-entered {
font-size: 12px;
color: var(--muted);
margin-top: 8px;
}
.word-due {
font-size: 12px;
color: var(--primary);
margin-top: 6px;
margin-top: 4px;
}
</style>
+335
View File
@@ -0,0 +1,335 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { MemoryGraphLink, MemoryGraphNode } from '../api/request'
const props = defineProps<{
nodes: MemoryGraphNode[]
links: MemoryGraphLink[]
}>()
const emit = defineEmits<{
select: [id: string]
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
const selectedId = ref<string | null>(null)
const cursorStyle = ref('default')
interface SimNode extends MemoryGraphNode {
x: number
y: number
vx: number
vy: number
pinned?: boolean
}
let simNodes: SimNode[] = []
let animId = 0
let width = 0
let height = 0
let draggingId: string | null = null
let dragOffsetX = 0
let dragOffsetY = 0
let pointerMoved = false
const statusColor: Record<string, string> = {
new: '#94a3b8',
learning: '#4f6ef7',
mastered: '#22c55e',
weak: '#ef4444',
}
function nodeRadius(n: SimNode) {
return 6 + n.size * 0.25
}
function clampPos(x: number, y: number) {
return {
x: Math.max(24, Math.min(width - 24, x)),
y: Math.max(24, Math.min(height - 24, y)),
}
}
function pointerPos(e: PointerEvent) {
const canvas = canvasRef.value!
const rect = canvas.getBoundingClientRect()
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
}
}
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
if ((x - n.x) ** 2 + (y - n.y) ** 2 <= r * r) return n
}
return null
}
function initSim() {
simNodes = props.nodes.map((n, i) => {
const angle = (i / Math.max(props.nodes.length, 1)) * Math.PI * 2
const r = Math.min(width, height) * 0.28
return {
...n,
x: width / 2 + Math.cos(angle) * r,
y: height / 2 + Math.sin(angle) * r,
vx: 0,
vy: 0,
}
})
}
function tick() {
const centerX = width / 2
const centerY = height / 2
for (let i = 0; i < simNodes.length; i++) {
for (let j = i + 1; j < simNodes.length; j++) {
const a = simNodes[i]
const b = simNodes[j]
if (a.id === draggingId || b.id === draggingId) continue
let dx = a.x - b.x
let dy = a.y - b.y
let dist = Math.sqrt(dx * dx + dy * dy) || 1
const repulse = (12000 / (dist * dist)) * 0.016
dx /= dist
dy /= dist
a.vx += dx * repulse
a.vy += dy * repulse
b.vx -= dx * repulse
b.vy -= dy * repulse
}
}
for (const l of props.links) {
const a = simNodes.find((n) => n.id === l.source)
const b = simNodes.find((n) => n.id === l.target)
if (!a || !b) continue
if (a.id === draggingId) {
const dx = b.x - a.x
const dy = b.y - a.y
const dist = Math.sqrt(dx * dx + dy * dy) || 1
const pull = (dist - 90) * 0.004 * l.strength
b.vx -= (dx / dist) * pull * 0.5
b.vy -= (dy / dist) * pull * 0.5
continue
}
if (b.id === draggingId) {
const dx = b.x - a.x
const dy = b.y - a.y
const dist = Math.sqrt(dx * dx + dy * dy) || 1
const pull = (dist - 90) * 0.004 * l.strength
a.vx += (dx / dist) * pull * 0.5
a.vy += (dy / dist) * pull * 0.5
continue
}
const dx = b.x - a.x
const dy = b.y - a.y
const dist = Math.sqrt(dx * dx + dy * dy) || 1
const pull = (dist - 90) * 0.004 * l.strength
a.vx += (dx / dist) * pull
a.vy += (dy / dist) * pull
b.vx -= (dx / dist) * pull
b.vy -= (dy / dist) * pull
}
for (const n of simNodes) {
if (n.id === draggingId) continue
n.vx += (centerX - n.x) * 0.0008
n.vy += (centerY - n.y) * 0.0008
n.vx *= 0.86
n.vy *= 0.86
n.x += n.vx
n.y += n.vy
const c = clampPos(n.x, n.y)
n.x = c.x
n.y = c.y
}
}
function draw() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, width, height)
for (const l of props.links) {
const a = simNodes.find((n) => n.id === l.source)
const b = simNodes.find((n) => n.id === l.target)
if (!a || !b) continue
ctx.beginPath()
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
ctx.strokeStyle =
l.kind === 'co_review' ? 'rgba(79,110,247,0.35)' : 'rgba(148,163,184,0.25)'
ctx.lineWidth = l.kind === 'co_review' ? 1.5 : 1
ctx.stroke()
}
for (const n of simNodes) {
const r = nodeRadius(n)
const color = statusColor[n.status] || '#64748b'
ctx.beginPath()
ctx.arc(n.x, n.y, r, 0, Math.PI * 2)
ctx.fillStyle = n.id === selectedId.value ? color : color + 'cc'
ctx.fill()
if (n.id === selectedId.value) {
ctx.strokeStyle = '#1e293b'
ctx.lineWidth = 2
ctx.stroke()
}
ctx.fillStyle = '#1e293b'
ctx.font = '10px system-ui'
ctx.textAlign = 'center'
ctx.fillText(n.label, n.x, n.y + r + 11)
}
}
function loop() {
tick()
draw()
animId = requestAnimationFrame(loop)
}
function resize() {
const canvas = canvasRef.value
if (!canvas?.parentElement) return
width = canvas.parentElement.clientWidth
height = 320
canvas.width = width * devicePixelRatio
canvas.height = height * devicePixelRatio
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const ctx = canvas.getContext('2d')
if (ctx) ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
}
function onPointerDown(e: PointerEvent) {
const { x, y } = pointerPos(e)
const hit = hitNode(x, y)
if (!hit) return
draggingId = hit.id
dragOffsetX = x - hit.x
dragOffsetY = y - hit.y
hit.vx = 0
hit.vy = 0
pointerMoved = false
selectedId.value = hit.id
emit('select', hit.id)
cursorStyle.value = 'grabbing'
canvasRef.value?.setPointerCapture(e.pointerId)
e.preventDefault()
}
function onPointerMove(e: PointerEvent) {
const { x, y } = pointerPos(e)
if (draggingId) {
const n = simNodes.find((node) => node.id === draggingId)
if (n) {
pointerMoved = true
const c = clampPos(x - dragOffsetX, y - dragOffsetY)
n.x = c.x
n.y = c.y
n.vx = 0
n.vy = 0
}
e.preventDefault()
return
}
cursorStyle.value = hitNode(x, y) ? 'grab' : 'default'
}
function onPointerUp(e: PointerEvent) {
if (draggingId) {
const n = simNodes.find((node) => node.id === draggingId)
if (n && !pointerMoved) {
selectedId.value = n.id
emit('select', n.id)
}
draggingId = null
canvasRef.value?.releasePointerCapture(e.pointerId)
}
pointerMoved = false
cursorStyle.value = 'default'
}
function onResize() {
resize()
if (!draggingId) initSim()
}
onMounted(() => {
const canvas = canvasRef.value
if (!canvas) return
resize()
initSim()
loop()
window.addEventListener('resize', onResize)
canvas.addEventListener('pointerdown', onPointerDown)
canvas.addEventListener('pointermove', onPointerMove)
canvas.addEventListener('pointerup', onPointerUp)
canvas.addEventListener('pointercancel', onPointerUp)
})
watch(
() => [props.nodes, props.links],
() => {
resize()
draggingId = null
initSim()
},
{ deep: true }
)
onBeforeUnmount(() => {
cancelAnimationFrame(animId)
window.removeEventListener('resize', onResize)
const canvas = canvasRef.value
if (!canvas) return
canvas.removeEventListener('pointerdown', onPointerDown)
canvas.removeEventListener('pointermove', onPointerMove)
canvas.removeEventListener('pointerup', onPointerUp)
canvas.removeEventListener('pointercancel', onPointerUp)
})
</script>
<template>
<div class="graph-wrap">
<canvas
ref="canvasRef"
class="graph-canvas"
:style="{ cursor: cursorStyle }"
/>
<p class="graph-hint">拖动节点调整位置画布固定· 点击节点查看单词</p>
</div>
</template>
<style scoped>
.graph-wrap {
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fafbff;
overflow: hidden;
}
.graph-canvas {
display: block;
touch-action: none;
user-select: none;
}
.graph-hint {
font-size: 11px;
color: var(--muted);
padding: 8px 12px;
margin: 0;
border-top: 1px solid var(--border);
}
</style>