SpringBoot + Vue前后端分离之旅游管理系统
旅游管理系统
自己在跟着视频学习完SSM,SpringBoot时,感觉不实践一下所学的知识一直停留在学习理论阶段,没有真正的会用在项目中,自己一直坚信实践是检验知识学习的有效方式。所以自己跟着视频学习了这个管理系统,总结一下自己的学习过程以及重要知识点。
项目简介
技术要求:
- 后端技术栈:Springboot + mybatis
- 前后端分析:axios、json
- 前端技术栈:Vue、nodejs
前置知识
- vue 组件之间的知识
- springboot + mybatis 知识
开发流程
- 需求分析
- 库表设计
- 编码(项目环境搭建 + 项目编码)
- 项目调试
需求分析
- 用户模块:登陆和注册
- 省份模块:一个省份可以有多个景点 CRUD
- 景点模块:一个景点对应多个省份
库表设计
- 分析系统中有哪些表? —> 表的个数
- 分析系统中表与表之间的关联关系
- 分析每个表中字段(显性字段 隐性字段(业务字段、经验字段))
数据库建表
用户表 t_user —— 独立表
- id、username、password、email
省份表t_province —— 省份表:景点表 === 1:n
- id、 name、 tags、 placecounts
景点表 t_place
- id、name、picpath、hottime、hotticket、dimticket、placedes、provinceid
数据库名:travels
用户SQL:
CREATE TABLE t_user(
id INT(6) PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(60),
PASSWORD VARCHAR(60),
email VARCHAR(60)
)
省份表:t_province
CREATE TABLE t_province(
id INT(6) PRIMARY KEY AUTO_INCREMENT,
NAME VARCHAR(60),
tags VARCHAR(80),
placecounts INT(4)
)
景点表:t_place
CREATE TABLE t_place(
id INT(6) PRIMARY KEY AUTO_INCREMENT,
NAME VARCHAR(60),
picpath VARCHAR(100),
hottime TIMESTAMP,
hotticket DOUBLE(7,2),
dimticket DOUBLE(7,1),
placedes VARCHAR(300),
provinceid INT(6) REFERENCES t_province(id)
)
项目编码
环境搭建
利用 Spring Initializr 快速搭建 SpringBoot 项目

引入依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.6.RELEASE</version>
<relativePath/> <!-- lookup parent from repository 2.2.6-->
</parent>
<groupId>com.baihzi</groupId>
<artifactId>travels</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>travels</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.19</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.4</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
注意:开发项目过程中遇到版本问题,导致项目在启动时成功,经过查询是 springboot 版本与 mybatis 版本有冲突,因此在这里设置 springboot 版本为 2.2.6.RELEASE mybatis 版本为 2.1.2
配置文件(application.properties)
server.port=8989
spring.application.name=travels
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/travels?serverTimezone=UTC&userSSL=true&userUnicode=true&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=li12345
mybatis.mapper-locations=classpath:com/baizhi/travels/mapper/*.xml
mybatis.type-aliases-package=com.baizhi.travels.entity
spring.web.resources.static-locations=classpath:/static/,file:${upload.dir}
upload.dir=D:\\project_work\\iamges
登陆注册的验证码功能
验证码工具类:
package com.baizhi.travels.utils;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Random;
import javax.imageio.ImageIO;
public class CreateImageCode {
// 图片的宽度。
private int width = 160;
// 图片的高度。
private int height = 40;
// 验证码字符个数
private int codeCount = 4;
// 验证码干扰线数
private int lineCount = 20;
// 验证码
private String code = null;
// 验证码图片Buffer
private BufferedImage buffImg = null;
Random random = new Random();
public CreateImageCode() {
creatImage();
}
public CreateImageCode(int width, int height) {
this.width = width;
this.height = height;
creatImage();
}
public CreateImageCode(int width, int height, int codeCount) {
this.width = width;
this.height = height;
this.codeCount = codeCount;
creatImage();
}
public CreateImageCode(int width, int height, int codeCount, int lineCount) {
this.width = width;
this.height = height;
this.codeCount = codeCount;
this.lineCount = lineCount;
creatImage();
}
// 生成图片
private void creatImage() {
int fontWidth = width / codeCount;// 字体的宽度
int fontHeight = height - 5;// 字体的高度
int codeY = height - 8;
// 图像buffer
buffImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics g = buffImg.getGraphics();
//Graphics2D g = buffImg.createGraphics();
// 设置背景色
g.setColor(getRandColor(200, 250));
g.fillRect(0, 0, width, height);
// 设置字体
//Font font1 = getFont(fontHeight);
Font font = new Font("Fixedsys", Font.BOLD, fontHeight);
g.setFont(font);
// 设置干扰线
for (int i = 0; i < lineCount; i++) {
int xs = random.nextInt(width);
int ys = random.nextInt(height);
int xe = xs + random.nextInt(width);
int ye = ys + random.nextInt(height);
g.setColor(getRandColor(1, 255));
g.drawLine(xs, ys, xe, ye);
}
// 添加噪点
float yawpRate = 0.01f;// 噪声率
int area = (int) (yawpRate * width * height);
for (int i = 0; i < area; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
buffImg.setRGB(x, y, random.nextInt(255));
}
String str1 = randomStr(codeCount);// 得到随机字符
this.code = str1;
for (int i = 0; i < codeCount; i++) {
String strRand = str1.substring(i, i + 1);
g.setColor(getRandColor(1, 255));
// g.drawString(a,x,y);
// a为要画出来的东西,x和y表示要画的东西最左侧字符的基线位于此图形上下文坐标系的 (x, y) 位置处
g.drawString(strRand, i*fontWidth+3, codeY);
}
}
// 得到随机字符
private String randomStr(int n) {
String str1 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890";
String str2 = "";
int len = str1.length() - 1;
double r;
for (int i = 0; i < n; i++) {
r = (Math.random()) * len;
str2 = str2 + str1.charAt((int) r);
}
return str2;
}
// 得到随机颜色
private Color getRandColor(int fc, int bc) {// 给定范围获得随机颜色
if (fc > 255)
fc = 255;
if (bc > 255)
bc = 255;
int r = fc + random.nextInt(bc - fc);
int g = fc + random.nextInt(bc - fc);
int b = fc + random.nextInt(bc - fc);
return new Color(r, g, b);
}
/**
* 产生随机字体
*/
private Font getFont(int size) {
Random random = new Random();
Font font[] = new Font[5];
font[0] = new Font("Ravie", Font.PLAIN, size);
font[1] = new Font("Antique Olive Compact", Font.PLAIN, size);
font[2] = new Font("Fixedsys", Font.PLAIN, size);
font[3] = new Font("Wide Latin", Font.PLAIN, size);
font[4] = new Font("Gill Sans Ultra Bold", Font.PLAIN, size);
return font[random.nextInt(5)];
}
// 扭曲方法
private void shear(Graphics g, int w1, int h1, Color color) {
shearX(g, w1, h1, color);
shearY(g, w1, h1, color);
}
private void shearX(Graphics g, int w1, int h1, Color color) {
int period = random.nextInt(2);
boolean borderGap = true;
int frames = 1;
int phase = random.nextInt(2);
for (int i = 0; i < h1; i++) {
double d = (double) (period >> 1)
* Math.sin((double) i / (double) period
+ (6.2831853071795862D * (double) phase)
/ (double) frames);
g.copyArea(0, i, w1, 1, (int) d, 0);
if (borderGap) {
g.setColor(color);
g.drawLine((int) d, i, 0, i);
g.drawLine((int) d + w1, i, w1, i);
}
}
}
private void shearY(Graphics g, int w1, int h1, Color color) {
int period = random.nextInt(40) + 10; // 50;
boolean borderGap = true;
int frames = 20;
int phase = 7;
for (int i = 0; i < w1; i++) {
double d = (double) (period >> 1)
* Math.sin((double) i / (double) period
+ (6.2831853071795862D * (double) phase)
/ (double) frames);
g.copyArea(i, 0, 1, h1, 0, (int) d);
if (borderGap) {
g.setColor(color);
g.drawLine(i, (int) d, i, 0);
g.drawLine(i, (int) d + h1, i, h1);
}
}
}
public void write(OutputStream sos) throws IOException {
ImageIO.write(buffImg, "png", sos);
sos.close();
}
public BufferedImage getBuffImg() {
return buffImg;
}
public String getCode() {
return code.toLowerCase();
}
//使用方法
/*public void getCode3(HttpServletRequest req, HttpServletResponse response,HttpSession session) throws IOException{
// 设置响应的类型格式为图片格式
response.setContentType("image/jpeg");
//禁止图像缓存。
response.setHeader("Pragma", "no-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
CreateImageCode vCode = new CreateImageCode(100,30,5,10);
session.setAttribute("code", vCode.getCode());
vCode.write(response.getOutputStream());
}*/
}
在后台中,我们需要对生成的验证码进行 Base64 编码之后传到前端页面进行展示
@RestController
@RequestMapping("user")
@CrossOrigin // 允许跨域
@Slf4j // 日志对象
public class UserController {
@Autowired
private UserService userService;
@GetMapping("getImage")
public Map<String,String> getImage(HttpServletRequest request) throws IOException {
Map<String,String> result = new HashMap<>();
CreateImageCode createImageCode = new CreateImageCode();
// 获取验证码
String securityCode = createImageCode.getCode();
// 验证码存入 session
String key = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
request.getServletContext().setAttribute(key, securityCode);
// 生成图片
BufferedImage image = createImageCode.getBuffImg();
// 进行Base64编码
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ImageIO.write(image,"png",bos);
String string = Base64Utils.encodeToString(bos.toByteArray());
result.put("key", key);
result.put("image", string);
return result;
}
}
点端页面:
<img :src="src" id="img-vcode" @click="getImage" :key="key">
<label>
<div class="label-text">验证码:</div>
<input type="text" v-model="code" name="vcode" style="width: 100px">
</label>
<script>
const app = new Vue({
el:"#app",
data:{
src:"",
key:""
},
methods:{
getImage(){
_this = this
axios.get("http://localhost:8989/user/getImage").then(res => {
console.log(res.data)
_this.src = "data:image/png;base64,"+res.data.image
_this.key = res.data.key
})
},
},
created(){ // 生命周期函数,提前初始化 data 和 methods 中的数据
this.getImage(); // 获取验证码图片
}
})
</script>
解释:在 vue 代码块中 路径解析部分,使用字符串拼接形式对图片相当于解码操作 data:image/png;base64, 最后的 逗号( , ) 不可以缺少,否则会出错
点后端分页查询功能
在我们使用查询语句的时候,经常要返回前几条或者中间某几行数据
LIMIT 5,10; 检索记录行 6-15
意思是从第6行开始,给下面查询10行数据
LIMIT 5 查询前 5 行数据
分页查询的SQL语句:参数1是开始查询的数据行,参数2是查询数据条数
<!--分页查询所有-->
<select id="findByPage" resultType="Province">
select id, name, tags, placecounts
from t_province
order by placecounts
limit #{start}, #{rows}
</select>
后台 业务层(serviceImpl) 代码
传入的参数是当前所在页数,以及页面显示数量,无法直接应用MySQL的 limit
@Override
public List<Province> findByPage(Integer page, Integer rows) {
int start = (page - 1) * rows; // 计算要查询的数据是从第几条开始的
return provinceDao.findByPage(start, rows);
}
后台 控制层(controller) 代码
// 分页查需
@GetMapping("findByPage")
public Map<String, Object> findByPage(Integer page, Integer rows){
page = page == null ? 1 : page; //
rows = rows == null ? 4 : rows; // 4 条数据显示一行
HashMap<String, Object> map = new HashMap<>();
// 分页处理
List<Province> provinces = provinceService.findByPage(page, rows);
// 计算总页数
Integer totals = provinceService.findTotals();
Integer totalPage = totals % rows == 0? totals / rows : totals / rows + 1;
map.put("provinces", provinces);
map.put("totals", totals);
map.put("totalPage", totalPage);
map.put("page", page);
return map;
}
前端页面代码
<div id="pages">
<a href="javascript:;" @click="findAll(page-1)" v-if="page>1" class="page">上一页</a>
<a href="javascript:;" @click="findAll(indexpage)" class="page" v-for="indexpage in totalPage" v-text="indexpage"></a>
<a href="javascript:;" v-if="page<totalPage" @click="findAll(page+1)" class="page">下一页</a>
</div>
<script>
const app = new Vue({
el:"#app",
data:{
provinces:[],
page:1,
rows:4,
totalPage:0,
totals:0
},
methods:{
findAll(indexpage){ // 查询所有
if(indexpage){
this.page = indexpage
}
_this = this
axios.get("http://localhost:8989/province/findByPage?page="+this.page).then(res => {
_this.provinces = res.data.provinces
_this.page = res.data.page
_this.totalPage = res.data.totalPage
_this.totals = res.data.totals
})
}
},
created(){
this.findAll();
}
})
</script>
前后端分离项目——文件上传
在后台中controller 实行文件注入方式,并实现文件上传(用 Base64 编码进行处理)
配置文件中 application.properties 中配置文件上传的路径
# 路径中最好不要出现中文,否则会有出现乱码的可能性
spring.web.resources.static-locations=classpath:/static/,file:${upload.dir}
upload.dir=D:\\project_work\\iamges
// 添加景点信息
@PostMapping("save")
public Result save(MultipartFile pic, Place place) throws IOException {
Result result = new Result();
try {
// 文件上传
System.out.println(pic);
String extension = FilenameUtils.getExtension(pic.getOriginalFilename());
String newFileName = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + extension;
//base64编码处理
place.setPicpath(Base64Utils.encodeToString(pic.getBytes()));
pic.transferTo(new File(realPath, newFileName));
// 保存place对象
placeService.save(place);
result.setMsg("保存景点信息成功!!!!!");
} catch (IOException e) {
result.setState(false).setMsg(e.getMessage());
}
return result;
}
前端文件上传:给标签添加 ref 属性 ref = "myFile"
<label>
<div class="label-text">印象图片:</div>
<div style="text-align: center;padding-left: 36%">
<div id="upload-tip">+</div>
<img src="" alt="" id="img-show" style="display: none">
<input type="file" id="imgfile" ref="myFile" style="display: none" onchange="imgfileChange()">
</div>
</label>
<script>
const app = new Vue({
el:"#app",
data:{
provinces:[],
place:{},
id:""
},
methods:{
savePlaceInfo(){
console.log(this.place);
let myFile = this.$refs.myFile
let files = myFile.files
let file = files[0]
let formData = new FormData()
formData.append("pic",file)
formData.append("name",this.place.name)
formData.append("hottime",this.place.hottime)
formData.append("hotticket",this.place.hotticket)
formData.append("dimticket",this.place.dimticket)
formData.append("placedes",this.place.placedes)
formData.append("provinceid",this.place.provinceid)
// axios
axios({
method:'post',
url:'http://localhost:8989/place/save',
data:formData,
headers:{
'Content-Type':'multipart/form-data'
}
}).then(res => {
console.log(res.data);
if (res.data.state){
alert(res.data.msg+",点击确定回到景点列表")
location.href='./viewspotlist.html?id='+this.place.provinceid
}else{
alert(res.data.msg+",点击确定回到景点列表")
}
})
}
},
})
</script>
在上传文件时,文件的路径大小设置,将数据库中 picpath 字段需要设置的足够大,这里我设置为 MEDIUMTEXT
href=“javascript:;” 的含义
在上面的代码中对于 a 标签,经常会出现以下的这种写法
<a href="javascript:;" @click="deletePlace(place.id)">删除景点</a>
首先对于<a> 标签的 href 属性用于指定超链接目标的 URL,href 属性的值可以是任何有效文档的相对或绝对 URL,包括片段标识符和 JavaScript 代码段
这里的 href="javascript:;" 其中 javascript: 是伪协议,它可以让我们通过一个链接来调用 javascript 函数。而采用这个方式 javascript:; 可以实现 a 标签的点击事件 运行时,如果页面内容很多,有滚动条时,页面不会乱跳,用户体验更好
javascript:;表示什么都不执行,这样点击时就没有任何反应,相当于去掉 a 标签的默认行为
关于 resultType 与 parameterType 的基本使用的区别 :
- 使用 resultType:主要针对于从数据库中提取相应的数据出来
- 使用 parameterType:主要针对于将信息存入到数据库中 如: insert 增加数据到数据库中 Update更新等
Vue 获取地址栏跳转的参数
在地址栏中我们会看到这样的路径
http://localhost:8989/viewspot/viewspotlist.html?id=9
这里的 id 会根据切换进行改动,那么这里的 id 怎么直接获取到
<a :href="'viewspotlist.html?id='+id">返回</a>
通过字符串截取的方法进行获取 id
let id = location.href.substring(location.href.indexOf("=")+1)
Vue v-for 的三种用法
v-for 的三种使用方法分别是:
- 使用 v-for 循环数组
<p v-for="(item,index) in list1" :key="index">索引值:{{index}}:id:{{item.userid}} 姓名:{{item.username}}</p>
- 使用 v-for 循环对象
<p v-for="(val,key,index) in list2" :key="index">id:{{val}},name:{{key}},index:{{index}}</p>
- v-for 循环一个迭代的数字
<p v-for="count in 10" :key="count">这是第{{count}}次循环</p>
页面效果展示
登陆页面

景点添加


景点列表展示

省份列表展示
