第一章:为什么需要前端现代化?
1.1 Layui 的局限性
| 问题 | 后果 |
|---|---|
| 非组件化 | 代码重复、难以复用(如多个表单校验逻辑) |
| 无状态管理 | 多页面间数据共享困难(如用户信息) |
| jQuery 风格 | DOM 操作繁琐,易出错 |
| 无构建工具 | 无法使用 ES6+、TypeScript、CSS 预处理器 |
| 体验割裂 | 每次操作都整页刷新 |
1.2 Vue 3 的优势
- 组合式 API(Composition API):逻辑复用更清晰
- 响应式系统:自动更新 UI,无需手动操作 DOM
- TypeScript 一流支持:类型安全,减少运行时错误
- Vite 极速构建:冷启动 < 500ms,HMR 瞬时更新
- 生态丰富:Pinia(状态管理)、Vue Router(路由)、Naive UI(组件库)
技术选型:
- 框架:
Vue 3( 语法)- 语言:
TypeScript- 构建工具:
Vite- 状态管理:
Pinia- UI 组件库:
Naive UI(轻量、TypeScript 友好、支持暗色主题)- HTTP 客户端:
Axios(带拦截器)
第二章:搭建 Vue 3 + TypeScript 项目
2.1 创建项目
npm create vue@latest myapp-frontend
选择:
- ✔ TypeScript
- ✔ Pinia
- ✔ Vue Router
- ✘ ESLint / Prettier(可后续添加)
进入目录并安装依赖:
cd myapp-frontend
npm install
2.2 集成 Naive UI
npm install naive-ui
在 main.ts 中全局注册(按需引入以减小体积):
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
// Naive UI
import { createDiscreteApi } from 'naive-ui'
const { message, dialog, notification } = createDiscreteApi(['message', 'dialog', 'notification'])
const app = createApp(App)
app.use(createPinia())
app.use(router)
// 挂载全局 API(用于组件内调用)
app.config.globalProperties.$message = message
app.config.globalProperties.$dialog = dialog
app.config.globalProperties.$notification = notification
app.mount('#app')
注意 :实际项目建议使用 插件方式 或 provide/inject,此处为简化演示。
2.3 配置 Vite 代理(开发环境)
避免跨域问题,在 vite.config.ts 中配置:
// vite.config.ts
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://localhost:5000', // Flask 开发服务器
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})
所有
/api/xxx请求将代理到http://localhost:5000/xxx。
第三章:API 封装与 Axios 拦截器
3.1 创建 API 客户端
新建 src/api/client.ts:
// src/api/client.ts
import axios from 'axios'
const apiClient = axios.create({
baseURL: import.meta.env.DEV ? '/api' : '/api', // 生产环境同域
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
export default apiClient
3.2 JWT 认证拦截器
新建 src/plugins/axios.ts:
// src/plugins/axios.ts
import type { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'
import apiClient from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import { refreshToken } from '@/api/auth'
// 请求拦截器:添加 Token
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const authStore = useAuthStore()
if (authStore.accessToken && config.headers) {
config.headers.Authorization = `Bearer ${authStore.accessToken}`
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器:处理 401 与 Token 刷新
let isRefreshing = false
let failedQueue: Array<{
resolve: (value?: unknown) => void
reject: (reason?: unknown) => void
}> = []
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean }
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 等待刷新完成
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then(() => apiClient(originalRequest))
}
originalRequest._retry = true
isRefreshing = true
try {
const authStore = useAuthStore()
await refreshToken(authStore.refreshToken!)
isRefreshing = false
// 重试队列中的请求
failedQueue.forEach(({ resolve }) => resolve(apiClient(originalRequest)))
failedQueue = []
return apiClient(originalRequest)
} catch (refreshError) {
// 刷新失败,登出
const authStore = useAuthStore()
authStore.logout()
failedQueue.forEach(({ reject }) => reject(refreshError))
failedQueue = []
return Promise.reject(refreshError)
}
}
return Promise.reject(error)
}
)
关键逻辑:
- 自动附加
Authorization头- 拦截 401,用 refresh_token 获取新 access_token
- 并发请求排队,避免多次刷新
在 main.ts 中导入该插件:
// main.ts
import '@/plugins/axios' // ← 添加此行
第四章:状态管理 ------ Pinia 用户认证模块
4.1 创建 Auth Store
新建 src/stores/auth.ts:
// src/stores/auth.ts
import { defineStore } from 'pinia'
import { login as apiLogin, logout as apiLogout } from '@/api/auth'
import type { LoginData } from '@/types'
interface AuthState {
accessToken: string | null
refreshToken: string | null
userId: number | null
username: string | null
}
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
accessToken: localStorage.getItem('access_token'),
refreshToken: localStorage.getItem('refresh_token'),
userId: parseInt(localStorage.getItem('user_id') || '0') || null,
username: localStorage.getItem('username')
}),
getters: {
isAuthenticated: (state) => !!state.accessToken,
currentUsername: (state) => state.username
},
actions: {
async login(credentials: LoginData) {
const response = await apiLogin(credentials)
const { access_token, refresh_token, user } = response.data
this.accessToken = access_token
this.refreshToken = refresh_token
this.userId = user.id
this.username = user.username
// 持久化
localStorage.setItem('access_token', access_token)
localStorage.setItem('refresh_token', refresh_token)
localStorage.setItem('user_id', user.id.toString())
localStorage.setItem('username', user.username)
},
logout() {
apiLogout() // 可选:通知后端失效 Token
this.$reset()
localStorage.clear()
}
}
})
4.2 类型定义
新建 src/types/index.ts:
// src/types/index.ts
export interface User {
id: number
username: string
email: string
}
export interface LoginData {
username: string
password: string
}
export interface LoginResponse {
access_token: string
refresh_token: string
user: User
}
第五章:登录与注册页面实现
5.1 路由配置
更新 src/router/index.ts:
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/LoginView.vue'),
meta: { guestOnly: true }
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/RegisterView.vue'),
meta: { guestOnly: true }
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Dashboard', component: () => import('@/views/DashboardView.vue') },
{ path: '/profile', name: 'Profile', component: () => import('@/views/ProfileView.vue') }
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next('/login')
} else if (to.meta.guestOnly && authStore.isAuthenticated) {
next('/')
} else {
next()
}
})
export default router
5.2 LoginView.vue
<!-- src/views/LoginView.vue -->
<template>
<div class="login-container">
<n-card title="登录" style="max-width: 400px; margin: 0 auto;">
<n-form :model="form" :rules="rules" ref="formRef">
<n-form-item label="用户名" path="username">
<n-input v-model:value="form.username" placeholder="请输入用户名" />
</n-form-item>
<n-form-item label="密码" path="password">
<n-input type="password" v-model:value="form.password" placeholder="请输入密码" />
</n-form-item>
<n-button type="primary" @click="handleLogin" block :loading="loading">
登录
</n-button>
</n-form>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
import type { FormInst } from 'naive-ui'
const formRef = ref<FormInst | null>(null)
const form = ref({ username: '', password: '' })
const loading = ref(false)
const router = useRouter()
const message = useMessage()
const authStore = useAuthStore()
const rules = {
username: { required: true, message: '请输入用户名', trigger: 'blur' },
password: { required: true, message: '请输入密码', trigger: 'blur' }
}
const handleLogin = async () => {
formRef.value?.validate(async (errors) => {
if (!errors) {
try {
loading.value = true
await authStore.login(form.value)
message.success('登录成功')
router.push('/')
} catch (error: any) {
message.error(error.response?.data?.msg || '登录失败')
} finally {
loading.value = false
}
}
})
}
</script>
特点:
- 表单校验(Naive UI Form)
- 加载状态
- 错误提示友好
第六章:主布局与权限控制
6.1 MainLayout.vue
<!-- src/layouts/MainLayout.vue -->
<template>
<n-layout has-sider>
<n-layout-sider bordered collapse-mode="width" :collapsed-width="64" :width="240">
<n-menu v-model:expanded-keys="expandedKeys" v-model:value="activeKey" :options="menuOptions" />
</n-layout-sider>
<n-layout>
<n-layout-header>
<div class="header">
<span>MyApp</span>
<n-button @click="handleLogout">退出</n-button>
</div>
</n-layout-header>
<n-layout-content>
<router-view />
</n-layout-content>
</n-layout>
</n-layout>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const activeKey = computed({
get: () => router.currentRoute.value.name as string,
set: (key) => router.push({ name: key })
})
const expandedKeys = ref<string[]>([])
const menuOptions = [
{ key: 'Dashboard', label: '仪表盘', icon: renderIcon(DashboardOutline) },
{ key: 'Profile', label: '个人资料', icon: renderIcon(PersonOutline) }
]
const handleLogout = () => {
authStore.logout()
router.push('/login')
}
</script>
第七章:与 Flask API 对接细节
7.1 后端 CORS 配置
确保 Flask 允许前端域名访问:
# app.py
from flask_cors import CORS
def create_app():
app = Flask(__name__)
CORS(app, origins=['http://localhost:5173', 'https://your-domain.com']) # ← 关键
# ...
安装 flask-cors:
pip install flask-cors
7.2 Token 刷新接口
Flask 需提供 /auth/refresh 接口:
@app.route('/auth/refresh', methods=['POST'])
@jwt_required(refresh=True)
def refresh():
identity = get_jwt_identity()
access_token = create_access_token(identity=identity)
return jsonify(access_token=access_token)
第八章:PWA 与离线支持(可选)
8.1 安装 Vite PWA 插件
npm install -D vite-plugin-pwa
配置 vite.config.ts:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'MyApp',
short_name: 'MyApp',
description: 'A modern web app',
theme_color: '#ffffff',
icons: [
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png' }
]
}
})
],
// ...
})
用户可将网站"安装"到桌面,支持离线访问。
第九章:生产构建与部署
9.1 构建前端
npm run build
输出到 dist/ 目录。
9.2 Nginx 配置(前后端同域)
更新后端 nginx.conf:
server {
listen 443 ssl;
server_name your-domain.com;
# 前端静态文件
location / {
root /app/frontend/dist; # Docker 内路径
try_files $uri $uri/ /index.html; # SPA 路由支持
}
# API 代理
location /api/ {
proxy_pass http://web:8000/; # Flask 服务
# ... 其他 proxy 设置 ...
}
}
9.3 Dockerfile 前端构建(多阶段)
更新主 Dockerfile:
# === 阶段1:构建前端 ===
FROM node:18-alpine AS frontend-builder
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
# === 阶段2:构建后端 ===
FROM python:3.11-slim AS backend-builder
# ... 原有后端构建 ...
# === 阶段3:最终镜像 ===
FROM python:3.11-slim
# ... 安装 tzdata, 创建用户 ...
# 复制后端代码与依赖
COPY --from=backend-builder --chown=app:app /home/app/.local /home/app/.local
COPY --chown=app:app . .
# 复制前端构建产物
COPY --from=frontend-builder --chown=app:app /app/dist ./frontend/dist
# ...
优势:单镜像包含前后端,部署简单。
第十章:性能优化与最佳实践
| 优化项 | 方案 |
|---|---|
| 懒加载路由 | () => import('@/views/...') |
| 图片压缩 | 使用 WebP 格式,<img loading="lazy"> |
| API 缓存 | Pinia store + setTimeout 清理 |
| 暗色主题 | Naive UI 内置,通过 localStorage 切换 |
| 错误边界 | 全局 onErrorCaptured 捕获组件错误 |