移除词汇掌握功能,专注试卷管理系统

This commit is contained in:
gao
2025-11-05 21:22:08 +08:00
parent 06427fa8ce
commit 8991f63f52
16 changed files with 7234 additions and 1843 deletions
+1
View File
@@ -0,0 +1 @@
88739
+1
View File
@@ -0,0 +1 @@
88751
+21
View File
@@ -0,0 +1,21 @@
INFO: Started server process [61028]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8888 (Press CTRL+C to quit)
INFO: 127.0.0.1:54462 - "GET /docs HTTP/1.1" 200 OK
INFO: 127.0.0.1:54501 - "GET /api/papers?limit=50&subject=math HTTP/1.1" 200 OK
INFO: 127.0.0.1:54519 - "GET /api/papers?limit=50&subject=math HTTP/1.1" 200 OK
INFO: 127.0.0.1:54527 - "GET /api/papers?limit=50&subject=math HTTP/1.1" 200 OK
INFO: 127.0.0.1:54531 - "GET /api/dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:54542 - "GET /api/dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:54576 - "GET /api/papers?limit=50&subject=math HTTP/1.1" 200 OK
INFO: 127.0.0.1:54680 - "GET /api/papers?limit=50&subject=math HTTP/1.1" 200 OK
INFO: 127.0.0.1:54686 - "GET /api/papers?limit=50&subject=math HTTP/1.1" 200 OK
INFO: 127.0.0.1:65058 - "GET /api/papers?limit=50&subject=math HTTP/1.1" 401 Unauthorized
INFO: 127.0.0.1:60764 - "POST /api/auth/login HTTP/1.1" 200 OK
INFO: 127.0.0.1:60772 - "GET /api/dashboard HTTP/1.1" 200 OK
INFO: 127.0.0.1:61103 - "GET /api/dashboard HTTP/1.1" 200 OK
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [61028]
+17
View File
@@ -0,0 +1,17 @@
# 开发环境配置
NODE_ENV=development
VITE_MODE=development
# API配置 - 开发环境使用代理
VITE_API_BASE_URL=/api
VITE_FILE_BASE_URL=/api
VITE_WS_BASE_URL=ws://localhost:8888/ws
# 后端服务地址 (仅用于代理配置)
VITE_BACKEND_HOST=localhost
VITE_BACKEND_PORT=8888
# 调试配置
VITE_DEBUG=true
VITE_LOG_LEVEL=debug
VITE_SERVER_PORT=3008
+4 -24
View File
@@ -2,14 +2,12 @@ import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import Login from '../views/Login.vue'
import Register from '../views/Register.vue'
import Learning from '../views/Learning.vue'
import Statistics from '../views/Statistics.vue'
import WordManagement from '../views/WordManagement.vue'
import MainDashboard from '../views/MainDashboard.vue'
import SubjectPapers from '../views/SubjectPapers.vue'
import PaperDetail from '../views/PaperDetail.vue'
import MainDashboard from '../views/DashboardEnhanced.vue'
import SubjectPapers from '../views/PaperManagementEnhanced.vue'
import PaperDetail from '../views/PaperDetailEnhanced.vue'
import PaperViewer from '../views/PaperViewer.vue'
import MistakeReview from '../views/MistakeReview.vue'
import MistakeReview from '../views/ReviewEnhanced.vue'
const routes = [
{
@@ -52,30 +50,12 @@ const routes = [
component: MistakeReview,
meta: { requiresAuth: true }
},
{
path: '/words',
name: 'WordLearning',
component: Learning,
meta: { requiresAuth: true }
},
{
path: '/learning',
name: 'Learning',
component: Learning,
meta: { requiresAuth: true }
},
{
path: '/statistics',
name: 'Statistics',
component: Statistics,
meta: { requiresAuth: true }
},
{
path: '/word-management',
name: 'WordManagement',
component: WordManagement,
meta: { requiresAuth: true }
},
{
path: '/login',
name: 'Login',
-117
View File
@@ -1,117 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '../services/api'
export const useWordStore = defineStore('word', () => {
const words = ref([])
const dueWords = ref([])
const currentWord = ref(null)
const currentIndex = ref(0)
const sessionWords = ref([])
const learningMode = ref('flashcard')
const direction = ref('en2zh')
const fetchWords = async (level = null) => {
try {
const params = level ? { level } : {}
const response = await api.get('/words', { params })
words.value = response.data
return response.data
} catch (error) {
throw new Error('获取单词失败')
}
}
const fetchDueWords = async () => {
try {
const response = await api.get('/study/due')
dueWords.value = response.data
sessionWords.value = [...response.data]
return response.data
} catch (error) {
throw new Error('获取待学习单词失败')
}
}
const submitStudySession = async (wordId, quality, timeSpent = 0) => {
try {
const response = await api.post('/study/submit', {
word_id: wordId,
quality,
time_spent: timeSpent
})
return response.data
} catch (error) {
throw new Error('提交学习结果失败')
}
}
const importWords = async (file) => {
try {
const formData = new FormData()
formData.append('file', file)
const response = await api.post('/words/import', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || '导入单词失败')
}
}
const createWord = async (wordData) => {
try {
const response = await api.post('/words', wordData)
return response.data
} catch (error) {
throw new Error('创建单词失败')
}
}
const nextWord = () => {
if (currentIndex.value < sessionWords.value.length) {
currentWord.value = sessionWords.value[currentIndex.value]
currentIndex.value++
return currentWord.value
}
return null
}
const startNewSession = async () => {
await fetchDueWords()
currentIndex.value = 0
if (sessionWords.value.length > 0) {
currentWord.value = sessionWords.value[0]
currentIndex.value = 1
}
}
const toggleDirection = () => {
direction.value = direction.value === 'en2zh' ? 'zh2en' : 'en2zh'
}
const setLearningMode = (mode) => {
learningMode.value = mode
}
return {
words,
dueWords,
currentWord,
currentIndex,
sessionWords,
learningMode,
direction,
fetchWords,
fetchDueWords,
submitStudySession,
importWords,
createWord,
nextWord,
startNewSession,
toggleDirection,
setLearningMode
}
})
+16 -92
View File
@@ -4,7 +4,7 @@
<!-- 导航栏 -->
<el-header class="header glass-effect">
<div class="header-content">
<h1>🧠 WordMaster Pro</h1>
<h1>🧠 试卷大师</h1>
<el-menu
:default-active="$route.name"
mode="horizontal"
@@ -14,9 +14,7 @@
active-text-color="#00f2fe"
>
<el-menu-item index="Dashboard">📊 仪表盘</el-menu-item>
<el-menu-item index="Learning">🎯 学习</el-menu-item>
<el-menu-item index="Statistics">📈 统计</el-menu-item>
<el-menu-item index="WordManagement">📚 词库</el-menu-item>
</el-menu>
<el-button @click="logout" type="danger" plain>退出登录</el-button>
</div>
@@ -29,20 +27,20 @@
<el-row :gutter="20" style="margin-bottom: 20px;">
<el-col :span="6">
<div class="stats-card">
<h3>{{ stats.total_words }}</h3>
<p>总词汇量</p>
<h3>{{ stats.papers_count }}</h3>
<p>试卷总数</p>
</div>
</el-col>
<el-col :span="6">
<div class="stats-card">
<h3>{{ stats.due_today }}</h3>
<p>今日待复习</p>
<h3>{{ stats.mistakes_count }}</h3>
<p>错题总数</p>
</div>
</el-col>
<el-col :span="6">
<div class="stats-card">
<h3>{{ stats.completed_today }}</h3>
<p>今日已完成</p>
<p>今日完成</p>
</div>
</el-col>
<el-col :span="6">
@@ -65,24 +63,16 @@
type="primary"
size="large"
@click="startLearning"
:disabled="stats.due_today === 0"
class="btn-gradient"
>
开始学习 ({{ stats.due_today }} 个单词)
开始学习
</el-button>
<el-button
size="large"
@click="quickReview"
@click="startReview"
class="btn-gradient"
>
快速复习 (3分钟)
</el-button>
<el-button
size="large"
@click="reviewLeechWords"
class="btn-gradient"
>
顽固词汇专练
开始复习
</el-button>
</div>
</el-card>
@@ -193,35 +183,21 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '../stores/auth'
import { useWordStore } from '../stores/word'
import api from '../services/api'
const router = useRouter()
const authStore = useAuthStore()
const wordStore = useWordStore()
const stats = ref({
total_words: 0,
due_today: 0,
papers_count: 0,
mistakes_count: 0,
completed_today: 0,
accuracy_rate: 0,
streak_days: 1
})
const recentWords = ref([])
const showAddWordDialog = ref(false)
const addingWord = ref(false)
const newWord = reactive({
word: '',
meaning: '',
example: '',
level: 'middle_school'
})
const todayProgress = computed(() => {
if (stats.value.due_today === 0) return 100
return Math.round((stats.value.completed_today / (stats.value.due_today + stats.value.completed_today)) * 100)
return Math.round((stats.value.completed_today / (stats.value.papers_count || 1)) * 100)
})
const progressColor = computed(() => {
@@ -232,70 +208,19 @@ const progressColor = computed(() => {
const fetchStats = async () => {
try {
const response = await api.get('/stats')
const response = await api.get('/dashboard')
stats.value = response.data
} catch (error) {
ElMessage.error('获取统计信息失败')
}
}
const fetchRecentWords = async () => {
try {
const response = await wordStore.fetchWords()
recentWords.value = response.slice(-5).reverse()
} catch (error) {
ElMessage.error('获取单词列表失败')
}
}
const startLearning = () => {
router.push('/learning')
router.push('/subjects/math')
}
const quickReview = () => {
router.push('/learning?mode=quick')
}
const reviewLeechWords = () => {
router.push('/learning?mode=leech')
}
const handleImport = async (file) => {
try {
await wordStore.importWords(file)
ElMessage.success('导入成功')
fetchStats()
fetchRecentWords()
} catch (error) {
ElMessage.error(error.message)
}
return false // 阻止自动上传
}
const addWord = async () => {
if (!newWord.word || !newWord.meaning) {
ElMessage.error('请填写单词和中文意思')
return
}
addingWord.value = true
try {
await wordStore.createWord(newWord)
ElMessage.success('添加成功')
showAddWordDialog.value = false
Object.assign(newWord, {
word: '',
meaning: '',
example: '',
level: 'middle_school'
})
fetchStats()
fetchRecentWords()
} catch (error) {
ElMessage.error('添加失败')
} finally {
addingWord.value = false
}
const startReview = () => {
router.push('/review')
}
const logout = () => {
@@ -305,7 +230,6 @@ const logout = () => {
onMounted(() => {
fetchStats()
fetchRecentWords()
})
</script>
File diff suppressed because it is too large Load Diff
-759
View File
@@ -1,759 +0,0 @@
<template>
<div class="gradient-bg">
<div class="learning-container">
<!-- 导航栏 -->
<el-header class="header glass-effect">
<div class="header-content">
<el-button @click="$router.back()" icon="ArrowLeft" circle />
<h1>🎯 单词学习</h1>
<div class="header-actions">
<el-button @click="toggleDirection" icon="Refresh">
{{ direction === 'en2zh' ? '英→中' : '中→英' }}
</el-button>
<el-button @click="speakWord" icon="VideoPlay" circle v-if="currentWord" />
</div>
</div>
</el-header>
<!-- 学习模式选择 -->
<div class="mode-selector">
<el-button-group>
<el-button
:type="learningMode === 'flashcard' ? 'primary' : ''"
@click="setLearningMode('flashcard')"
>
🃏 抽认卡
</el-button>
<el-button
:type="learningMode === 'typing' ? 'primary' : ''"
@click="setLearningMode('typing')"
>
⌨️ 输入
</el-button>
<el-button
:type="learningMode === 'choice' ? 'primary' : ''"
@click="setLearningMode('choice')"
>
🎯 选择
</el-button>
<el-button
:type="learningMode === 'cloze' ? 'primary' : ''"
@click="setLearningMode('cloze')"
>
📝 完形填空
</el-button>
</el-button-group>
</div>
<!-- 学习卡片 -->
<div class="learning-content">
<div v-if="!currentWord" class="learning-card">
<div class="content">
<div class="word-display">准备开始学习</div>
<div class="meaning-display">点击开始按钮开始学习之旅</div>
<el-button
type="primary"
size="large"
@click="startLearning"
class="btn-gradient"
>
🚀 开始学习
</el-button>
</div>
</div>
<div v-else class="learning-card">
<div class="content">
<!-- 抽认卡模式 -->
<div v-if="learningMode === 'flashcard'">
<div class="word-display">{{ displayWord }}</div>
<div class="meaning-display">{{ displayMeaning }}</div>
<div class="example-display" v-if="currentWord.example">
{{ currentWord.example }}
</div>
<!-- SM-2评分按钮 -->
<div class="rating-buttons">
<el-button
v-for="(rating, index) in ratings"
:key="index"
:type="getRatingType(index)"
@click="rateWord(index)"
size="large"
round
>
{{ index }}
</el-button>
</div>
<div class="rating-labels">
<span v-for="(rating, index) in ratings" :key="index">
{{ rating }}
</span>
</div>
</div>
<!-- 输入模式 -->
<div v-if="learningMode === 'typing'">
<div class="word-display">{{ direction === 'en2zh' ? currentWord.word : currentWord.meaning }}</div>
<div class="meaning-display">
请输入{{ direction === 'en2zh' ? '中文意思' : '英文单词' }}:
</div>
<div class="example-display" v-if="currentWord.example">
{{ currentWord.example }}
</div>
<div class="input-area">
<el-input
v-model="userInput"
size="large"
placeholder="请输入答案..."
@keyup.enter="checkAnswer"
ref="inputRef"
/>
<el-button
type="primary"
size="large"
@click="checkAnswer"
:disabled="!userInput.trim()"
style="margin-top: 15px;"
>
检查答案
</el-button>
</div>
<!-- 答案反馈 -->
<div v-if="showResult" class="result-feedback">
<div :class="['result-text', isCorrect ? 'correct' : 'incorrect']">
{{ isCorrect ? '✓ 正确!' : '✗ 错误!' }}
</div>
<div class="correct-answer" v-if="!isCorrect">
正确答案:{{ correctAnswer }}
</div>
</div>
</div>
<!-- 选择模式 -->
<div v-if="learningMode === 'choice'">
<div class="word-display">{{ direction === 'en2zh' ? currentWord.word : currentWord.meaning }}</div>
<div class="meaning-display">
请选择正确的{{ direction === 'en2zh' ? '中文意思' : '英文单词' }}:
</div>
<div class="choices">
<el-button
v-for="(choice, index) in choices"
:key="index"
size="large"
@click="selectChoice(choice, index)"
:disabled="choiceSelected"
:type="getChoiceType(choice, index)"
style="margin: 5px; width: 45%;"
>
{{ direction === 'en2zh' ? choice.meaning : choice.word }}
</el-button>
</div>
</div>
<!-- 完形填空模式 -->
<div v-if="learningMode === 'cloze'">
<div class="word-display">完形填空</div>
<div class="meaning-display">{{ currentWord.meaning }}</div>
<div class="cloze-sentence">{{ clozeText }}</div>
<div class="input-area">
<el-input
v-model="userInput"
size="large"
placeholder="请填入空缺的单词..."
@keyup.enter="checkAnswer"
ref="inputRef"
/>
<el-button
type="primary"
size="large"
@click="checkAnswer"
:disabled="!userInput.trim()"
style="margin-top: 15px;"
>
检查答案
</el-button>
</div>
<!-- 答案反馈 -->
<div v-if="showResult" class="result-feedback">
<div :class="['result-text', isCorrect ? 'correct' : 'incorrect']">
{{ isCorrect ? '✓ 正确!' : '✗ 错误!' }}
</div>
<div class="correct-answer" v-if="!isCorrect">
正确答案:{{ currentWord.word }}
</div>
</div>
</div>
</div>
</div>
<!-- 进度条 -->
<div class="progress-area" v-if="sessionWords.length > 0">
<el-progress
:percentage="sessionProgress"
:color="progressColor"
:stroke-width="8"
/>
<div class="progress-text">
进度:{{ currentIndex }} / {{ sessionWords.length }}
</div>
</div>
<!-- 控制按钮 -->
<div class="controls">
<el-button @click="nextWord" size="large" :disabled="!currentWord">
⏭️ 下一个
</el-button>
<el-button @click="skipWord" size="large" :disabled="!currentWord">
⏩ 跳过
</el-button>
<el-button @click="endSession" size="large" type="danger">
🛑 结束学习
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useWordStore } from '../stores/word'
const router = useRouter()
const route = useRoute()
const wordStore = useWordStore()
// 响应式数据
const currentWord = ref(null)
const sessionWords = ref([])
const currentIndex = ref(0)
const learningMode = ref('flashcard')
const direction = ref('en2zh')
const userInput = ref('')
const showResult = ref(false)
const isCorrect = ref(false)
const correctAnswer = ref('')
const choices = ref([])
const choiceSelected = ref(false)
const selectedChoiceIndex = ref(-1)
const clozeText = ref('')
const inputRef = ref()
const ratings = ['完全不记得', '有印象', '想起来了', '记得', '很容易', '太容易']
// 计算属性
const displayWord = computed(() => {
if (!currentWord.value) return ''
return direction.value === 'en2zh' ? currentWord.value.word : currentWord.value.meaning
})
const displayMeaning = computed(() => {
if (!currentWord.value) return ''
return direction.value === 'en2zh' ? currentWord.value.meaning : currentWord.value.word
})
const sessionProgress = computed(() => {
if (sessionWords.value.length === 0) return 0
return Math.round((currentIndex.value / sessionWords.value.length) * 100)
})
const progressColor = computed(() => {
if (sessionProgress.value >= 80) return '#67c23a'
if (sessionProgress.value >= 50) return '#e6a23c'
return '#f56c6c'
})
// 方法
const startLearning = async () => {
try {
await wordStore.startNewSession()
sessionWords.value = wordStore.sessionWords
currentWord.value = wordStore.currentWord
currentIndex.value = wordStore.currentIndex
if (sessionWords.value.length === 0) {
ElMessage.info('暂无需要学习的单词')
return
}
ElMessage.success(`开始学习,共 ${sessionWords.value.length} 个单词`)
displayCurrentWord()
} catch (error) {
ElMessage.error('获取学习单词失败')
}
}
const setLearningMode = (mode) => {
learningMode.value = mode
wordStore.setLearningMode(mode)
// 重置状态
resetCurrentState()
// 重新显示当前单词
if (currentWord.value) {
displayCurrentWord()
}
}
const toggleDirection = () => {
direction.value = direction.value === 'en2zh' ? 'zh2en' : 'en2zh'
wordStore.toggleDirection()
// 重置状态
resetCurrentState()
// 重新显示当前单词
if (currentWord.value) {
displayCurrentWord()
}
}
const displayCurrentWord = () => {
if (!currentWord.value) return
resetCurrentState()
switch (learningMode.value) {
case 'choice':
generateChoices()
break
case 'cloze':
generateCloze()
break
case 'typing':
nextTick(() => {
if (inputRef.value) {
inputRef.value.focus()
}
})
break
}
// 自动发音
if (direction.value === 'en2zh') {
setTimeout(() => speakWord(), 500)
}
}
const generateChoices = () => {
if (!currentWord.value) return
// 简单实现:随机选择其他单词作为干扰项
const otherWords = sessionWords.value.filter(w => w.id !== currentWord.value.id)
const distractors = otherWords.slice(0, 3)
choices.value = [currentWord.value, ...distractors]
// 随机打乱选项
for (let i = choices.value.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[choices.value[i], choices.value[j]] = [choices.value[j], choices.value[i]]
}
}
const generateCloze = () => {
if (!currentWord.value || !currentWord.value.example) {
// 如果没有例句,使用短语
if (currentWord.value.phrases && currentWord.value.phrases.length > 0) {
const phrase = currentWord.value.phrases[Math.floor(Math.random() * currentWord.value.phrases.length)]
clozeText.value = phrase.replace(new RegExp(currentWord.value.word, 'i'), '____')
} else {
// 降级到输入模式
learningMode.value = 'typing'
displayCurrentWord()
return
}
} else {
clozeText.value = currentWord.value.example.replace(
new RegExp(`\\b${currentWord.value.word}\\b`, 'i'),
'____'
)
}
nextTick(() => {
if (inputRef.value) {
inputRef.value.focus()
}
})
}
const checkAnswer = () => {
if (!userInput.value.trim()) return
const input = userInput.value.trim().toLowerCase()
let target = ''
if (learningMode.value === 'typing') {
target = direction.value === 'en2zh' ? currentWord.value.meaning : currentWord.value.word
} else if (learningMode.value === 'cloze') {
target = currentWord.value.word
}
correctAnswer.value = target
isCorrect.value = isAnswerCorrect(input, target.toLowerCase())
showResult.value = true
// 提交学习结果
const quality = isCorrect.value ? 4 : 1
submitLearningResult(quality)
// 2秒后自动下一题
setTimeout(() => {
nextWord()
}, 2000)
}
const isAnswerCorrect = (input, target) => {
// 完全匹配
if (input === target) return true
// 模糊匹配(简单实现)
const similarity = calculateSimilarity(input, target)
return similarity >= 0.8
}
const calculateSimilarity = (str1, str2) => {
const len1 = str1.length
const len2 = str2.length
const maxLen = Math.max(len1, len2)
if (maxLen === 0) return 1
// 计算编辑距离
const dp = Array(len1 + 1).fill().map(() => Array(len2 + 1).fill(0))
for (let i = 0; i <= len1; i++) dp[i][0] = i
for (let j = 0; j <= len2; j++) dp[0][j] = j
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
)
}
}
return 1 - dp[len1][len2] / maxLen
}
const selectChoice = (choice, index) => {
if (choiceSelected.value) return
choiceSelected.value = true
selectedChoiceIndex.value = index
const isCorrectChoice = choice.id === currentWord.value.id
const quality = isCorrectChoice ? 4 : 1
submitLearningResult(quality)
// 1.5秒后自动下一题
setTimeout(() => {
nextWord()
}, 1500)
}
const rateWord = (quality) => {
submitLearningResult(quality)
// 1秒后自动下一题
setTimeout(() => {
nextWord()
}, 1000)
}
const submitLearningResult = async (quality) => {
try {
await wordStore.submitStudySession(currentWord.value.id, quality)
} catch (error) {
console.error('提交学习结果失败:', error)
}
}
const nextWord = () => {
if (currentIndex.value >= sessionWords.value.length) {
endSession()
return
}
currentWord.value = sessionWords.value[currentIndex.value]
currentIndex.value++
displayCurrentWord()
}
const skipWord = () => {
// 跳过的单词评分为2
submitLearningResult(2)
nextWord()
}
const endSession = async () => {
try {
await ElMessageBox.confirm('确定要结束学习吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('学习完成!')
router.push('/dashboard')
} catch {
// 取消操作
}
}
const resetCurrentState = () => {
userInput.value = ''
showResult.value = false
isCorrect.value = false
correctAnswer.value = ''
choiceSelected.value = false
selectedChoiceIndex.value = -1
choices.value = []
clozeText.value = ''
}
const speakWord = () => {
if (!currentWord.value) return
const text = currentWord.value.word
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = 'en-US'
utterance.rate = 1
utterance.pitch = 1
speechSynthesis.speak(utterance)
}
const getRatingType = (index) => {
if (index <= 2) return 'danger'
if (index <= 3) return 'warning'
return 'success'
}
const getChoiceType = (choice, index) => {
if (!choiceSelected.value) return ''
if (choice.id === currentWord.value.id) return 'success'
if (index === selectedChoiceIndex.value) return 'danger'
return ''
}
// 监听学习模式变化
watch(learningMode, () => {
if (currentWord.value) {
displayCurrentWord()
}
})
// 检查路由参数
onMounted(() => {
const mode = route.query.mode
if (mode === 'quick') {
ElMessage.info('快速复习模式(3分钟)')
} else if (mode === 'leech') {
ElMessage.info('顽固词汇专练模式')
}
})
</script>
<style scoped>
.learning-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
padding: 0 20px;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.header h1 {
color: white;
margin: 0;
font-size: 1.5em;
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
}
.header-actions {
display: flex;
gap: 10px;
}
.mode-selector {
text-align: center;
padding: 20px;
}
.learning-content {
flex: 1;
padding: 20px;
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.input-area {
margin: 30px 0;
}
.input-area .el-input {
max-width: 400px;
}
.choices {
margin: 30px 0;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.rating-buttons {
display: flex;
gap: 10px;
justify-content: center;
margin: 30px 0 10px 0;
}
.rating-labels {
display: flex;
justify-content: center;
gap: 22px;
font-size: 0.8em;
color: rgba(255, 255, 255, 0.7);
}
.result-feedback {
margin: 20px 0;
}
.result-text {
font-size: 1.5em;
font-weight: bold;
margin-bottom: 10px;
}
.result-text.correct {
color: #67c23a;
}
.result-text.incorrect {
color: #f56c6c;
}
.correct-answer {
font-size: 1.2em;
color: rgba(255, 255, 255, 0.9);
}
.cloze-sentence {
font-size: 1.3em;
line-height: 1.6;
margin: 20px 0;
padding: 20px;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: white;
font-style: italic;
}
.progress-area {
margin: 30px 0;
text-align: center;
}
.progress-text {
margin-top: 10px;
color: white;
font-weight: 500;
}
.controls {
display: flex;
justify-content: center;
gap: 15px;
margin-top: 30px;
}
:deep(.el-input__wrapper) {
background-color: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
}
:deep(.el-input__inner) {
color: white;
font-size: 1.2em;
text-align: center;
}
:deep(.el-input__inner::placeholder) {
color: rgba(255, 255, 255, 0.7);
}
:deep(.el-button-group .el-button) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
color: white;
}
:deep(.el-button-group .el-button.el-button--primary) {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
border-color: #4facfe;
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 10px;
}
.word-display {
font-size: 2em !important;
}
.rating-buttons {
flex-wrap: wrap;
}
.choices {
flex-direction: column;
align-items: center;
}
.choices .el-button {
width: 90% !important;
}
.controls {
flex-direction: column;
align-items: center;
}
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-851
View File
@@ -1,851 +0,0 @@
<template>
<div class="gradient-bg">
<div class="word-management-container">
<!-- 导航栏 -->
<el-header class="header glass-effect">
<div class="header-content">
<el-button @click="$router.back()" icon="ArrowLeft" circle />
<h1>📚 词库管理</h1>
<div class="header-actions">
<el-button @click="showImportDialog = true" type="primary" icon="Upload">
导入单词
</el-button>
<el-button @click="exportWords" icon="Download">
导出单词
</el-button>
</div>
</div>
</el-header>
<!-- 主要内容 -->
<el-main class="main-content">
<!-- 操作栏 -->
<div class="action-bar">
<div class="search-area">
<el-input
v-model="searchQuery"
placeholder="搜索单词或中文意思..."
prefix-icon="Search"
size="large"
style="width: 400px;"
@input="handleSearch"
/>
<el-select
v-model="selectedLevel"
placeholder="选择级别"
size="large"
style="width: 150px; margin-left: 15px;"
@change="handleLevelFilter"
>
<el-option label="全部" value="" />
<el-option label="小学" value="elementary" />
<el-option label="初中" value="middle_school" />
<el-option label="高中" value="high_school" />
</el-select>
</div>
<el-button @click="showAddDialog = true" type="primary" icon="Plus" size="large">
添加单词
</el-button>
</div>
<!-- 统计信息 -->
<el-row :gutter="20" style="margin-bottom: 30px;">
<el-col :span="6">
<div class="stats-card">
<h3>{{ totalWords }}</h3>
<p>总单词数</p>
</div>
</el-col>
<el-col :span="6">
<div class="stats-card">
<h3>{{ filteredWords.length }}</h3>
<p>当前显示</p>
</div>
</el-col>
<el-col :span="6">
<div class="stats-card">
<h3>{{ leechCount }}</h3>
<p>顽固词汇</p>
</div>
</el-col>
<el-col :span="6">
<div class="stats-card">
<h3>{{ recentCount }}</h3>
<p>最近添加</p>
</div>
</el-col>
</el-row>
<!-- 单词列表 -->
<el-card class="glass-effect word-list-card">
<template #header>
<div class="card-header">
<h2>单词列表</h2>
<div class="list-actions">
<el-button @click="selectAll" :disabled="filteredWords.length === 0">
{{ isAllSelected ? '取消全选' : '全选' }}
</el-button>
<el-button
@click="batchDelete"
type="danger"
:disabled="selectedWords.length === 0"
>
批量删除 ({{ selectedWords.length }})
</el-button>
</div>
</div>
</template>
<el-table
:data="paginatedWords"
style="width: 100%"
@selection-change="handleSelectionChange"
v-loading="loading"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="word" label="单词" width="200" sortable>
<template #default="scope">
<div class="word-cell">
<strong>{{ scope.row.word }}</strong>
<el-button
@click="speakWord(scope.row.word)"
icon="VideoPlay"
size="small"
circle
style="margin-left: 10px;"
/>
</div>
</template>
</el-table-column>
<el-table-column prop="meaning" label="中文意思" width="250" />
<el-table-column prop="example" label="例句" min-width="300">
<template #default="scope">
<div class="example-cell">
{{ scope.row.example || '暂无例句' }}
</div>
</template>
</el-table-column>
<el-table-column prop="level" label="级别" width="100">
<template #default="scope">
<el-tag :type="getLevelType(scope.row.level)">
{{ getLevelText(scope.row.level) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="phrases" label="短语" width="200">
<template #default="scope">
<div class="phrases-cell">
<el-tag
v-for="phrase in (scope.row.phrases || []).slice(0, 2)"
:key="phrase"
size="small"
style="margin: 2px;"
>
{{ phrase }}
</el-tag>
<span v-if="(scope.row.phrases || []).length > 2" class="more-phrases">
+{{ (scope.row.phrases || []).length - 2 }}
</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button @click="editWord(scope.row)" icon="Edit" size="small" />
<el-button @click="deleteWord(scope.row)" type="danger" icon="Delete" size="small" />
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="filteredWords.length"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
</el-main>
</div>
<!-- 添加单词对话框 -->
<el-dialog v-model="showAddDialog" title="添加新单词" width="600px">
<el-form :model="newWord" :rules="wordRules" ref="addFormRef" label-width="100px">
<el-form-item label="英文单词" prop="word">
<el-input v-model="newWord.word" placeholder="请输入英文单词" />
</el-form-item>
<el-form-item label="中文意思" prop="meaning">
<el-input v-model="newWord.meaning" placeholder="请输入中文意思" />
</el-form-item>
<el-form-item label="例句">
<el-input
v-model="newWord.example"
type="textarea"
:rows="3"
placeholder="请输入例句(可选)"
/>
</el-form-item>
<el-form-item label="常用短语">
<el-input
v-model="phrasesInput"
type="textarea"
:rows="2"
placeholder="请输入常用短语,用分号分隔(可选)"
/>
</el-form-item>
<el-form-item label="级别">
<el-select v-model="newWord.level" placeholder="请选择级别">
<el-option label="小学" value="elementary" />
<el-option label="初中" value="middle_school" />
<el-option label="高中" value="high_school" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="addWord" :loading="submitting">确定</el-button>
</template>
</el-dialog>
<!-- 编辑单词对话框 -->
<el-dialog v-model="showEditDialog" title="编辑单词" width="600px">
<el-form :model="editingWord" :rules="wordRules" ref="editFormRef" label-width="100px">
<el-form-item label="英文单词" prop="word">
<el-input v-model="editingWord.word" placeholder="请输入英文单词" />
</el-form-item>
<el-form-item label="中文意思" prop="meaning">
<el-input v-model="editingWord.meaning" placeholder="请输入中文意思" />
</el-form-item>
<el-form-item label="例句">
<el-input
v-model="editingWord.example"
type="textarea"
:rows="3"
placeholder="请输入例句(可选)"
/>
</el-form-item>
<el-form-item label="常用短语">
<el-input
v-model="editPhrasesInput"
type="textarea"
:rows="2"
placeholder="请输入常用短语,用分号分隔(可选)"
/>
</el-form-item>
<el-form-item label="级别">
<el-select v-model="editingWord.level" placeholder="请选择级别">
<el-option label="小学" value="elementary" />
<el-option label="初中" value="middle_school" />
<el-option label="高中" value="high_school" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditDialog = false">取消</el-button>
<el-button type="primary" @click="updateWord" :loading="submitting">保存</el-button>
</template>
</el-dialog>
<!-- 导入对话框 -->
<el-dialog v-model="showImportDialog" title="导入单词" width="500px">
<div class="import-content">
<el-alert
title="导入说明"
type="info"
:closable="false"
style="margin-bottom: 20px;"
>
<template #default>
<p>支持 CSV 和 JSON 格式文件</p>
<p><strong>CSV 格式:</strong>word,meaning,example,phrases,level</p>
<p><strong>短语分隔:</strong>多个短语用分号(;)分隔</p>
</template>
</el-alert>
<el-upload
ref="uploadRef"
:auto-upload="false"
:on-change="handleFileChange"
:before-remove="handleFileRemove"
accept=".csv,.json"
drag
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
将文件拖到此处,或<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
只能上传 CSV/JSON 文件,且不超过 10MB
</div>
</template>
</el-upload>
</div>
<template #footer>
<el-button @click="showImportDialog = false">取消</el-button>
<el-button type="primary" @click="importWords" :loading="importing" :disabled="!selectedFile">
导入
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import { useWordStore } from '../stores/word'
import api from '../services/api'
const router = useRouter()
const wordStore = useWordStore()
// 响应式数据
const words = ref([])
const loading = ref(false)
const searchQuery = ref('')
const selectedLevel = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const selectedWords = ref([])
const showAddDialog = ref(false)
const showEditDialog = ref(false)
const showImportDialog = ref(false)
const submitting = ref(false)
const importing = ref(false)
const selectedFile = ref(null)
const newWord = reactive({
word: '',
meaning: '',
example: '',
level: 'middle_school'
})
const editingWord = reactive({
id: null,
word: '',
meaning: '',
example: '',
level: 'middle_school'
})
const phrasesInput = ref('')
const editPhrasesInput = ref('')
// 表单验证规则
const wordRules = {
word: [
{ required: true, message: '请输入英文单词', trigger: 'blur' },
{ min: 1, max: 50, message: '单词长度应在1-50字符', trigger: 'blur' }
],
meaning: [
{ required: true, message: '请输入中文意思', trigger: 'blur' },
{ min: 1, max: 100, message: '意思长度应在1-100字符', trigger: 'blur' }
]
}
// 计算属性
const filteredWords = computed(() => {
let result = words.value
// 级别过滤
if (selectedLevel.value) {
result = result.filter(word => word.level === selectedLevel.value)
}
// 搜索过滤
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(word =>
word.word.toLowerCase().includes(query) ||
word.meaning.toLowerCase().includes(query)
)
}
return result
})
const paginatedWords = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredWords.value.slice(start, end)
})
const totalWords = computed(() => words.value.length)
const leechCount = computed(() => words.value.filter(w => w.leech).length)
const recentCount = computed(() => {
const oneWeekAgo = Date.now() - 7 * 24 * 3600 * 1000
return words.value.filter(w => new Date(w.created_at || 0).getTime() > oneWeekAgo).length
})
const isAllSelected = computed(() => {
return paginatedWords.value.length > 0 && selectedWords.value.length === paginatedWords.value.length
})
// 方法
const fetchWords = async () => {
loading.value = true
try {
const response = await wordStore.fetchWords()
words.value = response
} catch (error) {
ElMessage.error('获取单词列表失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
currentPage.value = 1
}
const handleLevelFilter = () => {
currentPage.value = 1
}
const handleSizeChange = (val) => {
pageSize.value = val
currentPage.value = 1
}
const handleCurrentChange = (val) => {
currentPage.value = val
}
const handleSelectionChange = (selection) => {
selectedWords.value = selection
}
const selectAll = () => {
// 这里需要通过表格引用来实现全选/取消全选
// 简化实现
if (isAllSelected.value) {
selectedWords.value = []
} else {
selectedWords.value = [...paginatedWords.value]
}
}
const getLevelType = (level) => {
const types = {
elementary: 'success',
middle_school: 'warning',
high_school: 'danger'
}
return types[level] || 'info'
}
const getLevelText = (level) => {
const texts = {
elementary: '小学',
middle_school: '初中',
high_school: '高中'
}
return texts[level] || '未知'
}
const speakWord = (word) => {
const utterance = new SpeechSynthesisUtterance(word)
utterance.lang = 'en-US'
speechSynthesis.speak(utterance)
}
const addWord = async () => {
const addFormRef = ref()
if (!addFormRef.value) return
await addFormRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true
try {
const wordData = {
...newWord,
phrases: phrasesInput.value ? phrasesInput.value.split(';').map(p => p.trim()).filter(p => p) : []
}
await wordStore.createWord(wordData)
ElMessage.success('添加成功')
showAddDialog.value = false
// 重置表单
Object.assign(newWord, {
word: '',
meaning: '',
example: '',
level: 'middle_school'
})
phrasesInput.value = ''
// 刷新列表
await fetchWords()
} catch (error) {
ElMessage.error('添加失败')
} finally {
submitting.value = false
}
}
})
}
const editWord = (word) => {
Object.assign(editingWord, {
id: word.id,
word: word.word,
meaning: word.meaning,
example: word.example || '',
level: word.level
})
editPhrasesInput.value = (word.phrases || []).join('; ')
showEditDialog.value = true
}
const updateWord = async () => {
const editFormRef = ref()
if (!editFormRef.value) return
await editFormRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true
try {
const wordData = {
...editingWord,
phrases: editPhrasesInput.value ? editPhrasesInput.value.split(';').map(p => p.trim()).filter(p => p) : []
}
// 这里需要实现更新API
await api.put(`/words/${editingWord.id}`, wordData)
ElMessage.success('更新成功')
showEditDialog.value = false
// 刷新列表
await fetchWords()
} catch (error) {
ElMessage.error('更新失败')
} finally {
submitting.value = false
}
}
})
}
const deleteWord = async (word) => {
try {
await ElMessageBox.confirm(`确定要删除单词 "${word.word}" 吗?`, '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
// 这里需要实现删除API
await api.delete(`/words/${word.id}`)
ElMessage.success('删除成功')
// 刷新列表
await fetchWords()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
const batchDelete = async () => {
try {
await ElMessageBox.confirm(`确定要删除选中的 ${selectedWords.value.length} 个单词吗?`, '批量删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
// 这里需要实现批量删除API
const promises = selectedWords.value.map(word => api.delete(`/words/${word.id}`))
await Promise.all(promises)
ElMessage.success(`成功删除 ${selectedWords.value.length} 个单词`)
selectedWords.value = []
// 刷新列表
await fetchWords()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('批量删除失败')
}
}
}
const handleFileChange = (file) => {
selectedFile.value = file.raw
}
const handleFileRemove = () => {
selectedFile.value = null
}
const importWords = async () => {
if (!selectedFile.value) {
ElMessage.error('请选择要导入的文件')
return
}
importing.value = true
try {
await wordStore.importWords(selectedFile.value)
ElMessage.success('导入成功')
showImportDialog.value = false
selectedFile.value = null
// 刷新列表
await fetchWords()
} catch (error) {
ElMessage.error(error.message || '导入失败')
} finally {
importing.value = false
}
}
const exportWords = () => {
const csvContent = [
'word,meaning,example,phrases,level',
...words.value.map(w => `"${w.word}","${w.meaning}","${w.example || ''}","${(w.phrases || []).join(';')}","${w.level}"`)
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `wordmaster_export_${new Date().toISOString().slice(0, 10)}.csv`
link.click()
ElMessage.success('导出成功')
}
// 生命周期
onMounted(() => {
fetchWords()
})
// 监听搜索和过滤变化
watch([searchQuery, selectedLevel], () => {
currentPage.value = 1
})
</script>
<style scoped>
.word-management-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
padding: 0 20px;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.header h1 {
color: white;
margin: 0;
font-size: 1.5em;
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
}
.header-actions {
display: flex;
gap: 10px;
}
.main-content {
flex: 1;
padding: 20px;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
background: rgba(255, 255, 255, 0.1);
padding: 20px;
border-radius: 15px;
backdrop-filter: blur(10px);
}
.search-area {
display: flex;
align-items: center;
}
.stats-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 15px;
text-align: center;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
}
.stats-card h3 {
font-size: 2em;
margin: 0 0 10px 0;
font-weight: bold;
}
.stats-card p {
margin: 0;
opacity: 0.9;
}
.word-list-card {
min-height: 500px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h2 {
margin: 0;
color: white;
}
.list-actions {
display: flex;
gap: 10px;
}
.word-cell {
display: flex;
align-items: center;
}
.example-cell {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.phrases-cell {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.more-phrases {
color: #909399;
font-size: 0.8em;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 20px;
}
.import-content {
padding: 20px 0;
}
:deep(.el-card__header) {
background: rgba(255, 255, 255, 0.1);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
:deep(.el-card__body) {
background: rgba(255, 255, 255, 0.05);
}
:deep(.el-table) {
background: transparent;
}
:deep(.el-table th) {
background: rgba(255, 255, 255, 0.1);
color: white;
border: none;
}
:deep(.el-table td) {
background: transparent;
color: white;
border: none;
}
:deep(.el-table tr) {
background: transparent;
}
:deep(.el-input__wrapper) {
background-color: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
}
:deep(.el-input__inner) {
color: white;
}
:deep(.el-input__inner::placeholder) {
color: rgba(255, 255, 255, 0.7);
}
:deep(.el-select .el-input.is-focus .el-input__wrapper) {
border-color: #409eff;
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 10px;
}
.header-actions {
width: 100%;
justify-content: center;
}
.action-bar {
flex-direction: column;
gap: 15px;
}
.search-area {
width: 100%;
justify-content: center;
}
.search-area .el-input {
width: 100% !important;
}
.search-area .el-select {
margin-left: 0 !important;
margin-top: 10px;
}
}
</style>
+100
View File
@@ -0,0 +1,100 @@
#!/bin/bash
# 日志监控脚本 - 实时监控前后端日志
# 作者: Qwen Assistant
# 功能: 实时显示后端和前端日志
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 打印带颜色的消息
print_message() {
local color=$1
local message=$2
echo -e "${color}${message}${NC}"
}
print_message $BLUE "📋 WordMaster Pro 日志监控面板"
# 检查应用是否正在运行
if [ ! -f ".backend.pid" ] || [ ! -f ".frontend.pid" ]; then
print_message $YELLOW "⚠️ 应用程序似乎未运行"
print_message $CYAN " 提示: 请先运行 ./start_app.sh 启动应用程序"
exit 1
fi
BACKEND_PID=$(cat .backend.pid)
FRONTEND_PID=$(cat .frontend.pid)
if ! ps -p $BACKEND_PID > /dev/null 2>&1; then
print_message $YELLOW "⚠️ 后端进程 (PID: $BACKEND_PID) 未运行"
else
print_message $GREEN "✅ 后端进程 (PID: $BACKEND_PID) 正在运行"
fi
if ! ps -p $FRONTEND_PID > /dev/null 2>&1; then
print_message $YELLOW "⚠️ 前端进程 (PID: $FRONTEND_PID) 未运行"
else
print_message $GREEN "✅ 前端进程 (PID: $FRONTEND_PID) 正在运行"
fi
echo ""
print_message $BLUE "🔍 选择监控选项:"
echo "1) 监控后端日志 (实时)"
echo "2) 监控前端日志 (实时)"
echo "3) 同时监控所有日志 (分割显示)"
echo "4) 显示最近的日志 (各10行)"
echo ""
read -p "请选择 (1-4): " choice
case $choice in
1)
print_message $CYAN "📡 实时监控后端日志..."
print_message $YELLOW "按 Ctrl+C 退出监控"
tail -f backend/backend.log
;;
2)
print_message $CYAN "🎨 实时监控前端日志..."
print_message $YELLOW "按 Ctrl+C 退出监控"
tail -f frontend/frontend.log
;;
3)
print_message $CYAN "🔄 同时监控前后端日志..."
print_message $YELLOW "按 Ctrl+C 退出监控"
# Use multitail if available, otherwise use a simple approach
if command -v multitail &> /dev/null; then
multitail backend/backend.log frontend/frontend.log
else
print_message $YELLOW "💡 提示: 安装 multitail 可获得更好的多日志监控体验: brew install multitail"
print_message $YELLOW "当前使用简单模式监控 (混合输出):"
tail -f backend/backend.log frontend/frontend.log
fi
;;
4)
print_message $BLUE "📋 后端日志最后10行:"
if [ -f "backend/backend.log" ]; then
tail -10 backend/backend.log
else
print_message $YELLOW " 未找到后端日志文件"
fi
echo ""
print_message $BLUE "📋 前端日志最后10行:"
if [ -f "frontend/frontend.log" ]; then
tail -10 frontend/frontend.log
else
print_message $YELLOW " 未找到前端日志文件"
fi
echo ""
;;
*)
print_message $RED "❌ 无效选择"
;;
esac
Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/bash
# 开发模式启动脚本 - 前台启动前后端应用(带实时日志)
# 作者: Qwen Assistant
# 功能: 在前台启动后端和前端,直接在终端显示实时日志
set -e # 遇到错误立即退出
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 打印带颜色的消息
print_message() {
local color=$1
local message=$2
echo -e "${color}${message}${NC}"
}
# 检查端口是否被占用
check_port() {
local port=$1
if lsof -i :$port > /dev/null 2>&1; then
return 0 # 端口被占用
else
return 1 # 端口未被占用
fi
}
# 生成随机可用端口
get_available_port() {
local start_port=$1
local max_attempts=50
local port=$start_port
local attempts=0
while check_port $port && [ $attempts -lt $max_attempts ]; do
port=$((port + 1))
attempts=$((attempts + 1))
done
if [ $attempts -eq $max_attempts ]; then
print_message $RED "❌ 无法找到可用端口,请手动释放相关端口"
exit 1
fi
echo $port
}
print_message $BLUE "🚀 开始启动 WordMaster Pro 系统 (开发模式 - 实时日志)..."
# 获取脚本目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# 加载环境变量
if [ -f ".env" ]; then
print_message $BLUE "🔧 加载环境变量配置..."
export $(grep -v '^#' .env | xargs)
BACKEND_PORT=${BACKEND_PORT:-8888}
FRONTEND_PORT=${FRONTEND_PORT:-3008}
DATABASE_URL=${DATABASE_URL:-"sqlite:///./wordmaster.db"}
else
print_message $YELLOW "⚠️ 未找到 .env 文件,使用默认配置"
BACKEND_PORT=8888
FRONTEND_PORT=3008
DATABASE_URL="sqlite:///./wordmaster.db"
fi
print_message $BLUE "🔧 使用配置信息:"
print_message $GREEN " 后端端口: $BACKEND_PORT"
print_message $GREEN " 前端端口: $FRONTEND_PORT"
print_message $GREEN " 数据库: $DATABASE_URL"
# 检查端口占用情况
print_message $BLUE "🔍 检查端口占用情况..."
if check_port $BACKEND_PORT; then
print_message $YELLOW "⚠️ 后端端口 $BACKEND_PORT 被占用,寻找可用端口..."
BACKEND_PORT=$(get_available_port 8888)
print_message $GREEN "ℹ️ 使用后端端口: $BACKEND_PORT"
fi
if check_port $FRONTEND_PORT; then
print_message $YELLOW "⚠️ 前端端口 $FRONTEND_PORT 被占用,寻找可用端口..."
FRONTEND_PORT=$(get_available_port 3008)
print_message $GREEN "ℹ️ 使用前端端口: $FRONTEND_PORT"
fi
# 检查并清理之前的进程
print_message $BLUE "🧹 检查并清理之前的进程..."
if [ -f ".backend.pid" ]; then
OLD_BACKEND_PID=$(cat .backend.pid)
if ps -p $OLD_BACKEND_PID > /dev/null 2>&1; then
print_message $YELLOW "⚠️ 发现已存在的后端进程 $OLD_BACKEND_PID,正在停止..."
kill $OLD_BACKEND_PID 2>/dev/null || true
sleep 2
# 等待进程终止
attempts=0
while ps -p $OLD_BACKEND_PID > /dev/null 2>&1 && [ $attempts -lt 10 ]; do
sleep 1
attempts=$((attempts + 1))
done
# 如果仍未终止,强制杀死
if ps -p $OLD_BACKEND_PID > /dev/null 2>&1; then
print_message $YELLOW "⏳ 强制终止后端进程..."
kill -9 $OLD_BACKEND_PID 2>/dev/null || true
fi
fi
rm -f .backend.pid
fi
if [ -f ".frontend.pid" ]; then
OLD_FRONTEND_PID=$(cat .frontend.pid)
if ps -p $OLD_FRONTEND_PID > /dev/null 2>&1; then
print_message $YELLOW "⚠️ 发现已存在的前端进程 $OLD_FRONTEND_PID,正在停止..."
kill $OLD_FRONTEND_PID 2>/dev/null || true
sleep 2
# 等待进程终止
attempts=0
while ps -p $OLD_FRONTEND_PID > /dev/null 2>&1 && [ $attempts -lt 10 ]; do
sleep 1
attempts=$((attempts + 1))
done
# 如果仍未终止,强制杀死
if ps -p $OLD_FRONTEND_PID > /dev/null 2>&1; then
print_message $YELLOW "⏳ 强制终止前端进程..."
kill -9 $OLD_FRONTEND_PID 2>/dev/null || true
fi
fi
rm -f .frontend.pid
fi
# 检查依赖状态
check_dependencies() {
local missing_deps=()
# 检查 Python 依赖
if ! command -v python3 &> /dev/null; then
missing_deps+=("python3")
else
if ! python3 -c "import fastapi, uvicorn, sqlalchemy, pydantic, jwt, bcrypt, pandas, fitz, dotenv" > /dev/null 2>&1; then
missing_deps+=("python_backend_deps")
fi
fi
# 检查 Node.js 依赖
if ! command -v node &> /dev/null; then
missing_deps+=("nodejs")
fi
if ! command -v npm &> /dev/null; then
missing_deps+=("npm")
fi
# 检查前端依赖
if [ -f "frontend/package.json" ] && [ ! -d "frontend/node_modules" ]; then
missing_deps+=("frontend_deps")
fi
if [ ${#missing_deps[@]} -ne 0 ]; then
print_message $YELLOW "⚠️ 检测到以下依赖缺失或未安装:"
for dep in "${missing_deps[@]}"; do
echo " - $dep"
done
return 1
fi
return 0
}
print_message $BLUE "🔍 检查依赖安装状态..."
if ! check_dependencies; then
print_message $YELLOW "⚠️ 检测到依赖缺失,正在尝试自动安装..."
# 检查并创建后端虚拟环境
if [ ! -d "backend/venv" ]; then
print_message $BLUE "🔧 创建后端虚拟环境..."
python3 -m venv backend/venv
print_message $GREEN "✅ 虚拟环境创建成功"
fi
# 激活虚拟环境
source backend/venv/bin/activate
# 检查并安装后端依赖
if ! python -c "import fastapi, uvicorn, sqlalchemy, pydantic, jwt, bcrypt, pandas, fitz, dotenv" > /dev/null 2>&1; then
print_message $BLUE "🔧 安装后端依赖..."
cd backend
if [ ! -f "requirements.txt" ]; then
print_message $RED "❌ requirements.txt 不存在"
exit 1
fi
pip install --upgrade pip
if ! pip install -r requirements.txt; then
print_message $RED "❌ 后端依赖安装失败"
exit 1
fi
cd ..
print_message $GREEN "✅ 后端依赖安装完成"
fi
# 检查并安装前端依赖
if [ -f "frontend/package.json" ] && [ ! -d "frontend/node_modules" ]; then
print_message $BLUE "🔧 安装前端依赖..."
cd frontend
if ! npm install; then
print_message $RED "❌ 前端依赖安装失败"
exit 1
fi
cd ..
print_message $GREEN "✅ 前端依赖安装完成"
fi
else
print_message $GREEN "✅ 依赖检查通过"
fi
# 激活后端虚拟环境
print_message $BLUE "🔧 激活后端虚拟环境..."
source backend/venv/bin/activate
# 确保 uploads/images 目录存在
if [ ! -d "backend/uploads/images" ]; then
mkdir -p "backend/uploads/images"
print_message $GREEN "✅ 创建了 uploads/images 目录"
fi
print_message $BLUE "📡 启动后端服务 (端口: $BACKEND_PORT)..."
print_message $YELLOW "ℹ️ 后端日志将直接显示在当前终端..."
cd backend
# 启动后端服务(前台模式,直接显示日志)
if command -v uvicorn &> /dev/null; then
print_message $GREEN "✅ 后端启动命令: uvicorn main:app --host 0.0.0.0 --port $BACKEND_PORT --log-level info --reload"
echo ""
echo "========== 后端日志开始 =========="
uvicorn main:app --host 0.0.0.0 --port $BACKEND_PORT --log-level info --reload
else
# 如果uvicorn不在PATH中,使用虚拟环境中的
echo "========== 后端日志开始 =========="
../backend/venv/bin/uvicorn main:app --host 0.0.0.0 --port $BACKEND_PORT --log-level info --reload
fi
+311
View File
@@ -0,0 +1,311 @@
#!/bin/bash
# 开发模式启动脚本 - 前台启动前后端应用(带实时日志)
# 作者: Qwen Assistant
# 功能: 在前台启动后端和前端,直接在终端显示实时日志,并提供日志文件备份
set -e # 遌输错误立即退出
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 打印带颜色的消息
print_message() {
local color=$1
local message=$2
echo -e "${color}${message}${NC}"
}
# 检查端口是否被占用
check_port() {
local port=$1
if lsof -i :$port > /dev/null 2>&1; then
return 0 # 端口被占用
else
return 1 # 端口未被占用
fi
}
# 生成随机可用端口
get_available_port() {
local start_port=$1
local max_attempts=50
local port=$start_port
local attempts=0
while check_port $port && [ $attempts -lt $max_attempts ]; do
port=$((port + 1))
attempts=$((attempts + 1))
done
if [ $attempts -eq $max_attempts ]; then
print_message $RED "❌ 无法找到可用端口,请手动释放相关端口"
exit 1
fi
echo $port
}
print_message $BLUE "🚀 开始启动 WordMaster Pro 系统 (开发模式 - 实时日志)..."
# 获取脚本目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# 加载环境变量
if [ -f ".env" ]; then
print_message $BLUE "🔧 加载环境变量配置..."
export $(grep -v '^#' .env | xargs)
BACKEND_PORT=${BACKEND_PORT:-8888}
FRONTEND_PORT=${FRONTEND_PORT:-3008}
DATABASE_URL=${DATABASE_URL:-"sqlite:///./wordmaster.db"}
else
print_message $YELLOW "⚠️ 未找到 .env 文件,使用默认配置"
BACKEND_PORT=8888
FRONTEND_PORT=3008
DATABASE_URL="sqlite:///./wordmaster.db"
fi
print_message $BLUE "🔧 使用配置信息:"
print_message $GREEN " 后端端口: $BACKEND_PORT"
print_message $GREEN " 前端端口: $FRONTEND_PORT"
print_message $GREEN " 数据库: $DATABASE_URL"
# 检查端口占用情况
print_message $BLUE "🔍 检查端口占用情况..."
if check_port $BACKEND_PORT; then
print_message $YELLOW "⚠️ 后端端口 $BACKEND_PORT 被占用,寻找可用端口..."
BACKEND_PORT=$(get_available_port 8888)
print_message $GREEN "ℹ️ 使用后端端口: $BACKEND_PORT"
fi
if check_port $FRONTEND_PORT; then
print_message $YELLOW "⚠️ 前端端口 $FRONTEND_PORT 被占用,寻找可用端口..."
FRONTEND_PORT=$(get_available_port 3008)
print_message $GREEN "ℹ️ 使用前端端口: $FRONTEND_PORT"
fi
# 检查依赖状态
check_dependencies() {
local missing_deps=()
# 检查 Python 依赖
if ! command -v python3 &> /dev/null; then
missing_deps+=("python3")
else
if ! python3 -c "import fastapi, uvicorn, sqlalchemy, pydantic, jwt, bcrypt, pandas, fitz, dotenv" > /dev/null 2>&1; then
missing_deps+=("python_backend_deps")
fi
fi
# 检查 Node.js 依赖
if ! command -v node &> /dev/null; then
missing_deps+=("nodejs")
fi
if ! command -v npm &> /dev/null; then
missing_deps+=("npm")
fi
# 检查前端依赖
if [ -f "frontend/package.json" ] && [ ! -d "frontend/node_modules" ]; then
missing_deps+=("frontend_deps")
fi
if [ ${#missing_deps[@]} -ne 0 ]; then
print_message $YELLOW "⚠️ 检测到以下依赖缺失或未安装:"
for dep in "${missing_deps[@]}"; do
echo " - $dep"
done
return 1
fi
return 0
}
print_message $BLUE "🔍 检查依赖安装状态..."
if ! check_dependencies; then
print_message $YELLOW "⚠️ 检测到依赖缺失,正在尝试自动安装..."
# 检查并创建后端虚拟环境
if [ ! -d "backend/venv" ]; then
print_message $BLUE "🔧 创建后端虚拟环境..."
python3 -m venv backend/venv
print_message $GREEN "✅ 虚拟环境创建成功"
fi
# 激活虚拟环境
source backend/venv/bin/activate
# 检查并安装后端依赖
if ! python -c "import fastapi, uvicorn, sqlalchemy, pydantic, jwt, bcrypt, pandas, fitz, dotenv" > /dev/null 2>&1; then
print_message $BLUE "🔧 安装后端依赖..."
cd backend
if [ ! -f "requirements.txt" ]; then
print_message $RED "❌ requirements.txt 不存在"
exit 1
fi
pip install --upgrade pip
if ! pip install -r requirements.txt; then
print_message $RED "❌ 后端依赖安装失败"
exit 1
fi
cd ..
print_message $GREEN "✅ 后端依赖安装完成"
fi
# 检查并安装前端依赖
if [ -f "frontend/package.json" ] && [ ! -d "frontend/node_modules" ]; then
print_message $BLUE "🔧 安装前端依赖..."
cd frontend
if ! npm install; then
print_message $RED "❌ 前端依赖安装失败"
exit 1
fi
cd ..
print_message $GREEN "✅ 前端依赖安装完成"
fi
else
print_message $GREEN "✅ 依赖检查通过"
fi
# 激活后端虚拟环境
print_message $BLUE "🔧 激活后端虚拟环境..."
source backend/venv/bin/activate
# 确保 uploads/images 目录存在
if [ ! -d "backend/uploads/images" ]; then
mkdir -p "backend/uploads/images"
print_message $GREEN "✅ 创建了 uploads/images 目录"
fi
# 设置日志文件路径
BACKEND_LOG="backend/backend_dev.log"
FRONTEND_LOG="frontend/frontend_dev.log"
print_message $BLUE "📡 启动后端服务 (端口: $BACKEND_PORT)..."
print_message $YELLOW "ℹ️ 后端日志将直接显示在当前终端..."
# 启动后端服务(通过管道同时显示和记录日志)
cd backend
# 函数:启动后端服务
start_backend() {
print_message $GREEN "✅ 后端启动命令: uvicorn main:app --host 0.0.0.0 --port $BACKEND_PORT --log-level info --reload"
echo "========== 后端实时日志开始 =========="
# 清理旧日志文件
[ -f "$BACKEND_LOG" ] && rm "$BACKEND_LOG"
# 启动后端并记录日志
uvicorn main:app --host 0.0.0.0 --port $BACKEND_PORT --log-level info --reload 2>&1 | tee -a "$BACKEND_LOG" &
BACKEND_PID=$!
echo $BACKEND_PID > ../.backend.pid
print_message $GREEN "✅ 后端已启动 (PID: $BACKEND_PID, 端口: $BACKEND_PORT)"
# 后端启动后等待几秒让服务就绪
sleep 3
# 检查后端是否启动成功
if ! kill -0 $BACKEND_PID 2>/dev/null; then
print_message $RED "❌ 后端启动失败"
exit 1
fi
print_message $GREEN "✅ 后端服务已就绪"
}
# 启动前端服务(在后台)
start_frontend() {
print_message $BLUE "🎨 启动前端服务 (端口: $FRONTEND_PORT)..."
cd ../frontend
# 创建 .env.development 如果不存在,确保端口配置正确
if [ -f ".env.development" ]; then
# 更新 .env.development with the correct backend port
sed -i.bak "s|VITE_BACKEND_PORT=.*|VITE_BACKEND_PORT=$BACKEND_PORT|" .env.development 2>/dev/null || true
sed -i.bak "s|VITE_SERVER_PORT=.*|VITE_SERVER_PORT=$FRONTEND_PORT|" .env.development 2>/dev/null || true
else
# Create .env.development if it doesn't exist
cat > .env.development << EOF
# 开发环境配置
NODE_ENV=development
VITE_MODE=development
# API配置 - 开发环境使用代理
VITE_API_BASE_URL=/api
VITE_FILE_BASE_URL=/api
VITE_WS_BASE_URL=ws://localhost:$BACKEND_PORT/ws
# 后端服务地址 (仅用于代理配置)
VITE_BACKEND_HOST=localhost
VITE_BACKEND_PORT=$BACKEND_PORT
# 调试配置
VITE_DEBUG=true
VITE_LOG_LEVEL=info
VITE_SERVER_PORT=$FRONTEND_PORT
EOF
print_message $GREEN "✅ 创建了前端环境配置文件"
fi
# 清理旧日志文件
[ -f "$FRONTEND_LOG" ] && rm "$FRONTEND_LOG"
# 启动前端
print_message $GREEN "✅ 前端启动命令: npm run dev -- --port $FRONTEND_PORT"
echo "========== 前端实时日志开始 =========="
npm run dev -- --port $FRONTEND_PORT 2>&1 | tee -a "$FRONTEND_LOG" &
FRONTEND_PID=$!
echo $FRONTEND_PID > ../.frontend.pid
print_message $GREEN "✅ 前端已启动 (PID: $FRONTEND_PID, 端口: $FRONTEND_PORT)"
}
# 启动后端
start_backend
# 启动前端
start_frontend
# 显示完成信息
print_message $GREEN ""
print_message $GREEN "🎉 WordMaster Pro 系统启动成功!(开发模式)"
print_message $GREEN ""
echo ""
print_message $BLUE "📝 访问地址:"
print_message $GREEN "📖 前端访问地址: http://localhost:$FRONTEND_PORT"
print_message $GREEN "🔧 后端 API 地址: http://localhost:$BACKEND_PORT"
print_message $GREEN "📚 API 文档: http://localhost:$BACKEND_PORT/docs"
echo ""
print_message $BLUE "📋 进程信息:"
print_message $GREEN "📡 后端进程 PID: $(cat .backend.pid)"
print_message $GREEN "🎨 前端进程 PID: $(cat .frontend.pid)"
echo ""
print_message $BLUE "📁 实时日志文件 (同时输出到终端):"
print_message $CYAN " 后端日志: $(pwd)/$BACKEND_LOG"
print_message $CYAN " 前端日志: $(pwd)/$FRONTEND_LOG"
echo ""
print_message $YELLOW "💡 提示:"
print_message $YELLOW " - 实时日志已显示在当前终端"
print_message $YELLOW " - 使用 Ctrl+C 停止应用程序"
print_message $YELLOW " - 日志也同时保存到上述文件中"
# 等待用户中断
print_message $GREEN "✅ 服务正在前台运行,实时日志可见..."
trap "print_message $YELLOW '🛑 正在停止服务...'; kill $(cat .backend.pid) 2>/dev/null; kill $(cat .frontend.pid) 2>/dev/null; rm -f .backend.pid .frontend.pid; exit 0" INT
# 等待后台进程
wait