2025-09-25 13:47:30 by wst
flask前段时间用flask开发了一个简单的背单词系统,用html写的页面,现在打算用vue3重写一下。这里记录其中的关键经验。
1.确保已安装node22的前提下执行:
npm create vite@latest learning-english-app
整体运行过程如下:
> npx
> create-vite learning-english-app
│
◇ Select a framework:
│ Vue
│
◇ Select a variant:
│ JavaScript
│
◇ Use rolldown-vite (Experimental)?:
│ No
│
◇ Install with npm and start now?
│ Yes
│
◇ Scaffolding project in H:\Workspace\tvm\learning-english-app...
│
◇ Installing dependencies with npm...
added 35 packages, and audited 36 packages in 17s
6 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
│
◇ Starting dev server...
> learning-english-app@0.0.0 dev
> vite
VITE v7.1.7 ready in 741 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h + enter to show help
点击并拖拽以移动
2.安装必要的前端包:
npm install axios@^1.12.2 element-plus@^2.11.3 pinia@^3.0.3 vue@^3.5.21 vue-router@^4.5.1
要求:有导航栏且有选中效果,每个页面是一个单独的vue文件,有公用组件;
1.配置修改
在learning-english-app目录创建.env文件,写入:
VITE_API_URL = 'http://localhost:8000'
修改learning-english-app\src\main.js,内容如下:
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
createApp(App)
.use(router)
.use(ElementPlus)
.mount('#app')
2. 文件修改
learning-english-app\src的目录结构如下:
.
│ App.vue
│ main.js
│ style.css
│
├─assets
│ vue.svg
│
├─components
│ HelloWorld.vue
│ MenuBar.vue
│ Pagination.vue
│ WordCard.vue
│
├─router
│ index.js
│
├─services
│ wordService.js
│
└─views
Check.vue
Edit.vue
Learn.vue
Login.vue
Review.vue
Select.vue
下面列出必要文件的源码,并做相应解释:
import { createRouter, createWebHistory } from 'vue-router'
import Login from '../views/Login.vue'
import Review from '../views/Review.vue'
import Learn from '../views/Learn.vue'
import Edit from '../views/Edit.vue'
import Select from '../views/Select.vue'
import Check from '../views/Check.vue'
// 路由守卫
const requireAuth = (to, from, next) => {
const isAuthenticated = localStorage.getItem('token')
if (!isAuthenticated && to.name !== 'Login') {
next({ name: 'Login' })
} else {
next()
}
}
const routes = [
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/',
name: 'Review',
component: Review,
beforeEnter: requireAuth
},
{
path: '/learn',
name: 'Learn',
component: Learn,
beforeEnter: requireAuth
},
{
path: '/edit',
name: 'Edit',
component: Edit,
beforeEnter: requireAuth
},
{
path: '/select',
name: 'Select',
component: Select,
beforeEnter: requireAuth
},
{
path: '/check',
name: 'Check',
component: Check,
beforeEnter: requireAuth
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '',
withCredentials: true,
headers: {
'X-Requested-With': 'XMLHttpRequest' // 标识为 AJAX 请求,方便后端区分
}
})
// 响应拦截器:处理登录状态失效等问题
api.interceptors.response.use(
response => response,
error => {
// 未登录时后端返回 401,跳转到登录页
if (error.response && error.response.status === 401) {
window.location.href = '/auth/login';
}
return Promise.reject(error);
}
);
export default {
// 复习相关
getReviewWords(page = 1) {
return api.get(`/words/review?page=${page}`)
},
markAsGood(id) {
return api.put(`/words/good/${id}`, {})
},
resetReview() {
return api.get('/words/reset')
},
// 新学相关
getLearnWords(page = 1) {
return api.get(`/words/learn?page=${page}`)
},
// 检测相关
updateWordStatus(id, isMastered) {
return api.put(`/words/learn/${id}`, {
recited_at: isMastered ? new Date().toISOString() : null
})
},
// 编辑相关
getAllWords(page = 1) {
return api.get(`/words?page=${page}`)
},
getWordById(id) {
return api.get(`/words?id=${id}`)
},
saveWord(word) {
return api.post('/words', word)
},
deleteWord(id) {
return api.delete(`/words/${id}`)
},
// 选择相关
getSelectWords(page = 1) {
return api.get(`/list?page=${page}`)
},
selectWord(id) {
return api.put(`/words/change/${id}`, { status: 1 })
},
// 认证相关
login(username, password){
return api.post('/auth/login', { username, password },{
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
logout(){
return api.get('/auth/logout')
},
register(username, password){
return api.post('/auth/register', { username, password })
}
}
注意其中axios.create,因为后端采用的插件是Flask-Login, 前端请求后端的时候需要携带凭证。所以withCredentials设置为true。
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
<template>
<el-menu
mode="horizontal"
background-color="#2d3748"
text-color="#ffffff"
active-text-color="#93c5fd"
class="menu-bar"
:default-active="activeIndex"
@select="handleMenuSelect"
>
<!-- 菜单项 -->
<el-menu-item
v-for="item in filteredMenuItems"
:key="item.path"
:index="item.path"
>
{{ item.name }}
</el-menu-item>
<!-- 退出按钮 -->
<el-menu-item v-if="isAuthenticated" index="logout">
<el-button type="text" text-color="#ffffff" @click.stop="handleLogout">退出</el-button>
</el-menu-item>
</el-menu>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMenu, ElMenuItem, ElButton } from 'element-plus'
import wordService from '../services/wordService'
// 状态管理
const isAuthenticated = ref(false)
const router = useRouter()
const route = useRoute()
// 导航菜单数据
const menuItems = [
{ name: '复习', path: '/' },
{ name: '新学', path: '/learn' },
{ name: '检测', path: '/check' },
{ name: '编辑', path: '/edit' },
{ name: '选择', path: '/select' },
{ name: '登录', path: '/login' }
];
// 根据登录状态过滤菜单项
const filteredMenuItems = computed(() => {
return menuItems.filter(item => {
if (item.path === '/login') {
return !isAuthenticated.value
}
return isAuthenticated.value || item.path === '/'
})
})
// 计算当前激活的菜单索引
const activeIndex = computed(() => {
// 查找与当前路由匹配的菜单项
const matchedItem = menuItems.find(item =>
route.path === item.path || route.path.startsWith(item.path + '/')
)
// 如果找到匹配项则使用其路径,否则使用当前路由路径
return matchedItem ? matchedItem.path : route.path
})
// 处理菜单选择事件
const handleMenuSelect = (index) => {
if (index !== 'logout') {
router.push(index)
}
}
// 监听路由变化,确保选中状态正确更新
watch(
() => route.path,
() => {
// 路由变化时自动更新选中状态
}
)
onMounted(() => {
// 初始化登录状态
isAuthenticated.value = !!localStorage.getItem('token')
})
// 退出登录处理
const handleLogout = async () => {
if (confirm('确定要退出登录吗?')) {
try {
await wordService.logout()
} catch (error) {
console.error('Logout error:', error)
} finally {
localStorage.removeItem('token')
isAuthenticated.value = false
router.push('/login')
}
}
}
</script>
<style scoped>
.menu-bar {
width: 100%;
margin-bottom: 20px;
}
/* 调整菜单项间距 */
:deep(.el-menu-item) {
margin: 0 15px;
font-size: 16px;
padding: 0 10px;
}
/* 调整激活状态样式 - 去掉背景色,只保留文字效果 */
:deep(.el-menu-item.is-active) {
color: #93c5fd !important;
font-weight: 600;
background-color: transparent !important; /* 关键:去掉背景色 */
}
/* 优化悬停效果 */
:deep(.el-menu-item:hover) {
background-color: rgba(255, 255, 255, 0.1);
}
</style>
<template>
<div class="pagination">
<button class="page-button" @click="prevPage">上一页</button>
<span>第 <span>{{ currentPage }}</span> 页</span>
<button class="page-button" @click="nextPage">下一页</button>
</div>
</template>
<script setup>
import { defineProps } from 'vue'
const emit = defineEmits(['page-change']);
const props = defineProps({
currentPage: {
type: Number,
required: true
}
})
const prevPage = () => {
if (props.currentPage > 1) {
emit('page-change', props.currentPage - 1, 0)
}
}
const nextPage = () => {
emit('page-change', props.currentPage + 1, 1)
}
</script>
<style scoped>
.pagination {
margin-top: 20px;
}
.page-button {
background-color: #4299e1;
margin: 0 5px;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
.page-button.active {
background-color: #48bb78;
}
</style>
<template>
<div
class="word-card"
:class="{ active: showMeaning, completed: isCompleted }"
@click="toggleMeaning"
>
<div class="word">{{ word.word }}</div>
<div class="meaning" v-if="showMeaning">{{ word.meaning }}</div>
<slot name="operations"></slot>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
word: {
type: Object,
required: true
},
isCompleted: {
type: Boolean,
default: false
}
})
const showMeaning = ref(false)
const toggleMeaning = () => {
showMeaning.value = !showMeaning.value
}
</script>
<style scoped>
.word-card {
background-color: #f3f4f6;
color: black;
margin: 10px 0;
padding: 10px;
border-radius: 5px;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: background-color 0.3s, color 0.3s;
}
.word-card.active {
background-color: #f59e0b;
color: #4a5568;
}
.word-card.completed {
background-color: #037934;
color: white;
}
.word {
font-size: 48px;
margin: 0 20px;
}
.meaning {
color: #4a5568;
margin: 0 20px;
font-size: 40px;
}
</style>
<template>
<div class="container">
<MenuBar />
<h1>检测单词</h1>
<div id="words-container">
<WordCard
v-for="word in words"
:key="word.id"
:word="word"
>
<template #operations>
<button
class="mastered-button"
@click.stop="updateWordStatus(word.id, true)"
>
已掌握
</button>
</template>
</WordCard>
</div>
<div class="button-container">
<button class="next-button" @click="loadNextWords">下一批</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import WordCard from '../components/WordCard.vue'
import MenuBar from '../components/MenuBar.vue'
import wordService from '../services/wordService'
const words = ref([])
const currentPage = ref(1)
const fetchWords = (page) => {
wordService.getLearnWords(page)
.then(response => {
words.value = response.data
})
.catch(error => console.error('Error fetching words:', error))
}
const loadNextWords = () => {
currentPage.value++
fetchWords(currentPage.value)
}
const updateWordStatus = (id, isMastered) => {
wordService.updateWordStatus(id, isMastered)
.then(() => fetchWords(currentPage.value))
.catch(error => console.error('Error:', error))
}
onMounted(() => fetchWords(currentPage.value))
</script>
<style scoped>
.container {
width: 80%;
max-width: 600px;
margin: 0 auto;
text-align: center;
}
.button-container {
margin-top: 20px;
}
.next-button, .mastered-button {
padding: 10px 20px;
margin: 5px;
border: none;
border-radius: 5px;
cursor: pointer;
}
.next-button {
background-color: #48bb78;
}
.mastered-button {
background-color: #94d82d;
}
</style>
<template>
<div class="container">
<MenuBar />
<h1>编辑单词</h1>
<form @submit.prevent="saveWord">
<input type="hidden" v-model="wordId">
<div class="form-group">
<label for="word">单词:</label>
<input type="text" id="word" v-model="word" required>
</div>
<div class="form-group">
<label for="meaning">意思:</label>
<input type="text" id="meaning" v-model="meaning" required>
</div>
<button type="submit" class="add-button">添加单词</button>
<button type="button" class="update-button" @click="toRecite">识记</button>
</form>
<ul id="wordsList">
<li v-for="word in words" :key="word.id">
<div style="flex: 1;display: flex; align-items: center;">
<strong style="color: #f59e0b;">ID: {{ word.id }}</strong>
<strong>{{ word.word }}</strong> - {{ word.meaning }}
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="color:grey">状态: {{ word.status ? '已开始' : '未开始' }}</div>
<button class="update-button" @click="updateWord(word.id)">编辑</button>
<button class="delete-button" @click="deleteWord(word.id)">删除</button>
</div>
</li>
</ul>
<Pagination
:current-page="currentPage"
@page-change="handlePageChange"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import MenuBar from '../components/MenuBar.vue'
import Pagination from '../components/Pagination.vue'
import wordService from '../services/wordService'
const words = ref([])
const currentPage = ref(1)
const wordId = ref('')
const word = ref('')
const meaning = ref('')
const fetchWords = (page) => {
wordService.getAllWords(page)
.then(response => {
words.value = response.data
})
.catch(error => console.error('Error fetching words:', error))
}
const handlePageChange = (page, isAdd) => {
wordService.getAllWords(page)
.then(response => {
if (response.data.length === 0) {
alert("没有数据了,不要再翻了。")
if (isAdd) {
currentPage.value--
} else {
currentPage.value++
}
return
}
currentPage.value = page
words.value = response.data
})
.catch(error => console.error('Error fetching words:', error))
}
const saveWord = () => {
const wordData = {
wordId: wordId.value || null,
word: word.value,
meaning: meaning.value
}
wordService.saveWord(wordData)
.then(() => {
fetchWords(currentPage.value)
// 重置表单
wordId.value = ''
word.value = ''
meaning.value = ''
})
.catch(error => console.error('Error saving word:', error))
}
const updateWord = (id) => {
wordService.getWordById(id)
.then(response => {
const data = response.data
wordId.value = data.id
word.value = data.word
meaning.value = data.meaning
})
.catch(error => console.error('Error fetching word:', error))
}
const deleteWord = (id) => {
if (confirm("Are you sure you want to delete this word?")) {
wordService.deleteWord(id)
.then(() => fetchWords(currentPage.value))
.catch(error => console.error('Error deleting word:', error))
}
}
const toRecite = () => {
window.location.href = '/'
}
onMounted(() => fetchWords(currentPage.value))
</script>
<style scoped>
.container {
width: 80%;
max-width: 600px;
margin: 0 auto;
text-align: center;
}
.form-group {
margin-bottom: 15px;
text-align: left;
}
input[type="text"] {
width: 100%;
padding: 10px;
margin-top: 5px;
border: none;
border-radius: 5px;
}
button {
padding: 10px 20px;
margin: 5px;
border: none;
border-radius: 5px;
cursor: pointer;
}
.add-button {
background-color: #48bb78;
}
.update-button {
background-color: #f59e0b;
}
.delete-button {
background-color: #e53e3e;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: flex;
justify-content: space-between;
margin: 10px 0;
padding: 10px;
background-color: #f3f4f6;
color: black;
border-radius: 5px;
}
</style>
<template>
<div class="container">
<MenuBar />
<h1>单词识记</h1>
<div id="words-container">
<WordCard
v-for="word in words"
:key="word.id"
:word="word"
/>
</div>
<div class="button-container">
<button class="audio-button" @click="$router.push('/edit')">编辑</button>
<button class="random-button" @click="loadRandomWords">随机</button>
<button class="recite-button" @click="$router.push('/check')">检测</button>
<button class="next-button" @click="loadNextWords">下一批</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import WordCard from '../components/WordCard.vue'
import MenuBar from '../components/MenuBar.vue'
import wordService from '../services/wordService'
const words = ref([])
const currentPage = ref(1)
const fetchWords = (page) => {
wordService.getLearnWords(page)
.then(response => {
words.value = response.data
})
.catch(error => console.error('Error fetching words:', error))
}
const loadRandomWords = () => {
wordService.getLearnWords()
.then(response => {
// 随机排序
words.value = response.data.sort(() => Math.random() - 0.5)
})
.catch(error => console.error('Error fetching words:', error))
}
const loadNextWords = () => {
currentPage.value++
fetchWords(currentPage.value)
}
onMounted(() => fetchWords(currentPage.value))
</script>
<style scoped>
.container {
width: 80%;
max-width: 600px;
margin: 0 auto;
text-align: center;
}
.button-container {
margin-top: 20px;
}
.audio-button, .random-button, .next-button, .recite-button {
padding: 10px 20px;
margin: 5px;
border: none;
border-radius: 5px;
cursor: pointer;
}
.audio-button {
background-color: #4299e1;
}
.random-button {
background-color: #f59e0b;
}
.next-button {
background-color: #48bb78;
}
.recite-button {
background-color: #FFD700;
}
</style>
<template>
<div class="login-container">
<div class="card">
<div class="logo">📚</div>
<h2>欢迎回来</h2>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label for="username">用户名</label>
<input
id="username"
v-model="username"
type="text"
required
placeholder="请输入用户名"
>
</div>
<div class="form-group">
<label for="password">密码</label>
<input
id="password"
v-model="password"
type="password"
required
placeholder="请输入密码"
>
</div>
<button class="btn" type="submit">登 录</button>
<div class="error" v-if="error">{{ error }}</div>
<div class="link">
<a @click.prevent="goToRegister">还没有账号?注册</a>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import wordService from '../services/wordService'
const username = ref('')
const password = ref('')
const error = ref('')
const router = useRouter()
const handleLogin = async () => {
try {
const response = await wordService.login(username.value, password.value)
localStorage.setItem('token', response.data.token)
router.push('/')
} catch (err) {
error.value = err.response?.data?.message || '登录失败,请检查用户名和密码'
}
}
const goToRegister = () => {
// 这里可以跳转到注册页面
alert('注册功能待实现')
}
</script>
<style scoped>
:root {
--primary: #4F46E5; /* 靛蓝主色 */
--primary-light: #6366F1; /* 提亮 */
--bg: #f3f4f6; /* 背景灰 */
--radius: 12px;
--shadow: 0 8px 24px rgba(0,0,0,.08);
}
.login-container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
}
.card {
width: 100%;
max-width: 380px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,.08);
padding: 48px 40px 56px;
}
h2 {
text-align: center;
margin-bottom: 32px;
font-size: 26px;
color: #111827;
letter-spacing: 0.5px;
}
.form-group {
margin-bottom: 20px;
display: flex;
flex-direction: column;
}
label {
display: block;
font-size: 14px;
color: #4b5563;
margin-bottom: 6px;
}
input {
width: auto;
padding: 12px 16px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 16px;
transition: border 0.2s;
}
input:focus {
outline: none;
border-color: var(--primary-light);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.btn {
width: 100%;
padding: 12px;
margin-top: 8px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%);
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.btn:hover {
opacity: 0.92;
}
.error {
color: #ef4444;
font-size: 14px;
margin-top: 10px;
text-align: center;
}
.logo {
text-align: center;
margin-bottom: 24px;
font-size: 32px;
color: var(--primary);
}
</style>
<template>
<div class="container">
<MenuBar />
<h1>复习单词</h1>
<div id="words-container">
<WordCard
v-for="word in words"
:key="word.id"
:word="word"
>
<template #operations>
<button
class="check-button"
:class="{ disabled: word.learned }"
@click.stop="markAsGood(word.id)"
>
会了
</button>
</template>
</WordCard>
</div>
<div class="button-container">
<button class="audio-button" @click="$router.push('/learn')">新学</button>
<button class="random-button" @click="loadRandomWords">随机</button>
<button class="recite-button" @click="resetReview">重置</button>
<button class="next-button" @click="loadNextWords">下一批</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import WordCard from '../components/WordCard.vue'
import MenuBar from '../components/MenuBar.vue'
import wordService from '../services/wordService'
const words = ref([])
const currentPage = ref(1)
const fetchWords = (page) => {
wordService.getReviewWords(page)
.then(response => {
words.value = response.data
})
.catch(error => console.error('Error fetching words:', error))
}
const loadRandomWords = () => {
wordService.getReviewWords()
.then(response => {
// 随机排序
words.value = response.data.sort(() => Math.random() - 0.5)
})
.catch(error => console.error('Error fetching words:', error))
}
const loadNextWords = () => {
currentPage.value++
fetchWords(currentPage.value)
}
const markAsGood = (id) => {
wordService.markAsGood(id)
.then(() => fetchWords(currentPage.value))
.catch(error => console.error('Error:', error))
}
const resetReview = () => {
wordService.resetReview()
.then(() => {
alert('重置成功!')
fetchWords(1)
})
.catch(error => console.error('Error:', error))
}
onMounted(() => fetchWords(currentPage.value))
</script>
<style scoped>
.container {
width: 80%;
max-width: 600px;
margin: 0 auto;
text-align: center;
}
.button-container {
margin-top: 20px;
}
.check-button, .audio-button, .random-button, .next-button, .recite-button {
padding: 10px 20px;
margin: 5px;
border: none;
border-radius: 5px;
cursor: pointer;
}
.check-button {
background-color: #48bb78;
}
.check-button.disabled {
background-color: #ccc;
cursor: not-allowed;
}
.audio-button {
background-color: #4299e1;
}
.random-button {
background-color: #f59e0b;
}
.next-button {
background-color: #48bb78;
}
.recite-button {
background-color: #FFD700;
}
</style>
<template>
<div class="container">
<MenuBar />
<h1>单词选择</h1>
<ul id="wordsList">
<li v-for="word in words" :key="word.id">
<div style="flex: 1;display: flex; align-items: center;">
<strong style="color: #f59e0b;">ID: {{ word.id }}</strong>
<strong>{{ word.word }}</strong> - {{ word.meaning }}
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="color:grey">状态: {{ word.status > 0 ? '已选中' : '未选中' }}</div>
<button
class="select-button"
@click="selectWord(word.id)"
:disabled="word.status > 0"
>
{{ word.status > 0 ? '已选中' : '选中' }}
</button>
</div>
</li>
</ul>
<Pagination
:current-page="currentPage"
@page-change="handlePageChange"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import MenuBar from '../components/MenuBar.vue'
import Pagination from '../components/Pagination.vue'
import wordService from '../services/wordService'
const words = ref([])
const currentPage = ref(1)
const fetchWords = (page) => {
wordService.getSelectWords(page)
.then(response => {
words.value = response.data
})
.catch(error => console.error('Error fetching words:', error))
}
const handlePageChange = (page, isAdd) => {
wordService.getSelectWords(page)
.then(response => {
if (response.data.length === 0) {
alert("没有数据了,不要再翻了。")
if (isAdd) {
currentPage.value--
} else {
currentPage.value++
}
return
}
currentPage.value = page
words.value = response.data
})
.catch(error => console.error('Error fetching words:', error))
}
const selectWord = (id) => {
wordService.selectWord(id)
.then(() => fetchWords(currentPage.value))
.catch(error => console.error('Error selecting word:', error))
}
onMounted(() => fetchWords(currentPage.value))
</script>
<style scoped>
.container {
width: 80%;
max-width: 600px;
margin: 0 auto;
text-align: center;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: flex;
justify-content: space-between;
margin: 10px 0;
padding: 10px;
background-color: #f3f4f6;
color: black;
border-radius: 5px;
}
.select-button {
background-color: #4299e1;
padding: 10px 20px;
margin: 5px;
border: none;
border-radius: 5px;
cursor: pointer;
}
.select-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
</style>
目录结构如下:
.
│ .gitignore
│ auth.py
│ english.xml
│ init_db.py
│ learning_english.db
│ readme.md
│ word.py
│
├─templates
│ check_words.html
│ edit_words.html
│ index.html
│ learn_words.html
│ login.html
│ select_words.html
│
└─__pycache__
auth.cpython-311.pyc
主文件为word.py, 以及认证文件auth.py
1. wrod.py内容:
import sqlite3
import logging
from datetime import datetime, timedelta
from flask import Flask, jsonify, request, render_template, abort, redirect, url_for
from pathlib import Path
from flask_login import current_user
from flask_cors import CORS # 新增:导入 CORS
from auth import auth_bp, login_manager
app = Flask(__name__)
CORS(app,supports_credentials=True)
app.secret_key = 'change-me' # session 加密
login_manager.init_app(app) # 初始化登录管理
app.register_blueprint(auth_bp) # 注册蓝图
# 数据库文件路径
BASE_DIR = Path(__file__).resolve().parent # word.py 所在目录
db_path = str(BASE_DIR / 'learning_english.db') # 绝对路径
print('db-path:', db_path)
def uid():
return current_user.id
def get_db():
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row # 方便按列名取值
return conn
def get_db_connection():
try:
connection = sqlite3.connect(db_path)
return connection
except sqlite3.Error as e:
print(f"Error connecting to SQLite Platform: {e}")
return None
def get_target_dates():
"""获取目标日期列表"""
target_days = [1, 2, 3, 4, 7, 8, 10, 12, 13, 14]
target_dates = []
for days in target_days:
target_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
target_dates.append(target_date)
return ','.join(f"'{date}'" for date in target_dates)
@app.before_request
def protect_all():
# 放行OPTIONS请求
if request.method == 'OPTIONS':
return
if request.endpoint in {'auth.login', 'static'}:
return # 登录页和静态资源不拦截
if not current_user.is_authenticated:
return jsonify({'error': '未登录'}), 401
@app.route('/')
def index():
# 重定向到单词识记页面
return render_template('index.html')
@app.route('/edit', methods=['GET'])
def edit_words():
# 渲染编辑单词页面
return render_template('edit_words.html')
@app.route('/select', methods=['GET'])
def select_words():
# 渲染单词选择页面
return render_template('select_words.html')
@app.route('/check', methods=['GET'])
def check_words():
# 渲染检测单词页面
return render_template('check_words.html')
@app.route('/learn', methods=['GET'])
def learn_words():
# 渲染新学单词页面
return render_template('learn_words.html')
这里只列了关键的代码,至于业务代码,因为是商用项目不便透露。
2.auth.py内容:
from flask import Blueprint, request, render_template, redirect, url_for, flash, jsonify
from flask_login import login_user, logout_user, LoginManager, UserMixin
from werkzeug.security import check_password_hash
from datetime import timedelta
import sqlite3
from pathlib import Path
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
BASE_DIR = Path(__file__).resolve().parent
db_path = str(BASE_DIR / 'learning_english.db')
# auth.py
class User(UserMixin):
def __init__(self, uid, username):
self.id = uid # Flask-Login 必需
self.username = username
login_manager = LoginManager()
login_manager.remember_cookie_duration = timedelta(days=7)
login_manager.login_view = 'auth.login' # 未登录时自动跳转
login_manager.login_message = None # 关闭默认闪字
@login_manager.user_loader
def load_user(uid):
with sqlite3.connect(db_path) as conn:
cur = conn.cursor()
cur.execute('SELECT id, username FROM users WHERE id=?', (uid,))
row = cur.fetchone()
if row:
return User(row[0], row[1]) # 把 uid、username 都带回
return None
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
con = sqlite3.connect(db_path)
cur = con.cursor()
cur.execute('SELECT id, password_hash FROM users WHERE username=?', (username,))
row = cur.fetchone()
con.close()
if row and check_password_hash(row[1], password):
login_user(User(row[0], username), remember=True, duration=timedelta(days=7))
return jsonify({"msg": "登录成功", "code": 1})
flash('用户名或密码错误')
return jsonify({"msg": "登录失败", "code": 0})
@auth_bp.route('/logout')
def logout():
logout_user()
return redirect(url_for('auth.login'))
本文的主要目的是演示flask怎么配合vue3进行项目开发的,以及怎么在vue中进行页面布局权限控制等;
如有商业需要,欢迎联系wst_ccut,备注english-learning。