flask配合vue3做项目开发实战

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

下面列出必要文件的源码,并做相应解释:

  • 路由文件learning-english-app\src\router\index.js,约定路由便于跳转和权限控制:
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
  • 请求后端的api文件learning-english-app\src\services\wordService.js:
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。

  • 组件learning-english-app\src\components\HelloWorld.vue内容:
<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>
  • 组件learning-english-app\src\components\MenuBar.vue内容:
<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>
    
  • 组件learning-english-app\src\components\Pagination.vue内容:
<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>
  • 组件learning-english-app\src\components\WordCard.vue内容:
<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>
  • 页面learning-english-app\src\views\Check.vue内容:
<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>
  • 页面learning-english-app\src\views\Edit.vue内容:
<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> &nbsp;
          <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>
  • 页面learning-english-app\src\views\Learn.vue内容:
<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>
  • 页面learning-english-app\src\views\Login.vue内容:
<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>
  • 页面learning-english-app\src\views\Review.vue内容:
<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>
  • 页面learning-english-app\src\views\Select.vue内容:
<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> &nbsp;
          <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。

 


Comments(0) Add Your Comment

Not Comment!