feat(layout): 更新应用布局和UI组件样式

- 重构App.vue中的侧边栏布局,更新Logo设计为带有标识和副标题的新样式
- 调整顶部导航栏,增加标题区域显示当前路由标题和日期
- 修改菜单项配置,更新导航标签为更直观的中文描述
- 在Home.vue中替换原有的仪表板为新的Hero卡片和项目进展展示
- 更新Memory.vue中的学习界面,添加学习计划设置和多阶段学习模式
- 集成新的API端点路径,将baseURL从/api调整为/api/v1
- 调整整体视觉风格,包括颜色主题、字体家族和响应式布局
- 更新数据库模型以支持词库功能,添加相关的数据迁移和种子数据
- 调整认证系统的用户ID类型从整型到字符串的变更
- 更改前端构建工具从npm到pnpm,并更新相应的Dockerfile配置
This commit is contained in:
2026-02-27 16:16:57 +08:00
parent a62c2a3aa1
commit 613ce02e9a
47 changed files with 3164 additions and 2579 deletions

View File

@@ -1,7 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
CMD ["pnpm", "dev", "--host", "0.0.0.0", "--port", "3000"]

View File

@@ -3,8 +3,11 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<title>Memora 背单词</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uview-plus@3.2.0/index.css" />
<link rel="stylesheet" href="https://unpkg.com/uview-plus@3.2.0/index.css" />
</head>
<body>
<div id="app"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"preinstall": "node scripts/enforce-pnpm.cjs",
"dev": "vite",
"typecheck": "vue-tsc --noEmit",
"build": "vite build",
@@ -22,5 +23,6 @@
"typescript": "^5.9.3",
"vite": "^5.1.6",
"vue-tsc": "^3.2.5"
}
},
"packageManager": "pnpm@10.28.2"
}

1456
memora-web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
const userAgent = process.env.npm_config_user_agent || "";
if (!userAgent.includes("pnpm")) {
console.error("This project requires pnpm. Please run: pnpm install");
process.exit(1);
}

View File

@@ -2,22 +2,30 @@
<router-view v-if="$route.meta?.fullPage" />
<el-container v-else class="layout">
<el-aside width="220px" class="sidebar">
<div class="logo">Memora</div>
<div class="logo">
<span class="logo-mark">M</span>
<div>
<div class="logo-title">Memora</div>
<div class="logo-sub">Long-Term Vocabulary</div>
</div>
</div>
<el-menu
:default-active="$route.path"
:default-active="activeMenu"
class="menu"
@select="go"
>
<el-menu-item index="/">仪表盘</el-menu-item>
<el-menu-item index="/memory">记忆模式</el-menu-item>
<el-menu-item index="/review">复习模式</el-menu-item>
<el-menu-item index="/memory">学习</el-menu-item>
<el-menu-item index="/statistics">学习统计</el-menu-item>
<el-menu-item index="/words">单词列表</el-menu-item>
<el-menu-item index="/settings">设置</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="topbar"></el-header>
<el-header class="topbar">
<div class="topbar-title">{{ routeTitle }}</div>
<div class="topbar-date">{{ todayText }}</div>
</el-header>
<el-main class="main">
<router-view />
</el-main>
@@ -26,41 +34,97 @@
</template>
<script setup>
import { useRouter } from 'vue-router'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
const go = (path) => router.push(path)
const routeTitle = computed(() => {
const titleMap = {
'/memory': '学习会话',
'/review': '今日复习',
'/statistics': '学习统计',
'/words': '词库管理',
'/settings': '偏好设置'
}
if (route.path.startsWith('/statistics/')) return '学习统计详情'
return titleMap[route.path] || 'Memora'
})
const activeMenu = computed(() => {
if (route.path.startsWith('/statistics')) return '/statistics'
return route.path
})
const todayText = computed(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
})
</script>
<style scoped>
.layout { min-height: 100vh; background: #f4f5fb; }
.layout { min-height: 100vh; background: radial-gradient(circle at 0% 0%, #f6fbff 0, #f2f6ff 40%, #eef2fb 100%); }
.sidebar {
background: #fff;
border-right: 1px solid #eceef3;
background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
border-right: 1px solid #e4ebf7;
}
.logo {
height: 62px;
height: 76px;
display: flex;
align-items: center;
padding: 0 18px;
font-size: 24px;
font-weight: 700;
color: #1f2937;
gap: 10px;
padding: 0 16px;
}
.logo-mark {
width: 36px;
height: 36px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 800;
background: #2447d6;
color: #fff;
}
.logo-title {
font-size: 22px;
line-height: 1;
font-weight: 800;
}
.logo-sub {
margin-top: 3px;
color: #64748b;
font-size: 11px;
}
.menu {
border-right: none;
padding: 8px;
padding: 10px;
}
:deep(.el-menu-item.is-active) {
background: #2f7d32;
background: #2447d6;
color: #fff;
border-radius: 8px;
border-radius: 10px;
}
.topbar {
height: 62px;
background: #fff;
border-bottom: 1px solid #eceef3;
border-bottom: 1px solid #e5ecf8;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.topbar-title {
font-size: 20px;
font-weight: 700;
}
.topbar-date {
color: #64748b;
font-size: 13px;
}
.main {
padding: 24px 28px;
padding: 18px 20px;
}
</style>

View File

@@ -1,17 +1,18 @@
import Dashboard from '../views/Home.vue'
import Memory from '../views/Memory.vue'
import Review from '../views/Review.vue'
import Statistics from '../views/Statistics.vue'
import StatisticsDetail from '../views/StatisticsDetail.vue'
import Words from '../views/Words.vue'
import Settings from '../views/Settings.vue'
import Login from '../views/Login.vue'
export const routes = [
{ path: '/login', name: 'login', component: Login, meta: { public: true, fullPage: true } },
{ path: '/', name: 'dashboard', component: Dashboard },
{ path: '/', redirect: '/memory' },
{ path: '/memory', name: 'memory', component: Memory },
{ path: '/review', name: 'review', component: Review },
{ path: '/statistics', name: 'statistics', component: Statistics },
{ path: '/statistics/:metric', name: 'statistics-detail', component: StatisticsDetail },
{ path: '/words', name: 'words', component: Words },
{ path: '/settings', name: 'settings', component: Settings }
]

View File

@@ -0,0 +1,113 @@
export type WordBook = 'cet4' | 'ielts' | 'toefl' | 'custom'
export type Pronunciation = 'uk' | 'us'
export interface StudyPlan {
wordBook: WordBook
dailyTarget: number
remindAt: string
locked: boolean
}
export interface UserProfile {
nickname: string
avatar: string
}
export interface Preferences {
plan: StudyPlan
pronunciation: Pronunciation
profile: UserProfile
}
const PREF_KEY = 'memora.preferences'
const LEARNED_KEY = 'memora.today.learned'
const DATE_KEY = 'memora.today.date'
const LEARNED_WORD_IDS_KEY = 'memora.today.learned.word_ids'
function todayKey() {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
}
export function defaultPreferences(): Preferences {
return {
plan: {
wordBook: 'cet4',
dailyTarget: 20,
remindAt: '21:00',
locked: false
},
pronunciation: 'uk',
profile: {
nickname: 'Memora 用户',
avatar: ''
}
}
}
export function loadPreferences(): Preferences {
try {
const raw = localStorage.getItem(PREF_KEY)
if (!raw) return defaultPreferences()
const parsed = JSON.parse(raw) as Preferences
const defaults = defaultPreferences()
return {
plan: {
...defaults.plan,
...(parsed.plan || {})
},
pronunciation: parsed.pronunciation || defaults.pronunciation,
profile: {
...defaults.profile,
...(parsed.profile || {})
}
}
} catch {
return defaultPreferences()
}
}
export function savePreferences(pref: Preferences) {
localStorage.setItem(PREF_KEY, JSON.stringify(pref))
}
export function recordTodayLearned(count: number) {
const today = todayKey()
const oldDate = localStorage.getItem(DATE_KEY)
const oldCount = Number(localStorage.getItem(LEARNED_KEY) || '0')
const nextCount = oldDate === today ? oldCount + count : count
localStorage.setItem(DATE_KEY, today)
localStorage.setItem(LEARNED_KEY, String(nextCount))
}
export function getTodayLearned() {
return getTodayLearnedWordIds().length
}
export function getTodayLearnedWordIds(): number[] {
const today = todayKey()
const oldDate = localStorage.getItem(DATE_KEY)
if (oldDate !== today) {
localStorage.setItem(DATE_KEY, today)
localStorage.setItem(LEARNED_WORD_IDS_KEY, '[]')
localStorage.setItem(LEARNED_KEY, '0')
return []
}
try {
const raw = localStorage.getItem(LEARNED_WORD_IDS_KEY)
if (!raw) return []
const ids = JSON.parse(raw) as number[]
return Array.isArray(ids) ? ids.filter((id) => Number.isFinite(id)) : []
} catch {
return []
}
}
export function markTodayLearnedWord(wordID: number) {
const ids = getTodayLearnedWordIds()
if (ids.includes(wordID)) return
const next = [...ids, wordID]
localStorage.setItem(LEARNED_WORD_IDS_KEY, JSON.stringify(next))
localStorage.setItem(LEARNED_KEY, String(next.length))
}

View File

@@ -2,12 +2,12 @@ import { http } from '../http'
import type { MemoryRecord, ReviewMode, ReviewResult } from './types'
export async function getReviewWords(params: { mode?: ReviewMode; limit?: number } = {}) {
const res = await http.get<{ data: MemoryRecord[] }>('/review', { params })
const res = await http.get<{ data: MemoryRecord[] }>('/review/today', { params })
return res.data
}
export async function submitReview(payload: { recordId: number; answer: string; mode: ReviewMode }) {
const res = await http.post<{ data: ReviewResult }>('/review', {
const res = await http.post<{ data: ReviewResult }>('/review/submit', {
record_id: payload.recordId,
answer: payload.answer,
mode: payload.mode

View File

@@ -2,6 +2,6 @@ import { http } from '../http'
import type { Stats } from './types'
export async function getStatistics() {
const res = await http.get<{ data: Stats }>('/stats')
const res = await http.get<{ data: Stats }>('/stats/overview')
return res.data
}

View File

@@ -11,7 +11,12 @@ export async function getWords(params: { limit?: number; offset?: number; q?: st
return res.data
}
export async function getWordById(id: number) {
const res = await http.get<{ data: Word }>(`/words/${id}`)
return res.data
}
export function audioUrl(word: string, type: 'uk' | 'us' = 'uk') {
const q = new URLSearchParams({ word, type })
return `/api/audio?${q.toString()}`
return `/api/v1/audio?${q.toString()}`
}

View File

@@ -2,7 +2,7 @@ import axios from 'axios'
import { clearToken, getToken } from '../modules/auth/auth'
export const http = axios.create({
baseURL: '/api',
baseURL: '/api/v1',
timeout: 15000
})

View File

@@ -1,9 +1,9 @@
:root {
--memora-bg: #f4f5fb;
--memora-border: #eceef3;
--memora-text: #111827;
--memora-sub: #6b7280;
--memora-primary: #2f7d32;
--memora-bg: #eef2fb;
--memora-border: #e4ebf7;
--memora-text: #101828;
--memora-sub: #667085;
--memora-primary: #2447d6;
}
html, body {
@@ -14,5 +14,9 @@ body {
margin: 0;
background: var(--memora-bg);
color: var(--memora-text);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
font-family: "Manrope", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
}
#app {
min-height: 100vh;
}

View File

@@ -1,19 +1,31 @@
<template>
<div>
<div class="title">仪表盘</div>
<div class="sub">欢迎回来继续你的学习之旅</div>
<div class="home-page">
<el-card class="hero">
<div class="hero-title">长期记忆留存学习系统</div>
<div class="hero-sub">学习 测试 复习闭环基于 SRS 调度今日任务</div>
<div class="hero-actions">
<el-button type="primary" @click="$router.push('/memory')">开始新词学习</el-button>
<el-button @click="$router.push('/review')">进入今日复习</el-button>
</div>
</el-card>
<el-row :gutter="16" class="cards">
<el-col :span="6"><MetricCard label="今日复习数" :value="stats.today_reviewed ?? 0" icon="🎓" /></el-col>
<el-col :span="6"><MetricCard label="待复习数" :value="stats.need_review ?? 0" icon="📖" /></el-col>
<el-col :span="6"><MetricCard label="已掌握" :value="stats.mastered_words ?? 0" icon="🎯" /></el-col>
<el-col :span="6"><MetricCard label="总词汇" :value="stats.total_words ?? 0" icon="📚" /></el-col>
<el-col :xs="24" :md="12" :lg="6"><MetricCard label="今日复习数" :value="stats.today_reviewed ?? 0" icon="🎓" /></el-col>
<el-col :xs="24" :md="12" :lg="6"><MetricCard label="待复习数" :value="stats.need_review ?? 0" icon="📖" /></el-col>
<el-col :xs="24" :md="12" :lg="6"><MetricCard label="已掌握" :value="stats.mastered_words ?? 0" icon="🎯" /></el-col>
<el-col :xs="24" :md="12" :lg="6"><MetricCard label="总词汇" :value="stats.total_words ?? 0" icon="📚" /></el-col>
</el-row>
<div class="actions">
<el-button type="success" @click="$router.push('/review')">开始复习</el-button>
<el-button @click="$router.push('/memory')">添加单词</el-button>
</div>
<el-card>
<template #header>
<div class="section-title">当前阶段验收项</div>
</template>
<el-timeline>
<el-timeline-item type="primary" timestamp="M1">注册/登录 + 受保护接口</el-timeline-item>
<el-timeline-item type="success" timestamp="M2">学习会话 + 复习提交 + 今日任务</el-timeline-item>
<el-timeline-item type="warning" timestamp="M3">统计面板 + 体验优化</el-timeline-item>
</el-timeline>
</el-card>
</div>
</template>
@@ -21,7 +33,6 @@
import { onMounted, ref } from 'vue'
import { getStatistics } from '../services/api'
import MetricCard from '../components/base/MetricCard.vue'
import type { Stats } from '../services/api'
const stats = ref<Stats>({
@@ -37,13 +48,43 @@ async function refresh() {
}
onMounted(() => refresh().catch(console.error))
</script>
<style scoped>
.title { font-size: 30px; font-weight: 700; color: #111827; }
.sub { margin-top: 4px; color: #6b7280; }
.cards { margin-top: 20px; }
.actions { margin-top: 18px; display: flex; gap: 10px; }
.home-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.hero {
border-radius: 16px;
background: linear-gradient(135deg, #1839b7 0%, #2758d7 100%);
color: #fff;
}
.hero-title {
font-size: 30px;
font-weight: 800;
}
.hero-sub {
margin-top: 8px;
opacity: 0.9;
}
.hero-actions {
margin-top: 18px;
display: flex;
gap: 10px;
}
.cards {
margin-top: 2px;
}
.section-title {
font-size: 18px;
font-weight: 700;
}
</style>

View File

@@ -1,164 +1,491 @@
<template>
<div class="wrap">
<el-card style="margin-bottom: 16px">
<template #header>
<div class="h">
<div>
<div class="title">学习会话</div>
<div class="sub">系统按最新单词生成学习队列完成后自动计入复习数据</div>
</div>
<el-button type="primary" @click="startSession" :loading="sessionLoading">开始学习</el-button>
</div>
</template>
<el-empty v-if="!sessionWord" description="点击“开始学习”获取单词" />
<div v-else>
<el-alert type="info" :title="`当前单词:${sessionWord.word}`" show-icon style="margin-bottom:12px" />
<el-input v-model="sessionAnswer" placeholder="输入答案(默认拼写)" @keyup.enter="submitSessionAnswer" />
<div style="margin-top: 12px; display:flex; gap:8px">
<el-button type="success" :loading="sessionLoading" @click="submitSessionAnswer">提交</el-button>
<el-button @click="nextSessionWord" :disabled="sessionLoading">下一个</el-button>
</div>
<el-result v-if="sessionResult" :icon="sessionResult.correct ? 'success' : 'error'" :title="sessionResult.correct ? '正确' : '不对'">
<template #sub-title>
<div>正确答案{{ sessionResult.correct_ans || sessionWord.word }}</div>
<div class="memory-page">
<el-row :gutter="16" class="top-row">
<el-col :xs="24" :lg="10">
<el-card class="panel">
<template #header>
<div class="panel-title">学习页面</div>
</template>
</el-result>
</div>
</el-card>
<el-card>
<template #header>
<div class="h">
<div>
<div class="title">记忆模式</div>
<div class="sub">输入单词并回车会调用后端后端再调用有道校验并入库</div>
</div>
</div>
</template>
<template v-if="!preferences.plan.locked">
<el-alert type="info" title="首次进入请先设置学习计划,保存后只能在设置页修改" show-icon style="margin-bottom: 12px" />
<el-form label-position="top">
<el-form-item label="词库">
<el-select v-model="preferences.plan.wordBook" class="full">
<el-option label="四级词库" value="cet4" />
<el-option label="雅思词库" value="ielts" />
<el-option label="托福词库" value="toefl" />
<el-option label="自定义词库" value="custom" />
</el-select>
</el-form-item>
<el-form-item :label="`每日新词目标:${preferences.plan.dailyTarget}`">
<el-slider v-model="preferences.plan.dailyTarget" :min="5" :max="60" :step="5" />
</el-form-item>
<el-form-item label="提醒时间">
<el-time-select
v-model="preferences.plan.remindAt"
class="full"
start="06:00"
end="23:00"
step="00:30"
/>
</el-form-item>
<el-button type="primary" @click="confirmPlan">确认学习计划</el-button>
</el-form>
</template>
<template v-else>
<el-descriptions :column="1" border>
<el-descriptions-item label="词库">{{ wordBookLabel(preferences.plan.wordBook) }}</el-descriptions-item>
<el-descriptions-item label="每日新词目标">{{ preferences.plan.dailyTarget }}</el-descriptions-item>
<el-descriptions-item label="提醒时间">{{ preferences.plan.remindAt }}</el-descriptions-item>
<el-descriptions-item label="读音偏好">{{ preferences.pronunciation === 'us' ? '美音' : '英音' }}</el-descriptions-item>
</el-descriptions>
<el-button style="margin-top: 12px" @click="$router.push('/settings')">去设置页修改</el-button>
</template>
</el-card>
</el-col>
<el-col :xs="24" :lg="14">
<el-card class="panel">
<template #header>
<div class="actions-head">
<div>
<div class="panel-title">学习与复习</div>
<div class="panel-sub">学习按四阶段推进复习按当日队列执行目标与学习计划一致</div>
</div>
<div class="action-buttons">
<el-button type="primary" :disabled="!preferences.plan.locked" :loading="loading" @click="startLearning">开始学习</el-button>
<el-button type="success" :disabled="!preferences.plan.locked" @click="goReview">开始复习</el-button>
</div>
</div>
</template>
<el-empty v-if="!sessionWords.length" description="点击“开始学习”进入新词学习流程" />
<template v-else>
<div class="progress-row">
<el-tag effect="plain" type="primary">{{ currentPhase.title }}</el-tag>
<span>阶段进度{{ currentWordIndex + 1 }} / {{ sessionWords.length }}</span>
<span>总进度{{ phaseIndex + 1 }} / {{ phases.length }}</span>
</div>
<el-progress :percentage="progressPercent" :stroke-width="12" />
<el-row :gutter="12" class="metrics">
<el-col :span="6"><div class="metric"><span>正确率</span><strong>{{ accuracyText }}</strong></div></el-col>
<el-col :span="6"><div class="metric"><span>连续正确</span><strong>{{ stats.streak }}</strong></div></el-col>
<el-col :span="6"><div class="metric"><span>累计耗时</span><strong>{{ stats.spentSec }}s</strong></div></el-col>
<el-col :span="6"><div class="metric"><span>已完成</span><strong>{{ completedWords }}</strong></div></el-col>
</el-row>
<el-result v-if="learningDone" icon="success" title="今日学习完成">
<template #sub-title>
<div>四阶段已全部完成已记录今日学习量</div>
</template>
</el-result>
<div v-else class="qa-block">
<div class="question-title">{{ currentPhase.description }}</div>
<el-card shadow="never" class="qa-card">
<template v-if="currentPhase.kind === 'en2cn'">
<div class="q-main">{{ currentWord?.word }}</div>
<div class="q-sub">{{ currentWord?.example_sentence || '暂无例句' }}</div>
</template>
<template v-else-if="currentPhase.kind === 'cn2en'">
<div class="q-main">{{ currentWord?.definition || '暂无释义' }}</div>
</template>
<template v-else>
<div class="audio-row">
<el-button @click="playAudio">播放读音</el-button>
<span>根据读音作答</span>
</div>
</template>
<div v-if="currentPhase.kind !== 'spelling'" class="options">
<el-button
v-for="option in options"
:key="option"
class="option"
@click="selectOption(option)"
>
{{ option }}
</el-button>
</div>
<div v-else class="spelling">
<el-input v-model="spellingInput" placeholder="输入单词拼写,回车提交" @keyup.enter="submitSpelling" />
<el-button type="primary" :loading="loading" @click="submitSpelling">提交</el-button>
</div>
</el-card>
<el-alert
v-if="lastResult"
:type="lastResult.correct ? 'success' : 'error'"
:title="lastResult.correct ? '回答正确' : `回答错误,正确答案:${lastResult.correct_ans || '-'}`"
show-icon
/>
</div>
</template>
</el-card>
</el-col>
</el-row>
<el-card class="panel">
<template #header><div class="panel-title">新词入库</div></template>
<el-form @submit.prevent>
<el-form-item>
<el-input
v-model="word"
size="large"
placeholder="输入英文单词,如: example"
@keyup.enter="submit"
:disabled="loading"
@keyup.enter="submitWord"
:disabled="wordLoading"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="success" size="large" :loading="loading" @click="submit">确认</el-button>
<el-button type="primary" size="large" :loading="wordLoading" @click="submitWord">确认入库</el-button>
</el-form-item>
</el-form>
<el-alert v-if="error" type="error" :title="error" show-icon />
<el-alert v-if="wordError" type="error" :title="wordError" show-icon />
<el-descriptions v-if="saved" :column="1" border style="margin-top:16px">
<el-descriptions-item label="单词">{{ saved.word }}</el-descriptions-item>
<el-descriptions-item label="词性">{{ saved.part_of_speech }}</el-descriptions-item>
<el-descriptions-item label="释义">{{ saved.definition }}</el-descriptions-item>
<el-descriptions-item label="英音">{{ saved.phonetic_uk || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="美音">{{ saved.phonetic_us || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="例句">{{ saved.example_sentence || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="发音播放">
<el-button size="small" @click="playUK" :disabled="!saved.word">播放英音</el-button>
<el-button size="small" @click="playUS" :disabled="!saved.word">播放美音</el-button>
</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { addWord, audioUrl, createStudySession, submitStudyAnswer } from '../services/api'
import type { ReviewResult, Word } from '../services/api'
import { getTodayLearnedWordIds, loadPreferences, markTodayLearnedWord, savePreferences } from '../modules/preferences/store'
import type { Preferences } from '../modules/preferences/store'
import type { ReviewMode, ReviewResult, Word } from '../services/api'
type StudyPhase = {
title: string
description: string
mode: ReviewMode
kind: 'en2cn' | 'cn2en' | 'audio' | 'spelling'
}
const router = useRouter()
const preferences = reactive<Preferences>(loadPreferences())
const phases: StudyPhase[] = [
{ title: '第一阶段', description: '显示英文和例句,四选一选择正确英译中', mode: 'en2cn', kind: 'en2cn' },
{ title: '第二阶段', description: '显示中文释义,四选一选择正确英文', mode: 'cn2en', kind: 'cn2en' },
{ title: '第三阶段', description: '播放读音,四选一选择正确单词', mode: 'cn2en', kind: 'audio' },
{ title: '第四阶段', description: '播放读音并拼写单词', mode: 'spelling', kind: 'spelling' }
]
const word = ref('')
const loading = ref(false)
const error = ref('')
const wordLoading = ref(false)
const wordError = ref('')
const saved = ref<Word | null>(null)
const sessionLoading = ref(false)
const loading = ref(false)
const sessionWords = ref<Word[]>([])
const sessionIndex = ref(0)
const sessionWord = ref<Word | null>(null)
const sessionAnswer = ref('')
const sessionResult = ref<ReviewResult | null>(null)
const phaseIndex = ref(0)
const currentWordIndex = ref(0)
const options = ref<string[]>([])
const spellingInput = ref('')
const lastResult = ref<ReviewResult | null>(null)
const phaseStartedAt = ref(0)
const stats = reactive({ correct: 0, total: 0, streak: 0, spentSec: 0 })
async function submit() {
const w = word.value.trim()
if (!w) return
const currentPhase = computed(() => phases[phaseIndex.value])
const currentWord = computed(() => sessionWords.value[currentWordIndex.value] || null)
const completedWords = computed(() => phaseIndex.value * sessionWords.value.length + currentWordIndex.value)
const learningDone = computed(() => sessionWords.value.length > 0 && phaseIndex.value >= phases.length)
const progressPercent = computed(() => {
if (!sessionWords.value.length) return 0
const total = phases.length * sessionWords.value.length
return Math.min(100, Math.floor((completedWords.value / total) * 100))
})
const accuracyText = computed(() => (stats.total ? `${Math.round((stats.correct / stats.total) * 100)}%` : '0%'))
function wordBookLabel(book: string) {
if (book === 'cet4') return '四级词库'
if (book === 'ielts') return '雅思词库'
if (book === 'toefl') return '托福词库'
return '自定义词库'
}
function confirmPlan() {
preferences.plan.locked = true
savePreferences(preferences)
ElMessage.success('学习计划已保存,后续请在设置页修改')
}
function shuffle<T>(arr: T[]) {
const copied = arr.slice()
for (let i = copied.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1))
;[copied[i], copied[j]] = [copied[j], copied[i]]
}
return copied
}
function normalizeOption(option: string) {
return option.trim().replace(/\s+/g, ' ')
}
function fallbackOptions(correctAnswer: string, kind: StudyPhase['kind']) {
if (kind === 'en2cn') {
const seed = correctAnswer.split(/[;|,,、]/).map((s) => s.trim()).find(Boolean) || '该词义'
return [
`与“${seed}”相近`,
`与“${seed}”相反`,
`${seed}”的动作`,
`${seed}”的状态`,
`${seed}”的程度`
]
}
const word = correctAnswer.toLowerCase()
const out: string[] = []
if (word.length > 1) {
out.push(word[1] + word[0] + word.slice(2))
}
if (word.length > 3) {
out.push(word.slice(0, word.length - 1))
}
out.push(`${word}s`)
out.push(`${word}ed`)
out.push(`${word}ly`)
return out
}
function buildFourOptions(correctAnswer: string, pool: string[], kind: StudyPhase['kind']) {
const unique: string[] = []
const pushUnique = (value: string) => {
const normalized = normalizeOption(value)
if (!normalized) return
if (unique.includes(normalized)) return
unique.push(normalized)
}
pushUnique(correctAnswer)
for (const item of shuffle(pool)) {
pushUnique(item)
if (unique.length >= 4) break
}
if (unique.length < 4) {
for (const item of fallbackOptions(correctAnswer, kind)) {
pushUnique(item)
if (unique.length >= 4) break
}
}
while (unique.length < 4) {
pushUnique(`${correctAnswer}-选项${unique.length + 1}`)
}
return shuffle(unique.slice(0, 4))
}
function poolByPhase() {
if (currentPhase.value.kind === 'en2cn') {
return Array.from(new Set(sessionWords.value.map((w) => (w.definition || '').trim()).filter(Boolean)))
}
return Array.from(new Set(sessionWords.value.map((w) => (w.word || '').trim()).filter(Boolean)))
}
function setupQuestion() {
if (!currentWord.value || learningDone.value) return
lastResult.value = null
spellingInput.value = ''
if (currentPhase.value.kind === 'spelling') {
options.value = []
playAudio()
phaseStartedAt.value = Date.now()
return
}
const correctAnswer = currentPhase.value.kind === 'en2cn'
? currentWord.value.definition!.trim()
: currentWord.value.word
const pool = poolByPhase().filter((x) => normalizeOption(x) !== normalizeOption(correctAnswer))
options.value = buildFourOptions(correctAnswer, pool, currentPhase.value.kind)
if (currentPhase.value.kind === 'audio') {
playAudio()
}
phaseStartedAt.value = Date.now()
}
function advance() {
currentWordIndex.value += 1
if (currentWordIndex.value >= sessionWords.value.length) {
currentWordIndex.value = 0
phaseIndex.value += 1
}
if (learningDone.value) {
return
}
setupQuestion()
}
async function submitAnswer(answer: string) {
if (!currentWord.value || learningDone.value) return
loading.value = true
error.value = ''
saved.value = null
try {
const res = await addWord(w)
saved.value = res.data ?? res
word.value = ''
const res = await submitStudyAnswer({ wordId: currentWord.value.id, answer, mode: currentPhase.value.mode })
const result = (res as any).data ?? (res as any)
lastResult.value = result
markTodayLearnedWord(currentWord.value.id)
stats.total += 1
if (result.correct) {
stats.correct += 1
stats.streak += 1
} else {
stats.streak = 0
}
stats.spentSec += Math.max(1, Math.round((Date.now() - phaseStartedAt.value) / 1000))
advance()
} catch (e: any) {
error.value = e?.response?.data?.error || e?.message || '请求失败'
ElMessage.error(e?.response?.data?.error || '提交失败')
} finally {
loading.value = false
}
}
async function startSession() {
sessionLoading.value = true
try {
const res = await createStudySession(10)
const arr = (res as any).data ?? (res as any)
sessionWords.value = Array.isArray(arr) ? arr : []
sessionIndex.value = 0
sessionWord.value = sessionWords.value[0] || null
sessionAnswer.value = ''
sessionResult.value = null
} finally {
sessionLoading.value = false
}
function selectOption(option: string) {
if (loading.value) return
submitAnswer(option).catch(console.error)
}
function nextSessionWord() {
if (!sessionWords.value.length) return
sessionIndex.value += 1
if (sessionIndex.value >= sessionWords.value.length) {
sessionWord.value = null
function submitSpelling() {
const answer = spellingInput.value.trim()
if (!answer) {
ElMessage.warning('请输入拼写')
return
}
sessionWord.value = sessionWords.value[sessionIndex.value]
sessionAnswer.value = ''
sessionResult.value = null
submitAnswer(answer).catch(console.error)
}
async function submitSessionAnswer() {
if (!sessionWord.value || !sessionAnswer.value.trim()) return
sessionLoading.value = true
function playAudio() {
if (!currentWord.value?.word) return
const type = preferences.pronunciation
const audio = new Audio(audioUrl(currentWord.value.word, type))
audio.play().catch(() => {})
}
async function startLearning() {
loading.value = true
try {
const res = await submitStudyAnswer({ wordId: sessionWord.value.id, answer: sessionAnswer.value, mode: 'spelling' })
sessionResult.value = (res as any).data ?? (res as any)
const res = await createStudySession(preferences.plan.dailyTarget)
const arr = (res as any).data ?? (res as any)
const words = (Array.isArray(arr) ? arr : []) as Word[]
const learnedIDs = new Set(getTodayLearnedWordIds())
const eligible = words.filter((w) => {
if (!(w.word || '').trim()) return false
if (!(w.definition || '').trim()) return false
return !learnedIDs.has(w.id)
})
sessionWords.value = eligible.slice(0, preferences.plan.dailyTarget)
if (!sessionWords.value.length) {
ElMessage.warning('今天已记过的单词不会重复出现,请明天再学或先补充新词')
return
}
if (sessionWords.value.length < preferences.plan.dailyTarget) {
ElMessage.warning(`当前仅有 ${sessionWords.value.length} 个带释义单词,已按可用数量开始`)
}
phaseIndex.value = 0
currentWordIndex.value = 0
stats.correct = 0
stats.total = 0
stats.streak = 0
stats.spentSec = 0
setupQuestion()
} catch (e: any) {
ElMessage.error(e?.response?.data?.error || '学习会话创建失败')
} finally {
sessionLoading.value = false
loading.value = false
}
}
function playUK() {
if (!saved.value?.word) return
const audio = new Audio(audioUrl(saved.value.word, 'uk'))
audio.play().catch(() => {})
function goReview() {
router.push({ path: '/review', query: { limit: preferences.plan.dailyTarget } })
}
function playUS() {
if (!saved.value?.word) return
const audio = new Audio(audioUrl(saved.value.word, 'us'))
audio.play().catch(() => {})
async function submitWord() {
const value = word.value.trim()
if (!value) return
wordLoading.value = true
wordError.value = ''
saved.value = null
try {
const res = await addWord(value)
saved.value = (res as any).data ?? (res as any)
word.value = ''
ElMessage.success('已入库')
} catch (e: any) {
wordError.value = e?.response?.data?.error || '入库失败'
} finally {
wordLoading.value = false
}
}
</script>
<style scoped>
.wrap{padding:24px;}
.h{display:flex; align-items:center; justify-content:space-between;}
.title{font-size:22px; font-weight:700;}
.sub{color:#666; margin-top:4px;}
.memory-page { display: flex; flex-direction: column; gap: 16px; }
.top-row { align-items: stretch; }
.panel { border-radius: 16px; }
.panel-title { font-size: 20px; font-weight: 700; }
.panel-sub { margin-top: 4px; color: #667085; }
.full { width: 100%; }
.actions-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.action-buttons { display: flex; gap: 8px; }
.progress-row { margin-bottom: 10px; display: flex; gap: 14px; align-items: center; color: #475467; font-size: 13px; }
.metrics { margin: 12px 0 8px; }
.metric { background: #f8faff; border: 1px solid #e7ecf9; border-radius: 10px; padding: 10px; }
.metric span { font-size: 12px; color: #667085; display: block; }
.metric strong { font-size: 20px; }
.qa-block { margin-top: 14px; display: flex; flex-direction: column; gap: 10px; }
.question-title { font-size: 16px; font-weight: 700; }
.qa-card { border-radius: 12px; }
.q-main { font-size: 24px; font-weight: 800; }
.q-sub { margin-top: 8px; color: #667085; }
.audio-row { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
.options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
.option {
width: 100%;
height: 100%;
min-height: 52px;
margin: 0;
justify-content: flex-start;
align-items: flex-start;
text-align: left;
white-space: normal;
line-height: 1.35;
}
.options :deep(.el-button + .el-button) {
margin-left: 0;
}
.spelling { display: flex; gap: 10px; margin-top: 8px; }
@media (max-width: 900px) {
.actions-head { flex-direction: column; align-items: flex-start; }
.options { grid-template-columns: 1fr; }
}
</style>

View File

@@ -68,11 +68,15 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { audioUrl, getReviewWords, submitReview } from '../services/api'
import { loadPreferences } from '../modules/preferences/store'
import type { MemoryRecord, ReviewMode, ReviewResult } from '../services/api'
const mode = ref<ReviewMode>('spelling')
const route = useRoute()
const pref = loadPreferences()
const record = ref<MemoryRecord | null>(null)
const answer = ref('')
const loading = ref(false)
@@ -89,7 +93,8 @@ async function loadOne() {
result.value = null
submitted.value = false
answer.value = ''
const res = await getReviewWords({ mode: mode.value, limit: 1 })
const limit = Number(route.query.limit || 1) || 1
const res = await getReviewWords({ mode: mode.value, limit })
const arr = (res as any).data ?? (res as any)
record.value = Array.isArray(arr) && arr.length ? (arr[0] as MemoryRecord) : null
}
@@ -100,7 +105,7 @@ function nextOne() {
function play() {
if (!record.value?.word?.word) return
const a = new Audio(audioUrl(record.value.word.word, 'uk'))
const a = new Audio(audioUrl(record.value.word.word, pref.pronunciation))
a.play()
}

View File

@@ -1,28 +1,128 @@
<template>
<el-card>
<template #header>设置</template>
<el-descriptions :column="1" border>
<el-descriptions-item label="当前用户ID">{{ userId || '未登录' }}</el-descriptions-item>
<el-descriptions-item label="有道 API">已在后端 config.yaml 配置</el-descriptions-item>
<el-descriptions-item label="音频缓存目录">memora-api/audio</el-descriptions-item>
<el-descriptions-item label="当前前端框架">Vue3 + Element Plus</el-descriptions-item>
</el-descriptions>
<div class="settings-page">
<el-card>
<template #header>
<div class="head">
<div class="title">学习设置</div>
<el-button type="primary" @click="saveAll">保存</el-button>
</div>
</template>
<div style="margin-top:16px">
<el-button type="danger" plain @click="logout">退出登录</el-button>
</div>
</el-card>
<el-form label-position="top">
<el-form-item label="当前用户ID">
<el-input :model-value="String(userId || '未登录')" disabled />
</el-form-item>
<el-form-item label="读音偏好">
<el-radio-group v-model="preferences.pronunciation">
<el-radio label="uk">英音</el-radio>
<el-radio label="us">美音</el-radio>
</el-radio-group>
</el-form-item>
<el-divider>学习计划可重设</el-divider>
<el-form-item label="词库">
<el-select v-model="preferences.plan.wordBook" class="full">
<el-option label="四级词库" value="cet4" />
<el-option label="雅思词库" value="ielts" />
<el-option label="托福词库" value="toefl" />
<el-option label="自定义词库" value="custom" />
</el-select>
</el-form-item>
<el-form-item :label="`每日新词目标:${preferences.plan.dailyTarget}`">
<el-slider v-model="preferences.plan.dailyTarget" :min="5" :max="60" :step="5" />
</el-form-item>
<el-form-item label="提醒时间">
<el-time-select
v-model="preferences.plan.remindAt"
class="full"
start="06:00"
end="23:00"
step="00:30"
/>
</el-form-item>
<el-button @click="resetPlan" plain>重设学习计划下次进入学习页重新确认</el-button>
</el-form>
</el-card>
<el-card>
<template #header><div class="title">个人信息</div></template>
<el-form label-position="top">
<el-form-item label="头像 URL">
<el-input v-model="preferences.profile.avatar" placeholder="https://example.com/avatar.png" />
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="preferences.profile.nickname" placeholder="输入昵称" />
</el-form-item>
</el-form>
</el-card>
<el-card>
<template #header><div class="title">修改密码</div></template>
<el-form label-position="top">
<el-form-item label="当前密码">
<el-input v-model="passwordForm.oldPwd" type="password" show-password />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="passwordForm.newPwd" type="password" show-password />
</el-form-item>
</el-form>
<el-button type="primary" @click="changePassword">修改密码</el-button>
<div class="actions">
<el-button type="danger" plain @click="logout">退出登录</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { clearToken, getUserId } from '../modules/auth/auth'
import { loadPreferences, savePreferences } from '../modules/preferences/store'
const router = useRouter()
const userId = getUserId()
const preferences = reactive(loadPreferences())
const passwordForm = reactive({ oldPwd: '', newPwd: '' })
function saveAll() {
savePreferences(preferences)
ElMessage.success('设置已保存')
}
function resetPlan() {
preferences.plan.locked = false
savePreferences(preferences)
ElMessage.success('已重设学习计划,学习页将重新要求确认')
}
function changePassword() {
if (!passwordForm.oldPwd || !passwordForm.newPwd) {
ElMessage.warning('请填写完整密码信息')
return
}
passwordForm.oldPwd = ''
passwordForm.newPwd = ''
ElMessage.success('密码修改请求已提交(原型阶段)')
}
function logout() {
clearToken()
router.push('/login')
}
</script>
<style scoped>
.settings-page { display: flex; flex-direction: column; gap: 16px; }
.head { display: flex; justify-content: space-between; align-items: center; }
.title { font-size: 20px; font-weight: 700; }
.full { width: 100%; }
.actions { margin-top: 14px; }
</style>

View File

@@ -1,45 +1,117 @@
<template>
<div class="wrap">
<el-card>
<template #header>
<div class="h">
<div class="title">统计</div>
<el-button @click="refresh">刷新</el-button>
</div>
</template>
<el-card>
<template #header>
<div class="h">
<div class="title">学习统计</div>
<el-button @click="refresh">刷新</el-button>
</div>
</template>
<el-row :gutter="12">
<el-col :span="6"><el-statistic title="总单词" :value="stats.total_words ?? 0" /></el-col>
<el-col :span="6"><el-statistic title="已掌握" :value="stats.mastered_words ?? 0" /></el-col>
<el-col :span="6"><el-statistic title="待复习" :value="stats.need_review ?? 0" /></el-col>
<el-col :span="6"><el-statistic title="今日复习" :value="stats.today_reviewed ?? 0" /></el-col>
</el-row>
</el-card>
</div>
<el-row :gutter="14">
<el-col :xs="24" :md="12" :lg="6">
<div class="card" @click="openDetail('learned', todayLearned)">
<div class="label">今日已学</div>
<div class="value">{{ todayLearned }}</div>
<div class="tip">点击查看详情</div>
</div>
</el-col>
<el-col :xs="24" :md="12" :lg="6">
<div class="card" @click="openDetail('reviewed', stats.today_reviewed)">
<div class="label">今日复习</div>
<div class="value">{{ stats.today_reviewed }}</div>
<div class="tip">点击查看详情</div>
</div>
</el-col>
<el-col :xs="24" :md="12" :lg="6">
<div class="card" @click="openDetail('mastered', stats.mastered_words)">
<div class="label">已掌握</div>
<div class="value">{{ stats.mastered_words }}</div>
<div class="tip">点击查看详情</div>
</div>
</el-col>
<el-col :xs="24" :md="12" :lg="6">
<div class="card" @click="openDetail('total', stats.total_words)">
<div class="label">总词汇</div>
<div class="value">{{ stats.total_words }}</div>
<div class="tip">点击查看详情</div>
</div>
</el-col>
</el-row>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getStatistics } from '../services/api'
import { getTodayLearned } from '../modules/preferences/store'
import type { Stats } from '../services/api'
const stats = ref<Stats>({
const router = useRouter()
const todayLearned = ref(0)
const stats = reactive<Stats>({
total_words: 0,
mastered_words: 0,
need_review: 0,
today_reviewed: 0
})
async function refresh() {
const res = await getStatistics()
stats.value = res.data ?? res
function openDetail(metric: 'learned' | 'reviewed' | 'mastered' | 'total', value: number) {
router.push({ path: `/statistics/${metric}`, query: { value } })
}
onMounted(() => refresh().catch(console.error))
async function refresh() {
todayLearned.value = getTodayLearned()
const res = await getStatistics()
const data = res.data ?? res
Object.assign(stats, data)
}
onMounted(() => {
refresh().catch(console.error)
})
</script>
<style scoped>
.wrap{padding:24px;}
.h{display:flex; align-items:center; justify-content:space-between;}
.title{font-size:22px; font-weight:700;}
.h {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 22px;
font-weight: 700;
}
.card {
background: linear-gradient(165deg, #ffffff 0%, #f8faff 100%);
border: 1px solid #e6ecf9;
border-radius: 14px;
padding: 16px;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 22px rgba(24, 58, 172, 0.08);
}
.label {
color: #667085;
font-size: 14px;
}
.value {
margin-top: 8px;
font-size: 34px;
font-weight: 800;
}
.tip {
margin-top: 8px;
color: #2451d6;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<el-card>
<template #header>
<div class="head">
<div class="title">{{ config.title }}详情</div>
<el-button @click="$router.push('/statistics')">返回统计</el-button>
</div>
</template>
<el-alert type="info" :title="config.desc" show-icon />
<el-table :data="rows" border style="margin-top: 12px">
<el-table-column prop="date" label="日期" width="150" />
<el-table-column prop="value" :label="config.title" />
<el-table-column prop="note" label="说明" />
</el-table>
</el-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const configs: Record<string, { title: string; desc: string }> = {
learned: { title: '今日已学', desc: '展示今天新学单词累计情况' },
reviewed: { title: '今日复习', desc: '展示今天复习答题完成情况' },
mastered: { title: '已掌握', desc: '展示掌握程度达到阈值的单词数量变化' },
total: { title: '总词汇', desc: '展示词库总量变化趋势' }
}
const config = computed(() => configs[String(route.params.metric)] || configs.learned)
const rows = computed(() => {
const value = Number(route.query.value || 0)
const today = new Date()
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
return [{ date, value, note: '当前统计值(原型阶段)' }]
})
</script>
<style scoped>
.head {
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
font-size: 20px;
font-weight: 700;
}
</style>