旅遊管理系統
好像沒有必要一段段的把代碼貼上來,毫無意義,不如把完整的項目放出來,基本有點基礎的都可以看懂哈,然後博客記錄一下遇到的問題和重要的知識點。。
項目 github 網址:https://github.com/szluyu99/travels/tree/master
視頻鏈接:https://www.bilibili.com/video/BV1Nt4y127Jh?p=19
項目簡介
所需技術棧:
- 後端技術棧:springboot + mybatis
- 前後端分離:axios、json
- 前端技術棧、技術架構:Vue、node.js
前置知識:
- 瞭解 Vue 組件之前的知識
- 對 springboot + mybatis 較熟悉
開發流程:
- 需求分析
- 庫表設計
- 編碼(項目環境搭建+編碼)
- 項目調試
- 項目部署上線
需求分析:
- 用戶模塊:登錄 + 註冊
- 省份模塊:一個省份可能存在多個景點
- 景點模塊:一個景點對應多個省份
項目演示
進入系統需登錄:
用戶註冊頁面:
省份列表頁面:
添加省份頁面:
修改省份頁面:
景點列表頁面:
添加景點頁面:
修改景點頁面:
數據庫建表
用戶表 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)
);
省份表 SQL:
CREATE TABLE t_province(
id INT(6) PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(60),
tags VARCHAR(80),
placecounts INT(4)
);
景點表 SQL:
CREATE TABLE t_place(
id INT(6) PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(60),
picpath MEDIUMTEXT,
hottime TIMESTAMP,
hotticket DOUBLE(7,2),
dimticket DOUBLE(7,2),
placedes VARCHAR(300),
provinceid INT(6) REFERENCES t_province(id)
);
環境搭建
利用 Spring Initializr 快速搭建 SpringBoot 項目。
引入依賴(pom.xml)
<?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>
<!--繼承springboot父項目-->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.1.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.yusael</groupId>
<artifactId>mytravels</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mytravels</name>
<description>springboot + vue</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!--web依賴-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--mybatis依賴-->
<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>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--druid-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.12</version>
</dependency>
<!--test-->
<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>
<!--測試-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
配置文件(application.properties)
application.properties:
server.port=8989
spring.application.name=travels
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/travels?characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=1234
mybatis.mapper-locations=classpath:com/yusael/travels/mapper/*.xml
mybatis.type-aliases-package=com.yusael.travels.entity
logging.level.root=info
logging.level.com.yusael.travels.dao=debug
# 上傳的圖片存放的路徑
upload.dir=D:/CodePro/IdeaPro/SpringBoot/travels/images
spring.resources.static-locations=file:${upload.dir}
href=“javascript:;” 含義
代碼中經常遇到這種寫法:
<a href="javascript:;" @click="deleteProvince(province.id)">刪除省份</a>
其中的 href="javascript:;"
是什麼意思呢?
javascript:
表示在觸發默認動作時,執行一段 JavaScript 代碼;javascript:;
表示什麼都不執行,這樣點擊時就沒有任何反應,相當於去掉 a 標籤的默認行爲。
select - option 綁定 Vue 實例
select
中 通過 v-model
綁定當前的選項,option
中使用 v-for
遍歷顯示所有選項。
<label>
<div class="label-text">所屬省份:</div>
<select v-model="place.provinceid">
<option v-for="(pro,index) in provinces" :value="pro.id" v-text="pro.name"></option>
</select>
</label>
刪除時增加確認選項
if (confirm("確定要刪除景點嗎?")) {
// code....
}
Vue 獲取地址欄跳轉的參數
對於這麼一個 a 標籤,我們要在另一個頁面獲取這個 url 的參數 id:
<a :href="'./updateprovince.html?id=' + province.id">修改省份</a>
可以通過 location.href
獲取 url 再進行截取:
var id = location.href.substring(location.href.indexOf("=") + 1);
前後端分離項目—驗證碼功能
驗證碼工具類:
package com.yusael.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 {
/**
* 生成驗證碼
* @throws IOException
*/
@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" 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() {
this.getImage(); // 獲取驗證碼
}
});
</script>
前後端分離項目—分頁功能
mysql 的 LIMIT
分頁語句:
LIMIT n
: 取前 n 個數據,相當於LIMIT 0, n
;LIMIT 2, 4
: 從第 3 行開始檢索 4 條數據;
分頁查詢的SQL語句:參數1是開始查詢的數據行,參數2是查詢數據條數。
<!--分頁查詢所有-->
<select id="findByPage" resultType="Province">
SELECT * FROM t_province
ORDER BY placecounts
LIMIT #{start}, #{rows}
</select>
後臺業務層代碼:
傳入的參數是當前所在頁數,以及頁面顯示數量,無法直接應用 MySQL 的 limit
查詢子句中,需要轉換一下:start = (page - 1) * rows
計算出 limit
字句的第一個參數。
@Override
public List<Province> findByPage(Integer page, Integer rows) {
// 傳入的是當前頁數, 以及頁面顯示的數量
// 所以要根據這兩個參數計算從mysql中查詢數據要從第幾行開始查幾條
int start = (page - 1) * rows; // 計算要查詢的數據是從第幾條數據開始的
return provinceDAO.findByPage(start, rows);
}
後臺控制器代碼:
/**
* 分頁查詢數據
*/
@GetMapping("/findByPage")
public Map<String, Object> findByPage(Integer page, Integer rows) {
page = page==null ? 1 : page;
rows = rows==null ? 4 : rows;
System.out.println(page + " : " + rows);
HashMap<String, Object> map = new HashMap<>();
// 分頁查詢出當前頁面顯示的數據
List<Province> provinces = provinceService.findByPage(page, rows);
// 查詢總數據條數, 用於計算總頁數
Integer totals = provinceService.findTotals();
// 計算總頁數
// 如果總數據條數可以整除每一頁數據個數, 說明結果正好爲總頁數
// 如果總數據條數無法整除每一頁數據個數, 說明總頁數需要結果 + 1
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);
map.forEach((k, v) -> {
System.out.println(k + ": " + v);
});
return map;
}
前端頁面:
<div id="pages">
<!--上一頁, 只有當前所在頁數>1纔會顯示-->
<a href="javascript:;" class="page" v-if="page > 1" @click="findAll(page - 1)"><上一頁</a>
<!--頁面-->
<a href="javascript:;" class="page" v-for="index in totalPage" @click="findAll(index)" v-text="index"></a>
<!--下一頁, 只有當前所在頁數<總頁數纔會顯示-->
<a href="javascript:;" class="page" v-if="page < totalPage" @click="findAll(page + 1)">下一頁></a>
</div>
超鏈接的寫法可以更優化一下:優化後點擊當前所在頁數無效(不會發送任何請求)。
<div id="pages">
<a href="javascript:;" class="page" v-if="page > 1" @click="findAllPage(page - 1)"><上一頁</a>
<span v-for="index in totalPage">
<a href="javascript:;" class="page" v-if="page == index" v-text="index"></a>
<a href="javascript:;" class="page" v-if="page != index" @click="findAllPage(index)" v-text="index"></a>
</span>
<a href="javascript:;" class="page" v-if="page < totalPage" @click="findAllPage(page + 1)">下一頁></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 + "&rows=" + this.rows).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>
前後端分離項目—日期數據類型的處理
前後端數據交互採用的是 Json 的話,只需要在實體類中的屬性加一個註解即可:
@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
@ToString
public class Place {
private String id;
private String name;
private String picpath;
@JsonFormat(pattern = "yyyy-MM-dd")
private Date hottime; // 前後端分離項目對日期數據類型的處理
private Double hotticket;
private Double dimticket;
private String placedes;
private String provinceid;
}
前後端分離項目—文件上傳
注:由於我們往數據庫中插入的是文件的 Base64 編碼,因此需要將 數據庫中 picpath
字段的大小設置的足夠大,可以使用以下幾個數據類型:
數據類型 | 最大長度 | 近似值 |
---|---|---|
TINYTEXT | 256 bytes | |
TEXT | 65,535 bytes | ~64kb |
MEDIUMTEXT | 16,777,215 bytes | ~16MB |
LONGTEXT | 4,294,967,295 bytes | ~4GB |
在配置文件 application.properties
中配置文件上傳的路徑:
spring.resources.static-locations=file:${upload.dir}
upload.dir=D:/CodePro/IdeaPro/SpringBoot/travels/images
在後臺控制器中 注入路徑,並實現文件上傳(用 Base64 編碼進行處理):
@RestController
@RequestMapping("/place")
@CrossOrigin
public class PlaceController {
@Autowired
private PlaceService placeService;
@Value("${upload.dir}") // 注入
private String realPath;
/**
* 保存景點信息
* @param pic
* @return
*/
@PostMapping("save")
public Result save(MultipartFile pic, Place place) throws IOException {
Result result = new Result();
try {
// 文件上傳
String extension = FilenameUtils.getExtension(pic.getOriginalFilename());
String newFileName = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + extension;
// base64編碼處理(注意, 這一步必須放在 transferTo 操作前面!)
place.setPicpath(Base64Utils.encodeToString(pic.getBytes()));
// 文件上傳
File file = new File(realPath);
pic.transferTo(new File(file,newFileName));
// 保存place對象
placeService.save(place);
result.setMsg("保存景點信息成功!!!");
} catch (Exception e) {
e.printStackTrace();
result.setState(false).setMsg(e.getMessage());
}
return result;
}
}
前端中上傳文件:給標籤添加屬性 ref="myFile"
<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>
<script>
const app = new Vue({
el: "#app",
data: {
provinces: [],
place: {},
id: "",
},
methods: {
savePlaceInfo() { // 保存景點的方法
console.log(this.place); // 獲取到了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>
前端中展示 base64 格式的文件:
<img :src="'data:image/png;base64,' + place.picpath" class="viewspotimg">