第 49 课:前后端联调
本课目标:搭建Vue.js前端项目,配置Axios与后端联调,实现登录页面和CRUD页面。
一、概念讲解
1.1 前后端分离架构
┌──────────────┐ ┌──────────────┐
│ 前端(Vue) │ HTTP │ 后端(Java) │
│ 端口:5173 │ <─────> │ 端口:8080 │
└──────────────┘ └──────────────┘
│ │
│ 1. 登录请求 │
│ POST /api/auth/login │
│ ──────────────────────>│
│ │
│ 2. 返回Token │
│ <──────────────────────│
│ │
│ 3. 携带Token请求 │
│ GET /api/users │
│ Header: Authorization│
│ ──────────────────────>│
│ │
│ 4. 返回数据 │
│ <──────────────────────│
1.2 开发环境配置
开发阶段:
前端(5173) ──proxy──> 后端(8080)
使用Vite代理解决跨域
生产阶段:
浏览器 ──> Nginx ──> 静态文件(前端)
──> Nginx ──> API反向代理(后端)
1.3 API对接流程
1. 后端定义API接口 → Swagger/手写文档
2. 前端封装API调用 → Axios封装
3. 前端页面开发 → 调用API获取数据
4. 联调测试 → 解决跨域、参数、格式问题
二、语法格式
2.1 Axios基本用法
// GET请求
axios.get('/api/users', { params: { page: 1, size: 10 } })
// POST请求
axios.post('/api/users', { username: '张三', email: 'test@example.com' })
// PUT请求
axios.put('/api/users/1', { username: '李四' })
// DELETE请求
axios.delete('/api/users/1')
2.2 Vite代理配置
// vite.config.js
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
三、代码案例
3.1 创建Vue项目
# 使用Vite创建Vue项目
npm create vite@latest frontend — –template vue
# 进入项目目录
cd frontend
# 安装依赖
npm install
# 安装Axios和Element Plus
npm install axios element-plus @element-plus/icons-vue
# 启动开发服务器
npm run dev
3.2 项目结构
frontend/
├── src/
│ ├── api/
│ │ ├── index.js # Axios实例配置
│ │ ├── auth.js # 认证相关API
│ │ └── user.js # 用户相关API
│ ├── router/
│ │ └── index.js # 路由配置
│ ├── views/
│ │ ├── Login.vue # 登录页
│ │ ├── Home.vue # 首页
│ │ └── UserList.vue # 用户列表
│ ├── utils/
│ │ └── request.js # Axios封装
│ ├── App.vue
│ └── main.js
├── vite.config.js
└── package.json
3.3 Axios封装
// src/api/index.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
const service = axios.create({
baseURL: '/api',
timeout: 10000
})
// 请求拦截器:添加Token
service.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器:统一处理错误
service.interceptors.response.use(
response => {
const res = response.data
if (res.code !== 200) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
}
return res
},
error => {
if (error.response) {
const status = error.response.status
if (status === 401) {
ElMessage.error('登录已过期,请重新登录')
localStorage.removeItem('token')
window.location.href = '/login'
} else if (status === 403) {
ElMessage.error('没有权限访问')
} else {
ElMessage.error(error.message || '网络错误')
}
}
return Promise.reject(error)
}
)
export default service
3.4 API定义
// src/api/auth.js
import request from './index'
export function login(data) {
return request({
url: '/user/login',
method: 'post',
data
})
}
export function register(data) {
return request({
url: '/user/register',
method: 'post',
data
})
}
export function getUserInfo() {
return request({
url: '/user/info',
method: 'get'
})
}
// src/api/user.js
import request from './index'
export function getUserList(params) {
return request({
url: '/users',
method: 'get',
params
})
}
export function getUserById(id) {
return request({
url: `/users/${id}`,
method: 'get'
})
}
export function createUser(data) {
return request({
url: '/users',
method: 'post',
data
})
}
export function updateUser(id, data) {
return request({
url: `/users/${id}`,
method: 'put',
data
})
}
export function deleteUser(id) {
return request({
url: `/users/${id}`,
method: 'delete'
})
}
3.5 路由配置
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue')
},
{
path: '/',
name: 'Home',
component: () => import('../views/Home.vue'),
meta: { requiresAuth: true },
children: [
{
path: 'users',
name: 'UserList',
component: () => import('../views/UserList.vue'),
meta: { requiresAuth: true }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫:检查登录状态
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else {
next()
}
})
export default router
3.6 登录页面
<!– src/views/Login.vue –>
<template>
<div class="login-container">
<div class="login-card">
<h2 class="login-title">用户登录</h2>
<el-form ref="formRef" :model="form" :rules="rules" label-width="0">
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="请输入用户名"
:prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
size="large"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
style="width: 100%"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
还没有账号?<router-link to="/register">立即注册</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { login } from '../api/auth'
const router = useRouter()
const formRef = ref(null)
const loading = ref(false)
const form = reactive({
username: '',
password: ''
})
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码不少于6位', trigger: 'blur' }
]
}
const handleLogin = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
const res = await login(form)
localStorage.setItem('token', res.data.token)
localStorage.setItem('userInfo', JSON.stringify(res.data.userInfo))
ElMessage.success('登录成功')
router.push('/')
} catch (error) {
console.error('登录失败', error)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
padding: 40px;
background: white;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.login-title {
text-align: center;
margin-bottom: 30px;
color: #333;
}
.login-footer {
text-align: center;
color: #666;
font-size: 14px;
}
</style>
3.7 用户列表页面
<!– src/views/UserList.vue –>
<template>
<div class="user-list">
<div class="page-header">
<h2>用户管理</h2>
<el-button type="primary" @click="handleAdd">新增用户</el-button>
</div>
<!– 搜索栏 –>
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索用户名"
:prefix-icon="Search"
clearable
style="width: 300px"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!– 用户表格 –>
<el-table :data="userList" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" width="150" />
<el-table-column prop="email" label="邮箱" width="200" />
<el-table-column prop="role" label="角色" width="120">
<template #default="{ row }">
<el-tag :type="row.role === 'ADMIN' ? 'danger' : 'info'">
{{ row.role === 'ADMIN' ? '管理员' : '普通用户' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'warning'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" fixed="right" width="200">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!– 分页 –>
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!– 新增/编辑弹窗 –>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" :rules="formRules" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="年龄" prop="age">
<el-input-number v-model="form.age" :min="1" :max="150" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { getUserList, createUser, updateUser, deleteUser } from '../api/user'
const loading = ref(false)
const submitLoading = ref(false)
const userList = ref([])
const dialogVisible = ref(false)
const dialogTitle = ref('新增用户')
const searchKeyword = ref('')
const formRef = ref(null)
const pagination = reactive({
page: 1,
size: 10,
total: 0
})
const form = reactive({
id: null,
username: '',
email: '',
age: 25
})
const formRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱', trigger: 'blur' }
]
}
// 获取用户列表
const fetchUsers = async () => {
loading.value = true
try {
const res = await getUserList({
page: pagination.page,
size: pagination.size,
keyword: searchKeyword.value
})
userList.value = res.data.records
pagination.total = res.data.total
} catch (error) {
console.error('获取用户列表失败', error)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
fetchUsers()
}
// 重置
const handleReset = () => {
searchKeyword.value = ''
pagination.page = 1
fetchUsers()
}
// 新增
const handleAdd = () => {
dialogTitle.value = '新增用户'
form.id = null
form.username = ''
form.email = ''
form.age = 25
dialogVisible.value = true
}
// 编辑
const handleEdit = (row) => {
dialogTitle.value = '编辑用户'
form.id = row.id
form.username = row.username
form.email = row.email
form.age = row.age
dialogVisible.value = true
}
// 删除
const handleDelete = async (row) => {
await ElMessageBox.confirm('确定要删除该用户吗?', '提示', {
type: 'warning'
})
try {
await deleteUser(row.id)
ElMessage.success('删除成功')
fetchUsers()
} catch (error) {
console.error('删除失败', error)
}
}
// 提交表单
const handleSubmit = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (form.id) {
await updateUser(form.id, form)
ElMessage.success('更新成功')
} else {
await createUser(form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchUsers()
} catch (error) {
console.error('操作失败', error)
} finally {
submitLoading.value = false
}
}
// 分页
const handleSizeChange = () => {
pagination.page = 1
fetchUsers()
}
const handleCurrentChange = () => {
fetchUsers()
}
onMounted(() => {
fetchUsers()
})
</script>
<style scoped>
.user-list {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.search-bar {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
3.8 Vite配置(代理)
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
3.9 main.js入口
// src/main.js
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus)
app.use(router)
app.mount('#app')
3.10 后端CORS配置
package com.example.demo.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
四、常见错误
4.1 跨域请求被拒绝
现象:浏览器控制台显示CORS错误。
解决:
4.2 Token未携带或格式错误
现象:后端返回401。
解决:确保Axios拦截器正确添加Authorization: Bearer <token>。
4.3 接口404
现象:请求路径不对。
解决:检查后端context-path和前端baseURL是否匹配。
4.4 数据格式不匹配
现象:日期显示为时间戳或格式不对。
解决:统一使用yyyy-MM-dd HH:mm:ss格式,或前端格式化处理。
五、课后练习
六、本课小结
| Axios | HTTP客户端库,封装请求/响应拦截器 |
| 请求拦截器 | 在请求发出前添加Token等认证信息 |
| 响应拦截器 | 统一处理错误,如401跳转登录 |
| Vite代理 | 开发环境代理API请求,解决跨域 |
| Element Plus | Vue 3 UI组件库,提供表格、表单、弹窗等组件 |
| 路由守卫 | Vue Router拦截器,未登录跳转登录页 |
本课程持续更新中,欢迎关注!
网硕互联帮助中心



评论前必须登录!
注册