从零到上线:Vue + SpringBoot 前后端分离项目 本地联调 + 宝塔 Nginx 完整部署(超详细踩坑实录)
阅读标签:# 前后端分离 #SpringBoot #Vue #宝塔面板 #Nginx 部署 #Web 实训项目
适用人群:计算机专业学生、课程设计 / 实训作业、Java Web 入门开发者
技术栈:Vue2 + SpringBoot + MySQL + CentOS + 宝塔面板 + Nginx
前言
在当下 Web 开发领域,前后端分离已经成为主流架构方案。本文结合实际实训项目,完整讲解 Vue 前端 + SpringBoot 后端 项目从本地开发联调、项目打包、服务器上传、宝塔部署、Nginx 反向代理配置全流程,同时汇总实战中遇到的 404、500、端口拒绝连接、Jar 包启动失败等经典报错及解决方案。
全文图文结合、步骤可落地、踩坑点全覆盖,按照流程操作即可完成项目上线,非常适合作为课程设计、实训报告、个人学习博文使用。
一、项目整体架构与功能介绍
1.1 整体架构
架构说明
- 用户所有请求先到达 Nginx 服务(80 端口);
- 页面、JS、CSS 等静态资源,Nginx 直接返回 Vue 打包文件;
- 所有以
/api开头的接口请求,由 Nginx 反向代理转发至本地9763端口的 SpringBoot 项目; - SpringBoot 接收接口请求,操作 MySQL 数据库,完成业务逻辑并返回数据。
1.2 项目核心功能
本项目为登录 / 注册权限管理系统,附加实训信息展示,功能如下:
- 双模式切换:登录页 / 注册页;
- 登录功能:用户名 + 密码校验,密码采用 MD5 加密 存储;
- 注册功能:多表单校验(用户名、密码、部门 ID、手机号、邮箱、性别);
- 前端交互:加载状态、错误提示、表单非空校验;
- 实训展示:页面底部展示班级、姓名、考核日期固定信息。
1.3 环境版本说明
- 前端:Vue2 + Fetch 网络请求
- 后端:SpringBoot 2.x + MyBatis + MySQL 5.7
- 服务器:CentOS 7/8 + 宝塔面板
- 中间件:Nginx、JDK 1.8
二、本地开发环境搭建 & 前后端联调
2.1 后端 SpringBoot 本地启动
2.1.1 数据库准备
- 本地 MySQL 创建数据库:
myproject; - 执行建表语句创建
user用户表,重点踩坑点:数据表字段为user_name,代码中切勿直接写username,否则会报字段不存在错误; - 插入测试管理员账号:
- 用户名:
admin - 明文密码:
123456 - MD5 加密密文:
e10adc3949ba59abbe56e057f20f883e
- 用户名:
2.1.2 启动后端项目
使用 IDEA 打开 SpringBoot 项目,直接运行启动类,控制台输出以下内容代表启动成功:
Tomcat started on port(s): 9763 API服务 成功上线
后端固定监听端口:9763
2.1.3 本地接口测试
后端启动后,可直接使用接口地址测试:http://localhost:9763/home/user/login
常见报错 1:
Unknown column 'username' in 'field list'原因:代码 / MyBatis SQL 字段名和数据库user_name不匹配解决:统一实体类、SQL、数据库表字段名称。
常见报错 2:接口返回 500 错误原因:数据库连接地址、账号、密码配置错误;解决:检查
application.yml数据库配置,确保 MySQL 服务正常运行。
2.2 前端 Vue 本地运行 & 联调
2.2.1 启动前端项目
四、宝塔服务器部署(核心实操)
服务器公网 IP:120.79.30.163,提前装好 JDK、MySQL、Nginx。
4.1 数据库初始化
4.2 部署后端 Jar 包
4.2.1 上传文件
4.2.2 后台启动 Jar 包
打开宝塔终端,执行以下命令:# 进入Jar包所在目录 cd /www/backend # 后台运行项目,日志输出到 nohup.out nohup java -jar mini0-0.0.1-SNAPSHOT.jar &
4.2.3 常用运维命令(排错必备)
# 查看Java进程 ps aux | grep mini0-0.0.1-SNAPSHOT.jar # 查看9763端口监听状态 ss -tuln | grep 9763 # 查看启动日志(排查500错误) tail -50 nohup.out # 强制关闭进程 kill -9 $(lsof -t -i:9763 2>/dev/null)
- 本地提前安装 Node.js,打开终端进入 Vue 项目根目录;
- 安装项目依赖:npm install
- 启动本地开发服务:npm run dev
- 前端默认访问地址:
http://localhost:8081 -
2.2.2 核心页面代码(登录 + 注册页)
页面包含登录 / 注册切换、表单校验、样式美化、实训信息栏,完整代码如下:
-
<template> <div class="auth-page"> <div class="bg-layer"></div> <div class="grid-overlay"></div> <div class="auth-container"> <div class="auth-toggle"> <button :class="['toggle-btn', mode === 'login' ? 'active' : '']" @click="switchMode('login')" >登录</button> <button :class="['toggle-btn', mode === 'register' ? 'active' : '']" @click="switchMode('register')" >注册</button> </div> <div class="card" v-if="mode === 'login'"> <h2 class="title">欢迎回来</h2> <p class="subtitle">请登录以继续</p> <form class="form" @submit.prevent="onLogin"> <div class="field"> <label>用户名</label> <input v-model.trim="loginForm.username" type="text" autocomplete="username" placeholder="输入用户名" :disabled="loading" required /> </div> <div class="field"> <label>密码</label> <input v-model="loginForm.password" type="password" autocomplete="current-password" placeholder="输入密码" :disabled="loading" required /> </div> <p class="error" v-if="errorMsg">{{ errorMsg }}</p> <button class="primary" type="button" @click="onLogin" :disabled="loading"> <span v-if="!loading">登录</span> <span v-else>处理中...</span> </button> </form> <!-- 实训信息栏 --> <div class="info-footer"> <div class="info-item"> <span class="label">班级:</span> <span class="value">2024计算机应用技术3班</span> </div> <div class="info-item"> <span class="label">姓名:</span> <span class="value">周瑜</span> </div> <div class="info-item"> <span class="label">考核日期:</span> <span class="value">2026-06-04</span> </div> </div> </div> <div class="card" v-else> <h2 class="title">创建账户</h2> <p class="subtitle">填写信息完成注册</p> <!-- 注册表单区域 --> <form class="form" @submit.prevent="onRegister"> <div class="grid-two"> <div class="field"> <label>用户名</label> <input v-model.trim="registerForm.username" type="text" placeholder="输入用户名" :disabled="loading" required/> </div> <div class="field"> <label>密码</label> <input v-model="registerForm.password" type="password" placeholder="设置密码" :disabled="loading" required/> </div> </div> <div class="grid-two"> <div class="field"> <label>部门ID</label> <input v-model.number="registerForm.deptId" type="number" placeholder="例如 1" :disabled="loading" required/> </div> <div class="field"> <label>性别</label> <div class="segmented"> <button type="button" :class="{ active: registerForm.sex === 'male' }" @click="registerForm.sex = 'male'" :disabled="loading">男</button> <button type="button" :class="{ active: registerForm.sex === 'female' }" @click="registerForm.sex = 'female'" :disabled="loading">女</button> </div> </div> </div> <div class="grid-two"> <div class="field"> <label>手机号</label> <input v-model.trim="registerForm.phone" type="tel" placeholder="11位手机号" :disabled="loading" required/> </div> <div class="field"> <label>邮箱</label> <input v-model.trim="registerForm.email" type="email" placeholder="example@company.com" :disabled="loading" required/> </div> </div> <p class="error" v-if="errorMsg">{{ errorMsg }}</p> <p class="success" v-if="successMsg">{{ successMsg }}</p> <button class="primary" type="submit" :disabled="loading"> <span v-if="!loading">注册并登录</span> <span v-else>处理中...</span> </button> </form> </div> </div> </div> </template> <script> export default { name: 'AuthLogin', data() { return { mode: 'login', loading: false, errorMsg: '', successMsg: '', loginForm: { username: '', password: '' }, registerForm: { username: '', password: '', deptId: '', phone: '', email: '', sex: 'male' } } }, methods: { switchMode(next) { if (this.loading) return this.mode = next this.errorMsg = '' this.successMsg = '' }, validateRegister() { const { username, password, deptId, phone, email, sex } = this.registerForm if (!username || !password || !deptId || !phone || !email || !sex) { this.errorMsg = '请完整填写信息' return false } if (!/^\d{1,9}$/.test(String(deptId))) { this.errorMsg = '部门ID需为数字' return false } if (!/^1\d{10}$/.test(phone)) { this.errorMsg = '手机号格式不正确' return false } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { this.errorMsg = '邮箱格式不正确' return false } return true }, async onLogin() { this.errorMsg = '' this.successMsg = '' if (!this.loginForm.username || !this.loginForm.password) { this.errorMsg = '请输入用户名和密码' return } this.loading = true try { const resp = await fetch('/api/home/user/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/plain, */*' }, body: JSON.stringify({ username: this.loginForm.username, password: this.loginForm.password }) }) const raw = await resp.text() let json = null try { json = JSON.parse(raw) } catch (e) {} if (!resp.ok) { const msg = json && (json.msg || json.message) this.errorMsg = msg || '登录失败' return } const token = this.extractToken(json, raw) if (!token) { this.errorMsg = json?.msg || '用户名或密码错误' return } localStorage.setItem('token', token) localStorage.setItem('username', this.loginForm.username) this.successMsg = '登录成功' window.location.hash = '/week' } catch (e) { this.errorMsg = '网络异常,请稍后重试' } finally { this.loading = false } }, async onRegister() { this.errorMsg = '' this.successMsg = '' if (!this.validateRegister()) return this.loading = true try { const params = new URLSearchParams() params.set('username', this.registerForm.username) params.set('password', this.registerForm.password) params.set('deptId', String(this.registerForm.deptId)) params.set('phone', this.registerForm.phone) params.set('email', this.registerForm.email) params.set('sex', this.registerForm.sex) const resp = await fetch('/api/home/user/register', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }) const json = await resp.json().catch(() => null) if (!resp.ok) { this.errorMsg = json?.msg || '注册失败' return } if (!this.isSuccess(json)) { this.errorMsg = json?.msg || '注册失败,请检查信息' return } this.successMsg = '注册成功,请登录' this.loginForm.username = this.registerForm.username this.switchMode('login') } catch (e) { this.errorMsg = '网络异常,请稍后重试' } finally { this.loading = false } }, extractToken(json, raw) { if (json) { if (typeof json.msg === 'string' && json.msg.trim()) return json.msg if (typeof json.data === 'string') return json.data if (json.token) return json.token if ((json.code === 1 || json.code === 200) && json.data) { if (typeof json.data === 'string') return json.data } } if (raw && raw.trim()) return raw.trim() return '' }, isSuccess(json) { if (!json) return false if (json.code !== undefined) return Number(json.code) === 1 || Number(json.code) === 200 if (json.success !== undefined) return !!json.success return true } } } </script> <style scoped> .auth-page { position: relative; min-height: 100vh; display: flex; align-items: center; justify-content: center; overflow: hidden; background: radial-gradient(1200px 800px at 80% -10%, rgba(0, 255, 255, 0.15), transparent), radial-gradient(1000px 700px at 0% 120%, rgba(170, 0, 255, 0.18), transparent), linear-gradient(135deg, #0a0f1e 0%, #05070f 100%); } .bg-layer { position: absolute; inset: -10%; background: conic-gradient(from 0deg at 50% 50%, rgba(0, 255, 255, 0.06), rgba(0, 0, 0, 0), rgba(170, 0, 255, 0.06), rgba(0, 0, 0, 0)); filter: blur(30px); animation: spin 18s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .grid-overlay { position: absolute; inset: 0; background-image: linear-gradient(rgba(0, 255, 255, 0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(170, 0, 255, 0.08) 1px, transparent 1px); background-size: 40px 40px, 40px 40px; mask-image: radial-gradient(circle at 50% 50%, black 30%, transparent 70%); pointer-events: none; } .auth-container { position: relative; width: min(92vw, 980px); display: grid; gap: 18px; z-index: 1; } .auth-toggle { display: flex; justify-content: center; gap: 12px; } .toggle-btn { padding: 10px 18px; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.14); background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)); color: #c9d5ff; letter-spacing: 0.2px; cursor: pointer; transition: all 0.2s ease; } .toggle-btn:hover { border-color: rgba(0, 255, 255, 0.6); color: #eaf7ff; box-shadow: 0 0 0 3px rgba(0, 255, 255, 0.12) inset; } .toggle-btn.active { border-color: rgba(0, 255, 255, 0.9); color: #0ff; box-shadow: 0 0 0 2px rgba(0, 255, 255, 0.35), 0 0 20px rgba(0, 255, 255, 0.25); } .card { width: 100%; margin: 0 auto; max-width: 720px; padding: 28px; border-radius: 20px; background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.02)); border: 1px solid rgba(255, 255, 255, 0.16); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45), inset 0 0 40px rgba(255, 255, 255, 0.03); backdrop-filter: blur(10px); } .title { margin: 4px 0 8px; color: #eaf2ff; font-size: 24px; font-weight: 700; } .subtitle { margin: 0 0 18px; color: #9eb3d8; font-size: 13px; } .form { display: grid; gap: 16px; } .grid-two { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } @media (max-width: 680px) { .grid-two { grid-template-columns: 1fr; } } .field { display: grid; gap: 8px; } .field label { color: #c6d7ff; font-size: 12px; } .field input { height: 40px; padding: 0 12px; border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.14); background: rgba(7, 12, 24, 0.6); color: #eaf2ff; outline: none; transition: border 0.2s ease, box-shadow 0.2s ease; } .field input::placeholder { color: #7e8aa6; } .field input:focus { border-color: rgba(0, 255, 255, 0.7); box-shadow: 0 0 0 3px rgba(0, 255, 255, 0.15); } .segmented { display: grid; grid-auto-flow: column; gap: 8px; } .segmented button { height: 40px; border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.14); background: rgba(7, 12, 24, 0.6); color: #c9d5ff; cursor: pointer; } .segmented button.active { border-color: rgba(0, 255, 255, 0.8); color: #0ff; box-shadow: 0 0 0 2px rgba(0, 255, 255, 0.25); } .primary { height: 42px; border-radius: 12px; border: 1px solid rgba(0, 255, 255, 0.45); background: linear-gradient(180deg, rgba(0,255,255,0.28), rgba(0,255,255,0.14)); color: #04131a; font-weight: 700; letter-spacing: 0.4px; cursor: pointer; transition: all 0.2s ease; } .primary:hover { border-color: rgba(0, 255, 255, 0.9); box-shadow: 0 10px 30px rgba(0, 255, 255, 0.2), 0 0 0 3px rgba(0, 255, 255, 0.18) inset; } .error { margin: 4px 0 2px; color: #ff6b6b; font-size: 12px; } .success { margin: 4px 0 2px; color: #34d399; font-size: 12px; } .info-footer { margin-top: 20px; padding-top: 16px; border-top: 1px solid rgba(255, 255, 255, 0.1); display: grid; gap: 6px; } .info-item { display: flex; justify-content: center; font-size: 13px; } .info-item .label { color: #9eb3d8; } .info-item .value { color: #c6d7ff; margin-left: 4px; } </style>
-
2.2.3 本地联调说明
前端接口统一使用
/api作为前缀,本地联调可在后端添加@CrossOrigin注解临时解决跨域问题。前端页面访问:http://localhost:8081,输入账号admin、密码123456即可完成登录。 -
三、项目打包(准备服务器部署)
3.1 前端 Vue 打包
- 确认接口路径为相对路径
/api,无需写死 IP; - 终端执行打包命令:npm run build
- 打包完成后,项目根目录生成 dist 文件夹,该文件夹内所有文件为线上静态资源。
-
3.2 后端 SpringBoot 打包
- IDEA 使用 Maven/Gradle 执行
package打包; - 在
target目录生成 Jar 包:mini0-0.0.1-SNAPSHOT.jar; - 修改
application.yml,将数据库地址改为服务器 MySQL 地址、账号、密码。 - 宝塔面板打开数据库,创建
myproject库; - 导入本地数据表与管理员测试数据;
- 宝塔防火墙放行端口:
80、9763。 - 宝塔文件管理器创建目录
/www/backend; - 将
mini0-0.0.1-SNAPSHOT.jar上传到此目录。
经典报错:
Unable to access jarfile xxx.jar原因:终端当前目录和 Jar 包目录不一致解决:cd切换到 Jar 所在目录再执行启动命令。4.3 部署前端静态页面
- 宝塔 → 网站 → 添加站点,填写:
- 域名 / IP:
120.79.30.163- 端口:
80- 网站根目录:
/www/wwwroot/vue8081- 将前端
dist文件夹内所有文件,上传到站点根目录。五、Nginx 核心配置(反向代理解决 404)
5.1 问题分析
前端请求
/api/home/user/login出现 404 Not Found:Nginx 默认只托管静态页面,不会转发接口请求,必须配置 反向代理,将所有/api请求转发到127.0.0.1:9763。5.2 完整 Nginx 配置文件
进入站点 → 配置文件,全量替换为以下代码:
server { listen 80; server_name 120.79.30.163; index index.php index.html index.htm default.php default.htm default.html; root /www/wwwroot/vue8081; include /www/server/panel/vhost/nginx/extension/120.79.30.163/*.conf; include /www/server/panel/vhost/nginx/well-known/120.79.30.163.conf; error_page 404 /404.html; include enable-php-74.conf; include /www/server/panel/vhost/rewrite/120.79.30.163.conf; # 核心配置:api接口反向代理 location /api/ { proxy_pass http://127.0.0.1:9763/; 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; } # 禁止访问敏感文件 location ~ ^/(\.user.ini|\.htaccess|\.git|\.env|\.svn|\.project|LICENSE|README.md) { return 404; } location ~ \.well-known{ allow all; } if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) { return 403; } # 静态资源缓存优化 location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ { expires 30d; error_log /dev/null; access_log /dev/null; } location ~ .*\.(js|css)?$ { expires 12h; error_log /dev/null; access_log /dev/null; } access_log /www/wwwlogs/120.79.30.163.log; error_log /www/wwwlogs/120.79.30.163.error.log; }
5.3 配置生效
- 保存配置文件;
- 宝塔面板右上角点击 重载 Nginx 配置;
- 浏览器访问
http://120.79.30.163测试项目。六、线上高频报错汇总 & 解决方案
6.1 报错 1:接口 404 Not Found
- 现象:前端
/api/xxx请求 404- 原因:未配置 Nginx 反向代理、代理地址错误
- 解决:添加
location /api/代理规则,指向127.0.0.1:97636.2 报错 2:Connection refused(连接被拒绝)
- 现象:接口无法连接
- 原因:后端 Jar 包未启动、9763 端口未放行
- 解决:重启后端服务,检查宝塔防火墙 & 云服务器安全组端口。
6.3 报错 3:接口 500 Internal Server Error
- 现象:后端服务报错
- 原因:数据库配置错误、SQL 字段不匹配、密码加密逻辑异常
- 解决:查看
nohup.out日志,根据日志定位问题。6.4 报错 4:Jar 包启动提示
Unable to access jarfile- 原因:执行命令的目录和 Jar 包目录不一致
- 解决:
cd进入 Jar 包所在目录再启动。
七、项目总结与拓展
7.1 实战总结
本文完成了 Vue + SpringBoot 前后端分离项目 从本地开发、联调、打包、宝塔部署、Nginx 反向代理全流程。核心要点:
- 本地开发优先解决代码、数据库、跨域问题;
- 服务器部署拆分静态页面和后端接口两大模块;
- Nginx 反向代理是前后端分离项目上线的核心配置;
- 学会使用 日志、进程、端口 命令快速排错。
7.2 拓展学习方向
- 配置 HTTPS 证书,实现加密访问;
- 整合 Redis 实现登录令牌缓存;
- 编写 Shell 脚本,实现项目一键启停;
- 集成 Swagger 接口文档、MyBatis-Plus 简化开发。
八、写在最后
前后端分离部署是 Java Web 开发的必备技能,本文结合真实实训项目,把每一个踩坑点、操作步骤都做了详细记录。按照本文流程操作,新手也可以顺利完成项目上线。如果部署过程中遇到问题,优先查看日志文件定位错误,这是运维排错最有效的方式。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)