Spring Boot 3 + Vue 3 项目搭建实战指南
从零开始,手把手搭建一套前后端分离的全栈项目,包含环境准备、项目初始化、开发联调、打包部署的完整流程。
目录
- 技术栈概览
- 环境准备
- 后端:Spring Boot 3 项目搭建
- 前端:Vue 3 项目搭建
- 前后端联调配置
- 数据库接入(MySQL + MyBatis-Plus)
- 一个完整示例:用户管理 CRUD
- 项目打包与部署
- 推荐的项目结构总览
技术栈概览
| 层级 | 技术 | 版本 |
|---|---|---|
| 后端框架 | Spring Boot | 3.3.x |
| JDK | Java 17+ | 17 / 21 |
| 构建工具 | Maven / Gradle | 3.6+ / 8.x |
| ORM | MyBatis-Plus | 3.5.x |
| 数据库 | MySQL | 8.0+ |
| 前端框架 | Vue 3 | 3.5.x |
| 构建工具 | Vite | 5.x |
| UI 组件库 | Element Plus | 2.x |
| HTTP 客户端 | Axios | 1.x |
| 包管理 | pnpm / npm | — |
环境准备
必需安装的软件
# 1. JDK 17+
java -version
# 预期: openjdk version "17.0.x" ...
# 2. Maven 3.6+
mvn -v
# 预期: Apache Maven 3.9.x
# 3. Node.js 18+
node -v
# 预期: v18.x.x 或 v20.x.x
# 4. pnpm(推荐,比 npm 更快更省空间)
npm install -g pnpm
# 5. MySQL 8.0+(本地或 Docker)
docker run -d --name mysql8 \
-e MYSQL_ROOT_PASSWORD=root123 \
-p 3306:3306 mysql:8.0
推荐 IDE
| 用途 | IDE |
|---|---|
| 后端 | IntelliJ IDEA 2023+ |
| 前端 | VS Code + Volar 插件 |
| 数据库 | DataGrip / Navicat / DBeaver |
后端:Spring Boot 3 项目搭建
方式一:Spring Initializr(推荐)
访问 https://start.spring.io,按以下配置生成项目:
项目元数据:
Group: com.example
Artifact: demo
Name: demo
Package: com.example.demo
Language: Java
Type: Maven
Packaging: Jar
Java: 17
依赖(Dependencies):
✅ Spring Web — REST API
✅ Spring Boot DevTools — 热重载
✅ Lombok — 减少样板代码
✅ MySQL Driver — MySQL 连接
✅ MyBatis-Plus(后续手动添加)
✅ Spring Boot Actuator — 健康检查(可选)
✅ Spring Security(如需认证,后续添加)
方式二:命令行创建
# 通过官方 API 下载骨架
curl -o demo.zip "https://start.spring.io/starter.zip?\
type=maven-project&\
language=java&\
bootVersion=3.3.5&\
groupId=com.example&\
artifactId=demo&\
name=demo&\
packageName=com.example.demo&\
javaVersion=17&\
dependencies=web,devtools,lombok,mysql,actuator"
unzip demo.zip -d demo-backend
cd demo-backend
项目创建后的目录结构
demo-backend/
├── pom.xml
├── src/
│ ├── main/
│ │ ├── java/com/example/demo/
│ │ │ └── DemoApplication.java ← 主启动类
│ │ └── resources/
│ │ ├── application.yml ← 配置文件
│ │ ├── static/
│ │ └── templates/
│ └── test/
└── .mvn/
基础配置(application.yml)
# src/main/resources/application.yml
server:
port: 8080
spring:
profiles:
active: dev # 默认使用开发环境
---
# 开发环境
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:mysql://localhost:3306/demo_db?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: root123
driver-class-name: com.mysql.cj.jdbc.Driver
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
logging:
level:
com.example.demo: debug
org.springframework.web: info
---
# 生产环境
spring:
config:
activate:
on-profile: prod
datasource:
url: jdbc:mysql://your-prod-host:3306/demo_db?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
server:
port: 8080
logging:
level:
com.example.demo: info
org.springframework.web: warn
启动验证
cd demo-backend
mvn spring-boot:run
访问 http://localhost:8080/actuator/health,返回 {"status":"UP"} 即表示启动成功。
前端:Vue 3 项目搭建
使用 Vite 创建项目
# 创建 Vue 3 + Vite 项目
pnpm create vite demo-frontend -- --template vue
# 进入项目目录
cd demo-frontend
# 安装基础依赖
pnpm install
安装核心依赖
# 路由
pnpm add vue-router@4
# 状态管理(按需引入 Pinia,不引入亦可)
pnpm add pinia
# HTTP 客户端
pnpm add axios
# UI 组件库
pnpm add element-plus @element-plus/icons-vue
# 按需导入(推荐,减小打包体积)
pnpm add -D unplugin-vue-components unplugin-auto-import
配置 Vite(vite.config.js)
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
vue(),
// Element Plus 组件按需自动导入
AutoImport({
resolvers: [ElementPlusResolver()],
imports: ['vue', 'vue-router'],
dts: 'src/auto-imports.d.ts',
}),
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts',
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 3000,
// 代理配置 —— 将 /api 开头的请求转发到后端
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})
配置 Axios(src/utils/request.js)
// src/utils/request.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
// 创建 axios 实例
const request = axios.create({
baseURL: '/api', // 所有请求自动走代理到后端
timeout: 15000, // 15 秒超时
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
request.interceptors.request.use(
(config) => {
// 可从 localStorage 读取 token 并附加到请求头
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error),
)
// 响应拦截器
request.interceptors.response.use(
(response) => {
const { data } = response
// 根据业务后端的统一返回格式处理
if (data.code !== 200) {
ElMessage.error(data.message || '请求失败')
return Promise.reject(new Error(data.message))
}
return data
},
(error) => {
if (error.response) {
const { status } = error.response
switch (status) {
case 401: ElMessage.error('未登录或登录已过期'); break
case 403: ElMessage.error('没有权限'); break
case 500: ElMessage.error('服务器内部错误'); break
default: ElMessage.error('网络错误'); break
}
}
return Promise.reject(error)
},
)
export default request
配置路由(src/router/index.js)
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
},
{
path: '/users',
name: 'UserList',
component: () => import('@/views/user/UserList.vue'),
},
{
path: '/about',
name: 'About',
component: () => import('@/views/About.vue'),
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
入口配置(src/main.js)
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia' // 按需引入
import App from './App.vue'
import router from './router'
import './assets/styles/global.css' // 全局样式
const app = createApp(App)
app.use(router)
app.use(createPinia())
app.mount('#app')
App.vue 基础布局
<!-- src/App.vue -->
<template>
<div id="app">
<el-container>
<el-header>
<el-menu mode="horizontal" :router="true" :ellipsis="false">
<el-menu-item index="/">首页</el-menu-item>
<el-menu-item index="/users">用户管理</el-menu-item>
<el-menu-item index="/about">关于</el-menu-item>
</el-menu>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</div>
</template>
<script setup>
// 应用根组件,采用 Element Plus 的布局容器
</script>
<style>
#app {
font-family: 'Helvetica Neue', Helvetica, Arial, 'Microsoft YaHei', sans-serif;
-webkit-font-smoothing: antialiased;
}
</style>
启动前端
pnpm dev
# 访问 http://localhost:3000
前后端联调配置
架构图
┌─────────────────────────────────────────────────────────────┐
│ 浏览器 │
│ http://localhost:3000 │
└──────────┬──────────────────────────────────┬───────────────┘
│ 页面请求 │ /api/* 请求
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ Vite Dev Server │ │ Spring Boot 后端 │
│ (端口 3000) │ ──代理──► │ (端口 8080) │
│ │ │ │
│ Vue 3 + Vite │ │ Spring Boot 3 + Java 17 │
└──────────────────────┘ └──────────────────────────┘
跨域问题解决
方案一(开发环境推荐):Vite 代理
已经在 vite.config.js 中配置 server.proxy,所有 /api 前缀的请求自动转发到后端,绕过跨域问题。
方案二(生产环境):Spring Boot CORS 配置
// com/example/demo/config/CorsConfig.java
package com.example.demo.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;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
// 生产环境应限制为具体域名
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
方案三(生产环境推荐):Nginx 反向代理
前后端部署在同一域名下,由 Nginx 统一代理,从根源消除跨域。
数据库接入(MySQL + MyBatis-Plus)
添加 MyBatis-Plus 依赖
<!-- pom.xml -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.7</version>
</dependency>
注意:Spring Boot 3 必须使用 mybatis-plus-spring-boot3-starter,旧版 starter 不兼容。
MyBatis-Plus 配置
# application.yml 或 application-dev.yml 中追加
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml # XML 映射文件位置
type-aliases-package: com.example.demo.entity # 实体类包路径
configuration:
map-underscore-to-camel-case: true # 驼峰下划线自动转换
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # SQL 日志(仅开发环境)
global-config:
db-config:
id-type: auto # 主键自增策略
logic-delete-field: deleted # 逻辑删除字段
logic-delete-value: 1
logic-not-delete-value: 0
响应格式统一封装
// com/example/demo/common/Result.java
package com.example.demo.common;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
private int code;
private String message;
private T data;
public static <T> Result<T> ok(T data) {
return new Result<>(200, "操作成功", data);
}
public static <T> Result<T> ok() {
return ok(null);
}
public static <T> Result<T> fail(String message) {
return new Result<>(500, message, null);
}
public static <T> Result<T> fail(int code, String message) {
return new Result<>(code, message, null);
}
}
MyBatis-Plus 分页插件配置
// com/example/demo/config/MybatisPlusConfig.java
package com.example.demo.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
一个完整示例:用户管理 CRUD
1. 数据库表
CREATE DATABASE IF NOT EXISTS demo_db DEFAULT CHARACTER SET utf8mb4;
USE demo_db;
CREATE TABLE `user` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` VARCHAR(50) NOT NULL COMMENT '姓名',
`email` VARCHAR(100) NOT NULL COMMENT '邮箱',
`age` INT DEFAULT NULL COMMENT '年龄',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0=未删除, 1=已删除',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
2. 后端代码
实体类
// com/example/demo/entity/User.java
package com.example.demo.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("user")
public class User {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private String email;
private Integer age;
private String phone;
@TableLogic
private Integer deleted;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
Mapper 接口
// com/example/demo/mapper/UserMapper.java
package com.example.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.User;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserMapper extends BaseMapper<User> {
// MyBatis-Plus 的 BaseMapper 已提供常用 CRUD 方法
// 复杂查询可以在此定义并在 XML 中实现
}
Service 接口与实现
// com/example/demo/service/UserService.java
package com.example.demo.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.example.demo.entity.User;
public interface UserService extends IService<User> {
Page<User> getUserPage(int current, int size, String keyword);
boolean addUser(User user);
boolean updateUser(User user);
}
// com/example/demo/service/impl/UserServiceImpl.java
package com.example.demo.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.demo.entity.User;
import com.example.demo.mapper.UserMapper;
import com.example.demo.service.UserService;
import org.springframework.stereotype.Service;
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Override
public Page<User> getUserPage(int current, int size, String keyword) {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
if (keyword != null && !keyword.isBlank()) {
wrapper.like(User::getName, keyword)
.or()
.like(User::getEmail, keyword);
}
wrapper.orderByDesc(User::getCreateTime);
return page(new Page<>(current, size), wrapper);
}
@Override
public boolean addUser(User user) {
return save(user);
}
@Override
public boolean updateUser(User user) {
return updateById(user);
}
}
Controller
// com/example/demo/controller/UserController.java
package com.example.demo.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.demo.common.Result;
import com.example.demo.entity.User;
import com.example.demo.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
/** 分页查询 */
@GetMapping("/page")
public Result<Page<User>> page(
@RequestParam(defaultValue = "1") int current,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword) {
return Result.ok(userService.getUserPage(current, size, keyword));
}
/** 根据 ID 查询 */
@GetMapping("/{id}")
public Result<User> getById(@PathVariable Long id) {
return Result.ok(userService.getById(id));
}
/** 新增 */
@PostMapping
public Result<Void> add(@RequestBody User user) {
userService.addUser(user);
return Result.ok();
}
/** 修改 */
@PutMapping
public Result<Void> update(@RequestBody User user) {
userService.updateUser(user);
return Result.ok();
}
/** 删除(逻辑删除) */
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
userService.removeById(id);
return Result.ok();
}
}
3. 前端代码
API 封装(src/api/user.js)
// src/api/user.js
import request from '@/utils/request'
export function getUserPage(params) {
return request({
url: '/user/page',
method: 'get',
params,
})
}
export function getUserById(id) {
return request({
url: `/user/${id}`,
method: 'get',
})
}
export function addUser(data) {
return request({
url: '/user',
method: 'post',
data,
})
}
export function updateUser(data) {
return request({
url: '/user',
method: 'put',
data,
})
}
export function deleteUser(id) {
return request({
url: `/user/${id}`,
method: 'delete',
})
}
用户列表页面(src/views/user/UserList.vue)
<template>
<div class="user-list-container">
<!-- 搜索栏 -->
<el-card class="search-card">
<el-row :gutter="20">
<el-col :span="8">
<el-input
v-model="searchKeyword"
placeholder="搜索姓名或邮箱"
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-col>
<el-col :span="4">
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon> 搜索
</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-col>
<el-col :span="12" class="text-right">
<el-button type="success" @click="handleAdd">
<el-icon><Plus /></el-icon> 新增用户
</el-button>
</el-col>
</el-row>
</el-card>
<!-- 表格 -->
<el-card class="table-card">
<el-table :data="tableData" border stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="email" label="邮箱" width="200" />
<el-table-column prop="age" label="年龄" width="80" />
<el-table-column prop="phone" label="手机号" width="140" />
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" min-width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon> 编辑
</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">
<el-icon><Delete /></el-icon> 删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[5, 10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadData"
@current-change="loadData"
class="pagination"
/>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialog.visible"
:title="dialog.isEdit ? '编辑用户' : '新增用户'"
width="520px"
@close="resetForm"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" 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="0" :max="150" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialog.visible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="dialog.submitting">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getUserPage, addUser, updateUser, deleteUser } from '@/api/user'
// ===== 搜索 =====
const searchKeyword = ref('')
// ===== 表格数据 =====
const loading = ref(false)
const tableData = ref([])
const pagination = reactive({
current: 1,
size: 10,
total: 0,
})
const loadData = async () => {
loading.value = true
try {
const res = await getUserPage({
current: pagination.current,
size: pagination.size,
keyword: searchKeyword.value || undefined,
})
tableData.value = res.data.records
pagination.total = res.data.total
} catch {
// 错误已由拦截器处理
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.current = 1
loadData()
}
const resetSearch = () => {
searchKeyword.value = ''
handleSearch()
}
// ===== 对话框 =====
const dialog = reactive({
visible: false,
isEdit: false,
submitting: false,
})
const formRef = ref(null)
const initForm = {
id: null,
name: '',
email: '',
age: null,
phone: '',
}
const form = reactive({ ...initForm })
const rules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
],
}
const resetForm = () => {
formRef.value?.resetFields()
Object.assign(form, { ...initForm })
}
// ===== 新增 =====
const handleAdd = () => {
dialog.isEdit = false
resetForm()
dialog.visible = true
}
// ===== 编辑 =====
const handleEdit = (row) => {
dialog.isEdit = true
resetForm()
Object.assign(form, {
id: row.id,
name: row.name,
email: row.email,
age: row.age,
phone: row.phone,
})
dialog.visible = true
}
// ===== 提交 =====
const handleSubmit = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
dialog.submitting = true
try {
if (dialog.isEdit) {
await updateUser({ ...form })
ElMessage.success('修改成功')
} else {
await addUser({ ...form })
ElMessage.success('新增成功')
}
dialog.visible = false
loadData()
} catch {
// 已处理
} finally {
dialog.submitting = false
}
}
// ===== 删除 =====
const handleDelete = (row) => {
ElMessageBox.confirm(`确定删除用户「${row.name}」吗?`, '提示', {
type: 'warning',
}).then(async () => {
await deleteUser(row.id)
ElMessage.success('删除成功')
loadData()
})
}
onMounted(loadData)
</script>
<style scoped>
.user-list-container {
padding: 10px 0;
}
.search-card {
margin-bottom: 16px;
}
.table-card {
min-height: 400px;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
.text-right {
text-align: right;
}
</style>
项目打包与部署
后端打包
# 进入后端目录
cd demo-backend
# 编译打包(跳过测试)
mvn clean package -DskipTests
# 生成的 jar 位置: target/demo-0.0.1-SNAPSHOT.jar
# 启动
java -jar target/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod
前端打包
# 进入前端目录
cd demo-frontend
# 编译打包
pnpm build
# 产出目录: dist/
使用 Nginx 部署前后端
# /etc/nginx/conf.d/demo.conf
server {
listen 80;
server_name demo.example.com;
# 前端静态资源
root /var/www/demo;
index index.html;
location / {
try_files $uri $uri/ /index.html; # Vue History 模式必需
}
# 后端 API 反向代理
location /api/ {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# 部署前端静态文件
cp -r demo-frontend/dist/* /var/www/demo/
# 启动后端
nohup java -jar demo-backend/target/demo-0.0.1-SNAPSHOT.jar \
--spring.profiles.active=prod > app.log 2>&1 &
# 重载 Nginx
nginx -t && nginx -s reload
使用 Docker Compose 一键部署
# docker-compose.yml
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: demo-mysql
environment:
MYSQL_ROOT_PASSWORD: root123
MYSQL_DATABASE: demo_db
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
backend:
image: openjdk:17-slim
container_name: demo-backend
working_dir: /app
volumes:
- ./demo-backend/target/demo-0.0.1-SNAPSHOT.jar:/app/app.jar
command: ["java", "-jar", "/app/app.jar", "--spring.profiles.active=prod"]
ports:
- "8080:8080"
depends_on:
- mysql
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/demo_db?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
SPRING_DATASOURCE_USERNAME: root
SPRING_DATASOURCE_PASSWORD: root123
nginx:
image: nginx:alpine
container_name: demo-nginx
ports:
- "80:80"
volumes:
- ./demo-frontend/dist:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
volumes:
mysql_data:
# 启动全部服务
docker-compose up -d
推荐的项目结构总览
demo/
├── demo-backend/ ← 后端项目
│ ├── pom.xml
│ └── src/main/
│ ├── java/com/example/demo/
│ │ ├── DemoApplication.java ← 启动类
│ │ ├── common/ ← 公共类
│ │ │ ├── Result.java ← 统一响应体
│ │ │ └── GlobalExceptionHandler.java ← 全局异常处理
│ │ ├── config/ ← 配置类
│ │ │ ├── CorsConfig.java
│ │ │ └── MybatisPlusConfig.java
│ │ ├── controller/ ← 控制器
│ │ │ └── UserController.java
│ │ ├── entity/ ← 实体类
│ │ │ └── User.java
│ │ ├── mapper/ ← Mapper 接口
│ │ │ └── UserMapper.java
│ │ └── service/ ← 服务层
│ │ ├── UserService.java
│ │ └── impl/
│ │ └── UserServiceImpl.java
│ └── resources/
│ ├── application.yml
│ └── mapper/ ← MyBatis XML(可选)
│ └── UserMapper.xml
│
├── demo-frontend/ ← 前端项目
│ ├── vite.config.js
│ ├── package.json
│ ├── index.html
│ └── src/
│ ├── main.js
│ ├── App.vue
│ ├── api/ ← API 请求
│ │ └── user.js
│ ├── assets/styles/ ← 样式
│ │ └── global.css
│ ├── components/ ← 公共组件
│ │ └── ...
│ ├── router/ ← 路由
│ │ └── index.js
│ ├── utils/ ← 工具函数
│ │ └── request.js ← Axios 实例
│ └── views/ ← 页面
│ ├── Home.vue
│ ├── About.vue
│ └── user/
│ └── UserList.vue
│
├── nginx.conf ← Nginx 配置
└── docker-compose.yml ← Docker 编排
常用命令速查
# ==================== 后端 ====================
# 启动 (开发)
mvn spring-boot:run
mvn spring-boot:run -Dspring-boot.run.profiles=dev
# 启动 (指定端口)
mvn spring-boot:run -Dspring-boot.run.arguments=--server.port=9090
# 编译打包
mvn clean package -DskipTests
# 运行 jar
java -jar target/demo-0.0.1-SNAPSHOT.jar
# ==================== 前端 ====================
# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
# 生产构建
pnpm build
# 预览构建结果
pnpm preview
# ==================== Docker ====================
# 构建后端镜像
cd demo-backend
mvn clean package -DskipTests
docker build -t demo-backend:latest .
# 启动服务组
cd ..
docker-compose up -d
# 查看日志
docker-compose logs -f backend
# 停止服务
docker-compose down
常见问题 FAQ
Q: 启动后端报 “MySQL 连接被拒绝”?
A: 确保 MySQL 已启动,检查 application.yml 中的数据库连接信息是否正确,以及数据库 demo_db 是否已创建。
Q: 前端请求总是 404?
A: 检查 Vite 代理配置 — 确认请求的 baseURL 是 /api,且后端接口路径与之匹配。
Q: Element Plus 图标不显示?
A: 确认已安装 @element-plus/icons-vue,并且在 main.js 中全局注册或在组件中按需导入。
Q: MyBatis-Plus 实体类时间字段不自动填充?
A: 需要实现 MetaObjectHandler 接口,自定义填充逻辑。详见 MyBatis-Plus 官方文档。
Q: 生产环境部署后页面刷新 404?
A: Nginx 需要配置 try_files $uri $uri/ /index.html;,确保 Vue Router History 模式正常工作。
*最后更新:2026 年 6 月*