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
+26
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"axios": "^1.7.9",
"echarts": "^6.1.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
@@ -1226,6 +1227,16 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -1740,6 +1751,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
@@ -1888,6 +1905,15 @@
"peerDependencies": {
"typescript": ">=5.0.0"
}
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
}
}
}
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"axios": "^1.7.9",
"echarts": "^6.1.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
+91 -4
View File
@@ -1,4 +1,5 @@
import axios from 'axios'
import { clearAuth, getToken } from '../utils/auth'
const request = axios.create({
baseURL: '/api',
@@ -6,7 +7,7 @@ const request = axios.create({
})
request.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
@@ -17,7 +18,7 @@ request.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
clearAuth()
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login'
}
@@ -42,9 +43,89 @@ export interface Word {
wrong_count: number
consecutive_correct_count: number
mastery_score: number
train_count?: number
total_train_seconds?: number
review_due_date?: string
last_reviewed_at?: string
created_at: string
entered_at: string
}
export interface MemoryCurvePoint {
day_index: number
date: string
forgetting: number
mastery: number
risk: number
}
export interface MemoryFutureRiskPoint {
day_offset: number
date: string
risk: number
}
export interface MemoryGraphNode {
id: string
label: string
zh: string
status: string
mastery: number
entered_at: string
size: number
}
export interface MemoryGraphLink {
source: string
target: string
kind: string
strength: number
}
export interface MemoryWordSummary {
id: number
en: string
zh: string
status: string
mastery_score: number
correct_count: number
wrong_count: number
train_count: number
total_train_seconds: number
entered_at: string
retention_now: number
risk_7d: number
}
export interface WordMemoryCurvePoint {
date: string
datetime: string
forgetting: number
mastery: number
risk: number
wrong_count: number
train_count: number
train_seconds: number
is_correct?: boolean | null
}
export interface WordMemoryDetail {
word_id: number
en: string
zh: string
correct_count: number
wrong_count: number
train_count: number
total_train_seconds: number
curve_points: WordMemoryCurvePoint[]
future_risk: MemoryFutureRiskPoint[]
}
export interface MemoryVisualization {
curve_points: MemoryCurvePoint[]
future_risk: MemoryFutureRiskPoint[]
words: MemoryWordSummary[]
graph: { nodes: MemoryGraphNode[]; links: MemoryGraphLink[] }
}
export interface TranslateResult {
@@ -68,6 +149,7 @@ export interface QuizQuestion {
prompt: string
options: QuizOption[]
correct_answer: string
phonetic?: string
}
export interface QuizStats {
@@ -93,21 +175,26 @@ export interface Settings {
export const api = {
register: (username: string, password: string) =>
request.post('/auth/register', { username, password }),
login: (username: string, password: string) =>
request.post<{ access_token: string }>('/auth/login', { username, password }),
login: (username: string, password: string, remember = true) =>
request.post<{ access_token: string }>('/auth/login', { username, password, remember }),
me: () => request.get('/auth/me'),
translate: (text: string) => request.post<TranslateResult>('/translate', { text }),
createWord: (data: Partial<Word>) => request.post<Word>('/words', data),
listWords: (status?: string) =>
request.get<Word[]>('/words', { params: status ? { status } : {} }),
memoryViz: () => request.get<MemoryVisualization>('/words/memory-viz'),
wordMemory: (id: number) => request.get<WordMemoryDetail>(`/words/${id}/memory`),
deleteWord: (id: number) => request.delete(`/words/${id}`),
dailyQuiz: () =>
request.get<{ questions: QuizQuestion[]; total: number }>('/quiz/daily'),
spellQuiz: () =>
request.get<{ questions: QuizQuestion[]; total: number }>('/quiz/spell'),
submitAnswer: (data: {
word_id: number
question_type: string
user_answer: string
correct_answer: string
duration_seconds?: number
}) => request.post('/quiz/answer', data),
quizStats: () => request.get<QuizStats>('/quiz/stats'),
getSettings: () => request.get<Settings>('/settings'),
@@ -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>
+245
View File
@@ -0,0 +1,245 @@
import { onBeforeUnmount, onMounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import type { QuizQuestion } from '../api/request'
import { getToken } from '../utils/auth'
export type QuizMode = 'daily' | 'spell'
export interface QuizSessionSnapshot {
questions: QuizQuestion[]
currentIndex: number
sessionCorrect: number
sessionWrong: number
finished: boolean
selected?: string
showResult?: boolean
isCorrect?: boolean
submittedAnswer?: string
}
interface StoredSession extends QuizSessionSnapshot {
date: string
updatedAt: number
}
export interface PendingAnswer {
word_id: number
question_type: string
user_answer: string
correct_answer: string
duration_seconds?: number
}
function sessionKey(mode: QuizMode) {
return `wordloop_quiz_session_${mode}`
}
function pendingKey(mode: QuizMode) {
return `wordloop_quiz_pending_${mode}`
}
function todayStr() {
return new Date().toISOString().slice(0, 10)
}
function readJson<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key)
if (!raw) return null
return JSON.parse(raw) as T
} catch {
return null
}
}
function writeJson(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
/* quota or private mode */
}
}
export function saveQuizSession(mode: QuizMode, snapshot: QuizSessionSnapshot) {
if (!snapshot.questions.length) return
const stored: StoredSession = {
...snapshot,
date: todayStr(),
updatedAt: Date.now(),
}
writeJson(sessionKey(mode), stored)
}
export function loadQuizSession(mode: QuizMode): StoredSession | null {
const stored = readJson<StoredSession>(sessionKey(mode))
if (!stored?.questions?.length) return null
if (stored.date !== todayStr()) {
clearQuizSession(mode)
return null
}
return stored
}
export function clearQuizSession(mode: QuizMode) {
localStorage.removeItem(sessionKey(mode))
localStorage.removeItem(pendingKey(mode))
}
function getPendingList(mode: QuizMode): PendingAnswer[] {
return readJson<PendingAnswer[]>(pendingKey(mode)) ?? []
}
function setPendingList(mode: QuizMode, list: PendingAnswer[]) {
if (list.length === 0) {
localStorage.removeItem(pendingKey(mode))
} else {
writeJson(pendingKey(mode), list)
}
}
export function enqueuePendingAnswer(mode: QuizMode, payload: PendingAnswer) {
const list = getPendingList(mode)
const exists = list.some(
(p) => p.word_id === payload.word_id && p.question_type === payload.question_type
)
if (!exists) {
list.push(payload)
setPendingList(mode, list)
}
}
function postAnswerKeepalive(payload: PendingAnswer) {
const token = getToken()
if (!token) return
fetch('/api/quiz/answer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
keepalive: true,
}).catch(() => {})
}
export async function flushPendingAnswers(mode: QuizMode): Promise<void> {
const list = getPendingList(mode)
if (!list.length) return
const remaining: PendingAnswer[] = []
for (const item of list) {
try {
const token = getToken()
const res = await fetch('/api/quiz/answer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(item),
})
if (!res.ok) remaining.push(item)
} catch {
remaining.push(item)
}
}
setPendingList(mode, remaining)
}
export function flushPendingAnswersKeepalive(mode: QuizMode) {
for (const item of getPendingList(mode)) {
postAnswerKeepalive(item)
}
localStorage.removeItem(pendingKey(mode))
}
export async function submitQuizAnswer(
mode: QuizMode,
payload: PendingAnswer
): Promise<{ ok: boolean; is_correct?: boolean; correct_answer?: string }> {
const token = getToken()
try {
const res = await fetch('/api/quiz/answer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error('submit failed')
const data = await res.json()
return { ok: true, is_correct: data.is_correct, correct_answer: data.correct_answer }
} catch {
enqueuePendingAnswer(mode, payload)
return { ok: false }
}
}
export interface QuizAutoSaveOptions {
/** 拼写题:退出时若有未提交输入则自动提交 */
getDraftAnswer?: () => string
onDraftSubmit?: (answer: string) => void | Promise<void>
}
export function useQuizAutoSave(
mode: QuizMode,
getSnapshot: () => QuizSessionSnapshot | null,
options: QuizAutoSaveOptions = {}
) {
const persist = () => {
const snap = getSnapshot()
if (!snap) return
if (snap.finished) {
clearQuizSession(mode)
return
}
saveQuizSession(mode, snap)
}
const flushDraft = async () => {
const draft = options.getDraftAnswer?.()?.trim()
if (!draft || !options.onDraftSubmit) return
await options.onDraftSubmit(draft)
}
const onInterrupt = async () => {
await flushDraft()
persist()
await flushPendingAnswers(mode)
}
const onInterruptSync = () => {
const draft = options.getDraftAnswer?.()?.trim()
if (draft && options.onDraftSubmit) {
void options.onDraftSubmit(draft)
}
persist()
flushPendingAnswersKeepalive(mode)
}
onMounted(() => {
const handleVisibility = () => {
if (document.visibilityState === 'hidden') void onInterrupt()
}
const handlePageHide = () => onInterruptSync()
const handleBeforeUnload = () => onInterruptSync()
document.addEventListener('visibilitychange', handleVisibility)
window.addEventListener('pagehide', handlePageHide)
window.addEventListener('beforeunload', handleBeforeUnload)
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', handleVisibility)
window.removeEventListener('pagehide', handlePageHide)
window.removeEventListener('beforeunload', handleBeforeUnload)
void onInterrupt()
})
})
onBeforeRouteLeave(async () => {
await onInterrupt()
})
return { persist, flushPending: () => flushPendingAnswers(mode) }
}
+26
View File
@@ -0,0 +1,26 @@
import { ref } from 'vue'
const startedAt = ref<number | null>(null)
export function useQuizTimer() {
function startQuestionTimer() {
startedAt.value = Date.now()
}
function consumeDurationSeconds(): number {
if (startedAt.value == null) return 0
const sec = Math.round((Date.now() - startedAt.value) / 1000)
startedAt.value = null
if (sec <= 0) return 1
return Math.min(sec, 3600)
}
return { startQuestionTimer, consumeDurationSeconds }
}
export function formatTrainSeconds(total: number): string {
if (total < 60) return `${total}`
const m = Math.floor(total / 60)
const s = total % 60
return s ? `${m}${s}` : `${m} 分钟`
}
+109 -20
View File
@@ -1,7 +1,17 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, ref, watch } from 'vue'
import QuizCard from '../components/QuizCard.vue'
import { api, type QuizQuestion } from '../api/request'
import {
clearQuizSession,
loadQuizSession,
submitQuizAnswer,
useQuizAutoSave,
type QuizSessionSnapshot,
} from '../composables/useQuizSession'
import { useQuizTimer } from '../composables/useQuizTimer'
const { startQuestionTimer, consumeDurationSeconds } = useQuizTimer()
const questions = ref<QuizQuestion[]>([])
const currentIndex = ref(0)
@@ -13,18 +23,63 @@ const finished = ref(false)
const sessionCorrect = ref(0)
const sessionWrong = ref(0)
const empty = ref(false)
const restored = ref(false)
const current = () => questions.value[currentIndex.value]
function buildSnapshot(): QuizSessionSnapshot | null {
if (!questions.value.length) return null
return {
questions: questions.value,
currentIndex: currentIndex.value,
sessionCorrect: sessionCorrect.value,
sessionWrong: sessionWrong.value,
finished: finished.value,
selected: selected.value,
showResult: showResult.value,
isCorrect: isCorrect.value,
}
}
const { persist, flushPending } = useQuizAutoSave('daily', buildSnapshot)
function applySnapshot(snap: QuizSessionSnapshot) {
questions.value = snap.questions
currentIndex.value = snap.currentIndex
sessionCorrect.value = snap.sessionCorrect
sessionWrong.value = snap.sessionWrong
finished.value = snap.finished
selected.value = snap.selected ?? ''
showResult.value = snap.showResult ?? false
isCorrect.value = snap.isCorrect ?? false
}
onMounted(async () => {
try {
const { data } = await api.dailyQuiz()
questions.value = data.questions
if (data.questions.length === 0) {
empty.value = true
const saved = loadQuizSession('daily')
if (saved && !saved.finished) {
applySnapshot(saved)
restored.value = true
await flushPending()
} else {
clearQuizSession('daily')
const { data } = await api.dailyQuiz()
questions.value = data.questions
if (data.questions.length === 0) {
empty.value = true
} else {
persist()
}
}
} finally {
loading.value = false
if (current() && !showResult.value) startQuestionTimer()
}
})
watch(currentIndex, () => {
if (!loading.value && !finished.value && current() && !showResult.value) {
startQuestionTimer()
}
})
@@ -34,33 +89,39 @@ async function onSelect(answer: string) {
const q = current()
if (!q) return
try {
const { data } = await api.submitAnswer({
word_id: q.word_id,
question_type: q.question_type,
user_answer: answer,
correct_answer: q.correct_answer,
})
isCorrect.value = data.is_correct
if (data.is_correct) sessionCorrect.value++
else sessionWrong.value++
} catch {
isCorrect.value = answer === q.correct_answer
if (isCorrect.value) sessionCorrect.value++
else sessionWrong.value++
const payload = {
word_id: q.word_id,
question_type: q.question_type,
user_answer: answer,
correct_answer: q.correct_answer,
duration_seconds: consumeDurationSeconds(),
}
const result = await submitQuizAnswer('daily', payload)
if (result.ok && result.is_correct !== undefined) {
isCorrect.value = result.is_correct
} else {
isCorrect.value = answer === q.correct_answer
}
if (isCorrect.value) sessionCorrect.value++
else sessionWrong.value++
showResult.value = true
persist()
}
function nextQuestion() {
if (currentIndex.value >= questions.value.length - 1) {
finished.value = true
clearQuizSession('daily')
return
}
currentIndex.value++
selected.value = ''
showResult.value = false
isCorrect.value = false
startQuestionTimer()
persist()
}
const accuracy = () => {
@@ -71,7 +132,14 @@ const accuracy = () => {
<template>
<div class="page">
<h1 class="page-title">每日训练</h1>
<div class="page-header">
<h1 class="page-title">每日训练</h1>
<router-link to="/spell" class="spell-link">拼写练习 </router-link>
</div>
<p v-if="restored && !loading && !finished && !empty" class="restore-hint">
已恢复上次未完成的训练进度
</p>
<p v-if="loading" style="color: var(--muted)">加载题目...</p>
@@ -114,6 +182,27 @@ const accuracy = () => {
</template>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 4px;
}
.page-header .page-title {
margin: 0;
}
.spell-link {
font-size: 14px;
color: var(--primary);
text-decoration: none;
white-space: nowrap;
}
.restore-hint {
font-size: 13px;
color: var(--primary);
margin-bottom: 12px;
}
.summary h2 {
margin-bottom: 12px;
font-size: 20px;
+1
View File
@@ -61,6 +61,7 @@ onMounted(async () => {
<router-link to="/translate" class="btn btn-outline">去翻译</router-link>
<router-link to="/words" class="btn btn-outline">单词库</router-link>
<router-link to="/quiz" class="btn btn-primary">开始每日训练</router-link>
<router-link to="/spell" class="btn btn-outline">拼写练习</router-link>
</div>
</template>
</div>
+37 -5
View File
@@ -1,14 +1,21 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/request'
import { getRememberPreference, getSavedUsername, setAuth } from '../utils/auth'
const router = useRouter()
const username = ref('')
const password = ref('')
const rememberMe = ref(true)
const error = ref('')
const loading = ref(false)
onMounted(() => {
username.value = getSavedUsername()
rememberMe.value = getRememberPreference()
})
async function handleLogin() {
error.value = ''
if (!username.value || !password.value) {
@@ -17,8 +24,8 @@ async function handleLogin() {
}
loading.value = true
try {
const { data } = await api.login(username.value, password.value)
localStorage.setItem('token', data.access_token)
const { data } = await api.login(username.value, password.value, rememberMe.value)
setAuth(data.access_token, rememberMe.value, username.value)
router.push('/')
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }
@@ -36,9 +43,20 @@ async function handleLogin() {
<div v-if="error" class="message error">{{ error }}</div>
<div class="card">
<label class="label">用户名</label>
<input v-model="username" class="input" placeholder="请输入用户名" />
<input v-model="username" class="input" placeholder="请输入用户名" autocomplete="username" />
<label class="label">密码</label>
<input v-model="password" type="password" class="input" placeholder="请输入密码" />
<input
v-model="password"
type="password"
class="input"
placeholder="请输入密码"
autocomplete="current-password"
@keydown.enter="handleLogin"
/>
<label class="remember-row">
<input v-model="rememberMe" type="checkbox" class="remember-check" />
<span>记住登录状态关闭浏览器后仍保持登录有效期约 30 </span>
</label>
<button class="btn btn-primary" style="margin-top: 16px" :disabled="loading" @click="handleLogin">
{{ loading ? '登录中...' : '登录' }}
</button>
@@ -68,6 +86,20 @@ async function handleLogin() {
color: var(--muted);
margin: 12px 0 6px;
}
.remember-row {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 14px;
font-size: 13px;
color: var(--muted);
cursor: pointer;
line-height: 1.4;
}
.remember-check {
margin-top: 2px;
flex-shrink: 0;
}
.auth-link {
text-align: center;
margin-top: 20px;
+3 -2
View File
@@ -2,6 +2,7 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/request'
import { setAuth } from '../utils/auth'
const router = useRouter()
const username = ref('')
@@ -27,8 +28,8 @@ async function handleRegister() {
loading.value = true
try {
await api.register(username.value, password.value)
const { data } = await api.login(username.value, password.value)
localStorage.setItem('token', data.access_token)
const { data } = await api.login(username.value, password.value, true)
setAuth(data.access_token, true, username.value)
router.push('/')
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }
+2 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type Settings } from '../api/request'
import { clearAuth } from '../utils/auth'
const settings = ref<Settings>({
daily_target: 20,
@@ -30,7 +31,7 @@ async function save() {
}
function logout() {
localStorage.removeItem('token')
clearAuth()
window.location.href = '/login'
}
</script>
+204
View File
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import SpellCard from '../components/SpellCard.vue'
import { api, type QuizQuestion } from '../api/request'
import {
clearQuizSession,
loadQuizSession,
submitQuizAnswer,
useQuizAutoSave,
type QuizSessionSnapshot,
} from '../composables/useQuizSession'
import { useQuizTimer } from '../composables/useQuizTimer'
const { startQuestionTimer, consumeDurationSeconds } = useQuizTimer()
const questions = ref<QuizQuestion[]>([])
const currentIndex = ref(0)
const submittedAnswer = ref('')
const showResult = ref(false)
const isCorrect = ref(false)
const loading = ref(true)
const finished = ref(false)
const sessionCorrect = ref(0)
const sessionWrong = ref(0)
const empty = ref(false)
const restored = ref(false)
const spellCardRef = ref<InstanceType<typeof SpellCard> | null>(null)
const current = () => questions.value[currentIndex.value]
function buildSnapshot(): QuizSessionSnapshot | null {
if (!questions.value.length) return null
return {
questions: questions.value,
currentIndex: currentIndex.value,
sessionCorrect: sessionCorrect.value,
sessionWrong: sessionWrong.value,
finished: finished.value,
submittedAnswer: submittedAnswer.value,
showResult: showResult.value,
isCorrect: isCorrect.value,
}
}
const { persist, flushPending } = useQuizAutoSave('spell', buildSnapshot, {
getDraftAnswer: () => spellCardRef.value?.getDraft() ?? '',
onDraftSubmit: (answer) => submitAnswer(answer),
})
function applySnapshot(snap: QuizSessionSnapshot) {
questions.value = snap.questions
currentIndex.value = snap.currentIndex
sessionCorrect.value = snap.sessionCorrect
sessionWrong.value = snap.sessionWrong
finished.value = snap.finished
submittedAnswer.value = snap.submittedAnswer ?? ''
showResult.value = snap.showResult ?? false
isCorrect.value = snap.isCorrect ?? false
}
onMounted(async () => {
try {
const saved = loadQuizSession('spell')
if (saved && !saved.finished) {
applySnapshot(saved)
restored.value = true
await flushPending()
} else {
clearQuizSession('spell')
const { data } = await api.spellQuiz()
questions.value = data.questions
if (data.questions.length === 0) {
empty.value = true
} else {
persist()
}
}
} finally {
loading.value = false
if (current() && !showResult.value) startQuestionTimer()
}
})
watch(currentIndex, () => {
if (!loading.value && !finished.value && current() && !showResult.value) {
startQuestionTimer()
}
})
async function submitAnswer(answer: string) {
if (showResult.value) return
submittedAnswer.value = answer
const q = current()
if (!q) return
const payload = {
word_id: q.word_id,
question_type: q.question_type,
user_answer: answer,
correct_answer: q.correct_answer,
duration_seconds: consumeDurationSeconds(),
}
const result = await submitQuizAnswer('spell', payload)
if (result.ok && result.is_correct !== undefined) {
isCorrect.value = result.is_correct
} else {
isCorrect.value = answer.toLowerCase() === q.correct_answer.toLowerCase()
}
if (isCorrect.value) sessionCorrect.value++
else sessionWrong.value++
showResult.value = true
persist()
}
function onSubmit(answer: string) {
return submitAnswer(answer)
}
function nextQuestion() {
if (currentIndex.value >= questions.value.length - 1) {
finished.value = true
clearQuizSession('spell')
return
}
currentIndex.value++
submittedAnswer.value = ''
showResult.value = false
isCorrect.value = false
startQuestionTimer()
persist()
}
const accuracy = () => {
const total = sessionCorrect.value + sessionWrong.value
return total ? Math.round((sessionCorrect.value / total) * 100) : 0
}
</script>
<template>
<div class="page">
<h1 class="page-title">拼写练习</h1>
<p v-if="restored && !loading && !finished && !empty" class="restore-hint">
已恢复上次未完成的训练进度
</p>
<p v-if="loading" style="color: var(--muted)">加载题目...</p>
<div v-else-if="empty" class="card" style="text-align: center">
<p>词库暂无单词请先通过翻译添加单词</p>
<router-link to="/translate" class="btn btn-primary" style="margin-top: 12px; display: inline-block">
去翻译
</router-link>
</div>
<div v-else-if="finished" class="card summary">
<h2>拼写练习完成 🎉</h2>
<p>总题数{{ sessionCorrect + sessionWrong }}</p>
<p>答对{{ sessionCorrect }}</p>
<p>答错{{ sessionWrong }}</p>
<p>正确率{{ accuracy() }}%</p>
<router-link to="/" class="btn btn-primary" style="margin-top: 16px">返回首页</router-link>
</div>
<template v-else-if="current()">
<SpellCard
ref="spellCardRef"
:question="current()!"
:index="currentIndex"
:total="questions.length"
:show-result="showResult"
:is-correct="isCorrect"
:submitted-answer="submittedAnswer"
@submit="onSubmit"
/>
<button
v-if="showResult"
class="btn btn-primary"
style="margin-top: 12px"
@click="nextQuestion"
>
{{ currentIndex >= questions.length - 1 ? '查看结果' : '下一题' }}
</button>
</template>
</div>
</template>
<style scoped>
.restore-hint {
font-size: 13px;
color: var(--primary);
margin-bottom: 12px;
}
.summary h2 {
margin-bottom: 12px;
font-size: 20px;
}
.summary p {
margin: 6px 0;
font-size: 15px;
}
</style>
+421 -20
View File
@@ -1,7 +1,27 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
import WordCard from '../components/WordCard.vue'
import { api, type Word } from '../api/request'
const MemoryCurveChart = defineAsyncComponent(
() => import('../components/MemoryCurveChart.vue')
)
const WordGraphCanvas = defineAsyncComponent(
() => import('../components/WordGraphCanvas.vue')
)
import {
api,
type MemoryCurvePoint,
type MemoryVisualization,
type MemoryWordSummary,
type Word,
type WordMemoryDetail,
} from '../api/request'
import { formatTrainSeconds } from '../composables/useQuizTimer'
const viewTabs = [
{ key: 'list', label: '单词列表' },
{ key: 'memory', label: '记忆曲线' },
]
const tabs = [
{ key: '', label: '全部' },
@@ -11,52 +31,433 @@ const tabs = [
{ key: 'weak', label: '易错词' },
]
const PAGE_SIZE = 20
const activeView = ref('list')
const activeTab = ref('')
const words = ref<Word[]>([])
const currentPage = ref(1)
const loading = ref(true)
const vizLoading = ref(false)
const viz = ref<MemoryVisualization | null>(null)
const selectedWord = ref<MemoryWordSummary | null>(null)
const detailExpanded = ref(true)
const wordMemory = ref<WordMemoryDetail | null>(null)
const wordMemoryLoading = ref(false)
const wordCurvePoints = computed((): MemoryCurvePoint[] => {
if (!wordMemory.value?.curve_points.length) return []
return wordMemory.value.curve_points.map((p, i) => ({
day_index: i,
date: p.date,
forgetting: p.forgetting,
mastery: p.mastery,
risk: p.risk,
}))
})
const totalPages = computed(() =>
Math.max(1, Math.ceil(words.value.length / PAGE_SIZE))
)
const paginatedWords = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE
return words.value.slice(start, start + PAGE_SIZE)
})
const pageSummary = computed(() => {
if (!words.value.length) return ''
const start = (currentPage.value - 1) * PAGE_SIZE + 1
const end = Math.min(currentPage.value * PAGE_SIZE, words.value.length)
return `${currentPage.value} / ${totalPages.value} 页,显示 ${start}${end},共 ${words.value.length}`
})
function clampPage() {
if (currentPage.value > totalPages.value) {
currentPage.value = totalPages.value
}
if (currentPage.value < 1) {
currentPage.value = 1
}
}
function goToPage(page: number) {
currentPage.value = Math.min(Math.max(1, page), totalPages.value)
}
function goToPageAndScroll(page: number) {
goToPage(page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function loadWords() {
loading.value = true
try {
const { data } = await api.listWords(activeTab.value || undefined)
words.value = data
clampPage()
} finally {
loading.value = false
}
}
async function loadViz() {
vizLoading.value = true
try {
const { data } = await api.memoryViz()
viz.value = data
selectedWord.value = data.words[0] ?? null
detailExpanded.value = true
if (data.words[0]) loadWordMemory(data.words[0].id)
} finally {
vizLoading.value = false
}
}
async function handleDelete(id: number) {
if (!confirm('确定删除这个单词?')) return
await api.deleteWord(id)
await loadWords()
if (activeView.value === 'memory') await loadViz()
}
watch(activeTab, loadWords)
onMounted(loadWords)
async function loadWordMemory(wordId: number) {
wordMemoryLoading.value = true
try {
const { data } = await api.wordMemory(wordId)
wordMemory.value = data
} catch {
wordMemory.value = null
} finally {
wordMemoryLoading.value = false
}
}
function onGraphSelect(id: string) {
const w = viz.value?.words.find((x) => String(x.id) === id)
if (w) {
selectedWord.value = w
detailExpanded.value = true
loadWordMemory(w.id)
}
}
watch(selectedWord, (w) => {
if (w && activeView.value === 'memory') loadWordMemory(w.id)
})
watch(activeTab, () => {
currentPage.value = 1
if (activeView.value === 'list') loadWords()
})
watch(activeView, (v) => {
if (v === 'list') loadWords()
else loadViz()
})
onMounted(() => {
loadWords()
})
</script>
<template>
<div class="page">
<h1 class="page-title">单词库</h1>
<div class="tabs">
<div class="view-tabs">
<button
v-for="t in tabs"
:key="t.key"
:class="['tab', { active: activeTab === t.key }]"
@click="activeTab = t.key"
v-for="v in viewTabs"
:key="v.key"
:class="['view-tab', { active: activeView === v.key }]"
@click="activeView = v.key"
>
{{ t.label }}
{{ v.label }}
</button>
</div>
<p v-if="loading" style="color: var(--muted)">加载中...</p>
<p v-else-if="words.length === 0" style="color: var(--muted); text-align: center">
暂无单词去翻译页添加吧
</p>
<WordCard
v-for="w in words"
:key="w.id"
:word="w"
@delete="handleDelete"
/>
<template v-if="activeView === 'list'">
<div class="tabs">
<button
v-for="t in tabs"
:key="t.key"
:class="['tab', { active: activeTab === t.key }]"
@click="activeTab = t.key"
>
{{ t.label }}
</button>
</div>
<p v-if="loading" style="color: var(--muted)">加载中...</p>
<p v-else-if="words.length === 0" style="color: var(--muted); text-align: center">
暂无单词去翻译页添加吧
</p>
<template v-else>
<Teleport to="body">
<button
v-if="totalPages > 1"
type="button"
class="float-page-arrow prev"
:disabled="currentPage <= 1"
aria-label="上一页"
@click="goToPageAndScroll(currentPage - 1)"
>
</button>
<button
v-if="totalPages > 1"
type="button"
class="float-page-arrow next"
:disabled="currentPage >= totalPages"
aria-label="下一页"
@click="goToPageAndScroll(currentPage + 1)"
>
</button>
</Teleport>
<p class="page-summary">{{ pageSummary }}</p>
<WordCard v-for="w in paginatedWords" :key="w.id" :word="w" @delete="handleDelete" />
<div v-if="totalPages > 1" class="pagination">
<button
type="button"
class="page-btn"
:disabled="currentPage <= 1"
@click="goToPageAndScroll(currentPage - 1)"
>
上一页
</button>
<span class="page-indicator">{{ currentPage }} / {{ totalPages }}</span>
<button
type="button"
class="page-btn"
:disabled="currentPage >= totalPages"
@click="goToPageAndScroll(currentPage + 1)"
>
下一页
</button>
</div>
</template>
</template>
<template v-else>
<p v-if="vizLoading" style="color: var(--muted)">加载记忆数据...</p>
<template v-else-if="viz && viz.graph.nodes.length">
<div class="card chart-card">
<h3 class="section-title">记忆曲线</h3>
<p class="section-desc">
红线遗忘曲线 · 绿线熟练曲线 · 橙虚线可能遗忘(历史) · 紫点线未来预测
</p>
<MemoryCurveChart :curve-points="viz.curve_points" :future-risk="viz.future_risk" />
</div>
<div class="card graph-card">
<h3 class="section-title">单词关系图</h3>
<p class="section-desc">类似 Obsidian 的力导向图展示词与词之间的记忆关联</p>
<WordGraphCanvas
:nodes="viz.graph.nodes"
:links="viz.graph.links"
@select="onGraphSelect"
/>
</div>
<div v-if="selectedWord" class="card word-detail">
<div class="detail-header">
<h3 class="detail-word-title">{{ selectedWord.en }} {{ selectedWord.zh }}</h3>
<button type="button" class="detail-toggle" @click="detailExpanded = !detailExpanded">
{{ detailExpanded ? '收起' : '展开' }}
</button>
</div>
<div v-show="detailExpanded" class="detail-expanded">
<div class="detail-meta">
<span>进入词库{{ selectedWord.entered_at.replace('T', ' ').slice(0, 16) }}</span>
<span>训练 {{ selectedWord.train_count }} · 累计 {{ formatTrainSeconds(selectedWord.total_train_seconds) }}</span>
<span>答对 {{ selectedWord.correct_count }} · 答错 {{ selectedWord.wrong_count }}</span>
<span>掌握率 {{ selectedWord.mastery_score }}%</span>
<span>当前记忆保留 {{ selectedWord.retention_now }}%</span>
<span>7 日后预测 {{ selectedWord.risk_7d }}%</span>
</div>
<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>
<p v-else style="color: var(--muted); text-align: center">暂无单词无法生成记忆曲线</p>
</template>
</div>
</template>
<style scoped>
.view-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.view-tab {
flex: 1;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
font-size: 14px;
cursor: pointer;
}
.view-tab.active {
border-color: var(--primary);
background: rgba(79, 110, 247, 0.1);
color: var(--primary);
font-weight: 600;
}
.page-summary {
font-size: 13px;
color: var(--muted);
margin: 0 0 12px;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
padding-top: 12px;
}
.page-btn {
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
font-size: 14px;
color: var(--primary);
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
color: var(--muted);
}
.page-indicator {
font-size: 14px;
color: var(--muted);
min-width: 72px;
text-align: center;
}
.section-title {
font-size: 16px;
margin: 0 0 6px;
}
.section-desc {
font-size: 12px;
color: var(--muted);
margin: 0 0 12px;
}
.chart-card,
.graph-card {
margin-bottom: 16px;
}
.word-detail {
margin-top: 12px;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.detail-word-title {
font-size: 17px;
font-weight: 600;
margin: 0;
flex: 1;
min-width: 0;
}
.detail-toggle {
flex-shrink: 0;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
font-size: 13px;
color: var(--primary);
cursor: pointer;
}
.detail-toggle:hover {
background: rgba(79, 110, 247, 0.08);
}
.detail-expanded {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.detail-meta {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: var(--muted);
}
.word-curve-title {
font-size: 14px;
margin: 14px 0 4px;
color: #1e293b;
}
.curve-loading {
font-size: 13px;
color: var(--muted);
margin: 12px 0;
}
</style>
<style>
/* 悬浮翻页箭头挂到 body,避免被页面容器裁剪 */
.float-page-arrow {
position: fixed;
top: calc(50% - 28px);
z-index: 100;
width: 44px;
height: 52px;
border: 1px solid var(--border, #e2e8f0);
border-radius: 12px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.12);
font-size: 28px;
line-height: 1;
color: var(--primary, #4f6ef7);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
backdrop-filter: blur(6px);
-webkit-tap-highlight-color: transparent;
}
.float-page-arrow.prev {
left: max(6px, env(safe-area-inset-left));
}
.float-page-arrow.next {
right: max(6px, env(safe-area-inset-right));
}
.float-page-arrow:hover:not(:disabled) {
background: #fff;
border-color: var(--primary, #4f6ef7);
}
.float-page-arrow:disabled {
opacity: 0.35;
cursor: not-allowed;
box-shadow: none;
}
@media (min-width: 480px) {
.float-page-arrow.prev {
left: 12px;
}
.float-page-arrow.next {
right: 12px;
}
}
</style>
+3 -1
View File
@@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'
import { getToken } from '../utils/auth'
const router = createRouter({
history: createWebHistory(),
@@ -14,6 +15,7 @@ const router = createRouter({
{ path: 'translate', name: 'Translate', component: () => import('../pages/Translate.vue') },
{ 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: 'settings', name: 'Settings', component: () => import('../pages/Settings.vue') },
],
},
@@ -21,7 +23,7 @@ const router = createRouter({
})
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
const token = getToken()
if (to.meta.requiresAuth && !token) {
next('/login')
} else if ((to.path === '/login' || to.path === '/register') && token) {
+59
View File
@@ -0,0 +1,59 @@
const TOKEN_KEY = 'token'
const REMEMBER_KEY = 'wordloop_remember'
const USERNAME_KEY = 'wordloop_username'
/** 未设置过时默认勾选「记住登录」 */
export function getRememberPreference(): boolean {
const v = localStorage.getItem(REMEMBER_KEY)
if (v === null) return true
return v === '1'
}
export function setRememberPreference(remember: boolean) {
localStorage.setItem(REMEMBER_KEY, remember ? '1' : '0')
}
export function getSavedUsername(): string {
return localStorage.getItem(USERNAME_KEY) || ''
}
function clearTokenStores() {
localStorage.removeItem(TOKEN_KEY)
sessionStorage.removeItem(TOKEN_KEY)
}
export function getToken(): string | null {
if (getRememberPreference()) {
return localStorage.getItem(TOKEN_KEY)
}
return sessionStorage.getItem(TOKEN_KEY)
}
export function setAuth(token: string, remember: boolean, username?: string) {
setRememberPreference(remember)
clearTokenStores()
if (remember) {
localStorage.setItem(TOKEN_KEY, token)
} else {
sessionStorage.setItem(TOKEN_KEY, token)
}
if (username) {
localStorage.setItem(USERNAME_KEY, username)
}
}
/** 退出登录:清除凭证,保留用户名与「记住」偏好供下次登录 */
export function clearAuth() {
clearTokenStores()
}
/** 完全清除本地登录相关数据 */
export function clearAuthAll() {
clearTokenStores()
localStorage.removeItem(REMEMBER_KEY)
localStorage.removeItem(USERNAME_KEY)
}
export function isLoggedIn(): boolean {
return !!getToken()
}