Initial commit: WordLoop 单词学习应用

Vue 前端 + FastAPI 后端,含部署脚本与词典数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-04 14:30:53 -07:00
commit bd7635986a
66 changed files with 5495 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+115
View File
@@ -0,0 +1,115 @@
import axios from 'axios'
const request = axios.create({
baseURL: '/api',
timeout: 15000,
})
request.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
request.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login'
}
}
return Promise.reject(err)
}
)
export default request
export interface Word {
id: number
source_text: string
target_text: string
source_lang: string
target_lang: string
phonetic?: string
example_en?: string
example_cn?: string
status: string
correct_count: number
wrong_count: number
consecutive_correct_count: number
mastery_score: number
review_due_date?: string
last_reviewed_at?: string
created_at: string
}
export interface TranslateResult {
source_text: string
target_text: string
source_lang: string
target_lang: string
phonetic?: string
example_en?: string
example_cn?: string
}
export interface QuizOption {
label: string
text: string
}
export interface QuizQuestion {
word_id: number
question_type: string
prompt: string
options: QuizOption[]
correct_answer: string
}
export interface QuizStats {
total_words: number
new_count: number
learning_count: number
mastered_count: number
weak_count: number
today_quiz_count: number
today_correct_count: number
today_accuracy: number
daily_target: number
today_completed: number
streak_days: number
}
export interface Settings {
daily_target: number
master_required_count: number
weak_wrong_threshold: number
}
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 }),
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 } : {} }),
deleteWord: (id: number) => request.delete(`/words/${id}`),
dailyQuiz: () =>
request.get<{ questions: QuizQuestion[]; total: number }>('/quiz/daily'),
submitAnswer: (data: {
word_id: number
question_type: string
user_answer: string
correct_answer: string
}) => request.post('/quiz/answer', data),
quizStats: () => request.get<QuizStats>('/quiz/stats'),
getSettings: () => request.get<Settings>('/settings'),
updateSettings: (data: Partial<Settings>) => request.patch<Settings>('/settings', data),
}
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import type { QuizQuestion } from '../api/request'
defineProps<{
question: QuizQuestion
index: number
total: number
selected?: string
showResult?: boolean
isCorrect?: boolean
}>()
defineEmits<{
select: [answer: string]
}>()
const typeLabel: Record<string, string> = {
en_to_zh: '看英文选中文',
zh_to_en: '看中文选英文',
}
</script>
<template>
<div class="card quiz-card">
<div class="quiz-progress">{{ index + 1 }} / {{ total }}</div>
<div class="quiz-type">{{ typeLabel[question.question_type] || question.question_type }}</div>
<div class="quiz-prompt">{{ question.prompt }}</div>
<div class="options">
<button
v-for="opt in question.options"
:key="opt.label"
class="option-btn"
:class="{
selected: selected === opt.text,
correct: showResult && opt.text === question.correct_answer,
wrong: showResult && selected === opt.text && opt.text !== question.correct_answer,
}"
:disabled="showResult"
@click="$emit('select', opt.text)"
>
<span class="opt-label">{{ opt.label }}.</span> {{ opt.text }}
</button>
</div>
<div v-if="showResult" class="result" :class="isCorrect ? 'ok' : 'fail'">
{{ isCorrect ? '回答正确 ' : '回答错误 ' }}
<template v-if="!isCorrect"> 正确答案{{ 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;
}
.options {
display: flex;
flex-direction: column;
gap: 10px;
}
.option-btn {
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
font-size: 15px;
text-align: left;
cursor: pointer;
}
.option-btn.selected {
border-color: var(--primary);
background: rgba(79, 110, 247, 0.08);
}
.option-btn.correct {
border-color: var(--success);
background: #d1fae5;
}
.option-btn.wrong {
border-color: var(--danger);
background: #fee2e2;
}
.opt-label {
font-weight: 700;
margin-right: 6px;
}
.result {
margin-top: 16px;
padding: 12px;
border-radius: var(--radius);
font-size: 14px;
text-align: center;
}
.result.ok { background: #d1fae5; color: #047857; }
.result.fail { background: #fee2e2; color: #b91c1c; }
</style>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { Word } from '../api/request'
defineProps<{
word: Word
}>()
defineEmits<{
delete: [id: number]
}>()
const statusMap: Record<string, string> = {
new: '新词',
learning: '学习中',
mastered: '已掌握',
weak: '易错词',
}
function enText(word: Word) {
return word.source_lang === 'en' ? word.source_text : word.target_text
}
function zhText(word: Word) {
return word.source_lang === 'zh' ? word.source_text : word.target_text
}
</script>
<template>
<div class="card word-card">
<div class="word-header">
<div>
<div class="word-pair">{{ enText(word) }} {{ zhText(word) }}</div>
<span :class="['badge', `badge-${word.status}`]">{{ statusMap[word.status] || word.status }}</span>
</div>
<button class="btn btn-danger" @click="$emit('delete', word.id)">删除</button>
</div>
<div class="word-meta">
<span>答对 {{ word.correct_count }}</span>
<span>答错 {{ word.wrong_count }}</span>
<span>连续 {{ word.consecutive_correct_count }}</span>
<span>掌握率 {{ word.mastery_score }}%</span>
</div>
<div v-if="word.review_due_date" class="word-due">下次复习{{ word.review_due_date }}</div>
</div>
</template>
<style scoped>
.word-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.word-pair {
font-size: 17px;
font-weight: 600;
margin-bottom: 6px;
}
.word-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 12px;
color: var(--muted);
margin-top: 10px;
}
.word-due {
font-size: 12px;
color: var(--primary);
margin-top: 6px;
}
</style>
+27
View File
@@ -0,0 +1,27 @@
<template>
<div class="main-layout">
<router-view />
<nav class="nav-bottom">
<router-link to="/" class="nav-item">
<span class="nav-icon">📊</span>
首页
</router-link>
<router-link to="/translate" class="nav-item">
<span class="nav-icon">🔤</span>
翻译
</router-link>
<router-link to="/words" class="nav-item">
<span class="nav-icon">📚</span>
词库
</router-link>
<router-link to="/quiz" class="nav-item">
<span class="nav-icon"></span>
训练
</router-link>
<router-link to="/settings" class="nav-item">
<span class="nav-icon"></span>
设置
</router-link>
</nav>
</div>
</template>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './styles.css'
createApp(App).use(router).mount('#app')
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import QuizCard from '../components/QuizCard.vue'
import { api, type QuizQuestion } from '../api/request'
const questions = ref<QuizQuestion[]>([])
const currentIndex = ref(0)
const selected = 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 current = () => questions.value[currentIndex.value]
onMounted(async () => {
try {
const { data } = await api.dailyQuiz()
questions.value = data.questions
if (data.questions.length === 0) {
empty.value = true
}
} finally {
loading.value = false
}
})
async function onSelect(answer: string) {
if (showResult.value) return
selected.value = answer
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++
}
showResult.value = true
}
function nextQuestion() {
if (currentIndex.value >= questions.value.length - 1) {
finished.value = true
return
}
currentIndex.value++
selected.value = ''
showResult.value = false
isCorrect.value = false
}
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="loading" style="color: var(--muted)">加载题目...</p>
<div v-else-if="empty" class="card" style="text-align: center">
<p>词库单词不足请先通过翻译添加至少 4 个单词</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()">
<QuizCard
:question="current()!"
:index="currentIndex"
:total="questions.length"
:selected="selected"
:show-result="showResult"
:is-correct="isCorrect"
@select="onSelect"
/>
<button
v-if="showResult"
class="btn btn-primary"
style="margin-top: 12px"
@click="nextQuestion"
>
{{ currentIndex >= questions.length - 1 ? '查看结果' : '下一题' }}
</button>
</template>
</div>
</template>
<style scoped>
.summary h2 {
margin-bottom: 12px;
font-size: 20px;
}
.summary p {
margin: 6px 0;
font-size: 15px;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type QuizStats } from '../api/request'
const stats = ref<QuizStats | null>(null)
const loading = ref(true)
onMounted(async () => {
try {
const { data } = await api.quizStats()
stats.value = data
} finally {
loading.value = false
}
})
</script>
<template>
<div class="page">
<h1 class="page-title">WordLoop</h1>
<p v-if="loading" style="color: var(--muted)">加载中...</p>
<template v-else-if="stats">
<div class="card highlight-card">
<div class="highlight-title">今日复习</div>
<div class="highlight-value">
{{ stats.today_completed }} / {{ stats.daily_target }}
</div>
<div class="highlight-sub">
正确率 {{ stats.today_accuracy }}% · 连续学习 {{ stats.streak_days }}
</div>
</div>
<div class="stat-grid">
<div class="stat-card">
<div class="stat-value">{{ stats.total_words }}</div>
<div class="stat-label">总单词数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.mastered_count }}</div>
<div class="stat-label">已掌握</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.learning_count }}</div>
<div class="stat-label">学习中</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.weak_count }}</div>
<div class="stat-label">易错词</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.new_count }}</div>
<div class="stat-label">新词</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.today_accuracy }}%</div>
<div class="stat-label">今日正确率</div>
</div>
</div>
<div class="quick-actions">
<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>
</div>
</template>
</div>
</template>
<style scoped>
.highlight-card {
background: linear-gradient(135deg, #4f6ef7, #6b8cff);
color: #fff;
margin-bottom: 16px;
}
.highlight-title { font-size: 14px; opacity: 0.9; }
.highlight-value { font-size: 36px; font-weight: 800; margin: 8px 0; }
.highlight-sub { font-size: 13px; opacity: 0.85; }
.quick-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 20px;
}
.quick-actions .btn { text-decoration: none; }
</style>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/request'
const router = useRouter()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function handleLogin() {
error.value = ''
if (!username.value || !password.value) {
error.value = '请填写用户名和密码'
return
}
loading.value = true
try {
const { data } = await api.login(username.value, password.value)
localStorage.setItem('token', data.access_token)
router.push('/')
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }
error.value = err.response?.data?.detail || '登录失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="page auth-page">
<div class="auth-logo">WordLoop</div>
<p class="auth-sub">单词循环记忆系统</p>
<div v-if="error" class="message error">{{ error }}</div>
<div class="card">
<label class="label">用户名</label>
<input v-model="username" class="input" placeholder="请输入用户名" />
<label class="label">密码</label>
<input v-model="password" type="password" class="input" placeholder="请输入密码" />
<button class="btn btn-primary" style="margin-top: 16px" :disabled="loading" @click="handleLogin">
{{ loading ? '登录中...' : '登录' }}
</button>
</div>
<p class="auth-link">还没有账号<router-link to="/register">立即注册</router-link></p>
</div>
</template>
<style scoped>
.auth-page {
padding-top: 60px;
}
.auth-logo {
font-size: 32px;
font-weight: 800;
color: var(--primary);
text-align: center;
}
.auth-sub {
text-align: center;
color: var(--muted);
margin: 8px 0 24px;
}
.label {
display: block;
font-size: 13px;
color: var(--muted);
margin: 12px 0 6px;
}
.auth-link {
text-align: center;
margin-top: 20px;
font-size: 14px;
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/request'
const router = useRouter()
const username = ref('')
const password = ref('')
const confirm = ref('')
const error = ref('')
const loading = ref(false)
async function handleRegister() {
error.value = ''
if (!username.value || !password.value) {
error.value = '请填写完整信息'
return
}
if (password.value !== confirm.value) {
error.value = '两次密码不一致'
return
}
if (password.value.length < 6) {
error.value = '密码至少 6 位'
return
}
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)
router.push('/')
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }
error.value = err.response?.data?.detail || '注册失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="page auth-page">
<div class="auth-logo">注册账号</div>
<div v-if="error" class="message error">{{ error }}</div>
<div class="card">
<label class="label">用户名</label>
<input v-model="username" class="input" placeholder="2-50 个字符" />
<label class="label">密码</label>
<input v-model="password" type="password" class="input" placeholder="至少 6 位" />
<label class="label">确认密码</label>
<input v-model="confirm" type="password" class="input" placeholder="再次输入密码" />
<button class="btn btn-primary" style="margin-top: 16px" :disabled="loading" @click="handleRegister">
{{ loading ? '注册中...' : '注册' }}
</button>
</div>
<p class="auth-link">已有账号<router-link to="/login">去登录</router-link></p>
</div>
</template>
<style scoped>
.auth-page { padding-top: 40px; }
.auth-logo {
font-size: 26px;
font-weight: 700;
text-align: center;
margin-bottom: 20px;
}
.label {
display: block;
font-size: 13px;
color: var(--muted);
margin: 12px 0 6px;
}
.auth-link {
text-align: center;
margin-top: 20px;
font-size: 14px;
}
</style>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type Settings } from '../api/request'
const settings = ref<Settings>({
daily_target: 20,
master_required_count: 3,
weak_wrong_threshold: 3,
})
const message = ref('')
const loading = ref(false)
onMounted(async () => {
const { data } = await api.getSettings()
settings.value = data
})
async function save() {
loading.value = true
message.value = ''
try {
const { data } = await api.updateSettings(settings.value)
settings.value = data
message.value = '设置已保存 ✅'
} catch {
message.value = '保存失败'
} finally {
loading.value = false
}
}
function logout() {
localStorage.removeItem('token')
window.location.href = '/login'
}
</script>
<template>
<div class="page">
<h1 class="page-title">学习设置</h1>
<div v-if="message" class="message success">{{ message }}</div>
<div class="card">
<label class="label">每日训练数量</label>
<input v-model.number="settings.daily_target" type="number" min="1" max="100" class="input" />
<label class="label">连续答对几次算掌握</label>
<input v-model.number="settings.master_required_count" type="number" min="1" max="20" class="input" />
<label class="label">累计错几次进入易错词</label>
<input v-model.number="settings.weak_wrong_threshold" type="number" min="1" max="20" class="input" />
<button class="btn btn-primary" style="margin-top: 16px" :disabled="loading" @click="save">
{{ loading ? '保存中...' : '保存设置' }}
</button>
</div>
<button class="btn btn-outline" style="margin-top: 20px; width: 100%" @click="logout">
退出登录
</button>
</div>
</template>
<style scoped>
.label {
display: block;
font-size: 13px;
color: var(--muted);
margin: 14px 0 6px;
}
.label:first-child { margin-top: 0; }
</style>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref } from 'vue'
import { api, type TranslateResult } from '../api/request'
const input = ref('')
const result = ref<TranslateResult | null>(null)
const message = ref('')
const messageType = ref<'success' | 'error'>('success')
const loading = ref(false)
async function handleTranslate() {
if (!input.value.trim()) return
loading.value = true
message.value = ''
result.value = null
try {
const { data } = await api.translate(input.value.trim())
result.value = data
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }
message.value = err.response?.data?.detail || '翻译失败'
messageType.value = 'error'
} finally {
loading.value = false
}
}
async function addToLibrary() {
if (!result.value) return
message.value = ''
try {
await api.createWord({
source_text: result.value.source_text,
target_text: result.value.target_text,
source_lang: result.value.source_lang,
target_lang: result.value.target_lang,
phonetic: result.value.phonetic,
example_en: result.value.example_en,
example_cn: result.value.example_cn,
})
message.value = '已加入单词库 ✅'
messageType.value = 'success'
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }
message.value = err.response?.data?.detail || '添加失败'
messageType.value = 'error'
}
}
const langLabel = (s: string, t: string) =>
s === 'zh' ? '中文 → 英文' : '英文 → 中文'
</script>
<template>
<div class="page">
<h1 class="page-title">翻译</h1>
<textarea
v-model="input"
class="input translate-input"
rows="3"
placeholder="输入中文或英文,例如:苹果 或 apple"
/>
<button class="btn btn-primary" style="margin-top: 12px" :disabled="loading" @click="handleTranslate">
{{ loading ? '翻译中...' : '翻译' }}
</button>
<div v-if="message" :class="['message', messageType]">{{ message }}</div>
<div v-if="result" class="card result-card">
<div class="lang-dir">{{ langLabel(result.source_lang, result.target_lang) }}</div>
<div class="result-row">
<span class="label-sm">原文</span>
<span>{{ result.source_text }}</span>
</div>
<div class="result-row main">
<span class="label-sm">译文</span>
<span>{{ result.target_text }}</span>
</div>
<div v-if="result.phonetic" class="result-row">
<span class="label-sm">音标</span>
<span>{{ result.phonetic }}</span>
</div>
<div v-if="result.example_en" class="result-row">
<span class="label-sm">英文例句</span>
<span>{{ result.example_en }}</span>
</div>
<div v-if="result.example_cn" class="result-row">
<span class="label-sm">中文例句</span>
<span>{{ result.example_cn }}</span>
</div>
<button class="btn btn-primary" style="margin-top: 14px" @click="addToLibrary">
加入单词库
</button>
</div>
</div>
</template>
<style scoped>
.translate-input {
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.result-card { margin-top: 16px; }
.lang-dir {
font-size: 12px;
color: var(--primary);
margin-bottom: 12px;
}
.result-row {
margin-bottom: 10px;
font-size: 15px;
}
.result-row.main {
font-size: 22px;
font-weight: 700;
}
.label-sm {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 2px;
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import WordCard from '../components/WordCard.vue'
import { api, type Word } from '../api/request'
const tabs = [
{ key: '', label: '全部' },
{ key: 'new', label: '新词' },
{ key: 'learning', label: '学习中' },
{ key: 'mastered', label: '已掌握' },
{ key: 'weak', label: '易错词' },
]
const activeTab = ref('')
const words = ref<Word[]>([])
const loading = ref(true)
async function loadWords() {
loading.value = true
try {
const { data } = await api.listWords(activeTab.value || undefined)
words.value = data
} finally {
loading.value = false
}
}
async function handleDelete(id: number) {
if (!confirm('确定删除这个单词?')) return
await api.deleteWord(id)
await loadWords()
}
watch(activeTab, loadWords)
onMounted(loadWords)
</script>
<template>
<div class="page">
<h1 class="page-title">单词库</h1>
<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>
<WordCard
v-for="w in words"
:key="w.id"
:word="w"
@delete="handleDelete"
/>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'Login', component: () => import('../pages/Login.vue') },
{ path: '/register', name: 'Register', component: () => import('../pages/Register.vue') },
{
path: '/',
component: () => import('../layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Dashboard', component: () => import('../pages/Dashboard.vue') },
{ 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: 'settings', name: 'Settings', component: () => import('../pages/Settings.vue') },
],
},
],
})
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else if ((to.path === '/login' || to.path === '/register') && token) {
next('/')
} else {
next()
}
})
export default router
+227
View File
@@ -0,0 +1,227 @@
:root {
--primary: #4f6ef7;
--primary-dark: #3d57d4;
--bg: #f4f6fb;
--card: #ffffff;
--text: #1a1d26;
--muted: #6b7280;
--success: #10b981;
--danger: #ef4444;
--border: #e5e7eb;
--radius: 12px;
--shadow: 0 2px 12px rgba(79, 110, 247, 0.08);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC',
'Microsoft YaHei', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
#app {
min-height: 100vh;
}
a {
color: var(--primary);
text-decoration: none;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 20px;
border: none;
border-radius: var(--radius);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background: var(--primary);
color: #fff;
width: 100%;
}
.btn-primary:hover {
background: var(--primary-dark);
}
.btn-outline {
background: transparent;
border: 1px solid var(--primary);
color: var(--primary);
}
.btn-danger {
background: #fee2e2;
color: var(--danger);
padding: 8px 12px;
font-size: 13px;
}
.input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 16px;
outline: none;
background: #fff;
}
.input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(79, 110, 247, 0.15);
}
.card {
background: var(--card);
border-radius: var(--radius);
padding: 16px;
box-shadow: var(--shadow);
margin-bottom: 12px;
}
.page {
max-width: 480px;
margin: 0 auto;
padding: 16px;
padding-bottom: 80px;
}
.page-title {
font-size: 22px;
font-weight: 700;
margin-bottom: 16px;
}
.tabs {
display: flex;
gap: 8px;
overflow-x: auto;
margin-bottom: 16px;
padding-bottom: 4px;
}
.tab {
flex-shrink: 0;
padding: 8px 14px;
border-radius: 20px;
font-size: 13px;
background: #fff;
border: 1px solid var(--border);
cursor: pointer;
color: var(--muted);
}
.tab.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.nav-bottom {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-around;
padding: 8px 0 calc(8px + env(safe-area-inset-bottom));
z-index: 100;
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
font-size: 11px;
color: var(--muted);
padding: 4px 12px;
text-decoration: none;
}
.nav-item.router-link-active {
color: var(--primary);
font-weight: 600;
}
.nav-icon {
font-size: 20px;
margin-bottom: 2px;
}
.stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat-card {
background: var(--card);
border-radius: var(--radius);
padding: 14px;
text-align: center;
box-shadow: var(--shadow);
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--primary);
}
.stat-label {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.badge-new { background: #dbeafe; color: #1d4ed8; }
.badge-learning { background: #fef3c7; color: #b45309; }
.badge-mastered { background: #d1fae5; color: #047857; }
.badge-weak { background: #fee2e2; color: #b91c1c; }
.message {
padding: 10px 14px;
border-radius: var(--radius);
margin-bottom: 12px;
font-size: 14px;
}
.message.error {
background: #fee2e2;
color: var(--danger);
}
.message.success {
background: #d1fae5;
color: #047857;
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, unknown>
export default component
}