云计算百科
云计算领域专业知识百科平台

第49课-前后端联调

第 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错误。

解决:

  • 开发环境:配置Vite代理(见3.8节)
  • 生产环境:配置Nginx反向代理
  • 后端:添加CORS配置(见3.10节)
  • 4.2 Token未携带或格式错误

    现象:后端返回401。

    解决:确保Axios拦截器正确添加Authorization: Bearer <token>。

    4.3 接口404

    现象:请求路径不对。

    解决:检查后端context-path和前端baseURL是否匹配。

    4.4 数据格式不匹配

    现象:日期显示为时间戳或格式不对。

    解决:统一使用yyyy-MM-dd HH:mm:ss格式,或前端格式化处理。


    五、课后练习

  • 实现注册页面,对接后端注册接口
  • 为用户列表添加批量删除功能
  • 实现商品管理页面的完整CRUD
  • 添加请求加载状态和错误提示优化

  • 六、本课小结

    知识点说明
    Axios HTTP客户端库,封装请求/响应拦截器
    请求拦截器 在请求发出前添加Token等认证信息
    响应拦截器 统一处理错误,如401跳转登录
    Vite代理 开发环境代理API请求,解决跨域
    Element Plus Vue 3 UI组件库,提供表格、表单、弹窗等组件
    路由守卫 Vue Router拦截器,未登录跳转登录页

    本课程持续更新中,欢迎关注!

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 第49课-前后端联调
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!