Spring Boot 3 + Vue 3 项目搭建实战指南

📅

✍️


Spring Boot 3 + Vue 3 项目搭建实战指南

从零开始,手把手搭建一套前后端分离的全栈项目,包含环境准备、项目初始化、开发联调、打包部署的完整流程。

目录

  1. 技术栈概览
  2. 环境准备
  3. 后端:Spring Boot 3 项目搭建
  4. 前端:Vue 3 项目搭建
  5. 前后端联调配置
  6. 数据库接入(MySQL + MyBatis-Plus)
  7. 一个完整示例:用户管理 CRUD
  8. 项目打包与部署
  9. 推荐的项目结构总览

技术栈概览

层级 技术 版本
后端框架 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 月*