文章同步更新于我的个人博客:松果猿的博客,欢迎访问获取更多技术分享。

同时,您也可以关注我的微信公众号:****松果猿的代码工坊****,获取最新文章推送和编程技巧。

前言

通过前三期我们已经实现了这个共享单车管理系统的一部分功能,下面我们这一期实现单车投放区域查询

后端接口开发

毕竟我们这只是一个简单的系统,就不写这些复杂的业务逻辑了,后端的逻辑代码都是简化的,我们也不是写什么企业级的代码,就一切从简了。

我们需要安装PostGIS扩展来管理我们的地理数据,我们通过如下的SQL建表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- 启用PostGIS扩展
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- 区域表
CREATE TABLE regions (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL, -- 区域名称
rno VARCHAR(50) UNIQUE NOT NULL, -- 区域编号
capacity INTEGER NOT NULL, -- 区域单车容量
exist INTEGER DEFAULT 0, -- 当前存量
geometry GEOMETRY(Polygon, 4326) NOT NULL -- 地理边界
);

-- 单车表
CREATE TABLE bikes (
id BIGSERIAL PRIMARY KEY,
bno VARCHAR(50) UNIQUE NOT NULL, -- 单车编号
regionid BIGINT, -- 区域ID(逻辑外键)
location GEOMETRY(Point, 4326) NOT NULL -- 当前位置
);

-- 区域空间索引
CREATE INDEX idx_regions_geometry ON regions USING GIST(geometry);

-- 单车位置索引
CREATE INDEX idx_bikes_location ON bikes USING GIST(location);

-- 单车区域索引
CREATE INDEX idx_bikes_regionid ON bikes(regionid);

现在我们已经创建了region单车投放区域表以及单车位置bikes

我们需要一个统一的响应体类,我直接把苍穹外卖的弄过来(CV大法):

Result.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package com.beson.result;

import lombok.Data;

import java.io.Serializable;

/**
* 后端统一返回结果
* @param <T>
*/
@Data
public class Result<T> implements Serializable {

private Integer code; //编码:1成功,0和其它数字为失败
private String msg; //错误信息
private T data; //数据

public static <T> Result<T> success() {
Result<T> result = new Result<T>();
result.code = 1;
return result;
}

public static <T> Result<T> success(T object) {
Result<T> result = new Result<T>();
result.data = object;
result.code = 1;
return result;
}

public static <T> Result<T> error(String msg) {
Result result = new Result();
result.msg = msg;
result.code = 0;
return result;
}

}

PageResult.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.beson.result;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.util.List;

/**
* 封装分页查询结果
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResult implements Serializable {

private long total; //总记录数

private List records; //当前页数据集合

}

再来新建一个接收前端参数的DTO类以及Region的一个模型数据类:

RegionPageQueryDTO.java

1
2
3
4
5
6
7
8
9
10
11
12
package com.beson.DTO;

import lombok.Data;

@Data
public class RegionPageQueryDTO {
//页码
private int page;

//每页显示记录数
private int pageSize;
}

Region.java

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.beson.model;

import lombok.Data;

@Data
public class Region {
private Long id;
private String name;
private String rno;
private Integer capacity;
private Integer exist;
private String geometry; // WKT格式
}

我们使用PageHelper这个工具,简化一下分页逻辑代码:

引入依赖

1
2
3
4
5
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.6</version>
</dependency>

下面就是MVC三层架构了,懂得都懂:

RegionController.java(省略了包声明导入语句,不然太赘述)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RestController
@RequestMapping("/regions")
public class RegionController {
@Autowired
private RegionService regionService;

@GetMapping
public Result<PageResult> listRegions(RegionPageQueryDTO regionPageQueryDTO) {
PageResult pageResult=regionService.pageQuery(regionPageQueryDTO);
return Result.success(pageResult);
}

@GetMapping("/{rno}")
public Result<Region> getRegionById(@PathVariable String rno) {
return Result.success(regionService.getRegionByNo(rno));
}

@PostMapping
public Result createRegion(@RequestBody Region region) {
regionService.createRegion(region);
return Result.success();
}
}

RegionService.java(省略了包声明导入语句)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Service
public class RegionService {
@Autowired
private RegionMapper regionMapper;

public PageResult pageQuery(RegionPageQueryDTO regionPageQueryDTO) {
PageHelper.startPage(regionPageQueryDTO.getPage(), regionPageQueryDTO.getPageSize());

List<Region> regions = regionMapper.pageQuery(regionPageQueryDTO);

PageInfo<Region> pageInfo = new PageInfo<>(regions);

return PageResult.builder()
.total(pageInfo.getTotal())
.records(pageInfo.getList())
.build();
}

public Region getRegionByNo( String rno) {
return regionMapper.selectByNo(rno);
}

public void createRegion(Region region) {
region.setRno(generateUniqueRno());
regionMapper.insertRegion(region);
}

public String generateUniqueRno() {
return UUID.randomUUID().toString();
}
}

RegionMapper.java(省略了包声明导入语句)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Mapper
public interface RegionMapper {
/**
* 分页查询区域
* @return
*/
@Select("SELECT * FROM regions ORDER BY id")
List<Region> pageQuery(RegionPageQueryDTO regionPageQueryDTO);

/**
* 根据编号查询区域
* @param rno
* @return
*/
@Select("select * from regions where rno = #{rno}")
Region selectByNo(String rno);

/**
* 新增区域
* @param region
*/
@Insert("insert into regions(name, rno, capacity, exist, geometry) values(#{name}, #{rno}, #{capacity}, #{exist}, ST_GeomFromText(#{geometry}, 4326))")
void insertRegion(Region region);
}

我们加一些测试数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
INSERT INTO regions (name, rno, capacity, exist, geometry) VALUES
('Downtown', 'R001', 500, 0, 'POLYGON((0 0,10 0,10 10,0 10,0 0))'),
('Midtown', 'R002', 300, 0, 'POLYGON((10 0,20 0,20 10,10 10,10 0))'),
('Uptown', 'R003', 200, 0, 'POLYGON((0 10,10 10,10 20,0 20,0 10))'),
('Technology Park', 'R004', 1000, 0, 'POLYGON((20 0,30 0,30 10,20 10,20 0))'),
('Industrial Zone North', 'R005', 800, 0, 'POLYGON((20 10,30 10,30 20,20 20,20 10))'),
('Industrial Zone South', 'R006', 600, 0, 'POLYGON((30 0,40 0,40 10,30 10,30 0))'),
('Forest Reserve', 'R007', 100, 0, 'POLYGON((0 20,10 20,10 30,0 30,0 20))'),
('Wetland Conservation', 'R008', 50, 0, 'POLYGON((10 20,20 20,20 30,10 30,10 20))'),
('Mountain Preserve', 'R009', 200, 0, 'POLYGON((20 20,30 20,30 30,20 30,20 20))'),
('Residential Area A', 'R010', 400, 0, 'POLYGON((30 10,40 10,40 20,30 20,30 10))'),
('Residential Area B', 'R011', 350, 0, 'POLYGON((40 0,50 0,50 10,40 10,40 0))'),
('Residential Area C', 'R012', 250, 0, 'POLYGON((40 10,50 10,50 20,40 20,40 10))')

使用Postman进行测试一下接口是否可用:

可以看到接口正常,下面就可以进行我们前端的开发了

前端开发

查询区域列表功能

根目录下新建.env.developments

1
VITE_API_URL=http://localhost:8080 

新建@/components/RegionTable.vue,查询表格组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
<template>
<div class="region-table" @mousedown="startDrag">
<div class="region-table-header">
<el-icon class="close-icon" @click="handleClose"><Close /></el-icon>
</div>
<div>
<el-table :data="tableData" style="width: 600px" height="250">
<el-table-column label="Id" width="50">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column prop="rno" label="编号" :show-overflow-tooltip="true" width="100"/>
<el-table-column prop="name" label="名称" width="90" />
<el-table-column prop="capacity" label="容量" width="90" />
<el-table-column prop="exist" label="存量" width="90" />
<el-table-column label="警告等级" width="80">
<template #default="scope">
<el-tag
type="success"
v-if="scope.row.capacity / scope.row.exist > 0.6"
>正常</el-tag
>
<el-tag
type="warning"
v-if="scope.row.capacity / scope.row.exist <= 0.6"
>警告</el-tag
>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="scope">
<el-button type="primary" @click="handleLocation(scope.row.rno)"
>定位</el-button
>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 15]"
:background="true"
layout="sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>

<script setup>
import { Close } from '@element-plus/icons-vue'
import { useContentStore } from '@/stores/contentStore.ts'
import { ref, onMounted } from 'vue'
import axios from 'axios'

const contentStore = useContentStore()
const tableData = ref([])
const currentPage = ref(1)
const pageSize = ref(5)
const total = ref(0)

// 获取区域数据
const fetchRegions = async (page, pageSize) => {
try {
const response = await axios.get(`${import.meta.env.VITE_API_URL}/regions?page=${page}&pageSize=${pageSize}`)
const data = response.data.data
tableData.value = data.records
total.value = data.total

} catch (error) {
console.error('获取区域数据失败:', error)
}
}

// 页码改变时的处理函数
const handleCurrentChange = (val) => {
currentPage.value = val
fetchRegions(val, pageSize.value)
}

// 每页条数改变时的处理函数
const handleSizeChange = (val) => {
pageSize.value = val
fetchRegions(currentPage.value, val)
}

const handleClose = () => {
contentStore.toggleRegionTable(false)
}

// 组件挂载时获取数据
onMounted(() => {
fetchRegions(currentPage.value, pageSize.value)
})
</script>

<style lang="scss" scoped>
.region-table {
height: 300px;
width: 600px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 20px;

}

.region-table-header {
text-align: right;
border-bottom: 1px solid #ebeef5;

.close-icon {
cursor: pointer;
font-size: 20px;
transition: all 0.3s;

&:hover {
color: #409EFF;
transform: rotate(90deg);
}
}
}
</style>

新建@/stores/contentStore.ts,用于管理弹窗渲染条件

1
2
3
4
5
6
7
8
9
10
11
12
import { defineStore } from 'pinia'

export const useContentStore = defineStore('context', {
state: () => ({
isRegionTableVisible: false
}),
actions: {
toggleRegionTable(value: boolean) {
this.isRegionTableVisible = value
}
}
})

RegionTable.vue挂载在Home.vue:

Header.vue创建点击事件

下面我们来看一下效果:

可以看到是列表成功渲染出来了,下面来实现地理数据加载功能

加载区域地理数据

新建@/hooks/useRegionGeom.js,将后端传来的WKB数据转为geometry矢量图层添加进map对象中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { ref } from 'vue';
import { Vector as VectorLayer } from 'ol/layer';
import { Vector as VectorSource } from 'ol/source';
import { Style, Fill, Stroke } from 'ol/style';
import { Feature } from 'ol';
import WKB from 'ol/format/WKB';
import { useMapStore } from '@/stores/mapStore';

export function useRegionGeometry() {
const mapStore = useMapStore();
const regionGeoJson = ref([]);

const loadRegionGeometry = async (records) => {
try {
regionGeoJson.value = records.map((item) => {
let geomWKB = new WKB().readGeometry(item.geometry, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
let feature = new Feature({
geometry: geomWKB,
name: item.name,
});
return feature;
});

mapStore.map.addLayer(
new VectorLayer({
source: new VectorSource({
features: regionGeoJson.value,
}),
style: new Style({
fill: new Fill({
color: "rgba(255, 0, 0, 0.2)",
}),
stroke: new Stroke({
color: "#333",
width: 2,
}),
}),
})
);
} catch (error) {
console.error("加载地理数据失败:", error);
}
};

return {
loadRegionGeometry
};
}

我们测试数据的地理数据成功渲染在地图上了:

.gif)

不过这个弹窗有点遮挡视野,我们把它改为可以拖动位置的

RegionTable.vue

添加拖动逻辑代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 拖动逻辑
const startDrag = (e) => {
const target = e.currentTarget;
const startX = e.clientX - target.offsetLeft;
const startY = e.clientY - target.offsetTop;

const onMove = (moveEvent) => {
target.style.cursor = 'grab';

const newX = moveEvent.clientX - startX;
const newY = moveEvent.clientY - startY;
target.style.left = `${newX}px`;
target.style.top = `${newY}px`;
};

const onEnd = () => {
target.style.cursor = 'default';

document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onEnd);
};

document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onEnd);
};

查看效果:

.gif)

区域定位功能

首先根据编号获取到该区域的geojson数据:

RegionTable.vue中添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 根据rno获取区域数据
const fetchRegionByRno = async (rno) => {
const response = await axios.get(
`${import.meta.env.VITE_API_URL}/regions/${rno}`
);
const data = response.data.data;
return data.geometry;
};
//定位功能
const handleLocation = (rno) => {
const geometry = fetchRegionByRno(rno);
console.log(geometry);
};

然后就是获取几何中心点以及高亮显示

RegionTable.vue中添加获取数据事件以及定位事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 根据rno获取区域数据
const fetchRegionByRno = async (rno) => {
const response = await axios.get(
`${import.meta.env.VITE_API_URL}/regions/${rno}`
);
const data = response.data.data;
return data.geometry;
};

// 修改定位功能
const handleLocation = async (rno) => {
try {
const geometry = await fetchRegionByRno(rno);
} catch (error) {
console.error("定位区域失败:", error);
}
};

在将定位高亮逻辑封装进@/hooks/useRegionGeom.js(openlayers获取中心点的接口逻辑有点奇怪…)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
async function locateRegion(geometry) {
let polygonGeometry = new WKB().readGeometry(geometry, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
console.log(polygonGeometry);

let polygonFeature = new Feature({
geometry: polygonGeometry,
});

let geo = polygonFeature.getGeometry();

let center = getCenter(geo.getExtent());

let vectorSource = new VectorSource({
features: [polygonFeature],
});

let vectorLayer = new VectorLayer({
source: vectorSource,
style: new Style({
fill: new Fill({
color: "rgba(255, 0, 0, 0.3)",
}),
stroke: new Stroke({
color: "red",
width: 2,
}),
}),
});


mapStore.map.addLayer(vectorLayer);

mapStore.map.getView().animate({
center: center,
zoom: 10,
duration: 1000,
});
}

再在RegionTable.vue调用:

查看效果:

如果有疑问或者文章有错误的,请在评论区提出,我看到一定会解答

项目地址:https://github.com/songguo1/Share_bike