Initial commit: WordLoop 单词学习应用
Vue 前端 + FastAPI 后端,含部署脚本与词典数据。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, unknown>
|
||||
export default component
|
||||
}
|
||||
Reference in New Issue
Block a user