移除词汇掌握功能,专注试卷管理系统
This commit is contained in:
@@ -0,0 +1 @@
|
||||
88739
|
||||
@@ -0,0 +1 @@
|
||||
88751
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
Executable
+100
@@ -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
@@ -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
|
||||
Executable
+311
@@ -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
|
||||
Reference in New Issue
Block a user