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:
Generated
+26
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"echarts": "^6.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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} 分钟`
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } } }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user